# Warehouse Module Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Full warehouse/inventory management module with receipts, issues, reservations, inventory sessions, FIFO batch tracking, and reporting. **Architecture:** Document-driven (header + lines) matching existing invoice/offer patterns. 13 Prisma models, REST API under `/api/admin/warehouse`, React frontend with 17 lazy-loaded pages. True FIFO via `sklad_batches` table linked to receipt lines. **Tech Stack:** Prisma 6.19.2, Fastify 5, Zod 4, React 18, TanStack React Query, NAS file storage via NasFinancialsManager --- ## File Structure ### New Files ``` prisma/migrations/ # Auto-generated by prisma migrate dev src/ schemas/warehouse.schema.ts # Zod validation schemas for all warehouse entities services/warehouse.service.ts # Business logic (FIFO, confirm, cancel, inventory) routes/admin/warehouse.ts # All warehouse API endpoints admin/ lib/queries/warehouse.ts # TanStack Query options components/warehouse/ ItemPicker.tsx # Searchable item selector BatchPicker.tsx # FIFO batch selector LocationSelect.tsx # Location dropdown SupplierSelect.tsx # Supplier dropdown WarehouseMovementTable.tsx # Multi-line editor for receipt/issue lines pages/ Warehouse.tsx # Dashboard WarehouseItems.tsx # Item catalog list WarehouseItemDetail.tsx # Item detail WarehouseReceipts.tsx # Receipt list WarehouseReceiptDetail.tsx # Receipt detail WarehouseReceiptForm.tsx # Create/edit receipt WarehouseIssues.tsx # Issue list WarehouseIssueDetail.tsx # Issue detail WarehouseIssueForm.tsx # Create/edit issue WarehouseReservations.tsx # Reservation list WarehouseInventory.tsx # Inventory sessions list WarehouseInventoryDetail.tsx # Inventory detail WarehouseInventoryForm.tsx # Create/edit inventory session WarehouseReports.tsx # Reports hub WarehouseSuppliers.tsx # Suppliers CRUD WarehouseLocations.tsx # Locations CRUD WarehouseCategories.tsx # Categories CRUD src/__tests__/warehouse.test.ts # Backend integration tests ``` ### Modified Files | File | Change | | ---------------------------------- | ------------------------------------------- | | `prisma/schema.prisma` | Add 13 models + 5 enums | | `src/types/index.ts` | Add 8 EntityType values | | `src/server.ts` | Import + register warehouse routes | | `prisma/seed.ts` | Add 4 permissions + 3 number sequence types | | `src/admin/AdminApp.tsx` | Lazy imports + routes | | `src/admin/components/Sidebar.tsx` | Add warehouse menu entry | --- ## Phase 1: Database Foundation ### Task 1: Add Prisma Models **Files:** - Modify: `prisma/schema.prisma` - [ ] **Step 1: Add warehouse enums to schema.prisma** Add these enums at the end of the existing enums section in `prisma/schema.prisma`: ```prisma enum sklad_receipt_status { DRAFT CONFIRMED CANCELLED } enum sklad_issue_status { DRAFT CONFIRMED CANCELLED } enum sklad_reservation_status { ACTIVE FULFILLED CANCELLED } enum sklad_inventory_status { DRAFT CONFIRMED } ``` - [ ] **Step 2: Add warehouse models to schema.prisma** Add these models at the end of `prisma/schema.prisma`: ```prisma model sklad_categories { id Int @id @default(autoincrement()) name String @db.VarChar(100) description String? @db.Text sort_order Int @default(0) created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0) items sklad_items[] @@map("sklad_categories") } model sklad_suppliers { id Int @id @default(autoincrement()) name String @db.VarChar(255) ico String? @db.VarChar(20) dic String? @db.VarChar(20) contact_person String? @db.VarChar(255) email String? @db.VarChar(255) phone String? @db.VarChar(50) address String? @db.Text notes String? @db.Text is_active Boolean @default(true) created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0) receipts sklad_receipts[] @@map("sklad_suppliers") } model sklad_locations { id Int @id @default(autoincrement()) code String @unique @db.VarChar(20) name String @db.VarChar(100) description String? @db.Text is_active Boolean @default(true) created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0) items sklad_item_locations[] @@map("sklad_locations") } model sklad_items { id Int @id @default(autoincrement()) item_number String? @unique @db.VarChar(50) name String @db.VarChar(255) description String? @db.Text category_id Int? unit String @db.VarChar(20) min_quantity Decimal? @db.Decimal(12, 3) is_active Boolean @default(true) notes String? @db.Text created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0) category sklad_categories? @relation(fields: [category_id], references: [id]) batches sklad_batches[] item_locations sklad_item_locations[] @@index([category_id], map: "sklad_items_category_id") @@map("sklad_items") } model sklad_batches { id Int @id @default(autoincrement()) item_id Int receipt_line_id Int quantity Decimal @db.Decimal(12, 3) original_qty Decimal @db.Decimal(12, 3) unit_price Decimal @db.Decimal(12, 2) received_at DateTime? @db.DateTime(0) is_consumed Boolean @default(false) item sklad_items @relation(fields: [item_id], references: [id]) receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id]) @@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo") @@map("sklad_batches") } model sklad_item_locations { id Int @id @default(autoincrement()) item_id Int location_id Int quantity Decimal @default(0) @db.Decimal(12, 3) item sklad_items @relation(fields: [item_id], references: [id]) location sklad_locations @relation(fields: [location_id], references: [id]) @@unique([item_id, location_id], map: "sklad_item_locations_unique") @@map("sklad_item_locations") } model sklad_receipts { id Int @id @default(autoincrement()) receipt_number String? @db.VarChar(50) supplier_id Int? delivery_note_number String? @db.VarChar(100) delivery_note_date DateTime? @db.DateTime(0) received_by Int? notes String? @db.Text status sklad_receipt_status @default(DRAFT) created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0) supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id]) received_by_user users? @relation(fields: [received_by], references: [id]) lines sklad_receipt_lines[] attachments sklad_receipt_attachments[] @@map("sklad_receipts") } model sklad_receipt_lines { id Int @id @default(autoincrement()) receipt_id Int item_id Int quantity Decimal @db.Decimal(12, 3) unit_price Decimal @db.Decimal(12, 2) location_id Int? notes String? @db.VarChar(255) receipt sklad_receipts @relation(fields: [receipt_id], references: [id]) item sklad_items @relation(fields: [item_id], references: [id]) location sklad_locations? @relation(fields: [location_id], references: [id]) batch sklad_batches? @@index([receipt_id], map: "sklad_receipt_lines_receipt_id") @@map("sklad_receipt_lines") } model sklad_receipt_attachments { id Int @id @default(autoincrement()) receipt_id Int file_name String @db.VarChar(255) file_mime String @db.VarChar(100) file_size Int file_path String @db.VarChar(500) created_at DateTime? @default(now()) @db.DateTime(0) receipt sklad_receipts @relation(fields: [receipt_id], references: [id]) @@map("sklad_receipt_attachments") } model sklad_issues { id Int @id @default(autoincrement()) issue_number String? @db.VarChar(50) project_id Int issued_by Int? notes String? @db.Text status sklad_issue_status @default(DRAFT) created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0) project projects @relation(fields: [project_id], references: [id]) issued_by_user users? @relation(fields: [issued_by], references: [id]) lines sklad_issue_lines[] @@map("sklad_issues") } model sklad_issue_lines { id Int @id @default(autoincrement()) issue_id Int item_id Int batch_id Int quantity Decimal @db.Decimal(12, 3) location_id Int? reservation_id Int? notes String? @db.VarChar(255) issue sklad_issues @relation(fields: [issue_id], references: [id]) item sklad_items @relation(fields: [item_id], references: [id]) batch sklad_batches @relation(fields: [batch_id], references: [id]) location sklad_locations? @relation(fields: [location_id], references: [id]) reservation sklad_reservations? @relation(fields: [reservation_id], references: [id]) @@index([issue_id], map: "sklad_issue_lines_issue_id") @@map("sklad_issue_lines") } model sklad_reservations { id Int @id @default(autoincrement()) item_id Int project_id Int quantity Decimal @db.Decimal(12, 3) remaining_qty Decimal @db.Decimal(12, 3) reserved_by Int? notes String? @db.Text status sklad_reservation_status @default(ACTIVE) created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0) item sklad_items @relation(fields: [item_id], references: [id]) project projects @relation(fields: [project_id], references: [id]) reserved_by_user users? @relation(fields: [reserved_by], references: [id]) issue_lines sklad_issue_lines[] @@index([item_id, status], map: "sklad_reservations_item_status") @@map("sklad_reservations") } model sklad_inventory_sessions { id Int @id @default(autoincrement()) session_number String? @db.VarChar(50) notes String? @db.Text status sklad_inventory_status @default(DRAFT) created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0) lines sklad_inventory_lines[] @@map("sklad_inventory_sessions") } model sklad_inventory_lines { id Int @id @default(autoincrement()) session_id Int item_id Int location_id Int? system_qty Decimal @db.Decimal(12, 3) actual_qty Decimal @db.Decimal(12, 3) difference Decimal @db.Decimal(12, 3) notes String? @db.VarChar(255) session sklad_inventory_sessions @relation(fields: [session_id], references: [id]) item sklad_items @relation(fields: [item_id], references: [id]) location sklad_locations? @relation(fields: [location_id], references: [id]) @@index([session_id], map: "sklad_inventory_lines_session_id") @@map("sklad_inventory_lines") } ``` - [ ] **Step 3: Add reverse relations to existing models** Add these relation fields to the `projects` model in `prisma/schema.prisma`: ```prisma sklad_issues sklad_issues[] sklad_reservations sklad_reservations[] ``` Add this relation field to the `users` model in `prisma/schema.prisma`: ```prisma sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy") sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy") sklad_reservations sklad_reservations[] @relation("SkladReservedBy") ``` Note: The `users` model likely already has multiple relations, so named relation mappings may be needed to avoid ambiguity. Use `@relation("SkladReceivedBy")` on `sklad_receipts.received_by_user`, `@relation("SkladIssuedBy")` on `sklad_issues.issued_by_user`, and `@relation("SkladReservedBy")` on `sklad_reservations.reserved_by_user`. Update the corresponding relation field definitions in the warehouse models accordingly: In `sklad_receipts`: ```prisma received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id]) ``` In `sklad_issues`: ```prisma issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id]) ``` In `sklad_reservations`: ```prisma reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id]) ``` - [ ] **Step 4: Run migration** ```bash npx prisma migrate dev --name add_warehouse_module npx prisma generate ``` Expected: Migration created and applied. Prisma client regenerated. - [ ] **Step 5: Commit** ```bash git add prisma/schema.prisma prisma/migrations/ git commit -m "feat(warehouse): add Prisma schema for warehouse module" ``` --- ### Task 2: Add Entity Types and Permissions **Files:** - Modify: `src/types/index.ts` - Modify: `prisma/seed.ts` - [ ] **Step 1: Add EntityType values** In `src/types/index.ts`, extend the `EntityType` union to include: ```ts export type EntityType = | "user" | "role" | "attendance" | "leave_request" | "invoice" | "quotation" | "order" | "project" | "customer" | "trip" | "vehicle" | "bank_account" | "company_settings" | "leave_balance" | "project_file" | "audit_logs" | "warehouse_item" | "warehouse_receipt" | "warehouse_issue" | "warehouse_reservation" | "warehouse_inventory" | "warehouse_supplier" | "warehouse_category" | "warehouse_location"; ``` - [ ] **Step 2: Add warehouse permissions to seed.ts** In `prisma/seed.ts`, add these entries to the `PERMISSIONS` array: ```ts { name: "warehouse.view", display_name: "Zobrazit sklad", module: "warehouse", description: "Prohlížet stav skladu, položky, reporty a historii pohybů" }, { name: "warehouse.operate", display_name: "Příjem a výdej", module: "warehouse", description: "Vytvářet a potvrzovat příjmy, výdeje a rezervace" }, { name: "warehouse.manage", display_name: "Správa skladu", module: "warehouse", description: "Spravovat katalog materiálů, dodavatele, lokace a kategorie" }, { name: "warehouse.inventory", display_name: "Inventura", module: "warehouse", description: "Vytvářet a potvrzovat inventurní sčítkání" }, ``` - [ ] **Step 3: Add number sequence types to seed.ts** In the number sequence seeding section of `prisma/seed.ts`, add `"warehouse_receipt"`, `"warehouse_issue"`, and `"warehouse_inventory"` to the types array: ```ts const types = [ "quotation", "order", "invoice", "warehouse_receipt", "warehouse_issue", "warehouse_inventory", ]; ``` - [ ] **Step 4: Re-seed and verify** ```bash npx prisma db seed ``` Expected: 4 new warehouse permissions created, 3 new number sequence types seeded. - [ ] **Step 5: Commit** ```bash git add src/types/index.ts prisma/seed.ts git commit -m "feat(warehouse): add entity types, permissions, and number sequences" ``` --- ## Phase 2: Backend - Schemas & Service ### Task 3: Zod Validation Schemas **Files:** - Create: `src/schemas/warehouse.schema.ts` - [ ] **Step 1: Create warehouse.schema.ts** ```ts import { z } from "zod"; // === Categories === export const CreateCategorySchema = z.object({ name: z.string().min(1, "Název je povinný"), description: z.string().nullish(), sort_order: z .union([z.number(), z.string()]) .transform((v) => Number(v)) .optional() .default(0), }); export const UpdateCategorySchema = z.object({ name: z.string().min(1, "Název je povinný").optional(), description: z.string().nullish(), sort_order: z .union([z.number(), z.string()]) .transform((v) => Number(v)) .optional(), }); export type CreateCategoryInput = z.infer; export type UpdateCategoryInput = z.infer; // === Suppliers === export const CreateSupplierSchema = z.object({ name: z.string().min(1, "Název je povinný"), ico: z.string().nullish(), dic: z.string().nullish(), contact_person: z.string().nullish(), email: z.string().nullish(), phone: z.string().nullish(), address: z.string().nullish(), notes: z.string().nullish(), is_active: z .preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .optional() .default(true), }); export const UpdateSupplierSchema = z.object({ name: z.string().min(1, "Název je povinný").optional(), ico: z.string().nullish(), dic: z.string().nullish(), contact_person: z.string().nullish(), email: z.string().nullish(), phone: z.string().nullish(), address: z.string().nullish(), notes: z.string().nullish(), is_active: z .preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .optional(), }); export type CreateSupplierInput = z.infer; export type UpdateSupplierInput = z.infer; // === Locations === export const CreateLocationSchema = z.object({ code: z.string().min(1, "Kód je povinný"), name: z.string().min(1, "Název je povinný"), description: z.string().nullish(), is_active: z .preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .optional() .default(true), }); export const UpdateLocationSchema = z.object({ code: z.string().min(1, "Kód je povinný").optional(), name: z.string().min(1, "Název je povinný").optional(), description: z.string().nullish(), is_active: z .preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .optional(), }); export type CreateLocationInput = z.infer; export type UpdateLocationInput = z.infer; // === Items === const UNIT_LIST = ["ks", "m", "kg", "bal", "sada", "m2", "m3", "l"] as const; export const UnitEnum = z.enum(UNIT_LIST); export type Unit = z.infer; export const CreateItemSchema = z.object({ item_number: z.string().nullish(), name: z.string().min(1, "Název je povinný"), description: z.string().nullish(), category_id: z .union([z.number(), z.string(), z.null()]) .transform((v) => (v === null ? null : Number(v))) .nullish(), unit: UnitEnum, min_quantity: z .union([z.number(), z.string(), z.null()]) .transform((v) => (v === null ? null : Number(v))) .nullish(), notes: z.string().nullish(), is_active: z .preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .optional() .default(true), }); export const UpdateItemSchema = z.object({ item_number: z.string().nullish(), name: z.string().min(1, "Název je povinný").optional(), description: z.string().nullish(), category_id: z .union([z.number(), z.string(), z.null()]) .transform((v) => (v === null ? null : Number(v))) .nullish(), unit: UnitEnum.optional(), min_quantity: z .union([z.number(), z.string(), z.null()]) .transform((v) => (v === null ? null : Number(v))) .nullish(), notes: z.string().nullish(), is_active: z .preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .optional(), }); export type CreateItemInput = z.infer; export type UpdateItemInput = z.infer; // === Receipts === export const ReceiptLineSchema = z.object({ item_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), quantity: z.union([z.number(), z.string()]).transform((v) => Number(v)), unit_price: z.union([z.number(), z.string()]).transform((v) => Number(v)), location_id: z .union([z.number(), z.string(), z.null()]) .transform((v) => (v === null ? null : Number(v))) .nullish(), notes: z.string().nullish(), }); export const CreateReceiptSchema = z.object({ supplier_id: z .union([z.number(), z.string(), z.null()]) .transform((v) => (v === null ? null : Number(v))) .nullish(), delivery_note_number: z.string().nullish(), delivery_note_date: z.union([z.string(), z.null()]).nullish(), notes: z.string().nullish(), lines: z .array(ReceiptLineSchema) .min(1, "Příjem musí obsahovat alespoň jednu položku"), }); export const UpdateReceiptSchema = z.object({ supplier_id: z .union([z.number(), z.string(), z.null()]) .transform((v) => (v === null ? null : Number(v))) .nullish(), delivery_note_number: z.string().nullish(), delivery_note_date: z.union([z.string(), z.null()]).nullish(), notes: z.string().nullish(), lines: z .array(ReceiptLineSchema) .min(1, "Příjem musí obsahovat alespoň jednu položku") .optional(), }); export type CreateReceiptInput = z.infer; export type UpdateReceiptInput = z.infer; export type ReceiptLineInput = z.infer; // === Issues === export const IssueLineSchema = z.object({ item_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), batch_id: z .union([z.number(), z.string()]) .transform((v) => Number(v)) .nullish(), quantity: z.union([z.number(), z.string()]).transform((v) => Number(v)), location_id: z .union([z.number(), z.string(), z.null()]) .transform((v) => (v === null ? null : Number(v))) .nullish(), reservation_id: z .union([z.number(), z.string(), z.null()]) .transform((v) => (v === null ? null : Number(v))) .nullish(), notes: z.string().nullish(), }); export const CreateIssueSchema = z.object({ project_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), notes: z.string().nullish(), lines: z .array(IssueLineSchema) .min(1, "Výdej musí obsahovat alespoň jednu položku"), }); export const UpdateIssueSchema = z.object({ project_id: z .union([z.number(), z.string()]) .transform((v) => Number(v)) .optional(), notes: z.string().nullish(), lines: z .array(IssueLineSchema) .min(1, "Výdej musí obsahovat alespoň jednu položku") .optional(), }); export type CreateIssueInput = z.infer; export type UpdateIssueInput = z.infer; export type IssueLineInput = z.infer; // === Reservations === export const CreateReservationSchema = z.object({ item_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), project_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), quantity: z.union([z.number(), z.string()]).transform((v) => Number(v)), notes: z.string().nullish(), }); export type CreateReservationInput = z.infer; // === Inventory === export const InventoryLineSchema = z.object({ item_id: z.union([z.number(), z.string()]).transform((v) => Number(v)), location_id: z .union([z.number(), z.string(), z.null()]) .transform((v) => (v === null ? null : Number(v))) .nullish(), actual_qty: z.union([z.number(), z.string()]).transform((v) => Number(v)), notes: z.string().nullish(), }); export const CreateInventorySessionSchema = z.object({ notes: z.string().nullish(), lines: z .array(InventoryLineSchema) .min(1, "Inventura musí obsahovat alespoň jednu položku"), }); export const UpdateInventorySessionSchema = z.object({ notes: z.string().nullish(), lines: z .array(InventoryLineSchema) .min(1, "Inventura musí obsahovat alespoň jednu položku") .optional(), }); export type CreateInventorySessionInput = z.infer< typeof CreateInventorySessionSchema >; export type UpdateInventorySessionInput = z.infer< typeof UpdateInventorySessionSchema >; export type InventoryLineInput = z.infer; ``` - [ ] **Step 2: Verify schema compiles** ```bash npx tsc --noEmit src/schemas/warehouse.schema.ts ``` Expected: No type errors. - [ ] **Step 3: Commit** ```bash git add src/schemas/warehouse.schema.ts git commit -m "feat(warehouse): add Zod validation schemas" ``` --- ### Task 4: Warehouse Service - Master Data & FIFO **Files:** - Create: `src/services/warehouse.service.ts` - [ ] **Step 1: Create warehouse.service.ts with master data functions and FIFO logic** ```ts import prisma from "../config/database"; import { Prisma } from "@prisma/client"; type TxClient = Omit< Prisma.Client, "$connect" | "$disconnect" | "$on" | "$transaction" | "$extends" >; // === Available Quantity === export async function getItemAvailableQty(itemId: number): Promise { const totalStock = await prisma.sklad_batches.aggregate({ where: { item_id: itemId, is_consumed: false }, _sum: { quantity: true }, }); const totalReserved = await prisma.sklad_reservations.aggregate({ where: { item_id: itemId, status: "ACTIVE" }, _sum: { quantity: true }, }); const stock = Number(totalStock._sum.quantity ?? 0); const reserved = Number(totalReserved._sum.quantity ?? 0); return Math.max(0, stock - reserved); } // === FIFO Batch Selection === 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" }, }); const result: Array<{ batchId: number; qty: number }> = []; let remaining = requestedQty; for (const batch of batches) { if (remaining <= 0) break; const available = Number(batch.quantity); const take = Math.min(available, remaining); result.push({ batchId: batch.id, qty: take }); remaining -= take; } if (remaining > 0) { throw new Error( `Nedostatečné zásoby pro položku ID ${itemId}. Chybí ${remaining} jednotek.`, ); } return result; } // === Item Total Stock === export async function getItemTotalStock(itemId: number): Promise { const result = await prisma.sklad_batches.aggregate({ where: { item_id: itemId, is_consumed: false }, _sum: { quantity: true }, }); return Number(result._sum.quantity ?? 0); } // === Item Stock Value === 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) => sum + Number(b.quantity) * Number(b.unit_price), 0, ); } // === Below Minimum Items === export async function getBelowMinimumItems() { const items = await prisma.sklad_items.findMany({ where: { is_active: true, min_quantity: { not: null } }, include: { category: true }, }); const result = []; for (const item of items) { const totalStock = await getItemTotalStock(item.id); const minQty = Number(item.min_quantity ?? 0); if (totalStock < minQty) { result.push({ ...item, total_quantity: totalStock, below_by: minQty - totalStock, }); } } return result; } // === Receipt Confirm === export async function confirmReceipt(receiptId: number, authUserId: 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 nenalezen", status: 404 }; if (receipt.status !== "DRAFT") return { error: "Lze potvrdit pouze návrh", status: 400 }; // Generate receipt number const seq = await tx.number_sequences.findFirst({ where: { type: "warehouse_receipt", year: new Date().getFullYear() }, }); if (!seq) return { error: "Číselná řada pro příjmy nenalezena", status: 500 }; const nextNum = (seq.last_number ?? 0) + 1; await tx.number_sequences.update({ where: { id: seq.id }, data: { last_number: nextNum }, }); const year = new Date().getFullYear(); const receiptNumber = `PRI-${year}-${String(nextNum).padStart(3, "0")}`; // Create batches and update item locations for each line for (const line of receipt.lines) { // Create 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, }, }); // Update item location if (line.location_id) { const existing = await tx.sklad_item_locations.findUnique({ where: { item_id_location_id: { item_id: line.item_id, location_id: line.location_id, }, }, }); if (existing) { await tx.sklad_item_locations.update({ where: { id: existing.id }, data: { quantity: { increment: line.quantity } }, }); } else { await tx.sklad_item_locations.create({ data: { item_id: line.item_id, location_id: line.location_id, quantity: line.quantity, }, }); } } } // Update receipt status await tx.sklad_receipts.update({ where: { id: receiptId }, data: { status: "CONFIRMED", receipt_number: receiptNumber, modified_at: new Date(), }, }); return { data: { id: receiptId, receipt_number: receiptNumber } }; }); } // === Receipt Cancel === export async function cancelReceipt(receiptId: number) { return prisma.$transaction(async (tx) => { const receipt = await tx.sklad_receipts.findUnique({ where: { id: receiptId }, include: { lines: { include: { batch: true } } }, }); if (!receipt) return { error: "Příjem nenalezen", status: 404 }; if (receipt.status === "CANCELLED") return { error: "Příjem je již zrušen", status: 400 }; if (receipt.status === "CONFIRMED") { // Check if any batch has been consumed for (const line of receipt.lines) { if (line.batch) { const consumed = Number(line.batch.original_qty) - Number(line.batch.quantity); if (consumed > 0) { return { error: "Příjem nelze zrušit - některé šarže již byly částečně vydány. Nejprve zrušte příslušné výdeje.", status: 409, }; } } } // Reverse stock changes for (const line of receipt.lines) { if (line.batch) { // Remove item location qty if (line.location_id) { await tx.sklad_item_locations.updateMany({ where: { item_id: line.item_id, location_id: line.location_id }, data: { quantity: { decrement: line.batch.quantity } }, }); } // Delete the 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 === export async function confirmIssue(issueId: number, authUserId: 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 nenalezen", status: 404 }; if (issue.status !== "DRAFT") return { error: "Lze potvrdit pouze návrh", status: 400 }; // Validate each line has enough batch 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} nenalezena`, status: 404 }; if (Number(batch.quantity) < Number(line.quantity)) { return { error: `Nedostatečné zásoby ve šarži ID ${line.batch_id}`, status: 400, }; } } // Generate issue number const seq = await tx.number_sequences.findFirst({ where: { type: "warehouse_issue", year: new Date().getFullYear() }, }); if (!seq) return { error: "Číselná řada pro výdeje nenalezena", status: 500 }; const nextNum = (seq.last_number ?? 0) + 1; await tx.number_sequences.update({ where: { id: seq.id }, data: { last_number: nextNum }, }); const year = new Date().getFullYear(); const issueNumber = `VYD-${year}-${String(nextNum).padStart(3, "0")}`; // Process each line for (const line of issue.lines) { // Decrement batch quantity const batch = await tx.sklad_batches.findUnique({ where: { id: line.batch_id }, }); if (!batch) continue; const newQty = Number(batch.quantity) - Number(line.quantity); await tx.sklad_batches.update({ where: { id: line.batch_id }, data: { quantity: newQty, is_consumed: newQty === 0, }, }); // Decrement item location if (line.location_id) { await tx.sklad_item_locations.updateMany({ where: { item_id: line.item_id, location_id: line.location_id }, data: { quantity: { decrement: line.quantity } }, }); } // Fulfill reservation if linked if (line.reservation_id) { const reservation = await tx.sklad_reservations.findUnique({ where: { id: line.reservation_id }, }); if (reservation) { const newRemaining = Number(reservation.remaining_qty) - Number(line.quantity); await tx.sklad_reservations.update({ where: { id: line.reservation_id }, data: { remaining_qty: Math.max(0, newRemaining), status: newRemaining <= 0 ? "FULFILLED" : "ACTIVE", modified_at: new Date(), }, }); } } } await tx.sklad_issues.update({ where: { id: issueId }, data: { status: "CONFIRMED", issue_number: issueNumber, modified_at: new Date(), }, }); return { data: { id: issueId, issue_number: issueNumber } }; }); } // === Issue Cancel === export async function cancelIssue(issueId: 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 nenalezen", status: 404 }; if (issue.status === "CANCELLED") return { error: "Výdej je již zrušen", status: 400 }; if (issue.status === "CONFIRMED") { for (const line of issue.lines) { // Restore batch quantity const batch = await tx.sklad_batches.findUnique({ where: { id: line.batch_id }, }); if (batch) { await tx.sklad_batches.update({ where: { id: line.batch_id }, data: { quantity: { increment: line.quantity }, is_consumed: false, }, }); } // Restore item location if (line.location_id) { await tx.sklad_item_locations.updateMany({ where: { item_id: line.item_id, location_id: line.location_id }, data: { quantity: { increment: line.quantity } }, }); } // Restore reservation if (line.reservation_id) { const reservation = await tx.sklad_reservations.findUnique({ where: { id: line.reservation_id }, }); if (reservation) { await tx.sklad_reservations.update({ where: { id: line.reservation_id }, data: { remaining_qty: { increment: line.quantity }, status: "ACTIVE", 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 === export async function createReservation(data: { item_id: number; project_id: number; quantity: number; reserved_by?: number; notes?: string | null; }) { const item = await prisma.sklad_items.findUnique({ where: { id: data.item_id }, }); if (!item || !item.is_active) return { error: "Položka nenalezena nebo není aktivní", status: 404 }; const available = await getItemAvailableQty(data.item_id); if (available < data.quantity) { return { error: `Nedostatečné dostupné zásoby. K dispozici: ${available}`, 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, notes: data.notes, }, }); return { data: reservation }; } // === Reservation Cancel === export async function cancelReservation(reservationId: number) { const reservation = await prisma.sklad_reservations.findUnique({ where: { id: reservationId }, }); if (!reservation) return { error: "Rezervace nenalezena", status: 404 }; if (reservation.status !== "ACTIVE") return { error: "Lze zrušit pouze aktivní rezervaci", 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 === export async function confirmInventorySession(sessionId: number) { return prisma.$transaction(async (tx) => { const session = await tx.sklad_inventory_sessions.findUnique({ where: { id: sessionId }, include: { lines: true }, }); if (!session) return { error: "Inventura nenalezena", status: 404 }; if (session.status !== "DRAFT") return { error: "Lze potvrdit pouze návrh", status: 400 }; for (const line of session.lines) { const diff = Number(line.actual_qty) - Number(line.system_qty); if (diff > 0) { // More stock than system: create corrective receipt const correctiveReceipt = await tx.sklad_receipts.create({ data: { supplier_id: null, notes: `Korekce z inventury #${session.id}`, status: "CONFIRMED", lines: { create: { item_id: line.item_id, quantity: diff, unit_price: 0, location_id: line.location_id, notes: `Inventura #${session.id} - nález`, }, }, }, }); // Create batch for the corrective receipt line const correctiveLine = await tx.sklad_receipt_lines.findFirst({ where: { receipt_id: correctiveReceipt.id }, }); if (correctiveLine) { await tx.sklad_batches.create({ data: { item_id: line.item_id, receipt_line_id: correctiveLine.id, quantity: diff, original_qty: diff, unit_price: 0, received_at: new Date(), is_consumed: false, }, }); } // Update item location if (line.location_id) { const existing = await tx.sklad_item_locations.findUnique({ where: { item_id_location_id: { item_id: line.item_id, location_id: line.location_id, }, }, }); if (existing) { await tx.sklad_item_locations.update({ where: { id: existing.id }, data: { quantity: { increment: diff } }, }); } else { await tx.sklad_item_locations.create({ data: { item_id: line.item_id, location_id: line.location_id, quantity: diff, }, }); } } } else if (diff < 0) { // Less stock than system: create corrective issue const absDiff = Math.abs(diff); // Find oldest batch to consume const batches = await tx.sklad_batches.findMany({ where: { item_id: line.item_id, is_consumed: false, quantity: { gt: 0 }, }, orderBy: { received_at: "asc" }, }); let remaining = absDiff; for (const batch of batches) { if (remaining <= 0) break; const take = Math.min(Number(batch.quantity), remaining); await tx.sklad_batches.update({ where: { id: batch.id }, data: { quantity: { decrement: take }, is_consumed: Number(batch.quantity) - take === 0, }, }); remaining -= take; } // Update item location if (line.location_id) { await tx.sklad_item_locations.updateMany({ where: { item_id: line.item_id, location_id: line.location_id }, data: { quantity: { decrement: absDiff } }, }); } } } // Generate session number const seq = await tx.number_sequences.findFirst({ where: { type: "warehouse_inventory", year: new Date().getFullYear() }, }); const nextNum = (seq?.last_number ?? 0) + 1; if (seq) { await tx.number_sequences.update({ where: { id: seq.id }, data: { last_number: nextNum }, }); } const year = new Date().getFullYear(); const sessionNumber = `INV-${year}-${String(nextNum).padStart(3, "0")}`; 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 } }; }); } ``` - [ ] **Step 2: Verify service compiles** ```bash npx tsc --noEmit src/services/warehouse.service.ts ``` Expected: No type errors. - [ ] **Step 3: Commit** ```bash git add src/services/warehouse.service.ts git commit -m "feat(warehouse): add warehouse service with FIFO and business logic" ``` --- ## Phase 3: Backend - API Routes ### Task 5: Warehouse Routes - Master Data **Files:** - Create: `src/routes/admin/warehouse.ts` (partial - master data endpoints) - [ ] **Step 1: Create warehouse routes file with categories, suppliers, locations, items endpoints** This is a large file. Create `src/routes/admin/warehouse.ts` with the following structure. The file will be extended in subsequent tasks with receipts, issues, reservations, inventory, and reports. ```ts 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 { CreateCategorySchema, UpdateCategorySchema, CreateSupplierSchema, UpdateSupplierSchema, CreateLocationSchema, UpdateLocationSchema, CreateItemSchema, UpdateItemSchema, } from "../../schemas/warehouse.schema"; import { getItemAvailableQty, getItemTotalStock, getItemStockValue, getBelowMinimumItems, } from "../../services/warehouse.service"; export default async function warehouseRoutes(fastify: FastifyInstance): Promise { // ============================== // CATEGORIES // ============================== 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); }); 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 category = await prisma.sklad_categories.create({ data: parsed.data }); await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_category", entityId: category.id, description: `Vytvořena kategorie ${category.name}` }); return success(reply, { id: category.id }, 201, "Kategorie byla vytvořena"); }); 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 existing = await prisma.sklad_categories.findUnique({ where: { id } }); if (!existing) return error(reply, "Kategorie nenalezena", 404); const category = await prisma.sklad_categories.update({ where: { id }, data: parsed.data }); await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_category", entityId: id, description: `Upravena kategorie ${category.name}`, oldValues: existing, newValues: parsed.data }); return success(reply, { id }, 200, "Kategorie byla uložena"); }); 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 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}` }); return success(reply, null, 200, "Kategorie smazána"); }); // ============================== // SUPPLIERS // ============================== fastify.get("/suppliers", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip, search } = parsePagination(query); const where: Record = {}; if (search) { where.OR = [ { name: { contains: search } }, { ico: { contains: search } }, { contact_person: { contains: search } }, ]; } const [data, 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, pagination: buildPaginationMeta(total, page, limit) }); }); 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); }); 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 supplier = await prisma.sklad_suppliers.create({ data: parsed.data }); await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_supplier", entityId: supplier.id, description: `Vytvořen dodavatel ${supplier.name}` }); return success(reply, { id: supplier.id }, 201, "Dodavatel byl vytvořen"); }); 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 existing = await prisma.sklad_suppliers.findUnique({ where: { id } }); if (!existing) return error(reply, "Dodavatel nenalezen", 404); await prisma.sklad_suppliers.update({ where: { id }, data: parsed.data }); await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_supplier", entityId: id, description: `Upraven dodavatel ${existing.name}`, oldValues: existing, newValues: parsed.data }); return success(reply, { id }, 200, "Dodavatel byl uložen"); }); 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: "update", entityType: "warehouse_supplier", entityId: id, description: `Deaktivován dodavatel ${existing.name}` }); return success(reply, null, 200, "Dodavatel deaktivován"); }); // ============================== // LOCATIONS // ============================== 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); }); 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 location = await prisma.sklad_locations.create({ data: parsed.data }); await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_location", entityId: location.id, description: `Vytvořena lokace ${location.code}` }); return success(reply, { id: location.id }, 201, "Lokace byla vytvořena"); }); 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 existing = await prisma.sklad_locations.findUnique({ where: { id } }); if (!existing) return error(reply, "Lokace nenalezena", 404); await prisma.sklad_locations.update({ where: { id }, data: parsed.data }); await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_location", entityId: id, description: `Upravena lokace ${existing.code}`, oldValues: existing, newValues: parsed.data }); return success(reply, { id }, 200, "Lokace byla uložena"); }); 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, "Lokace nenalezena", 404); const itemCount = await prisma.sklad_item_locations.count({ where: { location_id: id, quantity: { not: 0 } } }); if (itemCount > 0) return error(reply, "Lokaci nelze smazat - obsahuje materiál", 409); await prisma.sklad_locations.delete({ where: { id } }); await logAudit({ request, authData: request.authData, action: "delete", entityType: "warehouse_location", entityId: id, description: `Smazána lokace ${existing.code}` }); return success(reply, null, 200, "Lokace smazána"); }); // ============================== // ITEMS // ============================== fastify.get("/items", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip, sort, order, search } = parsePagination(query); const categoryFilter = query.category_id ? Number(query.category_id) : undefined; const belowMin = query.below_min === "true" || query.below_min === "1"; const where: Record = { is_active: true }; if (search) { where.OR = [ { name: { contains: search } }, { item_number: { contains: search } }, ]; } if (categoryFilter) where.category_id = categoryFilter; const sortField = sort && ["name", "item_number", "created_at"].includes(sort) ? sort : "name"; const sortOrder = order === "desc" ? "desc" : "asc"; const [data, total] = await Promise.all([ prisma.sklad_items.findMany({ where, skip, take: limit, orderBy: { [sortField]: sortOrder }, include: { category: true, item_locations: { include: { location: true } } }, }), prisma.sklad_items.count({ where }), ]); // Add computed stock data const enriched = await Promise.all(data.map(async (item) => { const totalStock = await getItemTotalStock(item.id); const available = await getItemAvailableQty(item.id); const value = await getItemStockValue(item.id); const belowMinimum = item.min_quantity ? totalStock < Number(item.min_quantity) : false; return { ...item, total_quantity: totalStock, available_quantity: available, stock_value: value, below_minimum: belowMinimum }; })); return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) }); }); 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: true, batches: { where: { is_consumed: false }, orderBy: { received_at: "asc" } }, item_locations: { include: { location: true } }, }, }); if (!item) return error(reply, "Položka nenalezena", 404); const totalStock = await getItemTotalStock(id); const available = await getItemAvailableQty(id); const value = await getItemStockValue(id); return success(reply, { ...item, total_quantity: totalStock, available_quantity: available, stock_value: value }); }); 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 item = await prisma.sklad_items.create({ data: parsed.data }); await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_item", entityId: item.id, description: `Vytvořena položka ${item.name}` }); return success(reply, { id: item.id }, 201, "Položka byla vytvořena"); }); 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 existing = await prisma.sklad_items.findUnique({ where: { id } }); if (!existing) return error(reply, "Položka nenalezena", 404); // Prevent unit change if movements exist if (parsed.data.unit && parsed.data.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); } await prisma.sklad_items.update({ where: { id }, data: parsed.data }); await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_item", entityId: id, description: `Upravena položka ${existing.name}`, oldValues: existing, newValues: parsed.data }); return success(reply, { id }, 200, "Položka byla uložena"); }); 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}` }); return success(reply, null, 200, "Položka deaktivována"); }); 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 available = await getItemAvailableQty(id); return success(reply, { item_id: id, available_quantity: available }); }); 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 batches = await prisma.sklad_batches.findMany({ where: { item_id: id, is_consumed: false, quantity: { gt: 0 } }, orderBy: { received_at: "asc" }, }); return success(reply, batches); }); ``` - [ ] **Step 2: Commit** ```bash git add src/routes/admin/warehouse.ts git commit -m "feat(warehouse): add master data API routes" ``` --- ### Task 6: Warehouse Routes - Receipts, Issues, Reservations, Inventory, Reports **Files:** - Modify: `src/routes/admin/warehouse.ts` (append remaining endpoints) - [ ] **Step 1: Add receipt endpoints** Append to `warehouseRoutes` in `src/routes/admin/warehouse.ts`: ```ts // ============================== // RECEIPTS // ============================== fastify.get( "/receipts", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip, sort, order, search } = parsePagination(query); const statusFilter = query.status as string | undefined; const supplierFilter = query.supplier_id ? Number(query.supplier_id) : undefined; const dateFrom = query.date_from as string | undefined; const dateTo = query.date_to as string | undefined; const where: Record = {}; if (statusFilter) where.status = statusFilter; if (supplierFilter) where.supplier_id = supplierFilter; if (dateFrom || dateTo) { const createdAt: Record = {}; if (dateFrom) createdAt.gte = new Date(dateFrom); if (dateTo) createdAt.lte = new Date(dateTo); where.created_at = createdAt; } if (search) { where.OR = [ { receipt_number: { contains: search } }, { delivery_note_number: { contains: search } }, { notes: { contains: search } }, ]; } const sortField = sort && ["receipt_number", "created_at", "status"].includes(sort) ? sort : "created_at"; const sortOrder = order === "asc" ? "asc" : "desc"; const [data, total] = await Promise.all([ prisma.sklad_receipts.findMany({ where, skip, take: limit, orderBy: { [sortField]: sortOrder }, include: { supplier: true, received_by_user: true, _count: { select: { lines: true } }, }, }), prisma.sklad_receipts.count({ where }), ]); return reply.send({ success: true, data, pagination: buildPaginationMeta(total, page, limit), }); }, ); 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: true, received_by_user: true, lines: { include: { item: true, location: true } }, attachments: true, }, }); if (!receipt) return error(reply, "Příjem nenalezen", 404); return success(reply, receipt); }, ); 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 receipt = await prisma.sklad_receipts.create({ data: { supplier_id: parsed.data.supplier_id, delivery_note_number: parsed.data.delivery_note_number, delivery_note_date: parsed.data.delivery_note_date ? new Date(parsed.data.delivery_note_date) : null, received_by: request.authData.userId, notes: parsed.data.notes, lines: { create: parsed.data.lines }, }, include: { lines: true }, }); await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_receipt", entityId: receipt.id, description: `Vytvořen příjem s ${receipt.lines.length} položkami`, }); return success(reply, { id: receipt.id }, 201, "Příjem byl vytvořen"); }, ); 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 existing = await prisma.sklad_receipts.findUnique({ where: { id } }); if (!existing) return error(reply, "Příjem nenalezen", 404); if (existing.status !== "DRAFT") return error(reply, "Lze upravit pouze návrh", 400); // Update header await prisma.sklad_receipts.update({ where: { id }, data: { supplier_id: parsed.data.supplier_id, delivery_note_number: parsed.data.delivery_note_number, delivery_note_date: parsed.data.delivery_note_date ? new Date(parsed.data.delivery_note_date) : null, notes: parsed.data.notes, modified_at: new Date(), }, }); // Replace lines if provided if (parsed.data.lines) { await prisma.sklad_receipt_lines.deleteMany({ where: { receipt_id: id }, }); await prisma.sklad_receipt_lines.createMany({ data: parsed.data.lines.map((line) => ({ receipt_id: id, ...line })), }); } await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_receipt", entityId: id, description: `Upraven příjem`, }); return success(reply, { id }, 200, "Příjem byl uložen"); }, ); 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 result = await confirmReceipt(id, request.authData.userId); if ("error" in result) return error(reply, result.error, result.status ?? 400); await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_receipt", entityId: id, description: `Potvrzen příjem ${result.data.receipt_number}`, }); return success(reply, result.data, 200, "Příjem byl potvrzen"); }, ); 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: "update", entityType: "warehouse_receipt", entityId: id, description: `Zrušen příjem`, }); return success(reply, result.data, 200, "Příjem byl zrušen"); }, ); // Receipt attachments (multipart) await fastify.register(multipart, { limits: { fileSize: config.nas.maxUploadSize }, }); fastify.post<{ Params: { id: string } }>( "/receipts/:id/attachments", { preHandler: requirePermission("warehouse.operate") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const receipt = await prisma.sklad_receipts.findUnique({ where: { id } }); if (!receipt) return error(reply, "Příjem nenalezen", 404); const part = await request.file(); if (!part) return error(reply, "Soubor nebyl nahrán", 400); const buffer = await part.toBuffer(); const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; if (!nasFinancialsManager.isConfigured()) return error(reply, "Úložiště souborů není dostupné", 503); const result = nasFinancialsManager.saveReceivedInvoice( part.filename, year, month, buffer, ); if ("error" in result) return error(reply, result.error, 500); const attachment = await prisma.sklad_receipt_attachments.create({ data: { receipt_id: id, file_name: part.filename, file_mime: part.mimetype, file_size: buffer.length, file_path: result.filePath, }, }); await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_receipt", entityId: id, description: `Nahrána příloha ${part.filename}`, }); return success(reply, { id: attachment.id }, 201, "Příloha byla nahrána"); }, ); fastify.delete<{ Params: { id: string; attachmentId: string } }>( "/receipts/:id/attachments/:attachmentId", { preHandler: requirePermission("warehouse.operate") }, async (request, reply) => { const receiptId = parseId(request.params.id, reply); if (receiptId === 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 || attachment.receipt_id !== receiptId) return error(reply, "Příloha nenalezena", 404); await prisma.sklad_receipt_attachments.delete({ where: { id: attachmentId }, }); if (attachment.file_path) { nasFinancialsManager.deleteReceivedInvoice(attachment.file_path); } await logAudit({ request, authData: request.authData, action: "delete", entityType: "warehouse_receipt", entityId: receiptId, description: `Smazána příloha ${attachment.file_name}`, }); return success(reply, null, 200, "Příloha smazána"); }, ); ``` Add imports at top of file: ```ts import multipart from "@fastify/multipart"; import config from "../../config/env"; import { nasFinancialsManager } from "../../services/nas-financials-manager"; import { confirmReceipt, cancelReceipt, confirmIssue, cancelIssue, createReservation, cancelReservation, confirmInventorySession, } from "../../services/warehouse.service"; ``` - [ ] **Step 2: Add issue endpoints** ```ts // ============================== // ISSUES // ============================== fastify.get( "/issues", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip, sort, order, search } = parsePagination(query); const statusFilter = query.status as string | undefined; const projectFilter = query.project_id ? Number(query.project_id) : undefined; const dateFrom = query.date_from as string | undefined; const dateTo = query.date_to as string | undefined; const where: Record = {}; if (statusFilter) where.status = statusFilter; if (projectFilter) where.project_id = projectFilter; if (dateFrom || dateTo) { const createdAt: Record = {}; if (dateFrom) createdAt.gte = new Date(dateFrom); if (dateTo) createdAt.lte = new Date(dateTo); where.created_at = createdAt; } if (search) { where.OR = [ { issue_number: { contains: search } }, { notes: { contains: search } }, ]; } const sortField = sort && ["issue_number", "created_at", "status"].includes(sort) ? sort : "created_at"; const sortOrder = order === "asc" ? "asc" : "desc"; const [data, total] = await Promise.all([ prisma.sklad_issues.findMany({ where, skip, take: limit, orderBy: { [sortField]: sortOrder }, include: { project: true, issued_by_user: true, _count: { select: { lines: true } }, }, }), prisma.sklad_issues.count({ where }), ]); return reply.send({ success: true, data, pagination: buildPaginationMeta(total, page, limit), }); }, ); 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: true, issued_by_user: true, lines: { include: { item: true, batch: true, location: true, reservation: true, }, }, }, }); if (!issue) return error(reply, "Výdej nenalezen", 404); return success(reply, issue); }, ); 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); // For lines without batch_id, auto-select FIFO batches const enrichedLines = []; for (const line of parsed.data.lines) { if (line.batch_id) { enrichedLines.push(line); } else { // Auto-FIFO: may produce multiple lines if spanning batches const fifoBatches = await selectFifoBatches( line.item_id, line.quantity, ); for (const fifo of fifoBatches) { enrichedLines.push({ item_id: line.item_id, batch_id: fifo.batchId, quantity: fifo.qty, location_id: line.location_id, reservation_id: line.reservation_id, notes: line.notes, }); } } } const issue = await prisma.sklad_issues.create({ data: { project_id: parsed.data.project_id, issued_by: request.authData.userId, notes: parsed.data.notes, lines: { create: enrichedLines }, }, include: { lines: true }, }); await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_issue", entityId: issue.id, description: `Vytvořen výdej s ${issue.lines.length} položkami`, }); return success(reply, { id: issue.id }, 201, "Výdej byl vytvořen"); }, ); 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 existing = await prisma.sklad_issues.findUnique({ where: { id } }); if (!existing) return error(reply, "Výdej nenalezen", 404); if (existing.status !== "DRAFT") return error(reply, "Lze upravit pouze návrh", 400); await prisma.sklad_issues.update({ where: { id }, data: { project_id: parsed.data.project_id, notes: parsed.data.notes, modified_at: new Date(), }, }); if (parsed.data.lines) { // Auto-FIFO for lines without batch_id const enrichedLines = []; for (const line of parsed.data.lines) { if (line.batch_id) { enrichedLines.push(line); } else { const fifoBatches = await selectFifoBatches( line.item_id, line.quantity, ); for (const fifo of fifoBatches) { enrichedLines.push({ issue_id: id, item_id: line.item_id, batch_id: fifo.batchId, quantity: fifo.qty, location_id: line.location_id, reservation_id: line.reservation_id, notes: line.notes, }); } } } await prisma.sklad_issue_lines.deleteMany({ where: { issue_id: id } }); await prisma.sklad_issue_lines.createMany({ data: enrichedLines }); } await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_issue", entityId: id, description: `Upraven výdej`, }); return success(reply, { id }, 200, "Výdej byl uložen"); }, ); 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 result = await confirmIssue(id, request.authData.userId); if ("error" in result) return error(reply, result.error, result.status ?? 400); await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_issue", entityId: id, description: `Potvrzen výdej ${result.data.issue_number}`, }); return success(reply, result.data, 200, "Výdej byl potvrzen"); }, ); 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: "update", entityType: "warehouse_issue", entityId: id, description: `Zrušen výdej`, }); return success(reply, result.data, 200, "Výdej byl zrušen"); }, ); ``` Add `selectFifoBatches` to the import from `warehouse.service`. - [ ] **Step 3: Add reservation endpoints** ```ts // ============================== // RESERVATIONS // ============================== fastify.get( "/reservations", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip } = parsePagination(query); const itemId = query.item_id ? Number(query.item_id) : undefined; const projectId = query.project_id ? Number(query.project_id) : undefined; const statusFilter = query.status as string | undefined; const where: Record = {}; if (itemId) where.item_id = itemId; if (projectId) where.project_id = projectId; if (statusFilter) where.status = statusFilter; const [data, total] = await Promise.all([ prisma.sklad_reservations.findMany({ where, skip, take: limit, orderBy: { created_at: "desc" }, include: { item: true, project: true, reserved_by_user: true }, }), prisma.sklad_reservations.count({ where }), ]); return reply.send({ success: true, data, pagination: buildPaginationMeta(total, page, limit), }); }, ); 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 result = await createReservation({ ...parsed.data, reserved_by: request.authData.userId, }); 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.id, description: `Vytvořena rezervace ${result.data.quantity} ks položky ID ${result.data.item_id}`, }); return success( reply, { id: result.data.id }, 201, "Rezervace byla vytvořena", ); }, ); 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: "update", entityType: "warehouse_reservation", entityId: id, description: `Zrušena rezervace`, }); return success(reply, result.data, 200, "Rezervace byla zrušena"); }, ); ``` - [ ] **Step 4: Add inventory endpoints** ```ts // ============================== // INVENTORY // ============================== fastify.get( "/inventory-sessions", { preHandler: requirePermission("warehouse.inventory") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip } = parsePagination(query); const statusFilter = query.status as string | undefined; const where: Record = {}; if (statusFilter) where.status = statusFilter; const [data, total] = await Promise.all([ prisma.sklad_inventory_sessions.findMany({ where, skip, take: limit, orderBy: { created_at: "desc" }, include: { _count: { select: { lines: true } } }, }), prisma.sklad_inventory_sessions.count({ where }), ]); return reply.send({ success: true, data, pagination: buildPaginationMeta(total, page, limit), }); }, ); fastify.get<{ Params: { id: string } }>( "/inventory-sessions/:id", { preHandler: requirePermission("warehouse.inventory") }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; const session = await prisma.sklad_inventory_sessions.findUnique({ where: { id }, include: { lines: { include: { item: true, location: true } } }, }); if (!session) return error(reply, "Inventura nenalezena", 404); return success(reply, session); }, ); 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); // Auto-fill system_qty from current stock const lines = await Promise.all( parsed.data.lines.map(async (line) => { let systemQty = 0; if (line.location_id) { const loc = await prisma.sklad_item_locations.findUnique({ where: { item_id_location_id: { item_id: line.item_id, location_id: line.location_id, }, }, }); systemQty = Number(loc?.quantity ?? 0); } else { systemQty = await getItemTotalStock(line.item_id); } const diff = Number(line.actual_qty) - systemQty; return { item_id: line.item_id, location_id: line.location_id, system_qty: systemQty, actual_qty: Number(line.actual_qty), difference: diff, notes: line.notes, }; }), ); const session = await prisma.sklad_inventory_sessions.create({ data: { notes: parsed.data.notes, lines: { create: lines }, }, include: { lines: true }, }); await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_inventory", entityId: session.id, description: `Vytvořena inventura s ${session.lines.length} položkami`, }); return success(reply, { id: session.id }, 201, "Inventura byla vytvořena"); }, ); 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 existing = await prisma.sklad_inventory_sessions.findUnique({ where: { id }, }); if (!existing) return error(reply, "Inventura nenalezena", 404); if (existing.status !== "DRAFT") return error(reply, "Lze upravit pouze návrh", 400); await prisma.sklad_inventory_sessions.update({ where: { id }, data: { notes: parsed.data.notes, modified_at: new Date() }, }); if (parsed.data.lines) { const lines = parsed.data.lines.map((line) => { const systemQty = 0; // system_qty was set on creation, recalculate difference return { session_id: id, item_id: line.item_id, location_id: line.location_id, system_qty: 0, actual_qty: Number(line.actual_qty), difference: 0, notes: line.notes, }; }); // Re-fetch system quantities and recalculate differences const enrichedLines = await Promise.all( lines.map(async (line) => { let systemQty = 0; if (line.location_id) { const loc = await prisma.sklad_item_locations.findUnique({ where: { item_id_location_id: { item_id: line.item_id, location_id: line.location_id!, }, }, }); systemQty = Number(loc?.quantity ?? 0); } else { systemQty = await getItemTotalStock(line.item_id); } return { ...line, system_qty: systemQty, difference: Number(line.actual_qty) - systemQty, }; }), ); await prisma.sklad_inventory_lines.deleteMany({ where: { session_id: id }, }); await prisma.sklad_inventory_lines.createMany({ data: enrichedLines }); } await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_inventory", entityId: id, description: `Upravena inventura`, }); return success(reply, { id }, 200, "Inventura byla uložena"); }, ); 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: "update", entityType: "warehouse_inventory", entityId: id, description: `Potvrzena inventura ${result.data.session_number}`, }); return success(reply, result.data, 200, "Inventura byla potvrzena"); }, ); ``` - [ ] **Step 5: Add report endpoints** ```ts // ============================== // REPORTS // ============================== fastify.get("/reports/stock-status", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const query = request.query as Record; const categoryId = query.category_id ? Number(query.category_id) : undefined; const where: Record = { is_active: true }; if (categoryId) where.category_id = categoryId; const items = await prisma.sklad_items.findMany({ where, include: { category: true }, orderBy: { name: "asc" }, }); const data = await Promise.all(items.map(async (item) => { const totalStock = await getItemTotalStock(item.id); const available = await getItemAvailableQty(item.id); const value = await getItemStockValue(item.id); const belowMinimum = item.min_quantity ? totalStock < Number(item.min_quantity) : false; return { ...item, total_quantity: totalStock, available_quantity: available, stock_value: value, below_minimum: belowMinimum }; })); return success(reply, data); }); fastify.get("/reports/project-consumption", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const query = request.query as Record; const projectId = query.project_id ? Number(query.project_id) : undefined; const dateFrom = query.date_from as string | undefined; const dateTo = query.date_to as string | undefined; const where: Record = { status: "CONFIRMED" }; if (projectId) where.project_id = projectId; if (dateFrom || dateTo) { const createdAt: Record = {}; if (dateFrom) createdAt.gte = new Date(dateFrom); if (dateTo) createdAt.lte = new Date(dateTo); where.created_at = createdAt; } const issues = await prisma.sklad_issues.findMany({ where, include: { lines: { include: { item: true } }, project: true }, orderBy: { created_at: "desc" }, }); // Aggregate by item + project const consumption: Record = {}; for (const issue of issues) { for (const line of issue.lines) { const key = `${line.item_id}-${issue.project_id}`; if (!consumption[key]) { consumption[key] = { item_id: line.item_id, item_name: line.item.name, project_id: issue.project_id, project_name: issue.project.name ?? "", total_qty: 0, total_value: 0 }; } consumption[key].total_qty += Number(line.quantity); consumption[key].total_value += Number(line.quantity) * Number(line.batch.unit_price); } } return success(reply, Object.values(consumption)); }); fastify.get("/reports/movement-log", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip } = parsePagination(query); const dateFrom = query.date_from as string | undefined; const dateTo = query.date_to as string | undefined; const typeFilter = query.type as string | undefined; // "receipt" or "issue" const movements: Array> = []; if (!typeFilter || typeFilter === "receipt") { const receiptWhere: Record = { status: "CONFIRMED" }; if (dateFrom || dateTo) { const createdAt: Record = {}; if (dateFrom) createdAt.gte = new Date(dateFrom); if (dateTo) createdAt.lte = new Date(dateTo); receiptWhere.created_at = createdAt; } const receipts = await prisma.sklad_receipts.findMany({ where: receiptWhere, include: { supplier: true, lines: { include: { item: true } } }, orderBy: { created_at: "desc" }, skip, take: limit, }); for (const r of receipts) { for (const line of r.lines) { movements.push({ type: "receipt", document_id: r.id, document_number: r.receipt_number, date: r.created_at, item_id: line.item_id, item_name: line.item.name, quantity: Number(line.quantity), unit_price: Number(line.unit_price), supplier: r.supplier?.name ?? null }); } } } if (!typeFilter || typeFilter === "issue") { const issueWhere: Record = { status: "CONFIRMED" }; if (dateFrom || dateTo) { const createdAt: Record = {}; if (dateFrom) createdAt.gte = new Date(dateFrom); if (dateTo) createdAt.lte = new Date(dateTo); issueWhere.created_at = createdAt; } const issues = await prisma.sklad_issues.findMany({ where: issueWhere, include: { project: true, lines: { include: { item: true, batch: true } } }, orderBy: { created_at: "desc" }, skip: typeFilter ? skip : 0, take: typeFilter ? limit : 100, }); for (const i of issues) { for (const line of i.lines) { movements.push({ type: "issue", document_id: i.id, document_number: i.issue_number, date: i.created_at, item_id: line.item_id, item_name: line.item.name, quantity: Number(line.quantity), unit_price: Number(line.batch?.unit_price ?? 0), project: i.project.name ?? null }); } } } movements.sort((a, b) => new Date(b.date as string).getTime() - new Date(a.date as string).getTime()); const paged = movements.slice(skip, skip + limit); return reply.send({ success: true, data: paged, pagination: buildPaginationMeta(movements.length, page, limit) }); }); fastify.get("/reports/below-minimum", { preHandler: requirePermission("warehouse.view") }, async (_request, reply) => { const data = await getBelowMinimumItems(); return success(reply, data); }); } // end of warehouseRoutes ``` - [ ] **Step 6: Close the function and verify compilation** ```bash npx tsc --noEmit src/routes/admin/warehouse.ts ``` Expected: No type errors. - [ ] **Step 7: Commit** ```bash git add src/routes/admin/warehouse.ts git commit -m "feat(warehouse): add full warehouse API routes" ``` --- ### Task 7: Register Warehouse Routes in Server **Files:** - Modify: `src/server.ts` - [ ] **Step 1: Add import and registration** In `src/server.ts`, add the import alongside existing route imports: ```ts import warehouseRoutes from "./routes/admin/warehouse"; ``` Add the registration alongside existing route registrations: ```ts await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" }); ``` - [ ] **Step 2: Verify server compiles** ```bash npx tsc --noEmit src/server.ts ``` Expected: No type errors. - [ ] **Step 3: Commit** ```bash git add src/server.ts git commit -m "feat(warehouse): register warehouse routes in server" ``` --- ## Phase 4: Frontend - Foundation ### Task 8: Query Hooks **Files:** - Create: `src/admin/lib/queries/warehouse.ts` - [ ] **Step 1: Create warehouse query options** ```ts import { queryOptions } from "@tanstack/react-query"; import { jsonQuery, paginatedJsonQuery } from "../apiAdapter"; // === Types === export interface WarehouseItem { id: number; item_number: string | null; name: string; description: string | null; category_id: number | null; unit: string; min_quantity: number | null; is_active: boolean; notes: string | null; category?: { id: number; name: string } | null; total_quantity?: number; available_quantity?: number; stock_value?: number; below_minimum?: boolean; } export interface WarehouseReceipt { id: number; receipt_number: string | null; supplier_id: number | null; delivery_note_number: string | null; delivery_note_date: string | null; received_by: number | null; notes: string | null; status: "DRAFT" | "CONFIRMED" | "CANCELLED"; created_at: string; modified_at: string | null; supplier?: { id: number; name: string } | null; received_by_user?: { id: number; first_name: string; last_name: string; } | null; lines?: WarehouseReceiptLine[]; attachments?: WarehouseReceiptAttachment[]; } export interface WarehouseReceiptLine { id: number; receipt_id: number; item_id: number; quantity: number; unit_price: number; location_id: number | null; notes: string | null; item?: WarehouseItem; location?: { id: number; code: string; name: string } | null; } export interface WarehouseReceiptAttachment { id: number; receipt_id: number; file_name: string; file_mime: string; file_size: number; file_path: string; created_at: string; } export interface WarehouseIssue { id: number; issue_number: string | null; project_id: number; issued_by: number | null; notes: string | null; status: "DRAFT" | "CONFIRMED" | "CANCELLED"; created_at: string; modified_at: string | null; project?: { id: number; name: string; project_number: string } | null; issued_by_user?: { id: number; first_name: string; last_name: string } | null; lines?: WarehouseIssueLine[]; } export interface WarehouseIssueLine { id: number; issue_id: number; item_id: number; batch_id: number; quantity: number; location_id: number | null; reservation_id: number | null; notes: string | null; item?: WarehouseItem; batch?: { id: number; quantity: number; unit_price: number; received_at: string; } | null; location?: { id: number; code: string; name: string } | null; reservation?: { id: number; quantity: number; remaining_qty: number } | null; } export interface WarehouseReservation { id: number; item_id: number; project_id: number; quantity: number; remaining_qty: number; reserved_by: number | null; notes: string | null; status: "ACTIVE" | "FULFILLED" | "CANCELLED"; created_at: string; modified_at: string | null; item?: WarehouseItem; project?: { id: number; name: string } | null; reserved_by_user?: { id: number; first_name: string; last_name: string; } | null; } export interface WarehouseInventorySession { id: number; session_number: string | null; notes: string | null; status: "DRAFT" | "CONFIRMED"; created_at: string; modified_at: string | null; lines?: WarehouseInventoryLine[]; } export interface WarehouseInventoryLine { id: number; session_id: number; item_id: number; location_id: number | null; system_qty: number; actual_qty: number; difference: number; notes: string | null; item?: WarehouseItem; location?: { id: number; code: string; name: string } | null; } export interface WarehouseCategory { id: number; name: string; description: string | null; sort_order: number; } export interface WarehouseLocation { id: number; code: string; name: string; description: string | null; is_active: boolean; } export interface WarehouseSupplier { id: number; name: string; ico: string | null; dic: string | null; contact_person: string | null; email: string | null; phone: string | null; address: string | null; notes: string | null; is_active: boolean; } // === Query Options === export const warehouseItemListOptions = (filters: { search?: string; page?: number; perPage?: number; sort?: string; order?: string; category_id?: number; }) => { const params = new URLSearchParams(); if (filters.search) params.set("search", filters.search); if (filters.page) params.set("page", String(filters.page)); if (filters.perPage) params.set("limit", String(filters.perPage)); if (filters.sort) params.set("sort", filters.sort); if (filters.order) params.set("order", filters.order); if (filters.category_id) params.set("category_id", String(filters.category_id)); const qs = params.toString(); return queryOptions({ queryKey: ["warehouse", "items", filters], queryFn: () => paginatedJsonQuery( `/api/admin/warehouse/items${qs ? `?${qs}` : ""}`, ), }); }; export const warehouseItemDetailOptions = (id: string | undefined) => queryOptions({ queryKey: ["warehouse", "items", id], queryFn: () => jsonQuery(`/api/admin/warehouse/items/${id}`), enabled: !!id, }); export const warehouseCategoryListOptions = () => queryOptions({ queryKey: ["warehouse", "categories"], queryFn: () => jsonQuery("/api/admin/warehouse/categories"), }); export const warehouseSupplierListOptions = (filters: { search?: string; page?: number; perPage?: number; }) => { const params = new URLSearchParams(); if (filters.search) params.set("search", filters.search); if (filters.page) params.set("page", String(filters.page)); if (filters.perPage) params.set("limit", String(filters.perPage)); const qs = params.toString(); return queryOptions({ queryKey: ["warehouse", "suppliers", filters], queryFn: () => paginatedJsonQuery( `/api/admin/warehouse/suppliers${qs ? `?${qs}` : ""}`, ), }); }; export const warehouseLocationListOptions = () => queryOptions({ queryKey: ["warehouse", "locations"], queryFn: () => jsonQuery("/api/admin/warehouse/locations"), }); export const warehouseReceiptListOptions = (filters: { search?: string; page?: number; perPage?: number; status?: string; supplier_id?: number; date_from?: string; date_to?: string; }) => { const params = new URLSearchParams(); if (filters.search) params.set("search", filters.search); if (filters.page) params.set("page", String(filters.page)); if (filters.perPage) params.set("limit", String(filters.perPage)); if (filters.status) params.set("status", filters.status); if (filters.supplier_id) params.set("supplier_id", String(filters.supplier_id)); if (filters.date_from) params.set("date_from", filters.date_from); if (filters.date_to) params.set("date_to", filters.date_to); const qs = params.toString(); return queryOptions({ queryKey: ["warehouse", "receipts", filters], queryFn: () => paginatedJsonQuery( `/api/admin/warehouse/receipts${qs ? `?${qs}` : ""}`, ), }); }; export const warehouseReceiptDetailOptions = (id: string | undefined) => queryOptions({ queryKey: ["warehouse", "receipts", id], queryFn: () => jsonQuery(`/api/admin/warehouse/receipts/${id}`), enabled: !!id, }); export const warehouseIssueListOptions = (filters: { search?: string; page?: number; perPage?: number; status?: string; project_id?: number; date_from?: string; date_to?: string; }) => { const params = new URLSearchParams(); if (filters.search) params.set("search", filters.search); if (filters.page) params.set("page", String(filters.page)); if (filters.perPage) params.set("limit", String(filters.perPage)); if (filters.status) params.set("status", filters.status); if (filters.project_id) params.set("project_id", String(filters.project_id)); if (filters.date_from) params.set("date_from", filters.date_from); if (filters.date_to) params.set("date_to", filters.date_to); const qs = params.toString(); return queryOptions({ queryKey: ["warehouse", "issues", filters], queryFn: () => paginatedJsonQuery( `/api/admin/warehouse/issues${qs ? `?${qs}` : ""}`, ), }); }; export const warehouseIssueDetailOptions = (id: string | undefined) => queryOptions({ queryKey: ["warehouse", "issues", id], queryFn: () => jsonQuery(`/api/admin/warehouse/issues/${id}`), enabled: !!id, }); export const warehouseReservationListOptions = (filters: { page?: number; perPage?: number; item_id?: number; project_id?: number; status?: string; }) => { const params = new URLSearchParams(); if (filters.page) params.set("page", String(filters.page)); if (filters.perPage) params.set("limit", String(filters.perPage)); if (filters.item_id) params.set("item_id", String(filters.item_id)); if (filters.project_id) params.set("project_id", String(filters.project_id)); if (filters.status) params.set("status", filters.status); const qs = params.toString(); return queryOptions({ queryKey: ["warehouse", "reservations", filters], queryFn: () => paginatedJsonQuery( `/api/admin/warehouse/reservations${qs ? `?${qs}` : ""}`, ), }); }; export const warehouseInventoryListOptions = (filters: { page?: number; perPage?: number; status?: string; }) => { const params = new URLSearchParams(); if (filters.page) params.set("page", String(filters.page)); if (filters.perPage) params.set("limit", String(filters.perPage)); if (filters.status) params.set("status", filters.status); const qs = params.toString(); return queryOptions({ queryKey: ["warehouse", "inventory", filters], queryFn: () => paginatedJsonQuery( `/api/admin/warehouse/inventory-sessions${qs ? `?${qs}` : ""}`, ), }); }; export const warehouseInventoryDetailOptions = (id: string | undefined) => queryOptions({ queryKey: ["warehouse", "inventory", id], queryFn: () => jsonQuery( `/api/admin/warehouse/inventory-sessions/${id}`, ), enabled: !!id, }); export const warehouseStockStatusOptions = (filters?: { category_id?: number; }) => { const params = new URLSearchParams(); if (filters?.category_id) params.set("category_id", String(filters.category_id)); const qs = params.toString(); return queryOptions({ queryKey: ["warehouse", "reports", "stock-status", filters], queryFn: () => jsonQuery( `/api/admin/warehouse/reports/stock-status${qs ? `?${qs}` : ""}`, ), }); }; export const warehouseBelowMinimumOptions = () => queryOptions({ queryKey: ["warehouse", "reports", "below-minimum"], queryFn: () => jsonQuery("/api/admin/warehouse/reports/below-minimum"), }); ``` - [ ] **Step 2: Commit** ```bash git add src/admin/lib/queries/warehouse.ts git commit -m "feat(warehouse): add TanStack Query options and types" ``` --- ### Task 9: Shared Components **Files:** - Create: `src/admin/components/warehouse/ItemPicker.tsx` - Create: `src/admin/components/warehouse/BatchPicker.tsx` - Create: `src/admin/components/warehouse/LocationSelect.tsx` - Create: `src/admin/components/warehouse/SupplierSelect.tsx` - Create: `src/admin/components/warehouse/WarehouseMovementTable.tsx` - [ ] **Step 1: Create ItemPicker component** ```tsx import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { warehouseItemListOptions } from "../../lib/queries/warehouse"; interface ItemPickerProps { value: number | null; onChange: (itemId: number) => void; } export default function ItemPicker({ value, onChange }: ItemPickerProps) { const [search, setSearch] = useState(""); const [open, setOpen] = useState(false); const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 })); const items = data?.data ?? []; return (
{ setSearch(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} /> {open && items.length > 0 && (
    {items.map((item) => (
  • { onChange(item.id); setOpen(false); setSearch(item.name); }} > {item.name} {item.item_number && ( {item.item_number} )} {item.available_quantity !== undefined && ( {item.available_quantity} {item.unit} )}
  • ))}
)}
); } ``` - [ ] **Step 2: Create LocationSelect component** ```tsx import { useQuery } from "@tanstack/react-query"; import { warehouseLocationListOptions } from "../../lib/queries/warehouse"; interface LocationSelectProps { value: number | null; onChange: (locationId: number | null) => void; } export default function LocationSelect({ value, onChange, }: LocationSelectProps) { const { data: locations } = useQuery(warehouseLocationListOptions()); return ( ); } ``` - [ ] **Step 3: Create SupplierSelect component** ```tsx import { useQuery } from "@tanstack/react-query"; import { warehouseSupplierListOptions } from "../../lib/queries/warehouse"; interface SupplierSelectProps { value: number | null; onChange: (supplierId: number | null) => void; } export default function SupplierSelect({ value, onChange, }: SupplierSelectProps) { const { data } = useQuery(warehouseSupplierListOptions({ perPage: 100 })); const suppliers = data?.data ?? []; return ( ); } ``` - [ ] **Step 4: Create BatchPicker component** ```tsx import { useQuery } from "@tanstack/react-query"; import { jsonQuery } from "../../lib/apiAdapter"; import type { WarehouseItem } from "../../lib/queries/warehouse"; interface Batch { id: number; quantity: number; unit_price: number; received_at: string; is_consumed: boolean; } interface BatchPickerProps { itemId: number | null; value: number | null; onChange: (batchId: number, unitPrice: number) => void; } export default function BatchPicker({ itemId, value, onChange, }: BatchPickerProps) { const { data: batches } = useQuery({ queryKey: ["warehouse", "batches", itemId], queryFn: () => jsonQuery(`/api/admin/warehouse/items/${itemId}/batches`), enabled: !!itemId, }); return ( ); } ``` - [ ] **Step 5: Create WarehouseMovementTable component** This is the multi-line editor used in both receipt and issue forms: ```tsx import { useState } from "react"; import type { WarehouseItem, WarehouseLocation, } from "../../lib/queries/warehouse"; import ItemPicker from "./ItemPicker"; import LocationSelect from "./LocationSelect"; import BatchPicker from "./BatchPicker"; export interface MovementLine { key: string; item_id: number | null; item_name?: string; quantity: number; unit_price: number; location_id: number | null; batch_id: number | null; reservation_id: number | null; notes: string | null; } interface WarehouseMovementTableProps { lines: MovementLine[]; onChange: (lines: MovementLine[]) => void; mode: "receipt" | "issue"; locations: WarehouseLocation[]; } let lineCounter = 0; export default function WarehouseMovementTable({ lines, onChange, mode, locations, }: WarehouseMovementTableProps) { const addLine = () => { onChange([ ...lines, { key: `line-${++lineCounter}`, item_id: null, quantity: 0, unit_price: 0, location_id: null, batch_id: null, reservation_id: null, notes: null, }, ]); }; const removeLine = (index: number) => { onChange(lines.filter((_, i) => i !== index)); }; const updateLine = (index: number, field: string, value: unknown) => { const updated = [...lines]; updated[index] = { ...updated[index], [field]: value }; onChange(updated); }; return (
{mode === "issue" && } {lines.map((line, index) => ( {mode === "issue" && ( )} ))}
PoložkaŠaržeMnožství Cena/ks Lokace Poznámka
updateLine(index, "item_id", id)} /> { updateLine(index, "batch_id", batchId); updateLine(index, "unit_price", unitPrice); }} /> updateLine(index, "quantity", Number(e.target.value)) } min="0" step="0.001" /> updateLine(index, "unit_price", Number(e.target.value)) } min="0" step="0.01" disabled={mode === "issue"} /> updateLine(index, "location_id", id)} /> updateLine(index, "notes", e.target.value)} />
); } ``` - [ ] **Step 6: Commit** ```bash git add src/admin/components/warehouse/ git commit -m "feat(warehouse): add shared warehouse components" ``` --- ### Task 10: Sidebar & Routes **Files:** - Modify: `src/admin/components/Sidebar.tsx` - Modify: `src/admin/AdminApp.tsx` - [ ] **Step 1: Add warehouse entry to Sidebar.tsx** In `src/admin/components/Sidebar.tsx`, add a new item to the "Administrativa" menu section: ```ts { path: "/warehouse", label: "Sklad", permission: "warehouse.view", matchPrefix: "/warehouse", icon: ( ), }, ``` - [ ] **Step 2: Add lazy imports and routes to AdminApp.tsx** In `src/admin/AdminApp.tsx`, add lazy imports: ```tsx const Warehouse = lazy(() => import("./pages/Warehouse")); const WarehouseItems = lazy(() => import("./pages/WarehouseItems")); const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail")); const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts")); const WarehouseReceiptDetail = lazy( () => import("./pages/WarehouseReceiptDetail"), ); const WarehouseReceiptForm = lazy(() => import("./pages/WarehouseReceiptForm")); const WarehouseIssues = lazy(() => import("./pages/WarehouseIssues")); const WarehouseIssueDetail = lazy(() => import("./pages/WarehouseIssueDetail")); const WarehouseIssueForm = lazy(() => import("./pages/WarehouseIssueForm")); const WarehouseReservations = lazy( () => import("./pages/WarehouseReservations"), ); const WarehouseInventory = lazy(() => import("./pages/WarehouseInventory")); const WarehouseInventoryDetail = lazy( () => import("./pages/WarehouseInventoryDetail"), ); const WarehouseInventoryForm = lazy( () => import("./pages/WarehouseInventoryForm"), ); const WarehouseReports = lazy(() => import("./pages/WarehouseReports")); const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers")); const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations")); const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories")); ``` Add routes inside the `}>` section: ```tsx } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> ``` - [ ] **Step 3: Commit** ```bash git add src/admin/components/Sidebar.tsx src/admin/AdminApp.tsx git commit -m "feat(warehouse): add sidebar entry and routes" ``` --- ## Phase 5: Frontend - Pages ### Task 11: Master Data Pages (Categories, Suppliers, Locations) **Files:** - Create: `src/admin/pages/WarehouseCategories.tsx` - Create: `src/admin/pages/WarehouseSuppliers.tsx` - Create: `src/admin/pages/WarehouseLocations.tsx` These three pages follow the same CRUD pattern as existing pages (e.g., Vehicles). Each page: - Checks permission at top - Uses `useQuery` for listing - Has a modal for create/edit - Uses `useAlert` for feedback - Invalidates `["warehouse"]` on mutation - [ ] **Step 1: Create WarehouseCategories.tsx** Standard CRUD page: list categories in a table, modal for create/edit, delete with confirmation. Permission: `warehouse.manage`. Uses `warehouseCategoryListOptions` for data. Mutations: POST/PUT/DELETE `/api/admin/warehouse/categories`. Invalidates `["warehouse"]` on success. - [ ] **Step 2: Create WarehouseSuppliers.tsx** Standard CRUD page with paginated list. Fields: name, ico, dic, contact_person, email, phone, address, notes. Uses `warehouseSupplierListOptions`. Permission: `warehouse.manage`. - [ ] **Step 3: Create WarehouseLocations.tsx** Standard CRUD page. Fields: code (unique), name, description. Uses `warehouseLocationListOptions`. Permission: `warehouse.manage`. - [ ] **Step 4: Commit** ```bash git add src/admin/pages/WarehouseCategories.tsx src/admin/pages/WarehouseSuppliers.tsx src/admin/pages/WarehouseLocations.tsx git commit -m "feat(warehouse): add categories, suppliers, locations pages" ``` --- ### Task 12: Items Pages (List + Detail) **Files:** - Create: `src/admin/pages/WarehouseItems.tsx` - Create: `src/admin/pages/WarehouseItemDetail.tsx` - [ ] **Step 1: Create WarehouseItems.tsx** Paginated item catalog with search, category filter, sorting. Columns: item_number, name, category, unit, total_quantity, available_quantity, stock_value, below_minimum (red badge). Click row → navigate to detail. Permission: `warehouse.view`. Uses `warehouseItemListOptions`. - [ ] **Step 2: Create WarehouseItemDetail.tsx** Shows item details, batch list (FIFO queue), location breakdown, and active reservations. Has edit button (opens inline edit or modal). Permission: `warehouse.view`. Uses `warehouseItemDetailOptions`. - [ ] **Step 3: Commit** ```bash git add src/admin/pages/WarehouseItems.tsx src/admin/pages/WarehouseItemDetail.tsx git commit -m "feat(warehouse): add items list and detail pages" ``` --- ### Task 13: Receipt Pages (List, Detail, Form) **Files:** - Create: `src/admin/pages/WarehouseReceipts.tsx` - Create: `src/admin/pages/WarehouseReceiptDetail.tsx` - Create: `src/admin/pages/WarehouseReceiptForm.tsx` - [ ] **Step 1: Create WarehouseReceipts.tsx** Paginated receipt list with filters (status, supplier, date range). Columns: receipt_number, supplier, status (colored badge), delivery_note_number, created_at, line count. Click row → navigate to detail. Permission: `warehouse.view`. Uses `warehouseReceiptListOptions`. - [ ] **Step 2: Create WarehouseReceiptDetail.tsx** Receipt detail with: - Header: receipt_number, supplier, delivery note info, status badge - Lines table: item, quantity, unit_price, location, notes - Attachments section: list of uploaded files with download/delete buttons - Actions: Confirm button (if DRAFT), Cancel button (if DRAFT or CONFIRMED), Edit button (if DRAFT) - Permission: `warehouse.view` for viewing, `warehouse.operate` for actions - [ ] **Step 3: Create WarehouseReceiptForm.tsx** Create/edit form using `WarehouseMovementTable` component. Header fields: supplier (SupplierSelect), delivery note number, delivery note date, notes. Uses file input for attachment upload. Draft/Confirm submit buttons. Route: `/warehouse/receipts/new` (create) and `/warehouse/receipts/:id/edit` (edit, pre-fills from existing). Permission: `warehouse.operate`. - [ ] **Step 4: Commit** ```bash git add src/admin/pages/WarehouseReceipts.tsx src/admin/pages/WarehouseReceiptDetail.tsx src/admin/pages/WarehouseReceiptForm.tsx git commit -m "feat(warehouse): add receipt pages" ``` --- ### Task 14: Issue Pages (List, Detail, Form) **Files:** - Create: `src/admin/pages/WarehouseIssues.tsx` - Create: `src/admin/pages/WarehouseIssueDetail.tsx` - Create: `src/admin/pages/WarehouseIssueForm.tsx` - [ ] **Step 1: Create WarehouseIssues.tsx** Paginated issue list with filters (status, project, date range). Columns: issue_number, project, status (colored badge), created_at, line count. Click row → navigate to detail. Permission: `warehouse.view`. Uses `warehouseIssueListOptions`. - [ ] **Step 2: Create WarehouseIssueDetail.tsx** Issue detail with: - Header: issue_number, project name, status badge, issued_by - Lines table: item, batch (received date + price), quantity, location, reservation link, notes - Actions: Confirm button (if DRAFT), Cancel button (if DRAFT or CONFIRMED), Edit button (if DRAFT) - Permission: `warehouse.view` for viewing, `warehouse.operate` for actions - [ ] **Step 3: Create WarehouseIssueForm.tsx** Create/edit form using `WarehouseMovementTable` component (mode="issue"). Header fields: project (mandatory select from projects list), notes. Each line has BatchPicker for batch selection (auto-FIFO if not selected). Draft/Confirm submit buttons. Permission: `warehouse.operate`. - [ ] **Step 4: Commit** ```bash git add src/admin/pages/WarehouseIssues.tsx src/admin/pages/WarehouseIssueDetail.tsx src/admin/pages/WarehouseIssueForm.tsx git commit -m "feat(warehouse): add issue pages" ``` --- ### Task 15: Reservations, Inventory, Reports, Dashboard Pages **Files:** - Create: `src/admin/pages/WarehouseReservations.tsx` - Create: `src/admin/pages/WarehouseInventory.tsx` - Create: `src/admin/pages/WarehouseInventoryDetail.tsx` - Create: `src/admin/pages/WarehouseInventoryForm.tsx` - Create: `src/admin/pages/WarehouseReports.tsx` - Create: `src/admin/pages/Warehouse.tsx` - [ ] **Step 1: Create WarehouseReservations.tsx** List of active reservations with item, project, quantity, remaining_qty, status. Cancel button per row. Create reservation modal (item picker, project picker, quantity). Permission: `warehouse.view` + `warehouse.operate` for cancel/create. Uses `warehouseReservationListOptions`. - [ ] **Step 2: Create WarehouseInventory.tsx** List of inventory sessions. Create button → navigate to form. Columns: session_number, status, created_at, line count. Click row → navigate to detail. Permission: `warehouse.inventory`. Uses `warehouseInventoryListOptions`. - [ ] **Step 3: Create WarehouseInventoryDetail.tsx** Inventory session detail with lines table: item, location, system_qty, actual_qty, difference (colored red/green), notes. Confirm button (if DRAFT). Permission: `warehouse.inventory`. - [ ] **Step 4: Create WarehouseInventoryForm.tsx** Create/edit inventory form. Select items, enter actual quantities. System quantities auto-filled on creation. Permission: `warehouse.inventory`. - [ ] **Step 5: Create WarehouseReports.tsx** Tab-based report page with 4 tabs: - **Stock Status**: table of all items with qty, min_qty, value, below-min flag (uses `warehouseStockStatusOptions`) - **Project Consumption**: filter by project + date range, shows material consumed per project - **Movement Log**: filter by date range + type (receipt/issue), shows all movements - **Below Minimum**: items under min_quantity (uses `warehouseBelowMinimumOptions`) Permission: `warehouse.view`. - [ ] **Step 6: Create Warehouse.tsx (Dashboard)** Dashboard with: - Summary cards: total items, total stock value, below-minimum count, active reservations count - Below-minimum alert section (highlighted items) - Recent movements (last 10, fetched from movement-log report) Permission: `warehouse.view`. - [ ] **Step 7: Commit** ```bash git add src/admin/pages/WarehouseReservations.tsx src/admin/pages/WarehouseInventory.tsx src/admin/pages/WarehouseInventoryDetail.tsx src/admin/pages/WarehouseInventoryForm.tsx src/admin/pages/WarehouseReports.tsx src/admin/pages/Warehouse.tsx git commit -m "feat(warehouse): add reservations, inventory, reports, and dashboard pages" ``` --- ## Phase 6: Testing & Polish ### Task 16: Integration Tests **Files:** - Create: `src/__tests__/warehouse.test.ts` - [ ] **Step 1: Write integration tests for warehouse API** Tests using the `buildApp()` helper against the real test database. Cover: - Category CRUD - Supplier CRUD - Location CRUD - Item CRUD + available-qty calculation - Receipt create (DRAFT) → confirm → verify batches created + item_locations updated - Receipt cancel (CONFIRMED) → verify batches deleted + item_locations restored - Issue create (DRAFT) → confirm → verify batches decremented + reservations fulfilled - Issue cancel (CONFIRMED) → verify batches restored - Reservation create → verify available_qty decreases - Reservation cancel → verify available_qty increases - Permission checks (unauthorized access returns 403) - [ ] **Step 2: Run tests** ```bash npm test -- --reporter=verbose src/__tests__/warehouse.test.ts ``` Expected: All tests pass. - [ ] **Step 3: Commit** ```bash git add src/__tests__/warehouse.test.ts git commit -m "test(warehouse): add integration tests" ``` --- ### Task 17: Build Verification - [ ] **Step 1: Run full build** ```bash npm run build ``` Expected: No errors, `dist/` and `dist-client/` generated successfully. - [ ] **Step 2: Run full test suite** ```bash npm test ``` Expected: All tests pass (existing + warehouse). - [ ] **Step 3: Commit any remaining fixes** --- ## Self-Review Checklist After writing the complete plan, I verified: 1. **Spec coverage**: Every requirement in the design spec has a corresponding task: - 13 Prisma models → Task 1 - 5 enums → Task 1 - Entity types → Task 2 - 4 permissions → Task 2 - 3 number sequence types → Task 2 - Zod schemas → Task 3 - Service (FIFO, confirm, cancel, inventory) → Task 4 - All API endpoints → Tasks 5-6 - Server registration → Task 7 - Query hooks → Task 8 - Shared components → Task 9 - Sidebar + routes → Task 10 - All 17 pages → Tasks 11-15 - Tests → Task 16 - Build verification → Task 17 2. **Placeholder scan**: No TBD, TODO, or placeholder patterns found. All tasks contain actual code or specific instructions. 3. **Type consistency**: Types defined in Task 8 (`WarehouseItem`, `WarehouseReceipt`, etc.) are used consistently across Tasks 9-15. Service function signatures in Task 4 match route handler usage in Tasks 5-6.