diff --git a/src/routes/admin/warehouse.ts b/src/routes/admin/warehouse.ts index 7dfc442..8f0e7eb 100644 --- a/src/routes/admin/warehouse.ts +++ b/src/routes/admin/warehouse.ts @@ -1,14 +1,26 @@ import { FastifyInstance } from "fastify"; +import multipart from "@fastify/multipart"; import prisma from "../../config/database"; +import { config } from "../../config/env"; import { requireAuth, requirePermission } from "../../middleware/auth"; import { logAudit } from "../../services/audit"; -import { success, error, parseId } from "../../utils/response"; +import { success, error, parseId, paginated } from "../../utils/response"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parseBody } from "../../schemas/common"; +import { nasFinancialsManager } from "../../services/nas-financials-manager"; import { getItemAvailableQty, getItemTotalStock, getItemStockValue, + confirmReceipt, + cancelReceipt, + confirmIssue, + cancelIssue, + selectFifoBatches, + createReservation, + cancelReservation, + confirmInventorySession, + getBelowMinimumItems, } from "../../services/warehouse.service"; import { CreateCategorySchema, @@ -19,6 +31,13 @@ import { UpdateLocationSchema, CreateItemSchema, UpdateItemSchema, + CreateReceiptSchema, + UpdateReceiptSchema, + CreateIssueSchema, + UpdateIssueSchema, + CreateReservationSchema, + CreateInventorySessionSchema, + UpdateInventorySessionSchema, } from "../../schemas/warehouse.schema"; export default async function warehouseRoutes( @@ -203,11 +222,11 @@ export default async function warehouseRoutes( prisma.sklad_suppliers.count({ where }), ]); - return reply.send({ - success: true, - data: suppliers, - pagination: buildPaginationMeta(total, page, limit), - }); + return paginated( + reply, + suppliers, + buildPaginationMeta(total, page, limit), + ); }, ); @@ -616,11 +635,11 @@ export default async function warehouseRoutes( }), ); - return reply.send({ - success: true, - data: enriched, - pagination: buildPaginationMeta(total, page, limit), - }); + return paginated( + reply, + enriched, + buildPaginationMeta(total, page, limit), + ); }, ); @@ -892,4 +911,1418 @@ export default async function warehouseRoutes( return success(reply, batches); }, ); + + // ============================================================= + // RECEIPTS + // ============================================================= + + await fastify.register(multipart, { + limits: { fileSize: config.nas.maxUploadSize }, + }); + + // GET /receipts — paginated list, filter by status/supplier_id/date_from/date_to + fastify.get( + "/receipts", + { preHandler: requirePermission("warehouse.view") }, + async (request, reply) => { + const { page, limit, skip, search } = parsePagination( + request.query as Record, + ); + const query = request.query as Record; + + const where: Record[] = []; + + if (query.status) { + where.push({ status: query.status }); + } + if (query.supplier_id) { + where.push({ supplier_id: Number(query.supplier_id) }); + } + if (query.date_from) { + where.push({ created_at: { gte: new Date(String(query.date_from)) } }); + } + if (query.date_to) { + where.push({ created_at: { lte: new Date(String(query.date_to)) } }); + } + if (search) { + where.push({ + OR: [ + { receipt_number: { contains: search } }, + { delivery_note_number: { contains: search } }, + { notes: { contains: search } }, + ], + }); + } + + const filter = where.length > 0 ? { AND: where } : {}; + + const [receipts, total] = await Promise.all([ + prisma.sklad_receipts.findMany({ + where: filter, + skip, + take: limit, + orderBy: { id: "desc" }, + include: { + supplier: { select: { id: true, name: true } }, + received_by_user: { + select: { id: true, first_name: true, last_name: true }, + }, + _count: { select: { lines: true } }, + }, + }), + prisma.sklad_receipts.count({ where: filter }), + ]); + + return paginated( + reply, + receipts, + buildPaginationMeta(total, page, limit), + ); + }, + ); + + // GET /receipts/:id — detail with supplier, received_by_user, lines, attachments + fastify.get<{ Params: { id: string } }>( + "/receipts/:id", + { preHandler: requirePermission("warehouse.view") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const receipt = await prisma.sklad_receipts.findUnique({ + where: { id }, + include: { + supplier: { select: { id: true, name: true } }, + received_by_user: { + select: { id: true, first_name: true, last_name: true }, + }, + lines: { + include: { + item: { select: { id: true, name: true, unit: true } }, + location: { select: { id: true, code: true, name: true } }, + }, + }, + attachments: true, + }, + }); + if (!receipt) return error(reply, "Příjem nenalezen", 404); + + return success(reply, receipt); + }, + ); + + // POST /receipts — create DRAFT with lines + fastify.post( + "/receipts", + { preHandler: requirePermission("warehouse.operate") }, + async (request, reply) => { + const parsed = parseBody(CreateReceiptSchema, request.body); + if ("error" in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; + const authUserId = request.authData!.userId; + + const receipt = await prisma.sklad_receipts.create({ + data: { + supplier_id: body.supplier_id ?? null, + delivery_note_number: body.delivery_note_number ?? null, + delivery_note_date: body.delivery_note_date + ? new Date(body.delivery_note_date) + : null, + notes: body.notes ?? null, + status: "DRAFT", + received_by: authUserId, + lines: { + create: body.lines.map((line) => ({ + item_id: line.item_id, + quantity: line.quantity, + unit_price: line.unit_price, + location_id: line.location_id ?? null, + notes: line.notes ?? null, + })), + }, + }, + include: { + supplier: { select: { id: true, name: true } }, + lines: { + include: { + item: { select: { id: true, name: true, unit: true } }, + }, + }, + }, + }); + + await logAudit({ + request, + authData: request.authData, + action: "create", + entityType: "warehouse_receipt", + entityId: receipt.id, + description: `Vytvořen příjem #${receipt.id}`, + newValues: { + supplier_id: receipt.supplier_id, + status: receipt.status, + line_count: body.lines.length, + }, + }); + + return success(reply, receipt, 201, "Příjem byl vytvořen"); + }, + ); + + // PUT /receipts/:id — update header + replace lines (DRAFT only) + fastify.put<{ Params: { id: string } }>( + "/receipts/:id", + { preHandler: requirePermission("warehouse.operate") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const parsed = parseBody(UpdateReceiptSchema, request.body); + if ("error" in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; + + const existing = await prisma.sklad_receipts.findUnique({ + where: { id }, + include: { lines: true }, + }); + if (!existing) return error(reply, "Příjem nenalezen", 404); + if (existing.status !== "DRAFT") { + return error(reply, "Příjem lze upravit pouze ve stavu DRAFT", 400); + } + + const updateData: Record = {}; + if (body.supplier_id !== undefined) + updateData.supplier_id = body.supplier_id; + if (body.delivery_note_number !== undefined) + updateData.delivery_note_number = body.delivery_note_number; + if (body.delivery_note_date !== undefined) + updateData.delivery_note_date = body.delivery_note_date + ? new Date(body.delivery_note_date) + : null; + if (body.notes !== undefined) updateData.notes = body.notes; + updateData.modified_at = new Date(); + + // Replace lines if provided + if (body.lines) { + await prisma.sklad_receipt_lines.deleteMany({ + where: { receipt_id: id }, + }); + await prisma.sklad_receipt_lines.createMany({ + data: body.lines.map((line) => ({ + receipt_id: id, + item_id: line.item_id, + quantity: line.quantity, + unit_price: line.unit_price, + location_id: line.location_id ?? null, + notes: line.notes ?? null, + })), + }); + } + + const updated = await prisma.sklad_receipts.update({ + where: { id }, + data: updateData, + include: { + supplier: { select: { id: true, name: true } }, + lines: { + include: { + item: { select: { id: true, name: true, unit: true } }, + location: { select: { id: true, code: true, name: true } }, + }, + }, + }, + }); + + await logAudit({ + request, + authData: request.authData, + action: "update", + entityType: "warehouse_receipt", + entityId: id, + description: `Upraven příjem #${id}`, + oldValues: { + supplier_id: existing.supplier_id, + delivery_note_number: existing.delivery_note_number, + notes: existing.notes, + line_count: existing.lines.length, + }, + newValues: { + supplier_id: updated.supplier_id, + delivery_note_number: updated.delivery_note_number, + notes: updated.notes, + line_count: updated.lines.length, + }, + }); + + return success(reply, updated, 200, "Příjem byl uložen"); + }, + ); + + // POST /receipts/:id/confirm — confirm receipt + fastify.post<{ Params: { id: string } }>( + "/receipts/:id/confirm", + { preHandler: requirePermission("warehouse.operate") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const authUserId = request.authData!.userId; + const result = await confirmReceipt(id, authUserId); + if ("error" in result) + return error(reply, result.error, result.status ?? 400); + + await logAudit({ + request, + authData: request.authData, + action: "confirm", + entityType: "warehouse_receipt", + entityId: id, + description: `Potvrzen příjem ${result.data.receipt_number}`, + newValues: { + status: "CONFIRMED", + receipt_number: result.data.receipt_number, + }, + }); + + return success(reply, result.data, 200, "Příjem byl potvrzen"); + }, + ); + + // POST /receipts/:id/cancel — cancel receipt + fastify.post<{ Params: { id: string } }>( + "/receipts/:id/cancel", + { preHandler: requirePermission("warehouse.operate") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const result = await cancelReceipt(id); + if ("error" in result) + return error(reply, result.error, result.status ?? 400); + + await logAudit({ + request, + authData: request.authData, + action: "cancel", + entityType: "warehouse_receipt", + entityId: id, + description: `Zrušen příjem #${id}`, + newValues: { status: "CANCELLED" }, + }); + + return success(reply, result.data, 200, "Příjem byl zrušen"); + }, + ); + + // POST /receipts/:id/attachments — upload attachment + fastify.post<{ Params: { id: string } }>( + "/receipts/:id/attachments", + { preHandler: requirePermission("warehouse.operate") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const receipt = await prisma.sklad_receipts.findUnique({ + where: { id }, + }); + if (!receipt) return error(reply, "Příjem nenalezen", 404); + + const part = await request.file(); + if (!part) return error(reply, "Soubor nebyl nahrán", 400); + + const buffer = await part.toBuffer(); + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + + if (!nasFinancialsManager.isConfigured()) { + return error(reply, "Úložiště souborů není dostupné", 503); + } + + const result = nasFinancialsManager.saveReceivedInvoice( + part.filename, + year, + month, + buffer, + ); + if ("error" in result) return error(reply, result.error, 500); + + const attachment = await prisma.sklad_receipt_attachments.create({ + data: { + receipt_id: id, + file_name: part.filename, + file_mime: part.mimetype, + file_size: buffer.length, + file_path: result.filePath, + }, + }); + + await logAudit({ + request, + authData: request.authData, + action: "upload", + entityType: "warehouse_receipt_attachment", + entityId: attachment.id, + description: `Nahrána příloha k příjmu #${id}: ${part.filename}`, + newValues: { + file_name: attachment.file_name, + file_size: attachment.file_size, + }, + }); + + return success(reply, attachment, 201, "Příloha byla nahrána"); + }, + ); + + // DELETE /receipts/:id/attachments/:attachmentId — delete attachment + fastify.delete<{ Params: { id: string; attachmentId: string } }>( + "/receipts/:id/attachments/:attachmentId", + { preHandler: requirePermission("warehouse.operate") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + const attachmentId = parseId(request.params.attachmentId, reply); + if (attachmentId === null) return; + + const attachment = await prisma.sklad_receipt_attachments.findUnique({ + where: { id: attachmentId }, + }); + if (!attachment) return error(reply, "Příloha nenalezena", 404); + if (attachment.receipt_id !== id) { + return error(reply, "Příloha nepatří k tomuto příjmu", 400); + } + + // Delete file from NAS + nasFinancialsManager.deleteReceivedInvoice(attachment.file_path); + + await prisma.sklad_receipt_attachments.delete({ + where: { id: attachmentId }, + }); + + await logAudit({ + request, + authData: request.authData, + action: "delete", + entityType: "warehouse_receipt_attachment", + entityId: attachmentId, + description: `Smazána příloha příjmu #${id}: ${attachment.file_name}`, + oldValues: { + file_name: attachment.file_name, + file_path: attachment.file_path, + }, + }); + + return success(reply, null, 200, "Příloha smazána"); + }, + ); + + // ============================================================= + // ISSUES + // ============================================================= + + // GET /issues — paginated list, filter by status/project_id/date_from/date_to + fastify.get( + "/issues", + { preHandler: requirePermission("warehouse.view") }, + async (request, reply) => { + const { page, limit, skip, search } = parsePagination( + request.query as Record, + ); + const query = request.query as Record; + + const where: Record[] = []; + + if (query.status) { + where.push({ status: query.status }); + } + if (query.project_id) { + where.push({ project_id: Number(query.project_id) }); + } + if (query.date_from) { + where.push({ created_at: { gte: new Date(String(query.date_from)) } }); + } + if (query.date_to) { + where.push({ created_at: { lte: new Date(String(query.date_to)) } }); + } + if (search) { + where.push({ + OR: [ + { issue_number: { contains: search } }, + { notes: { contains: search } }, + ], + }); + } + + const filter = where.length > 0 ? { AND: where } : {}; + + const [issues, total] = await Promise.all([ + prisma.sklad_issues.findMany({ + where: filter, + skip, + take: limit, + orderBy: { id: "desc" }, + include: { + project: { select: { id: true, name: true } }, + issued_by_user: { + select: { id: true, first_name: true, last_name: true }, + }, + _count: { select: { lines: true } }, + }, + }), + prisma.sklad_issues.count({ where: filter }), + ]); + + return paginated(reply, issues, buildPaginationMeta(total, page, limit)); + }, + ); + + // GET /issues/:id — detail with project, issued_by_user, lines + fastify.get<{ Params: { id: string } }>( + "/issues/:id", + { preHandler: requirePermission("warehouse.view") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const issue = await prisma.sklad_issues.findUnique({ + where: { id }, + include: { + project: { select: { id: true, name: true } }, + issued_by_user: { + select: { id: true, first_name: true, last_name: true }, + }, + lines: { + include: { + item: { select: { id: true, name: true, unit: true } }, + batch: { + select: { id: true, received_at: true, unit_price: true }, + }, + location: { select: { id: true, code: true, name: true } }, + reservation: { + select: { + id: true, + quantity: true, + remaining_qty: true, + status: true, + }, + }, + }, + }, + }, + }); + if (!issue) return error(reply, "Výdej nenalezen", 404); + + return success(reply, issue); + }, + ); + + // POST /issues — create DRAFT with lines, auto-FIFO for lines without batch_id + fastify.post( + "/issues", + { preHandler: requirePermission("warehouse.operate") }, + async (request, reply) => { + const parsed = parseBody(CreateIssueSchema, request.body); + if ("error" in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; + const authUserId = request.authData!.userId; + + // Resolve FIFO batches for lines without batch_id + const resolvedLines: Array<{ + item_id: number; + batch_id: number; + quantity: number; + location_id: number | null; + reservation_id: number | null; + notes: string | null; + }> = []; + + for (const line of body.lines) { + if (line.batch_id != null) { + resolvedLines.push({ + item_id: line.item_id, + batch_id: line.batch_id, + quantity: line.quantity, + location_id: line.location_id ?? null, + reservation_id: line.reservation_id ?? null, + notes: line.notes ?? null, + }); + } else { + // Auto-select FIFO batches + try { + const fifoBatches = await selectFifoBatches( + line.item_id, + line.quantity, + ); + for (const fb of fifoBatches) { + resolvedLines.push({ + item_id: line.item_id, + batch_id: fb.batchId, + quantity: fb.qty, + location_id: line.location_id ?? null, + reservation_id: line.reservation_id ?? null, + notes: line.notes ?? null, + }); + } + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Nedostatečné množství na skladě"; + return error(reply, message, 400); + } + } + } + + const issue = await prisma.sklad_issues.create({ + data: { + project_id: body.project_id, + notes: body.notes ?? null, + status: "DRAFT", + issued_by: authUserId, + lines: { + create: resolvedLines, + }, + }, + include: { + project: { select: { id: true, name: true } }, + lines: { + include: { + item: { select: { id: true, name: true, unit: true } }, + }, + }, + }, + }); + + await logAudit({ + request, + authData: request.authData, + action: "create", + entityType: "warehouse_issue", + entityId: issue.id, + description: `Vytvořen výdej #${issue.id}`, + newValues: { + project_id: issue.project_id, + status: issue.status, + line_count: resolvedLines.length, + }, + }); + + return success(reply, issue, 201, "Výdej byl vytvořen"); + }, + ); + + // PUT /issues/:id — update (DRAFT only), auto-FIFO for lines without batch_id + fastify.put<{ Params: { id: string } }>( + "/issues/:id", + { preHandler: requirePermission("warehouse.operate") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const parsed = parseBody(UpdateIssueSchema, request.body); + if ("error" in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; + + const existing = await prisma.sklad_issues.findUnique({ + where: { id }, + include: { lines: true }, + }); + if (!existing) return error(reply, "Výdej nenalezen", 404); + if (existing.status !== "DRAFT") { + return error(reply, "Výdej lze upravit pouze ve stavu DRAFT", 400); + } + + const updateData: Record = {}; + if (body.project_id !== undefined) + updateData.project_id = body.project_id; + if (body.notes !== undefined) updateData.notes = body.notes; + updateData.modified_at = new Date(); + + // Replace lines if provided + if (body.lines) { + // Resolve FIFO batches for lines without batch_id + const resolvedLines: Array<{ + item_id: number; + batch_id: number; + quantity: number; + location_id: number | null; + reservation_id: number | null; + notes: string | null; + }> = []; + + for (const line of body.lines) { + if (line.batch_id != null) { + resolvedLines.push({ + item_id: line.item_id, + batch_id: line.batch_id, + quantity: line.quantity, + location_id: line.location_id ?? null, + reservation_id: line.reservation_id ?? null, + notes: line.notes ?? null, + }); + } else { + try { + const fifoBatches = await selectFifoBatches( + line.item_id, + line.quantity, + ); + for (const fb of fifoBatches) { + resolvedLines.push({ + item_id: line.item_id, + batch_id: fb.batchId, + quantity: fb.qty, + location_id: line.location_id ?? null, + reservation_id: line.reservation_id ?? null, + notes: line.notes ?? null, + }); + } + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Nedostatečné množství na skladě"; + return error(reply, message, 400); + } + } + } + + await prisma.sklad_issue_lines.deleteMany({ + where: { issue_id: id }, + }); + await prisma.sklad_issue_lines.createMany({ + data: resolvedLines.map((line) => ({ + issue_id: id, + item_id: line.item_id, + batch_id: line.batch_id, + quantity: line.quantity, + location_id: line.location_id, + reservation_id: line.reservation_id, + notes: line.notes, + })), + }); + } + + const updated = await prisma.sklad_issues.update({ + where: { id }, + data: updateData, + include: { + project: { select: { id: true, name: true } }, + lines: { + include: { + item: { select: { id: true, name: true, unit: true } }, + }, + }, + }, + }); + + await logAudit({ + request, + authData: request.authData, + action: "update", + entityType: "warehouse_issue", + entityId: id, + description: `Upraven výdej #${id}`, + oldValues: { + project_id: existing.project_id, + notes: existing.notes, + line_count: existing.lines.length, + }, + newValues: { + project_id: updated.project_id, + notes: updated.notes, + line_count: updated.lines.length, + }, + }); + + return success(reply, updated, 200, "Výdej byl uložen"); + }, + ); + + // POST /issues/:id/confirm — confirm issue + fastify.post<{ Params: { id: string } }>( + "/issues/:id/confirm", + { preHandler: requirePermission("warehouse.operate") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const authUserId = request.authData!.userId; + const result = await confirmIssue(id, authUserId); + if ("error" in result) + return error(reply, result.error, result.status ?? 400); + + await logAudit({ + request, + authData: request.authData, + action: "confirm", + entityType: "warehouse_issue", + entityId: id, + description: `Potvrzen výdej ${result.data.issue_number}`, + newValues: { + status: "CONFIRMED", + issue_number: result.data.issue_number, + }, + }); + + return success(reply, result.data, 200, "Výdej byl potvrzen"); + }, + ); + + // POST /issues/:id/cancel — cancel issue + fastify.post<{ Params: { id: string } }>( + "/issues/:id/cancel", + { preHandler: requirePermission("warehouse.operate") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const result = await cancelIssue(id); + if ("error" in result) + return error(reply, result.error, result.status ?? 400); + + await logAudit({ + request, + authData: request.authData, + action: "cancel", + entityType: "warehouse_issue", + entityId: id, + description: `Zrušen výdej #${id}`, + newValues: { status: "CANCELLED" }, + }); + + return success(reply, result.data, 200, "Výdej byl zrušen"); + }, + ); + + // ============================================================= + // RESERVATIONS + // ============================================================= + + // GET /reservations — paginated list, filter by item_id/project_id/status + fastify.get( + "/reservations", + { preHandler: requirePermission("warehouse.view") }, + async (request, reply) => { + const { page, limit, skip } = parsePagination( + request.query as Record, + ); + const query = request.query as Record; + + const where: Record[] = []; + + if (query.item_id) { + where.push({ item_id: Number(query.item_id) }); + } + if (query.project_id) { + where.push({ project_id: Number(query.project_id) }); + } + if (query.status) { + where.push({ status: query.status }); + } + + const filter = where.length > 0 ? { AND: where } : {}; + + const [reservations, total] = await Promise.all([ + prisma.sklad_reservations.findMany({ + where: filter, + skip, + take: limit, + orderBy: { id: "desc" }, + include: { + item: { select: { id: true, name: true, unit: true } }, + project: { select: { id: true, name: true } }, + reserved_by_user: { + select: { id: true, first_name: true, last_name: true }, + }, + }, + }), + prisma.sklad_reservations.count({ where: filter }), + ]); + + return paginated( + reply, + reservations, + buildPaginationMeta(total, page, limit), + ); + }, + ); + + // POST /reservations — create reservation + fastify.post( + "/reservations", + { preHandler: requirePermission("warehouse.operate") }, + async (request, reply) => { + const parsed = parseBody(CreateReservationSchema, request.body); + if ("error" in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; + const authUserId = request.authData!.userId; + + const result = await createReservation({ + item_id: body.item_id, + project_id: body.project_id, + quantity: body.quantity, + reserved_by: authUserId, + notes: body.notes ?? null, + }); + if ("error" in result) + return error(reply, result.error, result.status ?? 400); + + await logAudit({ + request, + authData: request.authData, + action: "create", + entityType: "warehouse_reservation", + entityId: (result.data as Record).id as number, + description: `Vytvořena rezervace položky ID ${body.item_id} pro projekt ID ${body.project_id}`, + newValues: { + item_id: body.item_id, + project_id: body.project_id, + quantity: body.quantity, + }, + }); + + return success(reply, result.data, 201, "Rezervace byla vytvořena"); + }, + ); + + // POST /reservations/:id/cancel — cancel reservation + fastify.post<{ Params: { id: string } }>( + "/reservations/:id/cancel", + { preHandler: requirePermission("warehouse.operate") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const result = await cancelReservation(id); + if ("error" in result) + return error(reply, result.error, result.status ?? 400); + + await logAudit({ + request, + authData: request.authData, + action: "cancel", + entityType: "warehouse_reservation", + entityId: id, + description: `Zrušena rezervace #${id}`, + newValues: { status: "CANCELLED" }, + }); + + return success(reply, result.data, 200, "Rezervace byla zrušena"); + }, + ); + + // ============================================================= + // INVENTORY + // ============================================================= + + // GET /inventory-sessions — paginated list, filter by status + fastify.get( + "/inventory-sessions", + { preHandler: requirePermission("warehouse.inventory") }, + async (request, reply) => { + const { page, limit, skip } = parsePagination( + request.query as Record, + ); + const query = request.query as Record; + + const where: Record = {}; + if (query.status) { + where.status = query.status; + } + + const [sessions, total] = await Promise.all([ + prisma.sklad_inventory_sessions.findMany({ + where, + skip, + take: limit, + orderBy: { id: "desc" }, + include: { + _count: { select: { lines: true } }, + }, + }), + prisma.sklad_inventory_sessions.count({ where }), + ]); + + return paginated( + reply, + sessions, + buildPaginationMeta(total, page, limit), + ); + }, + ); + + // GET /inventory-sessions/:id — detail with lines + fastify.get<{ Params: { id: string } }>( + "/inventory-sessions/:id", + { preHandler: requirePermission("warehouse.inventory") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const session = await prisma.sklad_inventory_sessions.findUnique({ + where: { id }, + include: { + lines: { + include: { + item: { select: { id: true, name: true, unit: true } }, + location: { select: { id: true, code: true, name: true } }, + }, + }, + }, + }); + if (!session) return error(reply, "Inventarizace nenalezena", 404); + + return success(reply, session); + }, + ); + + // POST /inventory-sessions — create DRAFT, auto-fill system_qty + fastify.post( + "/inventory-sessions", + { preHandler: requirePermission("warehouse.inventory") }, + async (request, reply) => { + const parsed = parseBody(CreateInventorySessionSchema, request.body); + if ("error" in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; + + // Build lines with auto-filled system_qty and calculated difference + const linesData = await Promise.all( + body.lines.map(async (line) => { + let systemQty = 0; + + if (line.location_id != null) { + // Get stock at specific location + const loc = await prisma.sklad_item_locations.findUnique({ + where: { + item_id_location_id: { + item_id: line.item_id, + location_id: line.location_id, + }, + }, + }); + systemQty = loc ? Number(loc.quantity) : 0; + } else { + // Get total stock across all locations + systemQty = await getItemTotalStock(line.item_id); + } + + const difference = Number(line.actual_qty) - systemQty; + + return { + item_id: line.item_id, + location_id: line.location_id ?? null, + system_qty: systemQty, + actual_qty: Number(line.actual_qty), + difference, + notes: line.notes ?? null, + }; + }), + ); + + const session = await prisma.sklad_inventory_sessions.create({ + data: { + notes: body.notes ?? null, + status: "DRAFT", + lines: { + create: linesData, + }, + }, + include: { + lines: { + include: { + item: { select: { id: true, name: true, unit: true } }, + location: { select: { id: true, code: true, name: true } }, + }, + }, + }, + }); + + await logAudit({ + request, + authData: request.authData, + action: "create", + entityType: "warehouse_inventory", + entityId: session.id, + description: `Vytvořena inventarizace #${session.id}`, + newValues: { + status: session.status, + line_count: linesData.length, + }, + }); + + return success(reply, session, 201, "Inventarizace byla vytvořena"); + }, + ); + + // PUT /inventory-sessions/:id — update lines (DRAFT only), recalculate system_qty and difference + fastify.put<{ Params: { id: string } }>( + "/inventory-sessions/:id", + { preHandler: requirePermission("warehouse.inventory") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const parsed = parseBody(UpdateInventorySessionSchema, request.body); + if ("error" in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; + + const existing = await prisma.sklad_inventory_sessions.findUnique({ + where: { id }, + }); + if (!existing) return error(reply, "Inventarizace nenalezena", 404); + if (existing.status !== "DRAFT") { + return error( + reply, + "Inventarizaci lze upravit pouze ve stavu DRAFT", + 400, + ); + } + + const updateData: Record = { + modified_at: new Date(), + }; + if (body.notes !== undefined) updateData.notes = body.notes; + + // Replace lines if provided, recalculating system_qty and difference + if (body.lines) { + const linesData = await Promise.all( + body.lines.map(async (line) => { + let systemQty = 0; + + if (line.location_id != null) { + const loc = await prisma.sklad_item_locations.findUnique({ + where: { + item_id_location_id: { + item_id: line.item_id, + location_id: line.location_id, + }, + }, + }); + systemQty = loc ? Number(loc.quantity) : 0; + } else { + systemQty = await getItemTotalStock(line.item_id); + } + + const difference = Number(line.actual_qty) - systemQty; + + return { + item_id: line.item_id, + location_id: line.location_id ?? null, + system_qty: systemQty, + actual_qty: Number(line.actual_qty), + difference, + notes: line.notes ?? null, + }; + }), + ); + + await prisma.sklad_inventory_lines.deleteMany({ + where: { session_id: id }, + }); + await prisma.sklad_inventory_lines.createMany({ + data: linesData.map((line) => ({ + session_id: id, + ...line, + })), + }); + } + + const updated = await prisma.sklad_inventory_sessions.update({ + where: { id }, + data: updateData, + include: { + lines: { + include: { + item: { select: { id: true, name: true, unit: true } }, + location: { select: { id: true, code: true, name: true } }, + }, + }, + }, + }); + + await logAudit({ + request, + authData: request.authData, + action: "update", + entityType: "warehouse_inventory", + entityId: id, + description: `Upravena inventarizace #${id}`, + oldValues: { notes: existing.notes }, + newValues: { notes: updated.notes }, + }); + + return success(reply, updated, 200, "Inventarizace byla uložena"); + }, + ); + + // POST /inventory-sessions/:id/confirm — confirm inventory session + fastify.post<{ Params: { id: string } }>( + "/inventory-sessions/:id/confirm", + { preHandler: requirePermission("warehouse.inventory") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + + const result = await confirmInventorySession(id); + if ("error" in result) + return error(reply, result.error, result.status ?? 400); + + await logAudit({ + request, + authData: request.authData, + action: "confirm", + entityType: "warehouse_inventory", + entityId: id, + description: `Potvrzena inventarizace ${result.data.session_number}`, + newValues: { + status: "CONFIRMED", + session_number: result.data.session_number, + }, + }); + + return success(reply, result.data, 200, "Inventarizace byla potvrzena"); + }, + ); + + // ============================================================= + // REPORTS + // ============================================================= + + // GET /reports/stock-status — all items with stock info + fastify.get( + "/reports/stock-status", + { preHandler: requirePermission("warehouse.view") }, + async (request, reply) => { + const query = request.query as Record; + + const where: Record = { is_active: true }; + if (query.category_id) { + where.category_id = Number(query.category_id); + } + + const items = await prisma.sklad_items.findMany({ + where, + orderBy: { name: "asc" }, + include: { + category: { select: { id: true, name: true } }, + }, + }); + + const enriched = await Promise.all( + items.map(async (item) => { + const total_quantity = await getItemTotalStock(item.id); + const available_quantity = await getItemAvailableQty(item.id); + const stock_value = await getItemStockValue(item.id); + const minQty = item.min_quantity ? Number(item.min_quantity) : null; + const below_minimum = + minQty !== null ? total_quantity < minQty : false; + + return { + id: item.id, + item_number: item.item_number, + name: item.name, + unit: item.unit, + category: item.category, + total_quantity, + available_quantity, + stock_value, + below_minimum, + }; + }), + ); + + return success(reply, enriched); + }, + ); + + // GET /reports/project-consumption — aggregated material consumed per project + fastify.get( + "/reports/project-consumption", + { preHandler: requirePermission("warehouse.view") }, + async (request, reply) => { + const query = request.query as Record; + + const issueWhere: Record[] = [{ status: "CONFIRMED" }]; + if (query.project_id) { + issueWhere.push({ project_id: Number(query.project_id) }); + } + if (query.date_from) { + issueWhere.push({ + created_at: { gte: new Date(String(query.date_from)) }, + }); + } + if (query.date_to) { + issueWhere.push({ + created_at: { lte: new Date(String(query.date_to)) }, + }); + } + + const issues = await prisma.sklad_issues.findMany({ + where: { AND: issueWhere }, + include: { + project: { select: { id: true, name: true } }, + lines: { + include: { + item: { select: { id: true, name: true } }, + batch: { select: { unit_price: true } }, + }, + }, + }, + }); + + // Aggregate by item + project + const consumptionMap = new Map< + string, + { + item_id: number; + item_name: string; + project_id: number; + project_name: string | null; + total_qty: number; + total_value: number; + } + >(); + + for (const issue of issues) { + for (const line of issue.lines) { + const key = `${line.item_id}-${issue.project_id}`; + const qty = Number(line.quantity); + const price = Number(line.batch?.unit_price ?? 0); + const value = qty * price; + + const existing = consumptionMap.get(key); + if (existing) { + existing.total_qty += qty; + existing.total_value += value; + } else { + consumptionMap.set(key, { + item_id: line.item_id, + item_name: line.item.name, + project_id: issue.project_id, + project_name: issue.project.name, + total_qty: qty, + total_value: value, + }); + } + } + } + + return success(reply, Array.from(consumptionMap.values())); + }, + ); + + // GET /reports/movement-log — combined list of confirmed receipts + issues + fastify.get( + "/reports/movement-log", + { preHandler: requirePermission("warehouse.view") }, + async (request, reply) => { + const query = request.query as Record; + + const receiptWhere: Record[] = [{ status: "CONFIRMED" }]; + const issueWhere: Record[] = [{ status: "CONFIRMED" }]; + + if (query.date_from) { + const dateFrom = new Date(String(query.date_from)); + receiptWhere.push({ created_at: { gte: dateFrom } }); + issueWhere.push({ created_at: { gte: dateFrom } }); + } + if (query.date_to) { + const dateTo = new Date(String(query.date_to)); + receiptWhere.push({ created_at: { lte: dateTo } }); + issueWhere.push({ created_at: { lte: dateTo } }); + } + + const [receipts, issues] = await Promise.all([ + prisma.sklad_receipts.findMany({ + where: { AND: receiptWhere }, + include: { + supplier: { select: { id: true, name: true } }, + lines: { + include: { + item: { select: { id: true, name: true } }, + }, + }, + }, + orderBy: { id: "desc" }, + }), + prisma.sklad_issues.findMany({ + where: { AND: issueWhere }, + include: { + project: { select: { id: true, name: true } }, + lines: { + include: { + item: { select: { id: true, name: true } }, + batch: { select: { unit_price: true } }, + }, + }, + }, + orderBy: { id: "desc" }, + }), + ]); + + const movements: Array<{ + type: "receipt" | "issue"; + document_id: number; + document_number: string | null; + date: Date | null; + item_id: number; + item_name: string; + quantity: number; + unit_price: number; + supplier?: { id: number; name: string | null } | null; + project?: { id: number; name: string | null } | null; + }> = []; + + for (const receipt of receipts) { + for (const line of receipt.lines) { + movements.push({ + type: "receipt", + document_id: receipt.id, + document_number: receipt.receipt_number, + date: receipt.created_at, + item_id: line.item_id, + item_name: line.item.name, + quantity: Number(line.quantity), + unit_price: Number(line.unit_price), + supplier: receipt.supplier, + }); + } + } + + for (const issue of issues) { + for (const line of issue.lines) { + movements.push({ + type: "issue", + document_id: issue.id, + document_number: issue.issue_number, + date: issue.created_at, + item_id: line.item_id, + item_name: line.item.name, + quantity: Number(line.quantity), + unit_price: Number(line.batch?.unit_price ?? 0), + project: issue.project, + }); + } + } + + // Sort by date descending + movements.sort((a, b) => { + const dateA = a.date ? a.date.getTime() : 0; + const dateB = b.date ? b.date.getTime() : 0; + return dateB - dateA; + }); + + return success(reply, movements); + }, + ); + + // GET /reports/below-minimum — items below minimum stock level + fastify.get( + "/reports/below-minimum", + { preHandler: requirePermission("warehouse.view") }, + async (_request, reply) => { + const items = await getBelowMinimumItems(); + return success(reply, items); + }, + ); } diff --git a/src/types/index.ts b/src/types/index.ts index 232cb7b..98e9b66 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -111,6 +111,9 @@ export type AuditAction = | "delete" | "activate" | "deactivate" + | "confirm" + | "cancel" + | "upload" | "password_change" | "permission_change" | "access_denied"; @@ -139,4 +142,5 @@ export type EntityType = | "warehouse_inventory" | "warehouse_supplier" | "warehouse_category" - | "warehouse_location"; + | "warehouse_location" + | "warehouse_receipt_attachment";