diff --git a/backend/migrations/031_auto_assign_strategy.sql b/backend/migrations/031_auto_assign_strategy.sql new file mode 100644 index 0000000..2adf8d6 --- /dev/null +++ b/backend/migrations/031_auto_assign_strategy.sql @@ -0,0 +1,3 @@ +ALTER TABLE housekeeping_settings + ADD COLUMN IF NOT EXISTS auto_assign BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS auto_assign_strategy VARCHAR(20) NOT NULL DEFAULT 'least_loaded'; diff --git a/backend/src/routes/housekeeping-settings.ts b/backend/src/routes/housekeeping-settings.ts index 1a77a71..7d7c8de 100644 --- a/backend/src/routes/housekeeping-settings.ts +++ b/backend/src/routes/housekeeping-settings.ts @@ -9,6 +9,8 @@ export interface HkSettings { inspection_after_clean: boolean require_completion_photo: boolean emergency_close_days: number + auto_assign: boolean + auto_assign_strategy: string } const DEFAULT_SETTINGS: HkSettings = { @@ -17,16 +19,93 @@ const DEFAULT_SETTINGS: HkSettings = { inspection_after_clean: true, require_completion_photo: false, emergency_close_days: 1, + auto_assign: false, + auto_assign_strategy: 'least_loaded', } export async function getHkSettings(hotelId: string): Promise { const { rows } = await db.query( - 'SELECT checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo, emergency_close_days FROM housekeeping_settings WHERE hotel_id = $1', + 'SELECT checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo, emergency_close_days, auto_assign, auto_assign_strategy FROM housekeeping_settings WHERE hotel_id = $1', [hotelId], ) return rows[0] ?? DEFAULT_SETTINGS } +// Pick a housekeeper based on strategy +export async function autoAssignHousekeeper( + hotelId: string, + strategy: string, +): Promise { + if (strategy === 'round_robin') { + // Housekeeper with fewest tasks assigned today + const { rows } = await db.query( + `SELECT u.id + FROM users u + WHERE u.hotel_id = $1 AND u.role = 'housekeeper' AND u.active = true + ORDER BY ( + SELECT COUNT(*) FROM housekeeping_tasks t + WHERE t.assignee_id = u.id + AND t.created_at::date = CURRENT_DATE + ) ASC, RANDOM() + LIMIT 1`, + [hotelId], + ) + return rows[0]?.id ?? null + } + + if (strategy === 'least_loaded') { + // Housekeeper with fewest active (pending/in_progress) tasks right now + const { rows } = await db.query( + `SELECT u.id + FROM users u + WHERE u.hotel_id = $1 AND u.role = 'housekeeper' AND u.active = true + ORDER BY ( + SELECT COUNT(*) FROM housekeeping_tasks t + WHERE t.assignee_id = u.id + AND t.status IN ('pending', 'in_progress') + ) ASC, RANDOM() + LIMIT 1`, + [hotelId], + ) + return rows[0]?.id ?? null + } + + if (strategy === 'first_free') { + // Housekeeper who most recently completed a task (or has never had one) and has no active tasks + const { rows } = await db.query( + `SELECT u.id + FROM users u + WHERE u.hotel_id = $1 AND u.role = 'housekeeper' AND u.active = true + AND NOT EXISTS ( + SELECT 1 FROM housekeeping_tasks t + WHERE t.assignee_id = u.id AND t.status IN ('pending', 'in_progress') + ) + ORDER BY ( + SELECT MAX(t.completed_at) FROM housekeeping_tasks t + WHERE t.assignee_id = u.id AND t.status = 'done' + ) DESC NULLS LAST + LIMIT 1`, + [hotelId], + ) + // If nobody is free, fall back to least_loaded + if (rows[0]) return rows[0].id + const { rows: fallback } = await db.query( + `SELECT u.id + FROM users u + WHERE u.hotel_id = $1 AND u.role = 'housekeeper' AND u.active = true + ORDER BY ( + SELECT COUNT(*) FROM housekeeping_tasks t + WHERE t.assignee_id = u.id AND t.status IN ('pending', 'in_progress') + ) ASC, RANDOM() + LIMIT 1`, + [hotelId], + ) + return fallback[0]?.id ?? null + } + + return null +} + const housekeepingSettings: FastifyPluginAsync = async (fastify) => { const getHotelId = async (slug: string): Promise => { const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) @@ -61,16 +140,23 @@ const housekeepingSettings: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo, emergency_close_days } = request.body + const { + checkout_auto, checkout_priority, inspection_after_clean, + require_completion_photo, emergency_close_days, + auto_assign, auto_assign_strategy, + } = request.body await db.query( - `INSERT INTO housekeeping_settings (hotel_id, checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo, emergency_close_days) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO housekeeping_settings + (hotel_id, checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo, emergency_close_days, auto_assign, auto_assign_strategy) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (hotel_id) DO UPDATE SET checkout_auto = COALESCE(EXCLUDED.checkout_auto, housekeeping_settings.checkout_auto), checkout_priority = COALESCE(EXCLUDED.checkout_priority, housekeeping_settings.checkout_priority), inspection_after_clean = COALESCE(EXCLUDED.inspection_after_clean, housekeeping_settings.inspection_after_clean), require_completion_photo = COALESCE(EXCLUDED.require_completion_photo, housekeeping_settings.require_completion_photo), emergency_close_days = COALESCE(EXCLUDED.emergency_close_days, housekeeping_settings.emergency_close_days), + auto_assign = COALESCE(EXCLUDED.auto_assign, housekeeping_settings.auto_assign), + auto_assign_strategy = COALESCE(EXCLUDED.auto_assign_strategy, housekeeping_settings.auto_assign_strategy), updated_at = NOW()`, [ hotelId, @@ -79,6 +165,8 @@ const housekeepingSettings: FastifyPluginAsync = async (fastify) => { inspection_after_clean ?? DEFAULT_SETTINGS.inspection_after_clean, require_completion_photo ?? DEFAULT_SETTINGS.require_completion_photo, emergency_close_days ?? DEFAULT_SETTINGS.emergency_close_days, + auto_assign ?? DEFAULT_SETTINGS.auto_assign, + auto_assign_strategy ?? DEFAULT_SETTINGS.auto_assign_strategy, ], ) return getHkSettings(hotelId) diff --git a/backend/src/routes/housekeeping.ts b/backend/src/routes/housekeeping.ts index d803a65..16ff5fa 100644 --- a/backend/src/routes/housekeeping.ts +++ b/backend/src/routes/housekeeping.ts @@ -1,6 +1,6 @@ import { FastifyPluginAsync } from 'fastify' import { db } from '../db' -import { getHkSettings } from './housekeeping-settings' +import { getHkSettings, autoAssignHousekeeper } from './housekeeping-settings' import { broadcast } from './ws' type SlugParam = { Params: { slug: string } } @@ -73,7 +73,19 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { room_id, type, priority = 'medium', assignee_id, notes, due_date, category = 'housekeeping', photos = [] } = request.body + const { room_id, type, priority = 'medium', notes, due_date, category = 'housekeeping', photos = [] } = request.body + let { assignee_id } = request.body + + // Auto-assign if enabled and no assignee provided + if (!assignee_id) { + const settings = await getHkSettings(hotelId).catch(() => null) + if (settings?.auto_assign) { + // Urgent tasks always use first_free strategy; otherwise use configured strategy + const strategy = priority === 'urgent' ? 'first_free' : (settings.auto_assign_strategy ?? 'least_loaded') + assignee_id = await autoAssignHousekeeper(hotelId, strategy).catch(() => null) ?? undefined + } + } + const { rows: inserted } = await db.query( `INSERT INTO housekeeping_tasks (hotel_id, room_id, type, priority, assignee_id, notes, due_date, category, photos) diff --git a/src/lib/api.ts b/src/lib/api.ts index 87e9392..67f3503 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -566,6 +566,8 @@ export interface HkSettings { inspection_after_clean: boolean require_completion_photo?: boolean emergency_close_days?: number + auto_assign?: boolean + auto_assign_strategy?: string } export interface HotelPayload { diff --git a/src/pages/HousekeepingPage.tsx b/src/pages/HousekeepingPage.tsx index 69f5176..4d494f8 100644 --- a/src/pages/HousekeepingPage.tsx +++ b/src/pages/HousekeepingPage.tsx @@ -113,6 +113,7 @@ export function HousekeepingPage() { const ss = s as unknown as { checkoutAuto: boolean; checkoutPriority: string inspectionAfterClean: boolean; requireCompletionPhoto: boolean; emergencyCloseDays: number + autoAssign: boolean; autoAssignStrategy: string } setPlans(prev => ({ ...prev, @@ -121,6 +122,8 @@ export function HousekeepingPage() { inspectionAfterClean: ss.inspectionAfterClean, requireCompletionPhoto: ss.requireCompletionPhoto ?? false, emergencyCloseDays: ss.emergencyCloseDays ?? 1, + autoAssign: ss.autoAssign ?? false, + autoAssignStrategy: (ss.autoAssignStrategy ?? 'least_loaded') as 'round_robin' | 'least_loaded' | 'first_free', })) } }).catch(console.error) @@ -143,8 +146,8 @@ export function HousekeepingPage() { checkoutInspection: true, dailyEnabled: true, dailyIntervalDays: 1, dailyStartFromDay: 1, onDemandEnabled: true, onDemandPriority: 'medium' as 'high' | 'medium' | 'low', deepCleanEnabled: false, deepCleanEveryDays: 7, - inspectionAfterClean: true, autoAssign: false, requireCompletionPhoto: false, - emergencyCloseDays: 1, + inspectionAfterClean: true, autoAssign: false, autoAssignStrategy: 'least_loaded' as 'round_robin' | 'least_loaded' | 'first_free', + requireCompletionPhoto: false, emergencyCloseDays: 1, }) const setP = (k: K, v: typeof plans[K]) => @@ -160,6 +163,8 @@ export function HousekeepingPage() { inspection_after_clean: plans.inspectionAfterClean, require_completion_photo: plans.requireCompletionPhoto, emergency_close_days: plans.emergencyCloseDays, + auto_assign: plans.autoAssign, + auto_assign_strategy: plans.autoAssignStrategy, } await api.housekeeping.saveSettings(slug, settings) setPlanSaved(true) @@ -516,14 +521,41 @@ export function HousekeepingPage() { -
+

Автоназначение горничной

-

Автоматически назначать ответственную горничную по расписанию смен

+

Автоматически назначать горничную при создании задачи

setP('autoAssign', !plans.autoAssign)} />
+ {plans.autoAssign && ( +
+

Стратегия распределения (обычные задачи)

+

Срочные задачи всегда назначаются первой свободной горничной

+
+ {([ + { value: 'round_robin', label: 'По кругу', desc: 'Кому сегодня назначено меньше задач' }, + { value: 'least_loaded', label: 'Наименее загруженная', desc: 'У кого сейчас меньше активных задач' }, + { value: 'first_free', label: 'Первая свободная', desc: 'Кто раньше завершила последнюю задачу' }, + ] as const).map(opt => ( + + ))} +
+
+ )}