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
|
||||
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<HkSettings> {
|
||||
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<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 getHotelId = async (slug: string): Promise<string | null> => {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user