feat: minibar — category dropdown, учёт остатков (приходы/списания/инвентаризация/отчёт)

- MinibarSettingsPage: поле категории → выпадающий список с существующими + «Новая...»
- Кнопка «Учёт и остатки» ведёт на /settings/minibar/stock
- MinibarStockPage: 5 вкладок — Остатки, Приходы, Списания, Инвентаризация, Отчёт
- api.ts: добавлены методы и типы для stock/receipts/writeoffs/inventory/report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 15:36:40 +03:00
parent 5b46175c91
commit 1f4def0a34
7 changed files with 1340 additions and 26 deletions

View File

@@ -6,6 +6,8 @@ type SlugItemParam = { Params: { slug: string; itemId: string } }
type SlugTaskParam = { Params: { slug: string; taskId: string } }
type SlugConsumpParam = { Params: { slug: string; consumptionId: string } }
type SlugBookingParam = { Params: { slug: string; bookingId: string } }
type SlugCheckParam = { Params: { slug: string; checkId: string } }
type SlugCheckItemParam = { Params: { slug: string; checkId: string; itemId: string } }
const minibar: FastifyPluginAsync = async (fastify) => {
const getHotelId = async (slug: string): Promise<string | null> => {
@@ -193,6 +195,16 @@ const minibar: FastifyPluginAsync = async (fastify) => {
[hotelId, roomId, bookingId, taskId, request.body.item_id,
request.body.quantity, itemRows[0].price, request.user.sub],
)
// Auto-deduct from stock
await db.query(
`INSERT INTO minibar_stock (hotel_id, item_id, quantity, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (hotel_id, item_id) DO UPDATE
SET quantity = minibar_stock.quantity - EXCLUDED.quantity, updated_at = NOW()`,
[hotelId, request.body.item_id, request.body.quantity],
)
return reply.code(201).send(rows[0])
},
)
@@ -248,6 +260,437 @@ const minibar: FastifyPluginAsync = async (fastify) => {
return rows
},
)
// ── GET /api/hotels/:slug/minibar-stock ───────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/minibar-stock',
{ 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 mi.*, COALESCE(ms.quantity, 0) AS stock_qty
FROM minibar_items mi
LEFT JOIN minibar_stock ms ON ms.item_id = mi.id AND ms.hotel_id = mi.hotel_id
WHERE mi.hotel_id = $1 AND mi.is_active = true
ORDER BY mi.sort_order, mi.name`,
[hotelId],
)
return rows
},
)
// ── GET /api/hotels/:slug/minibar-receipts ────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/minibar-receipts',
{ 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 r.*, COALESCE(
json_agg(json_build_object(
'id', ri.id,
'item_id', ri.item_id,
'item_name', mi.name,
'quantity', ri.quantity,
'cost_price', ri.cost_price
)) FILTER (WHERE ri.id IS NOT NULL), '[]'
) AS items
FROM minibar_receipts r
LEFT JOIN minibar_receipt_items ri ON ri.receipt_id = r.id
LEFT JOIN minibar_items mi ON mi.id = ri.item_id
WHERE r.hotel_id = $1
GROUP BY r.id ORDER BY r.created_at DESC LIMIT 50`,
[hotelId],
)
return rows
},
)
// ── POST /api/hotels/:slug/minibar-receipts ───────────────────────────────
fastify.post<SlugParam & { Body: { supplier?: string; notes?: string; items: Array<{ item_id: string; quantity: number; cost_price?: number }> } }>(
'/api/hotels/:slug/minibar-receipts',
{ 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 { supplier, notes, items } = request.body
if (!items || items.length === 0) return reply.code(400).send({ error: 'Items required' })
const { rows: receiptRows } = await db.query(
`INSERT INTO minibar_receipts (hotel_id, supplier, notes, created_by)
VALUES ($1, $2, $3, $4) RETURNING *`,
[hotelId, supplier ?? null, notes ?? null, request.user.sub],
)
const receipt = receiptRows[0]
for (const item of items) {
await db.query(
`INSERT INTO minibar_receipt_items (receipt_id, item_id, quantity, cost_price)
VALUES ($1, $2, $3, $4)`,
[receipt.id, item.item_id, item.quantity, item.cost_price ?? null],
)
// Upsert stock: add quantity
await db.query(
`INSERT INTO minibar_stock (hotel_id, item_id, quantity, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (hotel_id, item_id) DO UPDATE
SET quantity = minibar_stock.quantity + EXCLUDED.quantity, updated_at = NOW()`,
[hotelId, item.item_id, item.quantity],
)
}
// Return receipt with items
const { rows } = await db.query(
`SELECT r.*, COALESCE(
json_agg(json_build_object(
'id', ri.id,
'item_id', ri.item_id,
'item_name', mi.name,
'quantity', ri.quantity,
'cost_price', ri.cost_price
)) FILTER (WHERE ri.id IS NOT NULL), '[]'
) AS items
FROM minibar_receipts r
LEFT JOIN minibar_receipt_items ri ON ri.receipt_id = r.id
LEFT JOIN minibar_items mi ON mi.id = ri.item_id
WHERE r.id = $1
GROUP BY r.id`,
[receipt.id],
)
return reply.code(201).send(rows[0])
},
)
// ── GET /api/hotels/:slug/minibar-writeoffs ───────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/minibar-writeoffs',
{ 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 w.*, COALESCE(
json_agg(json_build_object(
'id', wi.id,
'item_id', wi.item_id,
'item_name', mi.name,
'quantity', wi.quantity
)) FILTER (WHERE wi.id IS NOT NULL), '[]'
) AS items
FROM minibar_writeoffs w
LEFT JOIN minibar_writeoff_items wi ON wi.writeoff_id = w.id
LEFT JOIN minibar_items mi ON mi.id = wi.item_id
WHERE w.hotel_id = $1
GROUP BY w.id ORDER BY w.created_at DESC LIMIT 50`,
[hotelId],
)
return rows
},
)
// ── POST /api/hotels/:slug/minibar-writeoffs ──────────────────────────────
fastify.post<SlugParam & { Body: { reason: string; notes?: string; items: Array<{ item_id: string; quantity: number }> } }>(
'/api/hotels/:slug/minibar-writeoffs',
{ 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 { reason, notes, items } = request.body
if (!reason) return reply.code(400).send({ error: 'Reason required' })
if (!items || items.length === 0) return reply.code(400).send({ error: 'Items required' })
const { rows: writeoffRows } = await db.query(
`INSERT INTO minibar_writeoffs (hotel_id, reason, notes, created_by)
VALUES ($1, $2, $3, $4) RETURNING *`,
[hotelId, reason, notes ?? null, request.user.sub],
)
const writeoff = writeoffRows[0]
for (const item of items) {
await db.query(
`INSERT INTO minibar_writeoff_items (writeoff_id, item_id, quantity)
VALUES ($1, $2, $3)`,
[writeoff.id, item.item_id, item.quantity],
)
// Deduct from stock
await db.query(
`INSERT INTO minibar_stock (hotel_id, item_id, quantity, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (hotel_id, item_id) DO UPDATE
SET quantity = minibar_stock.quantity - EXCLUDED.quantity, updated_at = NOW()`,
[hotelId, item.item_id, item.quantity],
)
}
// Return writeoff with items
const { rows } = await db.query(
`SELECT w.*, COALESCE(
json_agg(json_build_object(
'id', wi.id,
'item_id', wi.item_id,
'item_name', mi.name,
'quantity', wi.quantity
)) FILTER (WHERE wi.id IS NOT NULL), '[]'
) AS items
FROM minibar_writeoffs w
LEFT JOIN minibar_writeoff_items wi ON wi.writeoff_id = w.id
LEFT JOIN minibar_items mi ON mi.id = wi.item_id
WHERE w.id = $1
GROUP BY w.id`,
[writeoff.id],
)
return reply.code(201).send(rows[0])
},
)
// ── GET /api/hotels/:slug/minibar-inventory ───────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/minibar-inventory',
{ 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 * FROM minibar_inventory_checks
WHERE hotel_id = $1
ORDER BY created_at DESC LIMIT 50`,
[hotelId],
)
return rows
},
)
// ── POST /api/hotels/:slug/minibar-inventory ──────────────────────────────
fastify.post<SlugParam & { Body: { checked_at?: string; notes?: string } }>(
'/api/hotels/:slug/minibar-inventory',
{ 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 { checked_at, notes } = request.body
const { rows: checkRows } = await db.query(
`INSERT INTO minibar_inventory_checks (hotel_id, checked_at, notes, created_by)
VALUES ($1, $2, $3, $4) RETURNING *`,
[hotelId, checked_at ?? new Date().toISOString().slice(0, 10), notes ?? null, request.user.sub],
)
const check = checkRows[0]
// Pre-fill expected_qty from current stock for all active items
const { rows: stockRows } = await db.query(
`SELECT mi.id AS item_id, COALESCE(ms.quantity, 0) AS stock_qty
FROM minibar_items mi
LEFT JOIN minibar_stock ms ON ms.item_id = mi.id AND ms.hotel_id = mi.hotel_id
WHERE mi.hotel_id = $1 AND mi.is_active = true`,
[hotelId],
)
for (const row of stockRows) {
await db.query(
`INSERT INTO minibar_inventory_items (check_id, item_id, expected_qty, actual_qty)
VALUES ($1, $2, $3, 0)`,
[check.id, row.item_id, row.stock_qty],
)
}
// Return check with items
const { rows } = await db.query(
`SELECT ic.*, COALESCE(
json_agg(json_build_object(
'id', ii.id,
'item_id', ii.item_id,
'item_name', mi.name,
'category', mi.category,
'expected_qty', ii.expected_qty,
'actual_qty', ii.actual_qty
) ORDER BY mi.sort_order, mi.name) FILTER (WHERE ii.id IS NOT NULL), '[]'
) AS items
FROM minibar_inventory_checks ic
LEFT JOIN minibar_inventory_items ii ON ii.check_id = ic.id
LEFT JOIN minibar_items mi ON mi.id = ii.item_id
WHERE ic.id = $1
GROUP BY ic.id`,
[check.id],
)
return reply.code(201).send(rows[0])
},
)
// ── GET /api/hotels/:slug/minibar-inventory/:checkId ─────────────────────
fastify.get<SlugCheckParam>(
'/api/hotels/:slug/minibar-inventory/:checkId',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, checkId } = 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 ic.*, COALESCE(
json_agg(json_build_object(
'id', ii.id,
'item_id', ii.item_id,
'item_name', mi.name,
'category', mi.category,
'expected_qty', ii.expected_qty,
'actual_qty', ii.actual_qty
) ORDER BY mi.sort_order, mi.name) FILTER (WHERE ii.id IS NOT NULL), '[]'
) AS items
FROM minibar_inventory_checks ic
LEFT JOIN minibar_inventory_items ii ON ii.check_id = ic.id
LEFT JOIN minibar_items mi ON mi.id = ii.item_id
WHERE ic.id = $1 AND ic.hotel_id = $2
GROUP BY ic.id`,
[checkId, hotelId],
)
if (!rows[0]) return reply.code(404).send({ error: 'Check not found' })
return rows[0]
},
)
// ── PATCH /api/hotels/:slug/minibar-inventory/:checkId/items/:itemId ──────
fastify.patch<SlugCheckItemParam & { Body: { actual_qty: number } }>(
'/api/hotels/:slug/minibar-inventory/:checkId/items/:itemId',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, checkId, itemId } = 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' })
// Verify check belongs to hotel
const { rows: checkRows } = await db.query(
'SELECT id FROM minibar_inventory_checks WHERE id = $1 AND hotel_id = $2 AND is_complete = false',
[checkId, hotelId],
)
if (!checkRows[0]) return reply.code(404).send({ error: 'Check not found or already complete' })
const { rowCount } = await db.query(
`UPDATE minibar_inventory_items SET actual_qty = $1
WHERE check_id = $2 AND item_id = $3`,
[request.body.actual_qty, checkId, itemId],
)
if (!rowCount) return reply.code(404).send({ error: 'Item not found in check' })
return { ok: true }
},
)
// ── POST /api/hotels/:slug/minibar-inventory/:checkId/complete ────────────
fastify.post<SlugCheckParam>(
'/api/hotels/:slug/minibar-inventory/:checkId/complete',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, checkId } = 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 { rows: checkRows } = await db.query(
'SELECT id FROM minibar_inventory_checks WHERE id = $1 AND hotel_id = $2 AND is_complete = false',
[checkId, hotelId],
)
if (!checkRows[0]) return reply.code(404).send({ error: 'Check not found or already complete' })
// Get inventory items
const { rows: invItems } = await db.query(
'SELECT item_id, actual_qty FROM minibar_inventory_items WHERE check_id = $1',
[checkId],
)
// Adjust stock to actual counts
for (const item of invItems) {
await db.query(
`INSERT INTO minibar_stock (hotel_id, item_id, quantity, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (hotel_id, item_id) DO UPDATE
SET quantity = $3, updated_at = NOW()`,
[hotelId, item.item_id, item.actual_qty],
)
}
// Mark check as complete
await db.query(
'UPDATE minibar_inventory_checks SET is_complete = true WHERE id = $1',
[checkId],
)
return { ok: true }
},
)
// ── GET /api/hotels/:slug/minibar-report ──────────────────────────────────
fastify.get<SlugParam & { Querystring: { start?: string; end?: string } }>(
'/api/hotels/:slug/minibar-report',
{ 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 { start, end } = request.query as { start?: string; end?: string }
const startDate = start ?? new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10)
const endDate = end ?? new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1).toISOString().slice(0, 10)
const { rows } = await db.query(
`SELECT mi.name, mi.category,
SUM(mc.quantity) AS total_qty,
SUM(mc.quantity * mc.price_per_unit) AS total_revenue
FROM minibar_consumptions mc
JOIN minibar_items mi ON mi.id = mc.item_id
WHERE mc.hotel_id = $1 AND mc.recorded_at >= $2 AND mc.recorded_at < $3
GROUP BY mi.id, mi.name, mi.category
ORDER BY total_revenue DESC`,
[hotelId, startDate, endDate],
)
return rows
},
)
}
export default minibar