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, paginated } from "../../utils/response"; import { contentDisposition } from "../../utils/content-disposition"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { encodeCustomFields, decodeCustomFields, } from "../../utils/custom-fields"; import { parseBody } from "../../schemas/common"; import { nasInvoicesManager } from "../../services/nas-financials-manager"; import { getItemAvailableQty, getItemTotalStock, getItemStockValue, confirmReceipt, cancelReceipt, confirmIssue, cancelIssue, selectFifoBatches, createReservation, cancelReservation, confirmInventorySession, getBelowMinimumItems, validateIssueReservationRules, } from "../../services/warehouse.service"; import { CreateCategorySchema, UpdateCategorySchema, CreateSupplierSchema, UpdateSupplierSchema, CreateLocationSchema, UpdateLocationSchema, CreateItemSchema, UpdateItemSchema, CreateReceiptSchema, UpdateReceiptSchema, CreateIssueSchema, UpdateIssueSchema, CreateReservationSchema, CreateInventorySessionSchema, UpdateInventorySessionSchema, } from "../../schemas/warehouse.schema"; // Hard cap on the number of source documents (issues/receipts) a report will // scan before aggregating/expanding. Bounds worst-case work + response size on // a wide date range; the per-report pagination then pages the derived rows. const REPORT_SOURCE_CAP = 5000; /** * Coerce a querystring `status` filter to a plain string, or undefined if it * isn't a usable scalar. Querystrings can be arrays/objects (`?status[]=x`); * passing one straight into a Prisma string `where` is a type mismatch that * 500s. Returning undefined for non-strings makes a bad value a no-op filter * instead of a crash. */ function statusFilter(raw: unknown): string | undefined { return typeof raw === "string" && raw.length > 0 ? raw : undefined; } /** * Coerce a querystring numeric filter (FK id etc.) to a finite number, or * undefined if it isn't parseable. A junk value becomes a no-op filter rather * than a NaN reaching Prisma (which 500s). */ function numFilter(raw: unknown): number | undefined { if (raw === undefined || raw === null || raw === "") return undefined; const n = Number(raw); return Number.isFinite(n) ? n : undefined; } // NOTE: this file bundles 8 warehouse sub-entities (~2400 lines). Splitting it // per sub-entity (items/receipts/issues/reservations/inventory/reports) is a // tracked refactor, deferred — see REVIEW_FINDINGS.md. export default async function warehouseRoutes( fastify: FastifyInstance, ): Promise { // ============================================================= // CATEGORIES // ============================================================= // GET /categories — list all, ordered by sort_order asc fastify.get( "/categories", { preHandler: requirePermission("warehouse.manage") }, async (_request, reply) => { const categories = await prisma.sklad_categories.findMany({ orderBy: { sort_order: "asc" }, }); return success(reply, categories); }, ); // POST /categories — create fastify.post( "/categories", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const parsed = parseBody(CreateCategorySchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; const category = await prisma.sklad_categories.create({ data: { name: body.name, description: body.description ?? null, sort_order: body.sort_order ?? 0, }, }); await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_category", entityId: category.id, description: `Vytvořena kategorie ${category.name}`, newValues: { name: category.name, description: category.description, sort_order: category.sort_order, }, }); return success(reply, category, 201, "Kategorie byla vytvořena"); }, ); // PUT /categories/:id — update fastify.put<{ Params: { id: string } }>( "/categories/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const parsed = parseBody(UpdateCategorySchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; const existing = await prisma.sklad_categories.findUnique({ where: { id }, }); if (!existing) return error(reply, "Kategorie nenalezena", 404); const updateData: Record = {}; if (body.name !== undefined) updateData.name = body.name; if (body.description !== undefined) updateData.description = body.description; if (body.sort_order !== undefined) updateData.sort_order = body.sort_order; const updated = await prisma.sklad_categories.update({ where: { id }, data: updateData, }); await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_category", entityId: id, description: `Upravena kategorie ${updated.name}`, oldValues: { name: existing.name, description: existing.description, sort_order: existing.sort_order, }, newValues: { name: updated.name, description: updated.description, sort_order: updated.sort_order, }, }); return success(reply, updated, 200, "Kategorie byla uložena"); }, ); // DELETE /categories/:id — delete only if no items reference it fastify.delete<{ Params: { id: string } }>( "/categories/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const existing = await prisma.sklad_categories.findUnique({ where: { id }, }); if (!existing) return error(reply, "Kategorie nenalezena", 404); const itemCount = await prisma.sklad_items.count({ where: { category_id: id }, }); if (itemCount > 0) { return error( reply, "Kategorii nelze smazat — obsahuje přiřazené položky", 409, ); } await prisma.sklad_categories.delete({ where: { id } }); await logAudit({ request, authData: request.authData, action: "delete", entityType: "warehouse_category", entityId: id, description: `Smazána kategorie ${existing.name}`, oldValues: { name: existing.name, description: existing.description, sort_order: existing.sort_order, }, }); return success(reply, null, 200, "Kategorie smazána"); }, ); // ============================================================= // SUPPLIERS // ============================================================= // GET /suppliers — paginated list, search by name/ico/city fastify.get( "/suppliers", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const { page, limit, skip, search } = parsePagination( request.query as Record, ); const where: Record = {}; if (search) { where.OR = [ { name: { contains: search } }, { ico: { contains: search } }, { city: { contains: search } }, ]; } const [suppliers, total] = await Promise.all([ prisma.sklad_suppliers.findMany({ where, skip, take: limit, orderBy: { name: "asc" }, }), prisma.sklad_suppliers.count({ where }), ]); // Decode the custom_fields blob into the array shape the modal edits // (same model as customers). const enriched = suppliers.map((s) => ({ ...s, custom_fields: decodeCustomFields(s.custom_fields).custom_fields, })); return paginated( reply, enriched, buildPaginationMeta(total, page, limit), ); }, ); // GET /suppliers/:id — detail fastify.get<{ Params: { id: string } }>( "/suppliers/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const supplier = await prisma.sklad_suppliers.findUnique({ where: { id }, }); if (!supplier) return error(reply, "Dodavatel nenalezen", 404); return success(reply, { ...supplier, custom_fields: decodeCustomFields(supplier.custom_fields).custom_fields, }); }, ); // POST /suppliers — create fastify.post( "/suppliers", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const parsed = parseBody(CreateSupplierSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; const supplier = await prisma.sklad_suppliers.create({ data: { name: body.name, ico: body.ico ?? null, dic: body.dic ?? null, street: body.street ?? null, city: body.city ?? null, postal_code: body.postal_code ?? null, country: body.country ?? null, // Suppliers have no PDF field-order picker — order is always []. custom_fields: encodeCustomFields(body.custom_fields, []), is_active: body.is_active ?? true, }, }); await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_supplier", entityId: supplier.id, description: `Vytvořen dodavatel ${supplier.name}`, newValues: { name: supplier.name, ico: supplier.ico, city: supplier.city, is_active: supplier.is_active, }, }); return success(reply, supplier, 201, "Dodavatel byl vytvořen"); }, ); // PUT /suppliers/:id — update fastify.put<{ Params: { id: string } }>( "/suppliers/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const parsed = parseBody(UpdateSupplierSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; const existing = await prisma.sklad_suppliers.findUnique({ where: { id }, }); if (!existing) return error(reply, "Dodavatel nenalezen", 404); const updateData: Record = {}; if (body.name !== undefined) updateData.name = body.name; if (body.ico !== undefined) updateData.ico = body.ico; if (body.dic !== undefined) updateData.dic = body.dic; if (body.street !== undefined) updateData.street = body.street; if (body.city !== undefined) updateData.city = body.city; if (body.postal_code !== undefined) updateData.postal_code = body.postal_code; if (body.country !== undefined) updateData.country = body.country; if (body.custom_fields !== undefined) updateData.custom_fields = encodeCustomFields(body.custom_fields, []); if (body.is_active !== undefined) updateData.is_active = body.is_active; const updated = await prisma.sklad_suppliers.update({ where: { id }, data: updateData, }); await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_supplier", entityId: id, description: `Upraven dodavatel ${updated.name}`, oldValues: { name: existing.name, ico: existing.ico, city: existing.city, is_active: existing.is_active, }, newValues: { name: updated.name, ico: updated.ico, city: updated.city, is_active: updated.is_active, }, }); return success(reply, updated, 200, "Dodavatel byl uložen"); }, ); // DELETE /suppliers/:id — soft delete (set is_active=false) fastify.delete<{ Params: { id: string } }>( "/suppliers/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const existing = await prisma.sklad_suppliers.findUnique({ where: { id }, }); if (!existing) return error(reply, "Dodavatel nenalezen", 404); await prisma.sklad_suppliers.update({ where: { id }, data: { is_active: false }, }); await logAudit({ request, authData: request.authData, action: "deactivate", entityType: "warehouse_supplier", entityId: id, description: `Deaktivován dodavatel ${existing.name}`, oldValues: { is_active: existing.is_active }, newValues: { is_active: false }, }); return success(reply, null, 200, "Dodavatel byl deaktivován"); }, ); // ============================================================= // LOCATIONS // ============================================================= // GET /locations — list locations, ordered by code asc // Query param `active`: "true" (default — only active, used by dropdowns), // "false" (only inactive), or "all" (everything, used by the management page // so deactivated rows remain visible — soft-delete, not a hard hide). fastify.get<{ Querystring: { active?: string } }>( "/locations", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const where: Record = {}; const active = request.query.active ?? "true"; if (active === "true") where.is_active = true; else if (active === "false") where.is_active = false; // "all" or anything else: no filter const locations = await prisma.sklad_locations.findMany({ where, orderBy: { code: "asc" }, }); return success(reply, locations); }, ); // POST /locations — create fastify.post( "/locations", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const parsed = parseBody(CreateLocationSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; // Check for duplicate code const duplicate = await prisma.sklad_locations.findUnique({ where: { code: body.code }, }); if (duplicate) { return error(reply, "Kód umístění již existuje", 409); } const location = await prisma.sklad_locations.create({ data: { code: body.code, name: body.name, description: body.description ?? null, is_active: body.is_active ?? true, }, }); await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_location", entityId: location.id, description: `Vytvořeno umístění ${location.code} - ${location.name}`, newValues: { code: location.code, name: location.name, is_active: location.is_active, }, }); return success(reply, location, 201, "Umístění bylo vytvořeno"); }, ); // PUT /locations/:id — update fastify.put<{ Params: { id: string } }>( "/locations/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const parsed = parseBody(UpdateLocationSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; const existing = await prisma.sklad_locations.findUnique({ where: { id }, }); if (!existing) return error(reply, "Umístění nenalezeno", 404); // Check for duplicate code if code is being changed if (body.code !== undefined && body.code !== existing.code) { const duplicate = await prisma.sklad_locations.findUnique({ where: { code: body.code }, }); if (duplicate) { return error(reply, "Kód umístění již existuje", 409); } } const updateData: Record = {}; if (body.code !== undefined) updateData.code = body.code; if (body.name !== undefined) updateData.name = body.name; if (body.description !== undefined) updateData.description = body.description; if (body.is_active !== undefined) updateData.is_active = body.is_active; const updated = await prisma.sklad_locations.update({ where: { id }, data: updateData, }); await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_location", entityId: id, description: `Upraveno umístění ${updated.code} - ${updated.name}`, oldValues: { code: existing.code, name: existing.name, description: existing.description, is_active: existing.is_active, }, newValues: { code: updated.code, name: updated.name, description: updated.description, is_active: updated.is_active, }, }); return success(reply, updated, 200, "Umístění bylo uloženo"); }, ); // DELETE /locations/:id — delete only if no items at location with non-zero quantity fastify.delete<{ Params: { id: string } }>( "/locations/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const existing = await prisma.sklad_locations.findUnique({ where: { id }, }); if (!existing) return error(reply, "Umístění nenalezeno", 404); // Check for items at this location with non-zero quantity const itemsAtLocation = await prisma.sklad_item_locations.findFirst({ where: { location_id: id, quantity: { not: 0 }, }, }); if (itemsAtLocation) { return error( reply, "Umístění nelze smazat — obsahuje položky s nenulovým množstvím", 409, ); } // Clean up zero-quantity item_location rows before deleting the location await prisma.sklad_item_locations.deleteMany({ where: { location_id: id }, }); await prisma.sklad_locations.delete({ where: { id } }); await logAudit({ request, authData: request.authData, action: "delete", entityType: "warehouse_location", entityId: id, description: `Smazáno umístění ${existing.code} - ${existing.name}`, oldValues: { code: existing.code, name: existing.name, description: existing.description, }, }); return success(reply, null, 200, "Umístění smazáno"); }, ); // ============================================================= // ITEMS // ============================================================= // GET /items — paginated list, search by name/item_number, filter by category_id fastify.get( "/items", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const { page, limit, skip, search } = parsePagination( request.query as Record, ); const query = request.query as Record; const categoryFilter = query.category_id ? Number(query.category_id) : undefined; const where: Record = {}; if (search || categoryFilter !== undefined) { const conditions: Record[] = []; if (search) { conditions.push({ OR: [ { name: { contains: search } }, { item_number: { contains: search } }, ], }); } if (categoryFilter !== undefined && !isNaN(categoryFilter)) { conditions.push({ category_id: categoryFilter }); } if (conditions.length === 1) { Object.assign(where, conditions[0]); } else if (conditions.length > 1) { where.AND = conditions; } } const [items, total] = await Promise.all([ prisma.sklad_items.findMany({ where, skip, take: limit, orderBy: { name: "asc" }, include: { category: { select: { id: true, name: true } }, item_locations: { include: { location: { select: { id: true, code: true, name: true } }, }, }, }, }), prisma.sklad_items.count({ where }), ]); // Add computed fields for each item 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 { ...item, total_quantity, available_quantity, stock_value, below_minimum, }; }), ); return paginated( reply, enriched, buildPaginationMeta(total, page, limit), ); }, ); // GET /items/:id — detail with category, batches, item_locations, plus computed fields fastify.get<{ Params: { id: string } }>( "/items/:id", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const item = await prisma.sklad_items.findUnique({ where: { id }, include: { category: { select: { id: true, name: true } }, batches: { where: { is_consumed: false }, orderBy: { received_at: "asc" }, }, item_locations: { include: { location: { select: { id: true, code: true, name: true } }, }, }, }, }); if (!item) return error(reply, "Položka nenalezena", 404); const total_quantity = await getItemTotalStock(id); const available_quantity = await getItemAvailableQty(id); const stock_value = await getItemStockValue(id); return success(reply, { ...item, total_quantity, available_quantity, stock_value, }); }, ); // POST /items — create fastify.post( "/items", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const parsed = parseBody(CreateItemSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; // Check for duplicate item_number if provided if (body.item_number) { const duplicate = await prisma.sklad_items.findUnique({ where: { item_number: body.item_number }, }); if (duplicate) { return error(reply, "Číslo položky již existuje", 409); } } const item = await prisma.sklad_items.create({ data: { item_number: body.item_number ?? null, name: body.name, description: body.description ?? null, category_id: body.category_id ?? null, unit: body.unit, min_quantity: body.min_quantity ?? null, notes: body.notes ?? null, is_active: body.is_active ?? true, }, include: { category: { select: { id: true, name: true } }, }, }); await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_item", entityId: item.id, description: `Vytvořena položka ${item.name}`, newValues: { item_number: item.item_number, name: item.name, unit: item.unit, min_quantity: item.min_quantity, is_active: item.is_active, }, }); return success(reply, item, 201, "Položka byla vytvořena"); }, ); // PUT /items/:id — update fastify.put<{ Params: { id: string } }>( "/items/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const parsed = parseBody(UpdateItemSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; const existing = await prisma.sklad_items.findUnique({ where: { id }, }); if (!existing) return error(reply, "Položka nenalezena", 404); // Extra validation: if unit is being changed and batches exist, return 409 if (body.unit !== undefined && body.unit !== existing.unit) { const batchCount = await prisma.sklad_batches.count({ where: { item_id: id }, }); if (batchCount > 0) { return error(reply, "Jednotku nelze změnit - položka má pohyby", 409); } } // Check for duplicate item_number if changing if ( body.item_number !== undefined && body.item_number !== existing.item_number ) { if (body.item_number) { const duplicate = await prisma.sklad_items.findUnique({ where: { item_number: body.item_number }, }); if (duplicate) { return error(reply, "Číslo položky již existuje", 409); } } } const updateData: Record = {}; if (body.item_number !== undefined) updateData.item_number = body.item_number; if (body.name !== undefined) updateData.name = body.name; if (body.description !== undefined) updateData.description = body.description; if (body.category_id !== undefined) updateData.category_id = body.category_id; if (body.unit !== undefined) updateData.unit = body.unit; if (body.min_quantity !== undefined) updateData.min_quantity = body.min_quantity; if (body.notes !== undefined) updateData.notes = body.notes; if (body.is_active !== undefined) updateData.is_active = body.is_active; const updated = await prisma.sklad_items.update({ where: { id }, data: updateData, include: { category: { select: { id: true, name: true } }, }, }); await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_item", entityId: id, description: `Upravena položka ${updated.name}`, oldValues: { item_number: existing.item_number, name: existing.name, unit: existing.unit, min_quantity: existing.min_quantity, is_active: existing.is_active, category_id: existing.category_id, }, newValues: { item_number: updated.item_number, name: updated.name, unit: updated.unit, min_quantity: updated.min_quantity, is_active: updated.is_active, category_id: updated.category_id, }, }); return success(reply, updated, 200, "Položka byla uložena"); }, ); // DELETE /items/:id — hard delete, blocked if item has stock activity fastify.delete<{ Params: { id: string } }>( "/items/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const existing = await prisma.sklad_items.findUnique({ where: { id }, }); if (!existing) return error(reply, "Položka nenalezena", 404); // Check if item is referenced by any stock activity const [receiptLines, issueLines, activeReservations] = await Promise.all([ prisma.sklad_receipt_lines.count({ where: { item_id: id } }), prisma.sklad_issue_lines.count({ where: { item_id: id } }), prisma.sklad_reservations.count({ where: { item_id: id, status: "ACTIVE" }, }), ]); if (receiptLines > 0 || issueLines > 0 || activeReservations > 0) { return error( reply, "Položku nelze smazat — má vazby na příjmy, výdaje nebo aktivní rezervace", 409, ); } // Safe to delete: clean up locations and consumed batches first await prisma.sklad_item_locations.deleteMany({ where: { item_id: id } }); await prisma.sklad_batches.deleteMany({ where: { item_id: id } }); await prisma.sklad_reservations.deleteMany({ where: { item_id: id, status: "CANCELLED" }, }); await prisma.sklad_inventory_lines.deleteMany({ where: { item_id: id } }); await prisma.sklad_items.delete({ where: { id } }); await logAudit({ request, authData: request.authData, action: "delete", entityType: "warehouse_item", entityId: id, description: `Smazána položka ${existing.name}`, oldValues: { name: existing.name, item_number: existing.item_number }, }); return success(reply, null, 200, "Položka byla smazána"); }, ); // GET /items/:id/available-qty — return available quantity fastify.get<{ Params: { id: string } }>( "/items/:id/available-qty", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const item = await prisma.sklad_items.findUnique({ where: { id }, select: { id: true }, }); if (!item) return error(reply, "Položka nenalezena", 404); const available_quantity = await getItemAvailableQty(id); return success(reply, { item_id: id, available_quantity, }); }, ); // GET /items/:id/batches — return unconsumed batches with quantity > 0 fastify.get<{ Params: { id: string } }>( "/items/:id/batches", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const item = await prisma.sklad_items.findUnique({ where: { id }, select: { id: true }, }); if (!item) return error(reply, "Položka nenalezena", 404); const batches = await prisma.sklad_batches.findMany({ where: { item_id: id, is_consumed: false, quantity: { gt: 0 }, }, orderBy: { received_at: "asc" }, }); // Include reservation-aware available quantity const totalStock = await getItemTotalStock(id); const reservedResult = await prisma.sklad_reservations.aggregate({ _sum: { remaining_qty: true }, where: { item_id: id, status: "ACTIVE" }, }); const reservedQty = Number(reservedResult._sum.remaining_qty ?? 0); const availableQuantity = totalStock - reservedQty; return success(reply, { batches, total_stock: totalStock, reserved_quantity: reservedQty, available_quantity: availableQuantity, }); }, ); // ============================================================= // 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[] = []; const status = statusFilter(query.status); if (status) { where.push({ status }); } const supplierId = numFilter(query.supplier_id); if (supplierId !== undefined) { where.push({ supplier_id: supplierId }); } 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: { items: true } }, }, }), prisma.sklad_receipts.count({ where: filter }), ]); return paginated( reply, receipts, buildPaginationMeta(total, page, limit), ); }, ); // GET /receipts/:id — detail with supplier, received_by_user, items, 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 }, }, items: { 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 items 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, items: { create: body.items.map((item) => ({ item_id: item.item_id, quantity: item.quantity, unit_price: item.unit_price, location_id: item.location_id ?? null, notes: item.notes ?? null, })), }, }, include: { supplier: { select: { id: true, name: true } }, items: { 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, item_count: body.items.length, }, }); return success(reply, receipt, 201, "Příjem byl vytvořen"); }, ); // PUT /receipts/:id — update header + replace items (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: { items: 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 items + persist header atomically: the line deleteMany + // createMany and the header update run in ONE transaction, so a failure // mid-replacement can't leave the draft with zero (or partial) lines. const updated = await prisma.$transaction(async (tx) => { if (body.items) { await tx.sklad_receipt_lines.deleteMany({ where: { receipt_id: id }, }); await tx.sklad_receipt_lines.createMany({ data: body.items.map((item) => ({ receipt_id: id, item_id: item.item_id, quantity: item.quantity, unit_price: item.unit_price, location_id: item.location_id ?? null, notes: item.notes ?? null, })), }); } return tx.sklad_receipts.update({ where: { id }, data: updateData, include: { supplier: { select: { id: true, name: true } }, items: { 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, item_count: existing.items.length, }, newValues: { supplier_id: updated.supplier_id, delivery_note_number: updated.delivery_note_number, notes: updated.notes, item_count: updated.items.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"); }, ); // GET /receipts/:id/attachments/:attachmentId — download attachment from NAS fastify.get<{ Params: { id: string; attachmentId: string } }>( "/receipts/:id/attachments/:attachmentId", { preHandler: requirePermission("warehouse.view") }, 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 }, }); // 404 (not 400) when the attachment belongs to a different receipt: // a read endpoint should not confirm that the id exists elsewhere. if (!attachment || attachment.receipt_id !== id) { return error(reply, "Příloha nenalezena", 404); } const nasFile = nasInvoicesManager.readReceived(attachment.file_path); if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404); const mime = attachment.file_mime || "application/octet-stream"; // "attachment": the UI always downloads via blob + a.download, never // renders inline — and attachment is safer with client-controlled mime. return reply .type(mime) .header( "Content-Disposition", contentDisposition("attachment", attachment.file_name), ) .send(nasFile.data); }, ); // 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 (!nasInvoicesManager.isConfigured()) { return error(reply, "Úložiště souborů není dostupné", 503); } const result = nasInvoicesManager.saveReceived( part.filename, year, month, buffer, ); if ("error" in result) return error(reply, result.error, 500); // Compensating delete: the NAS write already succeeded above. If the DB // insert now fails we must remove the just-written file, otherwise it is // orphaned on the NAS with no DB row pointing at it. let attachment: Awaited< ReturnType >; try { 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, }, }); } catch (e) { const removed = nasInvoicesManager.deleteReceived(result.filePath); request.log.error( { err: e, filePath: result.filePath, orphanRemoved: removed }, "warehouse attachment DB insert failed after NAS write; rolled back NAS file", ); return error(reply, "Přílohu se nepodařilo uložit", 500); } 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. A failure here is non-fatal (we still remove the // DB row) but must not be silent — log it so an undeleted NAS file is // visible rather than leaking quietly. const nasDeleted = nasInvoicesManager.deleteReceived( attachment.file_path, ); if (!nasDeleted) { request.log.error( { filePath: attachment.file_path, attachmentId }, "warehouse attachment NAS delete failed; DB row will still be removed", ); } 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[] = []; const status = statusFilter(query.status); if (status) { where.push({ status }); } const projectId = numFilter(query.project_id); if (projectId !== undefined) { where.push({ project_id: projectId }); } 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, project_number: true } }, issued_by_user: { select: { id: true, first_name: true, last_name: true }, }, _count: { select: { items: true } }, }, }), prisma.sklad_issues.count({ where: filter }), ]); return paginated(reply, issues, buildPaginationMeta(total, page, limit)); }, ); // GET /issues/:id — detail with project, issued_by_user, items 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, project_number: true } }, issued_by_user: { select: { id: true, first_name: true, last_name: true }, }, items: { 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 items, auto-FIFO for items 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 items without batch_id const resolvedItems: Array<{ item_id: number; batch_id: number; quantity: number; location_id: number | null; reservation_id: number | null; notes: string | null; }> = []; for (const item of body.items) { if (item.batch_id != null) { resolvedItems.push({ item_id: item.item_id, batch_id: item.batch_id, quantity: item.quantity, location_id: item.location_id ?? null, reservation_id: item.reservation_id ?? null, notes: item.notes ?? null, }); } else { // Auto-select FIFO batches try { const fifoBatches = await selectFifoBatches( item.item_id, item.quantity, ); for (const fb of fifoBatches) { resolvedItems.push({ item_id: item.item_id, batch_id: fb.batchId, quantity: fb.qty, location_id: item.location_id ?? null, reservation_id: item.reservation_id ?? null, notes: item.notes ?? null, }); } } catch (err) { const message = err instanceof Error ? err.message : "Nedostatečné množství na skladě"; return error(reply, message, 400); } } } // Validate reservation rules const reservationValidation = await validateIssueReservationRules( resolvedItems, body.project_id, ); if ("error" in reservationValidation) { return error( reply, reservationValidation.error, reservationValidation.status, ); } const issue = await prisma.sklad_issues.create({ data: { project_id: body.project_id, notes: body.notes ?? null, status: "DRAFT", issued_by: authUserId, items: { create: resolvedItems, }, }, include: { project: { select: { id: true, name: true, project_number: true } }, items: { 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, item_count: resolvedItems.length, }, }); return success(reply, issue, 201, "Výdej byl vytvořen"); }, ); // PUT /issues/:id — update (DRAFT only), auto-FIFO for items 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: { items: 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(); // Resolved replacement lines (null = caller did not send `items`, so the // lines are left untouched). Populated/validated below, then persisted // atomically with the header update. let resolvedItems: Array<{ item_id: number; batch_id: number; quantity: number; location_id: number | null; reservation_id: number | null; notes: string | null; }> | null = null; // Replace items if provided if (body.items) { resolvedItems = []; for (const item of body.items) { if (item.batch_id != null) { resolvedItems.push({ item_id: item.item_id, batch_id: item.batch_id, quantity: item.quantity, location_id: item.location_id ?? null, reservation_id: item.reservation_id ?? null, notes: item.notes ?? null, }); } else { try { const fifoBatches = await selectFifoBatches( item.item_id, item.quantity, ); for (const fb of fifoBatches) { resolvedItems.push({ item_id: item.item_id, batch_id: fb.batchId, quantity: fb.qty, location_id: item.location_id ?? null, reservation_id: item.reservation_id ?? null, notes: item.notes ?? null, }); } } catch (err) { const message = err instanceof Error ? err.message : "Nedostatečné množství na skladě"; return error(reply, message, 400); } } } // Validate reservation rules const issueProjectId = body.project_id ?? existing.project_id; const reservationValidation = await validateIssueReservationRules( resolvedItems, issueProjectId, ); if ("error" in reservationValidation) { return error( reply, reservationValidation.error, reservationValidation.status, ); } } // Replace lines + persist header atomically: the line deleteMany + // createMany and the header update run in ONE transaction, so a failure // mid-replacement can't leave the draft with zero (or partial) lines. const linesToWrite = resolvedItems; const updated = await prisma.$transaction(async (tx) => { if (linesToWrite) { await tx.sklad_issue_lines.deleteMany({ where: { issue_id: id }, }); await tx.sklad_issue_lines.createMany({ data: linesToWrite.map((item) => ({ issue_id: id, item_id: item.item_id, batch_id: item.batch_id, quantity: item.quantity, location_id: item.location_id, reservation_id: item.reservation_id, notes: item.notes, })), }); } return tx.sklad_issues.update({ where: { id }, data: updateData, include: { project: { select: { id: true, name: true, project_number: true }, }, items: { 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, item_count: existing.items.length, }, newValues: { project_id: updated.project_id, notes: updated.notes, item_count: updated.items.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[] = []; const itemId = numFilter(query.item_id); if (itemId !== undefined) { where.push({ item_id: itemId }); } const projectId = numFilter(query.project_id); if (projectId !== undefined) { where.push({ project_id: projectId }); } const status = statusFilter(query.status); if (status) { where.push({ 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, project_number: 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 = {}; const status = statusFilter(query.status); if (status) { where.status = status; } const [sessions, total] = await Promise.all([ prisma.sklad_inventory_sessions.findMany({ where, skip, take: limit, orderBy: { id: "desc" }, include: { _count: { select: { items: true } }, }, }), prisma.sklad_inventory_sessions.count({ where }), ]); return paginated( reply, sessions, buildPaginationMeta(total, page, limit), ); }, ); // GET /inventory-sessions/:id — detail with items 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: { items: { 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 items with auto-filled system_qty and calculated difference const itemsData = await Promise.all( body.items.map(async (item) => { let systemQty = 0; if (item.location_id != null) { // Get stock at specific location const loc = await prisma.sklad_item_locations.findUnique({ where: { item_id_location_id: { item_id: item.item_id, location_id: item.location_id, }, }, }); systemQty = loc ? Number(loc.quantity) : 0; } else { // Get total stock across all locations systemQty = await getItemTotalStock(item.item_id); } const difference = Number(item.actual_qty) - systemQty; return { item_id: item.item_id, location_id: item.location_id ?? null, system_qty: systemQty, actual_qty: Number(item.actual_qty), difference, notes: item.notes ?? null, }; }), ); const session = await prisma.sklad_inventory_sessions.create({ data: { notes: body.notes ?? null, status: "DRAFT", items: { create: itemsData, }, }, include: { items: { 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, item_count: itemsData.length, }, }); return success(reply, session, 201, "Inventarizace byla vytvořena"); }, ); // PUT /inventory-sessions/:id — update items (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 items if provided, recalculating system_qty and difference. // The system_qty/difference computation is read-only and runs first; // the resulting lines are then persisted atomically with the header. let itemsData: Array<{ item_id: number; location_id: number | null; system_qty: number; actual_qty: number; difference: number; notes: string | null; }> | null = null; if (body.items) { itemsData = await Promise.all( body.items.map(async (item) => { let systemQty = 0; if (item.location_id != null) { const loc = await prisma.sklad_item_locations.findUnique({ where: { item_id_location_id: { item_id: item.item_id, location_id: item.location_id, }, }, }); systemQty = loc ? Number(loc.quantity) : 0; } else { systemQty = await getItemTotalStock(item.item_id); } const difference = Number(item.actual_qty) - systemQty; return { item_id: item.item_id, location_id: item.location_id ?? null, system_qty: systemQty, actual_qty: Number(item.actual_qty), difference, notes: item.notes ?? null, }; }), ); } // Replace lines + persist header atomically: the line deleteMany + // createMany and the header update run in ONE transaction, so a failure // mid-replacement can't leave the session with zero (or partial) lines. const linesToWrite = itemsData; const updated = await prisma.$transaction(async (tx) => { if (linesToWrite) { await tx.sklad_inventory_lines.deleteMany({ where: { session_id: id }, }); await tx.sklad_inventory_lines.createMany({ data: linesToWrite.map((item) => ({ session_id: id, ...item, })), }); } return tx.sklad_inventory_sessions.update({ where: { id }, data: updateData, include: { items: { 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 — items with stock info (paginated) // Pagination + a hard `take` cap (parsePagination clamps limit to ≤100) keep // this from returning an unbounded result set. Response stays `data`-as-array // (paginated() adds a top-level `pagination` meta the existing clients ignore). fastify.get( "/reports/stock-status", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip } = parsePagination(query, { defaultLimit: 2000, maxLimit: 5000, }); const where: Record = { is_active: true }; // NaN-guard the category filter: a junk value yields no filter rather // than a NaN reaching Prisma (which 500s) or silently matching nothing. if (query.category_id !== undefined) { const categoryId = Number(query.category_id); if (!Number.isNaN(categoryId)) where.category_id = categoryId; } const [items, total] = await Promise.all([ prisma.sklad_items.findMany({ where, orderBy: { name: "asc" }, skip, take: limit, include: { category: { select: { id: true, name: true } }, }, }), prisma.sklad_items.count({ where }), ]); 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 paginated( reply, enriched, buildPaginationMeta(total, page, limit), ); }, ); // GET /reports/project-consumption — aggregated material consumed per project // Bounded two ways: (1) at most REPORT_SOURCE_CAP source issues are scanned // for the aggregate, and (2) the aggregated rows are paginated. This prevents // an unbounded scan/response on a wide date range. Response stays // `data`-as-array via paginated(). fastify.get( "/reports/project-consumption", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip } = parsePagination(query, { defaultLimit: 2000, maxLimit: 5000, }); const issueWhere: Record[] = [{ status: "CONFIRMED" }]; // NaN-guard the project filter (junk value -> no filter, not a 500). if (query.project_id !== undefined) { const projectId = Number(query.project_id); if (!Number.isNaN(projectId)) issueWhere.push({ project_id: projectId }); } 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 }, take: REPORT_SOURCE_CAP, orderBy: { id: "desc" }, include: { project: { select: { id: true, name: true, project_number: true } }, items: { 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 item of issue.items) { const key = `${item.item_id}-${issue.project_id}`; const qty = Number(item.quantity); const price = Number(item.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: item.item_id, item_name: item.item.name, project_id: issue.project_id, project_name: issue.project.name, total_qty: qty, total_value: value, }); } } } const allRows = Array.from(consumptionMap.values()); const pageRows = allRows.slice(skip, skip + limit); return paginated( reply, pageRows, buildPaginationMeta(allRows.length, page, limit), ); }, ); // GET /reports/movement-log — combined list of confirmed receipts + issues // Bounded: each source query is capped at REPORT_SOURCE_CAP rows and the // merged, date-sorted movement list is paginated. Without this a wide date // range expands to an unbounded number of movement lines. Response stays // `data`-as-array via paginated(). fastify.get( "/reports/movement-log", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip } = parsePagination(query, { defaultLimit: 2000, maxLimit: 5000, }); 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 }, take: REPORT_SOURCE_CAP, include: { supplier: { select: { id: true, name: true } }, items: { include: { item: { select: { id: true, name: true } }, }, }, }, orderBy: { id: "desc" }, }), prisma.sklad_issues.findMany({ where: { AND: issueWhere }, take: REPORT_SOURCE_CAP, include: { project: { select: { id: true, name: true, project_number: true } }, items: { 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 item of receipt.items) { movements.push({ type: "receipt", document_id: receipt.id, document_number: receipt.receipt_number, date: receipt.created_at, item_id: item.item_id, item_name: item.item.name, quantity: Number(item.quantity), unit_price: Number(item.unit_price), supplier: receipt.supplier, }); } } for (const issue of issues) { for (const item of issue.items) { movements.push({ type: "issue", document_id: issue.id, document_number: issue.issue_number, date: issue.created_at, item_id: item.item_id, item_name: item.item.name, quantity: Number(item.quantity), unit_price: Number(item.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; }); const pageMovements = movements.slice(skip, skip + limit); return paginated( reply, pageMovements, buildPaginationMeta(movements.length, page, limit), ); }, ); // GET /reports/below-minimum — items below minimum stock level (paginated) // getBelowMinimumItems() now does ONE grouped aggregate (no per-item N+1), // and the result is paginated here so the response can't grow unbounded. fastify.get( "/reports/below-minimum", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const { page, limit, skip } = parsePagination( request.query as Record, { defaultLimit: 2000, maxLimit: 5000 }, ); const all = await getBelowMinimumItems(); const pageItems = all.slice(skip, skip + limit); return paginated( reply, pageItems, buildPaginationMeta(all.length, page, limit), ); }, ); }