- agentic chat loop (max 6 round-trips, budget re-checked between iterations) over 10 read-only, permission-delegated tools in src/services/ai-tools.ts - persist assistant tool trace in ai_chat_messages.content_json (+ migration) - consulted-tools chips in OdinThread; header now shows month spend only (budget cap hidden from the UI, server-side 402 guard unchanged) - seed: ai.use permission; Settings exposes the ai permission module - docs: REVIEW consolidation; deployment-guide.md superseded by docs/release.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
29 KiB
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_supplierstable (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
projectstable. - 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_sequenceson 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
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
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
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
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
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
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
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)
- Validate status is DRAFT
- Generate
receipt_numbervianumber_sequences(type:warehouse_receipt) - For each receipt line:
- Create
sklad_batchesrow (quantity = original_qty = line qty, unit_price from line, received_at = now) - Upsert
sklad_item_locations(add qty to specified location)
- Create
- Update receipt status to CONFIRMED
- Log audit
Receipt Cancel
If DRAFT: Set status to CANCELLED. No stock changes.
If CONFIRMED (storno):
- Validate no issue lines have consumed batches from this receipt
- For each receipt line / batch:
- If batch is fully consumed (is_consumed=true), reject cancellation
- Decrement
sklad_item_locationsby batch remaining quantity - Delete the batch row
- Set receipt status to CANCELLED
- Log audit
Issue Confirm (single Prisma transaction)
- Validate status is DRAFT
- For each issue line, validate: batch has enough remaining quantity
- Generate
issue_numbervianumber_sequences(type:warehouse_issue) - For each issue line:
- Decrement
sklad_batches.quantity, setis_consumed=trueif 0 - Decrement
sklad_item_locations.quantity - If
reservation_idset, decrementsklad_reservations.remaining_qty
- Decrement
- Check reservations: if remaining_qty = 0, set status to FULFILLED
- Update issue status to CONFIRMED
- Log audit
Issue Cancel
If DRAFT: Set status to CANCELLED. No stock changes.
If CONFIRMED (storno):
- For each issue line:
- Increment
sklad_batches.quantity, setis_consumed=falseif was true - Increment
sklad_item_locations.quantity - If
reservation_idset, incrementsklad_reservations.remaining_qty, set status back to ACTIVE if was FULFILLED
- Increment
- Set issue status to CANCELLED
- 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_fifoindex) - May span multiple batches if quantity exceeds one batch's remaining qty
- Frontend shows available batches for manual override
Reservation Creation
- Validate item exists and is_active
- Calculate available_qty = total stock qty - sum of active reservation quantities for this item
- Validate available_qty >= requested quantity
- Create reservation with quantity = remaining_qty
- Does NOT modify
sklad_batchesorsklad_item_locations
Inventory Confirm (single Prisma transaction)
- Validate status is DRAFT
- 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)
- Generate
session_numbervianumber_sequences(type:warehouse_inventory) - Set status to CONFIRMED
- 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 confirmwarehouse_issue- generated on issue confirmwarehouse_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_itemwarehouse_receiptwarehouse_issuewarehouse_reservationwarehouse_inventorywarehouse_supplierwarehouse_categorywarehouse_location
Constraints & Edge Cases
-
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.
-
Reservation availability: Available qty = sum of active batch quantities - sum of active reservation quantities. Reservation creation validates available_qty >= requested.
-
Location qty consistency:
sklad_item_locationsis a denormalized cache for quick lookups. It must always stay in sync withsklad_batches. The confirm/cancel transactions maintain this. -
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_linesrow has exactly onebatch_id. -
Inventory corrective movements: On confirm, each line with a difference creates an auto-confirmed receipt or issue. The
notesfield 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. -
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.