Files
app/docs/superpowers/specs/2026-05-29-warehouse-module-design.md
BOHA 674b44d047 feat(odin): Phase 2a read-only agentic assistant + tool-trace UI
- 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>
2026-06-10 23:31:50 +02:00

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_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

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)

  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.