diff --git a/src/routes/admin/warehouse.ts b/src/routes/admin/warehouse.ts new file mode 100644 index 0000000..7dfc442 --- /dev/null +++ b/src/routes/admin/warehouse.ts @@ -0,0 +1,895 @@ +import { FastifyInstance } from "fastify"; +import prisma from "../../config/database"; +import { requireAuth, requirePermission } from "../../middleware/auth"; +import { logAudit } from "../../services/audit"; +import { success, error, parseId } from "../../utils/response"; +import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; +import { parseBody } from "../../schemas/common"; +import { + getItemAvailableQty, + getItemTotalStock, + getItemStockValue, +} from "../../services/warehouse.service"; +import { + CreateCategorySchema, + UpdateCategorySchema, + CreateSupplierSchema, + UpdateSupplierSchema, + CreateLocationSchema, + UpdateLocationSchema, + CreateItemSchema, + UpdateItemSchema, +} from "../../schemas/warehouse.schema"; + +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/contact_person + 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 } }, + { contact_person: { contains: search } }, + ]; + } + + const [suppliers, total] = await Promise.all([ + prisma.sklad_suppliers.findMany({ + where, + skip, + take: limit, + orderBy: { name: "asc" }, + }), + prisma.sklad_suppliers.count({ where }), + ]); + + return reply.send({ + success: true, + data: suppliers, + pagination: 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); + }, + ); + + // 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, + contact_person: body.contact_person ?? null, + email: body.email ?? null, + phone: body.phone ?? null, + address: body.address ?? null, + notes: body.notes ?? null, + 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, + contact_person: supplier.contact_person, + 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.contact_person !== undefined) + updateData.contact_person = body.contact_person; + if (body.email !== undefined) updateData.email = body.email; + if (body.phone !== undefined) updateData.phone = body.phone; + if (body.address !== undefined) updateData.address = body.address; + if (body.notes !== undefined) updateData.notes = body.notes; + 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, + contact_person: existing.contact_person, + is_active: existing.is_active, + }, + newValues: { + name: updated.name, + ico: updated.ico, + contact_person: updated.contact_person, + 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 all active, ordered by code asc + fastify.get( + "/locations", + { preHandler: requirePermission("warehouse.manage") }, + async (_request, reply) => { + const locations = await prisma.sklad_locations.findMany({ + where: { is_active: true }, + 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 reply.send({ + success: true, + data: enriched, + pagination: 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 — soft delete (set is_active=false) + 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); + + await prisma.sklad_items.update({ + where: { id }, + data: { is_active: false }, + }); + + await logAudit({ + request, + authData: request.authData, + action: "deactivate", + entityType: "warehouse_item", + entityId: id, + description: `Deaktivována položka ${existing.name}`, + oldValues: { is_active: existing.is_active }, + newValues: { is_active: false }, + }); + + return success(reply, null, 200, "Položka byla deaktivová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" }, + }); + + return success(reply, batches); + }, + ); +}