feat: enforce checklist/minibar completion before marking task done
- ApiError now carries errors[] array from 422 responses - updateStatus re-throws 422 so TaskCard can surface errors - TaskCard: minibarCheckedLocal state + 'Подтвердить проверку минибара' button - TaskCard: async handleComplete with completionErrors display - Backend validation errors shown inline in completion panel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -83,7 +83,7 @@ const checklists: FastifyPluginAsync = async (fastify) => {
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const allowed = ['name', 'task_type', 'is_active', 'sort_order']
|
||||
const allowed = ['name', 'task_type', 'is_active', 'sort_order', 'require_before_complete']
|
||||
const updates: string[] = []
|
||||
const values: unknown[] = []
|
||||
let idx = 1
|
||||
@@ -369,4 +369,48 @@ const checklists: FastifyPluginAsync = async (fastify) => {
|
||||
)
|
||||
}
|
||||
|
||||
// ── GET /api/hotels/:slug/housekeeping-rules ──────────────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/housekeeping-rules',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT require_minibar_check FROM hotel_housekeeping_rules WHERE hotel_id = $1',
|
||||
[hotelId],
|
||||
)
|
||||
return { requireMinibarCheck: rows[0]?.require_minibar_check ?? false }
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/housekeeping-rules ─────────────────────────────
|
||||
fastify.patch<SlugParam & { Body: { require_minibar_check?: boolean } }>(
|
||||
'/api/hotels/:slug/housekeeping-rules',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { require_minibar_check } = request.body
|
||||
await db.query(
|
||||
`INSERT INTO hotel_housekeeping_rules (hotel_id, require_minibar_check)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (hotel_id) DO UPDATE SET require_minibar_check = $2`,
|
||||
[hotelId, require_minibar_check ?? false],
|
||||
)
|
||||
return { requireMinibarCheck: require_minibar_check ?? false }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default checklists
|
||||
|
||||
@@ -159,7 +159,67 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
||||
updates.push(`completed_at = NULL`)
|
||||
}
|
||||
|
||||
// Also allow updating minibar_checked flag
|
||||
if ((request.body as Record<string, unknown>).minibar_checked !== undefined) {
|
||||
updates.push(`minibar_checked = $${idx}`)
|
||||
values.push((request.body as Record<string, unknown>).minibar_checked)
|
||||
idx++
|
||||
}
|
||||
|
||||
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
||||
|
||||
// ── Validate completion requirements ──────────────────────────────────
|
||||
if (request.body.status === 'done') {
|
||||
// Get current task (need task_type, minibar_checked, room_id)
|
||||
const { rows: taskRows } = await db.query(
|
||||
`SELECT t.*, t.minibar_checked FROM housekeeping_tasks t WHERE t.id = $1 AND t.hotel_id = $2`,
|
||||
[id, hotelId],
|
||||
)
|
||||
const currentTask = taskRows[0]
|
||||
if (!currentTask) return reply.code(404).send({ error: 'Task not found' })
|
||||
|
||||
// Merge minibar_checked from request body (may be set in same request)
|
||||
const minibarCheckedNow = (request.body as Record<string, unknown>).minibar_checked ?? currentTask.minibar_checked
|
||||
|
||||
const errors: string[] = []
|
||||
|
||||
// 1. Check checklist requirements
|
||||
const { rows: templates } = await db.query(
|
||||
`SELECT id, name FROM checklist_templates
|
||||
WHERE hotel_id = $1 AND is_active = true AND require_before_complete = true
|
||||
AND (task_type IS NULL OR task_type = $2)`,
|
||||
[hotelId, currentTask.type ?? null],
|
||||
)
|
||||
|
||||
for (const tpl of templates) {
|
||||
const { rows: items } = await db.query(
|
||||
`SELECT ci.id, ci.text,
|
||||
cc.id IS NOT NULL AS is_completed
|
||||
FROM checklist_items ci
|
||||
LEFT JOIN checklist_completions cc ON cc.item_id = ci.id AND cc.task_id = $1
|
||||
WHERE ci.template_id = $2
|
||||
ORDER BY ci.sort_order`,
|
||||
[id, tpl.id],
|
||||
)
|
||||
const unchecked = items.filter((i: Record<string, unknown>) => !i.is_completed)
|
||||
if (unchecked.length > 0) {
|
||||
errors.push(`Чек-лист «${tpl.name}»: не выполнено ${unchecked.length} из ${items.length} пунктов`)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check minibar requirement
|
||||
const { rows: rules } = await db.query(
|
||||
`SELECT require_minibar_check FROM hotel_housekeeping_rules WHERE hotel_id = $1`,
|
||||
[hotelId],
|
||||
)
|
||||
if (rules[0]?.require_minibar_check && !minibarCheckedNow) {
|
||||
errors.push('Минибар не проверен')
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return reply.code(422).send({ error: errors.join('; '), errors })
|
||||
}
|
||||
}
|
||||
values.push(id, hotelId)
|
||||
|
||||
const { rows: updated } = await db.query(
|
||||
|
||||
Reference in New Issue
Block a user