feat: auto-assign strategy (round_robin/least_loaded/first_free); urgent tasks always use first_free
This commit is contained in:
3
backend/migrations/031_auto_assign_strategy.sql
Normal file
3
backend/migrations/031_auto_assign_strategy.sql
Normal file
@@ -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';
|
||||||
@@ -9,6 +9,8 @@ export interface HkSettings {
|
|||||||
inspection_after_clean: boolean
|
inspection_after_clean: boolean
|
||||||
require_completion_photo: boolean
|
require_completion_photo: boolean
|
||||||
emergency_close_days: number
|
emergency_close_days: number
|
||||||
|
auto_assign: boolean
|
||||||
|
auto_assign_strategy: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: HkSettings = {
|
const DEFAULT_SETTINGS: HkSettings = {
|
||||||
@@ -17,16 +19,93 @@ const DEFAULT_SETTINGS: HkSettings = {
|
|||||||
inspection_after_clean: true,
|
inspection_after_clean: true,
|
||||||
require_completion_photo: false,
|
require_completion_photo: false,
|
||||||
emergency_close_days: 1,
|
emergency_close_days: 1,
|
||||||
|
auto_assign: false,
|
||||||
|
auto_assign_strategy: 'least_loaded',
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHkSettings(hotelId: string): Promise<HkSettings> {
|
export async function getHkSettings(hotelId: string): Promise<HkSettings> {
|
||||||
const { rows } = await db.query(
|
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],
|
[hotelId],
|
||||||
)
|
)
|
||||||
return rows[0] ?? DEFAULT_SETTINGS
|
return rows[0] ?? DEFAULT_SETTINGS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pick a housekeeper based on strategy
|
||||||
|
export async function autoAssignHousekeeper(
|
||||||
|
hotelId: string,
|
||||||
|
strategy: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
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 housekeepingSettings: FastifyPluginAsync = async (fastify) => {
|
||||||
const getHotelId = async (slug: string): Promise<string | null> => {
|
const getHotelId = async (slug: string): Promise<string | null> => {
|
||||||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
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)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
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(
|
await db.query(
|
||||||
`INSERT INTO housekeeping_settings (hotel_id, checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo, emergency_close_days)
|
`INSERT INTO housekeeping_settings
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
(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
|
ON CONFLICT (hotel_id) DO UPDATE SET
|
||||||
checkout_auto = COALESCE(EXCLUDED.checkout_auto, housekeeping_settings.checkout_auto),
|
checkout_auto = COALESCE(EXCLUDED.checkout_auto, housekeeping_settings.checkout_auto),
|
||||||
checkout_priority = COALESCE(EXCLUDED.checkout_priority, housekeeping_settings.checkout_priority),
|
checkout_priority = COALESCE(EXCLUDED.checkout_priority, housekeeping_settings.checkout_priority),
|
||||||
inspection_after_clean = COALESCE(EXCLUDED.inspection_after_clean, housekeeping_settings.inspection_after_clean),
|
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),
|
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),
|
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()`,
|
updated_at = NOW()`,
|
||||||
[
|
[
|
||||||
hotelId,
|
hotelId,
|
||||||
@@ -79,6 +165,8 @@ const housekeepingSettings: FastifyPluginAsync = async (fastify) => {
|
|||||||
inspection_after_clean ?? DEFAULT_SETTINGS.inspection_after_clean,
|
inspection_after_clean ?? DEFAULT_SETTINGS.inspection_after_clean,
|
||||||
require_completion_photo ?? DEFAULT_SETTINGS.require_completion_photo,
|
require_completion_photo ?? DEFAULT_SETTINGS.require_completion_photo,
|
||||||
emergency_close_days ?? DEFAULT_SETTINGS.emergency_close_days,
|
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)
|
return getHkSettings(hotelId)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify'
|
import { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { getHkSettings } from './housekeeping-settings'
|
import { getHkSettings, autoAssignHousekeeper } from './housekeeping-settings'
|
||||||
import { broadcast } from './ws'
|
import { broadcast } from './ws'
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
@@ -73,7 +73,19 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
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(
|
const { rows: inserted } = await db.query(
|
||||||
`INSERT INTO housekeeping_tasks
|
`INSERT INTO housekeeping_tasks
|
||||||
(hotel_id, room_id, type, priority, assignee_id, notes, due_date, category, photos)
|
(hotel_id, room_id, type, priority, assignee_id, notes, due_date, category, photos)
|
||||||
|
|||||||
@@ -566,6 +566,8 @@ export interface HkSettings {
|
|||||||
inspection_after_clean: boolean
|
inspection_after_clean: boolean
|
||||||
require_completion_photo?: boolean
|
require_completion_photo?: boolean
|
||||||
emergency_close_days?: number
|
emergency_close_days?: number
|
||||||
|
auto_assign?: boolean
|
||||||
|
auto_assign_strategy?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HotelPayload {
|
export interface HotelPayload {
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export function HousekeepingPage() {
|
|||||||
const ss = s as unknown as {
|
const ss = s as unknown as {
|
||||||
checkoutAuto: boolean; checkoutPriority: string
|
checkoutAuto: boolean; checkoutPriority: string
|
||||||
inspectionAfterClean: boolean; requireCompletionPhoto: boolean; emergencyCloseDays: number
|
inspectionAfterClean: boolean; requireCompletionPhoto: boolean; emergencyCloseDays: number
|
||||||
|
autoAssign: boolean; autoAssignStrategy: string
|
||||||
}
|
}
|
||||||
setPlans(prev => ({
|
setPlans(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -121,6 +122,8 @@ export function HousekeepingPage() {
|
|||||||
inspectionAfterClean: ss.inspectionAfterClean,
|
inspectionAfterClean: ss.inspectionAfterClean,
|
||||||
requireCompletionPhoto: ss.requireCompletionPhoto ?? false,
|
requireCompletionPhoto: ss.requireCompletionPhoto ?? false,
|
||||||
emergencyCloseDays: ss.emergencyCloseDays ?? 1,
|
emergencyCloseDays: ss.emergencyCloseDays ?? 1,
|
||||||
|
autoAssign: ss.autoAssign ?? false,
|
||||||
|
autoAssignStrategy: (ss.autoAssignStrategy ?? 'least_loaded') as 'round_robin' | 'least_loaded' | 'first_free',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}).catch(console.error)
|
}).catch(console.error)
|
||||||
@@ -143,8 +146,8 @@ export function HousekeepingPage() {
|
|||||||
checkoutInspection: true, dailyEnabled: true, dailyIntervalDays: 1,
|
checkoutInspection: true, dailyEnabled: true, dailyIntervalDays: 1,
|
||||||
dailyStartFromDay: 1, onDemandEnabled: true, onDemandPriority: 'medium' as 'high' | 'medium' | 'low',
|
dailyStartFromDay: 1, onDemandEnabled: true, onDemandPriority: 'medium' as 'high' | 'medium' | 'low',
|
||||||
deepCleanEnabled: false, deepCleanEveryDays: 7,
|
deepCleanEnabled: false, deepCleanEveryDays: 7,
|
||||||
inspectionAfterClean: true, autoAssign: false, requireCompletionPhoto: false,
|
inspectionAfterClean: true, autoAssign: false, autoAssignStrategy: 'least_loaded' as 'round_robin' | 'least_loaded' | 'first_free',
|
||||||
emergencyCloseDays: 1,
|
requireCompletionPhoto: false, emergencyCloseDays: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const setP = <K extends keyof typeof plans>(k: K, v: typeof plans[K]) =>
|
const setP = <K extends keyof typeof plans>(k: K, v: typeof plans[K]) =>
|
||||||
@@ -160,6 +163,8 @@ export function HousekeepingPage() {
|
|||||||
inspection_after_clean: plans.inspectionAfterClean,
|
inspection_after_clean: plans.inspectionAfterClean,
|
||||||
require_completion_photo: plans.requireCompletionPhoto,
|
require_completion_photo: plans.requireCompletionPhoto,
|
||||||
emergency_close_days: plans.emergencyCloseDays,
|
emergency_close_days: plans.emergencyCloseDays,
|
||||||
|
auto_assign: plans.autoAssign,
|
||||||
|
auto_assign_strategy: plans.autoAssignStrategy,
|
||||||
}
|
}
|
||||||
await api.housekeeping.saveSettings(slug, settings)
|
await api.housekeeping.saveSettings(slug, settings)
|
||||||
setPlanSaved(true)
|
setPlanSaved(true)
|
||||||
@@ -516,14 +521,41 @@ export function HousekeepingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-4">
|
<div className="card p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Автоназначение горничной</p>
|
<p className="font-semibold text-slate-900 dark:text-slate-100">Автоназначение горничной</p>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Автоматически назначать ответственную горничную по расписанию смен</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Автоматически назначать горничную при создании задачи</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle on={plans.autoAssign} onChange={() => setP('autoAssign', !plans.autoAssign)} />
|
<Toggle on={plans.autoAssign} onChange={() => setP('autoAssign', !plans.autoAssign)} />
|
||||||
</div>
|
</div>
|
||||||
|
{plans.autoAssign && (
|
||||||
|
<div className="border-t border-slate-100 dark:border-slate-700 pt-3 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">Стратегия распределения (обычные задачи)</p>
|
||||||
|
<p className="text-[11px] text-slate-400 dark:text-slate-500">Срочные задачи всегда назначаются первой свободной горничной</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{([
|
||||||
|
{ value: 'round_robin', label: 'По кругу', desc: 'Кому сегодня назначено меньше задач' },
|
||||||
|
{ value: 'least_loaded', label: 'Наименее загруженная', desc: 'У кого сейчас меньше активных задач' },
|
||||||
|
{ value: 'first_free', label: 'Первая свободная', desc: 'Кто раньше завершила последнюю задачу' },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setP('autoAssignStrategy', opt.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 rounded-lg border text-xs transition-colors',
|
||||||
|
plans.autoAssignStrategy === opt.value
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-blue-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{opt.label}</span>
|
||||||
|
<span className="text-slate-400 dark:text-slate-500 ml-2">{opt.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user