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:
64
backend/migrations/063_minibar_stock.sql
Normal file
64
backend/migrations/063_minibar_stock.sql
Normal file
@@ -0,0 +1,64 @@
|
||||
CREATE TABLE minibar_stock (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
|
||||
item_id UUID NOT NULL REFERENCES minibar_items(id) ON DELETE CASCADE,
|
||||
quantity NUMERIC(10,3) NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(hotel_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE minibar_receipts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
|
||||
supplier TEXT,
|
||||
notes TEXT,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE minibar_receipt_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
receipt_id UUID NOT NULL REFERENCES minibar_receipts(id) ON DELETE CASCADE,
|
||||
item_id UUID NOT NULL REFERENCES minibar_items(id),
|
||||
quantity NUMERIC(10,3) NOT NULL,
|
||||
cost_price NUMERIC(10,2)
|
||||
);
|
||||
|
||||
CREATE TABLE minibar_writeoffs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
|
||||
reason TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE minibar_writeoff_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
writeoff_id UUID NOT NULL REFERENCES minibar_writeoffs(id) ON DELETE CASCADE,
|
||||
item_id UUID NOT NULL REFERENCES minibar_items(id),
|
||||
quantity NUMERIC(10,3) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE minibar_inventory_checks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
|
||||
checked_at DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
notes TEXT,
|
||||
is_complete BOOLEAN NOT NULL DEFAULT false,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE minibar_inventory_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
check_id UUID NOT NULL REFERENCES minibar_inventory_checks(id) ON DELETE CASCADE,
|
||||
item_id UUID NOT NULL REFERENCES minibar_items(id),
|
||||
expected_qty NUMERIC(10,3) NOT NULL DEFAULT 0,
|
||||
actual_qty NUMERIC(10,3) NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX ON minibar_stock(hotel_id);
|
||||
CREATE INDEX ON minibar_receipts(hotel_id);
|
||||
CREATE INDEX ON minibar_writeoffs(hotel_id);
|
||||
CREATE INDEX ON minibar_inventory_checks(hotel_id);
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user