# Warehouse (Sklad) Module Design **Date:** 2026-05-29 **Status:** Approved **Module:** Warehouse/Inventory management for boha-app-ts --- ## Overview Full warehouse management module under the Administration section. Tracks material receiving, issuing, reservations, inventory, and project assignment. Uses true FIFO with batch-level tracking, document-driven architecture (header + lines), and integrates with existing projects, number sequences, and NAS file storage. --- ## Requirements ### Movements - **Receipt (Prijem):** Material enters warehouse. Multi-item document with delivery note attachment. - **Issue (Vydej):** Material leaves warehouse, mandatory project assignment. Multi-item document. - **Reservation:** Virtual hold on stock before issue. Reduces available qty but not physical stock. Issue consumes reservation. - **Inventory (Inventura):** Corrective movements based on physical count vs system count. - **Storno:** Cancel a confirmed receipt or issue. Creates opposite corrective movements, preserves audit trail. ### Catalog - Each material has: catalog number, name, description, category, unit, min quantity. - Categories are user-defined (CRUD). - Units are fixed list: ks, m, kg, bal, sada, m2, m3, l. - Items can be soft-deactivated (is_active=false). ### Pricing - Purchase price per unit, recorded bez DPH. - True FIFO: each receipt line creates a batch. Issues consume oldest batches first. - Batch-level tracking with remaining quantity. ### Delivery Notes - File attachment (PDF/image) uploaded to NAS. - Delivery note number and date stored as metadata on receipt header. ### Suppliers - Separate `sklad_suppliers` table (ICO, DIC, contact info). - Not shared with customers table. ### Locations - Warehouse has named locations/shelves (code + name, e.g. "A1 - Regal A, police 1"). - Junction table tracks qty per item per location. ### Projects - Every issue is mandatory FK to `projects` table. - No free/issues without project assignment. ### Min Stock Alerts - Each item has optional `min_quantity`. - Dashboard highlights items below minimum. - No email notification (UI alert only). ### Immutability - All movements are immutable once confirmed. - Corrections via storno (cancel receipt/issue) which creates opposite movements. - Draft movements can be edited freely. ### Numbering - Auto-numbering via `number_sequences` on confirm. - Types: `warehouse_receipt` (e.g. PRI-2026-001), `warehouse_issue` (e.g. VYD-2026-001). ### Issue Documents - No PDF generation for issues. Record only in system. ### Reports - Stock status: all items with qty, min_qty, value, below-min flag. - Project consumption: material consumed per project (filter by project, date range). - Movement log: all receipts + issues with filters. - Below minimum: items under min_quantity. --- ## Architecture: Document-Driven Each movement type is a document (header + lines), matching the existing invoice/offer pattern. ### Document Flow ``` DRAFT → CONFIRMED (affects stock) DRAFT → CANCELLED (no stock effect) CONFIRMED → CANCELLED (storno: reverses stock changes) ``` Only CONFIRMED documents affect `sklad_batches`, `sklad_item_locations`, and reservations. --- ## Database Schema ### Enums ```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 } ``` ### Master Data ```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") } ``` ### FIFO Batches & Location Tracking ```prisma 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") } ``` ### Receipts ```prisma 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") } ``` ### Issues ```prisma 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") } ``` ### Reservations ```prisma 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") } ``` ### Inventory ```prisma 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") } ``` **Total: 13 models + 5 enums** --- ## API Endpoints All under prefix `/api/admin/warehouse`. ### Items (warehouse.manage) | Method | Path | Description | | ------ | ------------ | ------------------------------------------------------------- | | GET | `/items` | List items (paginated, filter by category, search, below-min) | | GET | `/items/:id` | Item detail with batches, locations, reservations | | POST | `/items` | Create item | | PUT | `/items/:id` | Update item | | DELETE | `/items/:id` | Soft-delete (is_active=false) | ### Categories (warehouse.manage) | Method | Path | Description | | ------ | ----------------- | ------------------------- | | GET | `/categories` | List all | | POST | `/categories` | Create | | PUT | `/categories/:id` | Update | | DELETE | `/categories/:id` | Delete (only if no items) | ### Suppliers (warehouse.manage) | Method | Path | Description | | ------ | ---------------- | ----------------------------- | | GET | `/suppliers` | List (paginated, search) | | GET | `/suppliers/:id` | Detail | | POST | `/suppliers` | Create | | PUT | `/suppliers/:id` | Update | | DELETE | `/suppliers/:id` | Soft-delete (is_active=false) | ### Locations (warehouse.manage) | Method | Path | Description | | ------ | ---------------- | ------------------------------------- | | GET | `/locations` | List all | | POST | `/locations` | Create | | PUT | `/locations/:id` | Update | | DELETE | `/locations/:id` | Delete (only if no items at location) | ### Receipts (warehouse.operate) | Method | Path | Description | | ------ | ----------------------------------------- | ------------------------------------------------------------- | | GET | `/receipts` | List (paginated, filter by status/supplier/date) | | GET | `/receipts/:id` | Detail with lines + attachments | | POST | `/receipts` | Create receipt (DRAFT, with lines) | | PUT | `/receipts/:id` | Update receipt + lines (DRAFT only) | | POST | `/receipts/:id/confirm` | Confirm: creates batches, updates locations, generates number | | POST | `/receipts/:id/cancel` | Cancel: storno batches if confirmed | | POST | `/receipts/:id/attachments` | Upload delivery note (multipart) | | DELETE | `/receipts/:id/attachments/:attachmentId` | Delete attachment | ### Issues (warehouse.operate) | Method | Path | Description | | ------ | --------------------- | --------------------------------------------------------------------------------------- | | GET | `/issues` | List (paginated, filter by status/project/date) | | GET | `/issues/:id` | Detail with lines | | POST | `/issues` | Create issue (DRAFT, with lines) | | PUT | `/issues/:id` | Update issue + lines (DRAFT only) | | POST | `/issues/:id/confirm` | Confirm: decrements batches, updates locations, fulfills reservations, generates number | | POST | `/issues/:id/cancel` | Cancel: restores batch quantities if confirmed | ### Reservations (warehouse.operate) | Method | Path | Description | | ------ | -------------------------- | ------------------------------------ | | GET | `/reservations` | List (filter by item/project/status) | | POST | `/reservations` | Create reservation | | POST | `/reservations/:id/cancel` | Cancel reservation | ### Inventory (warehouse.inventory) | Method | Path | Description | | ------ | --------------------------------- | --------------------------------------- | | GET | `/inventory-sessions` | List sessions | | GET | `/inventory-sessions/:id` | Detail with lines | | POST | `/inventory-sessions` | Create session (system_qty auto-filled) | | PUT | `/inventory-sessions/:id` | Update lines (DRAFT only) | | POST | `/inventory-sessions/:id/confirm` | Confirm: creates corrective movements | ### Reports (warehouse.view) | Method | Path | Description | | ------ | ------------------------------ | ---------------------------------------------------- | | GET | `/reports/stock-status` | All items with qty, min_qty, value, below-min flag | | GET | `/reports/project-consumption` | Material per project (filter by project, date range) | | GET | `/reports/movement-log` | All movements with filters | | GET | `/reports/below-minimum` | Items below min_quantity | ### Utility (warehouse.view) | Method | Path | Description | | ------ | -------------------------- | -------------------------------------- | | GET | `/items/:id/available-qty` | Available qty (total stock - reserved) | | GET | `/items/:id/batches` | FIFO batch queue for item | --- ## Business Logic ### Receipt Confirm (single Prisma transaction) 1. Validate status is DRAFT 2. Generate `receipt_number` via `number_sequences` (type: `warehouse_receipt`) 3. For each receipt line: - Create `sklad_batches` row (quantity = original_qty = line qty, unit_price from line, received_at = now) - Upsert `sklad_item_locations` (add qty to specified location) 4. Update receipt status to CONFIRMED 5. Log audit ### Receipt Cancel **If DRAFT:** Set status to CANCELLED. No stock changes. **If CONFIRMED (storno):** 1. Validate no issue lines have consumed batches from this receipt 2. For each receipt line / batch: - If batch is fully consumed (is_consumed=true), reject cancellation - Decrement `sklad_item_locations` by batch remaining quantity - Delete the batch row 3. Set receipt status to CANCELLED 4. Log audit ### Issue Confirm (single Prisma transaction) 1. Validate status is DRAFT 2. For each issue line, validate: batch has enough remaining quantity 3. Generate `issue_number` via `number_sequences` (type: `warehouse_issue`) 4. For each issue line: - Decrement `sklad_batches.quantity`, set `is_consumed=true` if 0 - Decrement `sklad_item_locations.quantity` - If `reservation_id` set, decrement `sklad_reservations.remaining_qty` 5. Check reservations: if remaining_qty = 0, set status to FULFILLED 6. Update issue status to CONFIRMED 7. Log audit ### Issue Cancel **If DRAFT:** Set status to CANCELLED. No stock changes. **If CONFIRMED (storno):** 1. For each issue line: - Increment `sklad_batches.quantity`, set `is_consumed=false` if was true - Increment `sklad_item_locations.quantity` - If `reservation_id` set, increment `sklad_reservations.remaining_qty`, set status back to ACTIVE if was FULFILLED 2. Set issue status to CANCELLED 3. Log audit ### Auto-FIFO on Issue Creation When creating an issue line, if user doesn't pick a specific batch: - Service selects oldest unconsumed batches for the item (via `sklad_batches_fifo` index) - May span multiple batches if quantity exceeds one batch's remaining qty - Frontend shows available batches for manual override ### Reservation Creation 1. Validate item exists and is_active 2. Calculate available_qty = total stock qty - sum of active reservation quantities for this item 3. Validate available_qty >= requested quantity 4. Create reservation with quantity = remaining_qty 5. Does NOT modify `sklad_batches` or `sklad_item_locations` ### Inventory Confirm (single Prisma transaction) 1. Validate status is DRAFT 2. For each inventory line where difference != 0: - If difference > 0 (more stock than system): create a corrective receipt (type: inventory, auto-confirmed) - If difference < 0 (less stock than system): create a corrective issue (type: inventory, auto-confirmed) 3. Generate `session_number` via `number_sequences` (type: `warehouse_inventory`) 4. Set status to CONFIRMED 5. Log audit --- ## Frontend ### Sidebar Entry in `Sidebar.tsx` with permission `warehouse.view`: - Icon: package/box icon - Label: "Sklad" - Path: `/warehouse` ### Pages (lazy-loaded) | Route | Component | Permission | | ------------------------------ | ------------------------ | ------------------- | | `/warehouse` | Warehouse | warehouse.view | | `/warehouse/items` | WarehouseItems | warehouse.view | | `/warehouse/items/:id` | WarehouseItemDetail | warehouse.view | | `/warehouse/receipts` | WarehouseReceipts | warehouse.view | | `/warehouse/receipts/new` | WarehouseReceiptForm | warehouse.operate | | `/warehouse/receipts/:id` | WarehouseReceiptDetail | warehouse.view | | `/warehouse/receipts/:id/edit` | WarehouseReceiptForm | warehouse.operate | | `/warehouse/issues` | WarehouseIssues | warehouse.view | | `/warehouse/issues/new` | WarehouseIssueForm | warehouse.operate | | `/warehouse/issues/:id` | WarehouseIssueDetail | warehouse.view | | `/warehouse/issues/:id/edit` | WarehouseIssueForm | warehouse.operate | | `/warehouse/reservations` | WarehouseReservations | warehouse.view | | `/warehouse/inventory` | WarehouseInventory | warehouse.inventory | | `/warehouse/inventory/new` | WarehouseInventoryForm | warehouse.inventory | | `/warehouse/inventory/:id` | WarehouseInventoryDetail | warehouse.inventory | | `/warehouse/reports` | WarehouseReports | warehouse.view | | `/warehouse/suppliers` | WarehouseSuppliers | warehouse.manage | | `/warehouse/locations` | WarehouseLocations | warehouse.manage | | `/warehouse/categories` | WarehouseCategories | warehouse.manage | ### Warehouse Dashboard - Summary cards: total items, total stock value, below-minimum count, active reservations - Below-minimum alert section (highlighted) - Recent movements (last 10) ### WarehouseReports - Tab-based: Stock Status | Project Consumption | Movement Log | Below Minimum - Each tab has filters (date range, project, category, supplier) - Paginated data tables with sorting ### Key Shared Components | Component | Purpose | | ---------------------- | ----------------------------------------------------------------------------- | | ItemPicker | Searchable item selector (name + catalog number), used in receipt/issue forms | | BatchPicker | FIFO batch selector for issue lines, shows available qty per batch | | LocationSelect | Location dropdown | | SupplierSelect | Supplier dropdown | | WarehouseMovementTable | Reusable multi-line editor for receipt/issue lines | ### Query Keys ``` ["warehouse"] -> invalidates all ["warehouse", "items", filters] -> item list ["warehouse", "items", id] -> item detail ["warehouse", "receipts", filters] -> receipt list ["warehouse", "receipts", id] -> receipt detail ["warehouse", "issues", filters] -> issue list ["warehouse", "issues", id] -> issue detail ["warehouse", "reservations", filters] -> reservation list ["warehouse", "inventory", filters] -> inventory sessions ["warehouse", "inventory", id] -> inventory detail ["warehouse", "suppliers", filters] -> supplier list ["warehouse", "locations"] -> location list ["warehouse", "categories"] -> category list ["warehouse", "reports", type, filters] -> report data ``` Mutation invalidation: broad `["warehouse"]` for any create/update/delete. --- ## Permissions | Permission | Display Name | Module | Description | | --------------------- | -------------- | ------ | ------------------------------------------------------ | | `warehouse.view` | Zobrazit sklad | sklad | View stock status, items, reports, movement history | | `warehouse.operate` | Prijem a vydej | sklad | Create/confirm receipts, issues, reservations | | `warehouse.manage` | Sprava skladu | sklad | Manage items catalog, suppliers, locations, categories | | `warehouse.inventory` | Inventura | sklad | Create/confirm inventory sessions | --- ## Number Sequences Three new types registered in `number_sequences`: - `warehouse_receipt` - generated on receipt confirm - `warehouse_issue` - generated on issue confirm - `warehouse_inventory` - generated on inventory session confirm Pattern configurable via `company_settings`, same as invoices. --- ## File Storage Receipt attachments use existing `NasFinancialsManager`: - Stored under `warehouse/YYYY/MM/` on NAS - DB metadata in `sklad_receipt_attachments` (file_name, file_mime, file_size, file_path) - Upload via `@fastify/multipart` --- ## File Structure ### New Files ``` src/ routes/admin/warehouse.ts services/warehouse.service.ts schemas/warehouse.schema.ts admin/ pages/ Warehouse.tsx WarehouseItems.tsx WarehouseItemDetail.tsx WarehouseReceipts.tsx WarehouseReceiptDetail.tsx WarehouseReceiptForm.tsx WarehouseIssues.tsx WarehouseIssueDetail.tsx WarehouseIssueForm.tsx WarehouseReservations.tsx WarehouseInventory.tsx WarehouseInventoryDetail.tsx WarehouseInventoryForm.tsx WarehouseReports.tsx WarehouseSuppliers.tsx WarehouseLocations.tsx WarehouseCategories.tsx lib/queries/warehouse.ts components/warehouse/ ItemPicker.tsx BatchPicker.tsx LocationSelect.tsx SupplierSelect.tsx WarehouseMovementTable.tsx ``` ### Modified Files | File | Change | | ---------------------------------- | ---------------------------------- | | `src/server.ts` | Import + register warehouse routes | | `src/types/index.ts` | Add 8 EntityType values | | `src/admin/AdminApp.tsx` | Lazy imports + routes | | `src/admin/components/Sidebar.tsx` | Add warehouse menu entry | | `prisma/schema.prisma` | Add 13 models + 5 enums | | `prisma/seed.ts` | Add 4 permissions | --- ## Audit Entity Types New values added to `EntityType` union in `src/types/index.ts`: - `warehouse_item` - `warehouse_receipt` - `warehouse_issue` - `warehouse_reservation` - `warehouse_inventory` - `warehouse_supplier` - `warehouse_category` - `warehouse_location` --- ## Constraints & Edge Cases 1. **Batch consumption on cancel:** If any batch from a receipt has been partially or fully consumed by an issue, the receipt cannot be cancelled. User must first cancel the consuming issues. 2. **Reservation availability:** Available qty = sum of active batch quantities - sum of active reservation quantities. Reservation creation validates available_qty >= requested. 3. **Location qty consistency:** `sklad_item_locations` is a denormalized cache for quick lookups. It must always stay in sync with `sklad_batches`. The confirm/cancel transactions maintain this. 4. **FIFO spanning batches:** When auto-FIFO selects batches for an issue line and the quantity spans multiple batches, the service creates **multiple issue lines** (one per batch consumed), all under the same issue document. The user's original requested quantity is preserved across the split lines. This works because each `sklad_issue_lines` row has exactly one `batch_id`. 5. **Inventory corrective movements:** On confirm, each line with a difference creates an auto-confirmed receipt or issue. The `notes` field of these corrective documents references the inventory session ID and line, so they can be traced back. These corrective documents are fully audited like manual receipts/issues. 6. **Unit consistency:** Once an item is created with a unit, it cannot be changed if any movements exist. This prevents unit mismatches in stock calculations.