From 637499a8ec9dd85c82edd62f99d0d0dc4ca08e48 Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 29 May 2026 14:11:22 +0200 Subject: [PATCH] feat(warehouse): add warehouse service with FIFO and business logic Co-Authored-By: Claude Opus 4.8 --- src/services/warehouse.service.ts | 918 ++++++++++++++++++++++++++++++ 1 file changed, 918 insertions(+) create mode 100644 src/services/warehouse.service.ts diff --git a/src/services/warehouse.service.ts b/src/services/warehouse.service.ts new file mode 100644 index 0000000..0539733 --- /dev/null +++ b/src/services/warehouse.service.ts @@ -0,0 +1,918 @@ +import prisma from "../config/database"; +import type { PrismaClient } from "@prisma/client"; + +type TxClient = Omit< + PrismaClient, + "$connect" | "$disconnect" | "$on" | "$transaction" | "$extends" +>; + +// --------------------------------------------------------------------------- +// Number sequence helpers for warehouse documents +// --------------------------------------------------------------------------- + +const WAREHOUSE_NUMBER_PREFIXES: Record = { + warehouse_receipt: "PRI", + warehouse_issue: "VYD", + warehouse_inventory: "INV", +}; + +/** + * Atomically get and increment the next sequence number for a warehouse doc type. + * Uses SELECT ... FOR UPDATE to prevent race conditions. + */ +async function getNextSequence( + type: string, + year: number, + tx: TxClient, +): Promise { + const existing = await tx.$queryRaw< + Array<{ id: number; last_number: number }> + >` + SELECT id, last_number FROM number_sequences + WHERE \`type\` = ${type} AND \`year\` = ${year} + FOR UPDATE + `; + + if (existing.length === 0) { + await tx.$executeRaw` + INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`) + VALUES (${type}, ${year}, 1) + `; + return 1; + } + + const next = existing[0].last_number + 1; + await tx.$executeRaw` + UPDATE number_sequences + SET \`last_number\` = ${next} + WHERE id = ${existing[0].id} + `; + return next; +} + +/** + * Generate a formatted warehouse document number like "PRI-2026-001". + */ +async function generateWarehouseNumber( + type: string, + tx: TxClient, +): Promise { + const year = new Date().getFullYear(); + const prefix = WAREHOUSE_NUMBER_PREFIXES[type] || "WH"; + const seq = await getNextSequence(type, year, tx); + return `${prefix}-${year}-${String(seq).padStart(3, "0")}`; +} + +// --------------------------------------------------------------------------- +// Available Quantity +// --------------------------------------------------------------------------- + +/** + * Calculates total stock (sum of unconsumed batch quantities) minus active + * reservation quantities for a given item. + */ +export async function getItemAvailableQty(itemId: number): Promise { + const [batchResult, reservationResult] = await Promise.all([ + prisma.sklad_batches.aggregate({ + _sum: { quantity: true }, + where: { item_id: itemId, is_consumed: false }, + }), + prisma.sklad_reservations.aggregate({ + _sum: { remaining_qty: true }, + where: { item_id: itemId, status: "ACTIVE" }, + }), + ]); + + const totalStock = Number(batchResult._sum.quantity ?? 0); + const reservedQty = Number(reservationResult._sum.remaining_qty ?? 0); + return totalStock - reservedQty; +} + +// --------------------------------------------------------------------------- +// FIFO Batch Selection +// --------------------------------------------------------------------------- + +/** + * Selects oldest unconsumed batches for an item using FIFO. + * If one batch doesn't have enough, it spans multiple batches. + * Throws if insufficient stock. + */ +export async function selectFifoBatches( + itemId: number, + requestedQty: number, + tx?: TxClient, +): Promise> { + const client = tx ?? prisma; + const batches = await client.sklad_batches.findMany({ + where: { item_id: itemId, is_consumed: false, quantity: { gt: 0 } }, + orderBy: { received_at: "asc" }, + select: { id: true, quantity: true }, + }); + + const result: Array<{ batchId: number; qty: number }> = []; + let remaining = requestedQty; + + for (const batch of batches) { + if (remaining <= 0) break; + const batchQty = Number(batch.quantity); + const take = Math.min(batchQty, remaining); + result.push({ batchId: batch.id, qty: take }); + remaining -= take; + } + + if (remaining > 0) { + throw new Error("Nedostatečné množství na skladě"); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Item Total Stock +// --------------------------------------------------------------------------- + +/** + * Sum of unconsumed batch quantities for an item. + */ +export async function getItemTotalStock(itemId: number): Promise { + const result = await prisma.sklad_batches.aggregate({ + _sum: { quantity: true }, + where: { item_id: itemId, is_consumed: false }, + }); + return Number(result._sum.quantity ?? 0); +} + +// --------------------------------------------------------------------------- +// Item Stock Value +// --------------------------------------------------------------------------- + +/** + * Sum of (quantity * unit_price) for unconsumed batches. + */ +export async function getItemStockValue(itemId: number): Promise { + const batches = await prisma.sklad_batches.findMany({ + where: { item_id: itemId, is_consumed: false }, + select: { quantity: true, unit_price: true }, + }); + + return batches.reduce((sum, b) => { + return sum + Number(b.quantity) * Number(b.unit_price); + }, 0); +} + +// --------------------------------------------------------------------------- +// Below Minimum Items +// --------------------------------------------------------------------------- + +/** + * Returns items where current stock is below their min_quantity threshold. + */ +export async function getBelowMinimumItems() { + const items = await prisma.sklad_items.findMany({ + where: { + is_active: true, + min_quantity: { not: null }, + }, + include: { + category: { select: { id: true, name: true } }, + }, + }); + + const results = []; + for (const item of items) { + const totalStock = await getItemTotalStock(item.id); + const minQty = Number(item.min_quantity ?? 0); + if (totalStock < minQty) { + results.push({ + id: item.id, + item_number: item.item_number, + name: item.name, + unit: item.unit, + min_quantity: minQty, + current_stock: totalStock, + category: item.category, + }); + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// Receipt Confirm +// --------------------------------------------------------------------------- + +/** + * Confirms a warehouse receipt: + * 1. Validate status is DRAFT + * 2. Generate receipt_number via number_sequences + * 3. For each receipt line: create sklad_batches row, upsert sklad_item_locations + * 4. Update receipt status to CONFIRMED + */ +export async function confirmReceipt( + receiptId: number, + authUserId: number, +): Promise< + | { data: { id: number; receipt_number: string } } + | { error: string; status: number } +> { + return prisma.$transaction(async (tx) => { + const receipt = await tx.sklad_receipts.findUnique({ + where: { id: receiptId }, + include: { lines: true }, + }); + + if (!receipt) { + return { error: "Příjem nebyl nalezen", status: 404 }; + } + + if (receipt.status !== "DRAFT") { + return { error: "Příjem není ve stavu DRAFT", status: 400 }; + } + + const receiptNumber = await generateWarehouseNumber( + "warehouse_receipt", + tx, + ); + + for (const line of receipt.lines) { + const lineQty = Number(line.quantity); + const linePrice = Number(line.unit_price); + + // Create batch for this receipt line + const batch = await tx.sklad_batches.create({ + data: { + item_id: line.item_id, + receipt_line_id: line.id, + quantity: line.quantity, + original_qty: line.quantity, + unit_price: line.unit_price, + received_at: new Date(), + is_consumed: false, + }, + }); + + // Upsert item location if location_id is specified + if (line.location_id != null) { + await tx.sklad_item_locations.upsert({ + where: { + item_id_location_id: { + item_id: line.item_id, + location_id: line.location_id, + }, + }, + create: { + item_id: line.item_id, + location_id: line.location_id, + quantity: line.quantity, + }, + update: { + quantity: { + increment: line.quantity, + }, + }, + }); + } + } + + // Update receipt status and number + await tx.sklad_receipts.update({ + where: { id: receiptId }, + data: { + status: "CONFIRMED", + receipt_number: receiptNumber, + received_by: authUserId, + modified_at: new Date(), + }, + }); + + return { data: { id: receiptId, receipt_number: receiptNumber } }; + }); +} + +// --------------------------------------------------------------------------- +// Receipt Cancel +// --------------------------------------------------------------------------- + +/** + * Cancel a receipt: + * - If DRAFT: just set CANCELLED. + * - If CONFIRMED (storno): validate no batch consumed, reverse item_locations, + * delete batches, set CANCELLED. + */ +export async function cancelReceipt( + receiptId: number, +): Promise<{ data: { id: number } } | { error: string; status: number }> { + const receipt = await prisma.sklad_receipts.findUnique({ + where: { id: receiptId }, + include: { lines: { include: { batch: true } } }, + }); + + if (!receipt) { + return { error: "Příjem nebyl nalezen", status: 404 }; + } + + if (receipt.status === "CANCELLED") { + return { error: "Příjem je již zrušen", status: 400 }; + } + + if (receipt.status === "DRAFT") { + await prisma.sklad_receipts.update({ + where: { id: receiptId }, + data: { status: "CANCELLED", modified_at: new Date() }, + }); + return { data: { id: receiptId } }; + } + + // CONFIRMED -> CANCELLED (storno) + return prisma.$transaction(async (tx) => { + // Validate no batch has been consumed + for (const line of receipt.lines) { + if (line.batch) { + const batchData = await tx.sklad_batches.findUnique({ + where: { id: line.batch.id }, + }); + if (batchData && batchData.is_consumed) { + return { + error: "Příjem nelze zrušit - některé šarže byly již spotřebovány", + status: 400, + }; + } + // Also check if quantity has been partially consumed (original_qty != quantity) + if ( + batchData && + Number(batchData.quantity) < Number(batchData.original_qty) + ) { + return { + error: + "Příjem nelze zrušit - některé šarže byly částečně spotřebovány", + status: 400, + }; + } + } + } + + // Reverse item_locations and delete batches + for (const line of receipt.lines) { + if (line.location_id != null) { + // Decrement location quantity (or delete if it would go to zero/below) + const loc = await tx.sklad_item_locations.findUnique({ + where: { + item_id_location_id: { + item_id: line.item_id, + location_id: line.location_id, + }, + }, + }); + + if (loc) { + const newQty = Number(loc.quantity) - Number(line.quantity); + if (newQty <= 0) { + await tx.sklad_item_locations.delete({ + where: { id: loc.id }, + }); + } else { + await tx.sklad_item_locations.update({ + where: { id: loc.id }, + data: { quantity: newQty }, + }); + } + } + } + + // Delete the batch (it's a 1:1 with receipt_line via @unique) + if (line.batch) { + await tx.sklad_batches.delete({ + where: { id: line.batch.id }, + }); + } + } + + await tx.sklad_receipts.update({ + where: { id: receiptId }, + data: { status: "CANCELLED", modified_at: new Date() }, + }); + + return { data: { id: receiptId } }; + }); +} + +// --------------------------------------------------------------------------- +// Issue Confirm +// --------------------------------------------------------------------------- + +/** + * Confirms a warehouse issue: + * 1. Validate status is DRAFT + * 2. Validate each line's batch has enough quantity + * 3. Generate issue_number via number_sequences + * 4. For each line: decrement batch quantity (set is_consumed if 0), + * decrement item_locations, fulfill reservation if linked + * 5. Update status to CONFIRMED + */ +export async function confirmIssue( + issueId: number, + authUserId: number, +): Promise< + | { data: { id: number; issue_number: string } } + | { error: string; status: number } +> { + return prisma.$transaction(async (tx) => { + const issue = await tx.sklad_issues.findUnique({ + where: { id: issueId }, + include: { lines: true }, + }); + + if (!issue) { + return { error: "Výdej nebyl nalezen", status: 404 }; + } + + if (issue.status !== "DRAFT") { + return { error: "Výdej není ve stavu DRAFT", status: 400 }; + } + + // Validate each line's batch has enough quantity + for (const line of issue.lines) { + const batch = await tx.sklad_batches.findUnique({ + where: { id: line.batch_id }, + }); + if (!batch) { + return { + error: `Šarže ID ${line.batch_id} nebyla nalezena`, + status: 404, + }; + } + if (batch.is_consumed) { + return { + error: `Šarže ID ${line.batch_id} je již spotřebována`, + status: 400, + }; + } + if (Number(batch.quantity) < Number(line.quantity)) { + return { + error: `Šarže ID ${line.batch_id} nemá dostatečné množství`, + status: 400, + }; + } + } + + const issueNumber = await generateWarehouseNumber("warehouse_issue", tx); + + // Process each issue line + for (const line of issue.lines) { + const lineQty = Number(line.quantity); + + // Decrement batch quantity + const batch = await tx.sklad_batches.findUnique({ + where: { id: line.batch_id }, + }); + if (!batch) continue; // already validated above + + const newBatchQty = Number(batch.quantity) - lineQty; + await tx.sklad_batches.update({ + where: { id: line.batch_id }, + data: { + quantity: newBatchQty, + is_consumed: newBatchQty <= 0, + }, + }); + + // Decrement item_locations if location_id is specified + if (line.location_id != null) { + const loc = await tx.sklad_item_locations.findUnique({ + where: { + item_id_location_id: { + item_id: line.item_id, + location_id: line.location_id, + }, + }, + }); + + if (loc) { + const newLocQty = Number(loc.quantity) - lineQty; + if (newLocQty <= 0) { + await tx.sklad_item_locations.delete({ + where: { id: loc.id }, + }); + } else { + await tx.sklad_item_locations.update({ + where: { id: loc.id }, + data: { quantity: newLocQty }, + }); + } + } + } + + // Fulfill reservation if linked + if (line.reservation_id != null) { + const reservation = await tx.sklad_reservations.findUnique({ + where: { id: line.reservation_id }, + }); + if (reservation) { + const newRemaining = Number(reservation.remaining_qty) - lineQty; + await tx.sklad_reservations.update({ + where: { id: line.reservation_id }, + data: { + remaining_qty: Math.max(0, newRemaining), + status: newRemaining <= 0 ? "FULFILLED" : reservation.status, + modified_at: new Date(), + }, + }); + } + } + } + + // Update issue status and number + await tx.sklad_issues.update({ + where: { id: issueId }, + data: { + status: "CONFIRMED", + issue_number: issueNumber, + issued_by: authUserId, + modified_at: new Date(), + }, + }); + + return { data: { id: issueId, issue_number: issueNumber } }; + }); +} + +// --------------------------------------------------------------------------- +// Issue Cancel +// --------------------------------------------------------------------------- + +/** + * Cancel a confirmed issue: restore batch quantities, restore item_locations, + * restore reservations. + */ +export async function cancelIssue( + issueId: number, +): Promise<{ data: { id: number } } | { error: string; status: number }> { + const issue = await prisma.sklad_issues.findUnique({ + where: { id: issueId }, + include: { lines: true }, + }); + + if (!issue) { + return { error: "Výdej nebyl nalezen", status: 404 }; + } + + if (issue.status === "CANCELLED") { + return { error: "Výdej je již zrušen", status: 400 }; + } + + if (issue.status === "DRAFT") { + await prisma.sklad_issues.update({ + where: { id: issueId }, + data: { status: "CANCELLED", modified_at: new Date() }, + }); + return { data: { id: issueId } }; + } + + // CONFIRMED -> CANCELLED: restore everything + return prisma.$transaction(async (tx) => { + for (const line of issue.lines) { + const lineQty = Number(line.quantity); + + // Restore batch quantity + const batch = await tx.sklad_batches.findUnique({ + where: { id: line.batch_id }, + }); + if (batch) { + const restoredQty = Number(batch.quantity) + lineQty; + await tx.sklad_batches.update({ + where: { id: line.batch_id }, + data: { + quantity: restoredQty, + is_consumed: false, + }, + }); + } + + // Restore item_locations + if (line.location_id != null) { + await tx.sklad_item_locations.upsert({ + where: { + item_id_location_id: { + item_id: line.item_id, + location_id: line.location_id, + }, + }, + create: { + item_id: line.item_id, + location_id: line.location_id, + quantity: lineQty, + }, + update: { + quantity: { + increment: lineQty, + }, + }, + }); + } + + // Restore reservation + if (line.reservation_id != null) { + const reservation = await tx.sklad_reservations.findUnique({ + where: { id: line.reservation_id }, + }); + if (reservation) { + const restoredRemaining = Number(reservation.remaining_qty) + lineQty; + // Restore to ACTIVE if it was FULFILLED + const newStatus = + reservation.status === "FULFILLED" ? "ACTIVE" : reservation.status; + await tx.sklad_reservations.update({ + where: { id: line.reservation_id }, + data: { + remaining_qty: restoredRemaining, + status: newStatus, + modified_at: new Date(), + }, + }); + } + } + } + + await tx.sklad_issues.update({ + where: { id: issueId }, + data: { status: "CANCELLED", modified_at: new Date() }, + }); + + return { data: { id: issueId } }; + }); +} + +// --------------------------------------------------------------------------- +// Reservation Create +// --------------------------------------------------------------------------- + +/** + * Create a reservation: + * Validate item exists and is_active. + * Calculate available_qty (total stock - active reservations). + * Validate available_qty >= quantity. + * Create reservation with quantity = remaining_qty. + */ +export async function createReservation(data: { + item_id: number; + project_id: number; + quantity: number; + reserved_by?: number; + notes?: string | null; +}): Promise< + { data: Record } | { error: string; status: number } +> { + const item = await prisma.sklad_items.findUnique({ + where: { id: data.item_id }, + }); + + if (!item) { + return { error: "Položka nebyla nalezena", status: 404 }; + } + + if (!item.is_active) { + return { error: "Položka není aktivní", status: 400 }; + } + + const availableQty = await getItemAvailableQty(data.item_id); + + if (availableQty < data.quantity) { + return { + error: `Nedostatečné dostupné množství (dostupné: ${availableQty}, požadované: ${data.quantity})`, + status: 400, + }; + } + + const reservation = await prisma.sklad_reservations.create({ + data: { + item_id: data.item_id, + project_id: data.project_id, + quantity: data.quantity, + remaining_qty: data.quantity, + reserved_by: data.reserved_by ?? null, + notes: data.notes ?? null, + status: "ACTIVE", + }, + }); + + return { data: reservation }; +} + +// --------------------------------------------------------------------------- +// Reservation Cancel +// --------------------------------------------------------------------------- + +/** + * Set reservation status to CANCELLED, remaining_qty to 0. + */ +export async function cancelReservation( + reservationId: number, +): Promise<{ data: { id: number } } | { error: string; status: number }> { + const reservation = await prisma.sklad_reservations.findUnique({ + where: { id: reservationId }, + }); + + if (!reservation) { + return { error: "Rezervace nebyla nalezena", status: 404 }; + } + + if (reservation.status === "CANCELLED") { + return { error: "Rezervace je již zrušena", status: 400 }; + } + + if (reservation.status === "FULFILLED") { + return { error: "Rezervace je již vyplněna", status: 400 }; + } + + await prisma.sklad_reservations.update({ + where: { id: reservationId }, + data: { + status: "CANCELLED", + remaining_qty: 0, + modified_at: new Date(), + }, + }); + + return { data: { id: reservationId } }; +} + +// --------------------------------------------------------------------------- +// Inventory Confirm +// --------------------------------------------------------------------------- + +/** + * Confirms an inventory session: + * 1. Validate status is DRAFT + * 2. For each line where difference != 0: + * - If diff > 0: create corrective receipt (auto-confirmed), create batch, + * update item_locations + * - If diff < 0: consume from oldest batches (FIFO), update item_locations + * 3. Generate session_number via number_sequences + * 4. Update status to CONFIRMED + */ +export async function confirmInventorySession( + sessionId: number, +): Promise< + | { data: { id: number; session_number: string } } + | { error: string; status: number } +> { + return prisma.$transaction(async (tx) => { + const session = await tx.sklad_inventory_sessions.findUnique({ + where: { id: sessionId }, + include: { lines: true }, + }); + + if (!session) { + return { error: "Inventarizace nebyla nalezena", status: 404 }; + } + + if (session.status !== "DRAFT") { + return { error: "Inventarizace není ve stavu DRAFT", status: 400 }; + } + + for (const line of session.lines) { + const diff = Number(line.difference); + if (diff === 0) continue; + + if (diff > 0) { + // Positive difference: stock surplus -> create corrective receipt + const receiptNumber = await generateWarehouseNumber( + "warehouse_receipt", + tx, + ); + + const correctiveReceipt = await tx.sklad_receipts.create({ + data: { + receipt_number: receiptNumber, + notes: `Korekční příjem z inventarizace #${sessionId}`, + status: "CONFIRMED", + }, + }); + + const receiptLine = await tx.sklad_receipt_lines.create({ + data: { + receipt_id: correctiveReceipt.id, + item_id: line.item_id, + quantity: diff, + unit_price: 0, + location_id: line.location_id, + notes: `Korekce inventarizace (přebytek)`, + }, + }); + + // Create batch for the corrective receipt + await tx.sklad_batches.create({ + data: { + item_id: line.item_id, + receipt_line_id: receiptLine.id, + quantity: diff, + original_qty: diff, + unit_price: 0, + received_at: new Date(), + is_consumed: false, + }, + }); + + // Upsert item_locations + if (line.location_id != null) { + await tx.sklad_item_locations.upsert({ + where: { + item_id_location_id: { + item_id: line.item_id, + location_id: line.location_id, + }, + }, + create: { + item_id: line.item_id, + location_id: line.location_id, + quantity: diff, + }, + update: { + quantity: { + increment: diff, + }, + }, + }); + } + } else { + // Negative difference: stock deficit -> consume from oldest batches (FIFO) + const absDiff = Math.abs(diff); + + try { + const fifoBatches = await selectFifoBatches( + line.item_id, + absDiff, + tx, + ); + + for (const fb of fifoBatches) { + const batch = await tx.sklad_batches.findUnique({ + where: { id: fb.batchId }, + }); + if (!batch) continue; + + const newBatchQty = Number(batch.quantity) - fb.qty; + await tx.sklad_batches.update({ + where: { id: fb.batchId }, + data: { + quantity: newBatchQty, + is_consumed: newBatchQty <= 0, + }, + }); + } + + // Decrement item_locations + if (line.location_id != null) { + const loc = await tx.sklad_item_locations.findUnique({ + where: { + item_id_location_id: { + item_id: line.item_id, + location_id: line.location_id, + }, + }, + }); + + if (loc) { + const newLocQty = Number(loc.quantity) - absDiff; + if (newLocQty <= 0) { + await tx.sklad_item_locations.delete({ + where: { id: loc.id }, + }); + } else { + await tx.sklad_item_locations.update({ + where: { id: loc.id }, + data: { quantity: newLocQty }, + }); + } + } + } + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Nedostatečné množství na skladě"; + return { + error: `Inventarizace položky ID ${line.item_id}: ${message}`, + status: 400, + }; + } + } + } + + const sessionNumber = await generateWarehouseNumber( + "warehouse_inventory", + tx, + ); + + await tx.sklad_inventory_sessions.update({ + where: { id: sessionId }, + data: { + status: "CONFIRMED", + session_number: sessionNumber, + modified_at: new Date(), + }, + }); + + return { data: { id: sessionId, session_number: sessionNumber } }; + }); +}