Files
app/docs/superpowers/plans/2026-05-29-warehouse-module.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

126 KiB

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:

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:

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:

  sklad_issues       sklad_issues[]
  sklad_reservations sklad_reservations[]

Add this relation field to the users model in prisma/schema.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:

  received_by_user  users? @relation("SkladReceivedBy", fields: [received_by], references: [id])

In sklad_issues:

  issued_by_user  users? @relation("SkladIssuedBy", fields: [issued_by], references: [id])

In sklad_reservations:

  reserved_by_user  users? @relation("SkladReservedBy", fields: [reserved_by], references: [id])
  • Step 4: Run migration
npx prisma migrate dev --name add_warehouse_module
npx prisma generate

Expected: Migration created and applied. Prisma client regenerated.

  • Step 5: Commit
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:

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:

  { 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:

const types = [
  "quotation",
  "order",
  "invoice",
  "warehouse_receipt",
  "warehouse_issue",
  "warehouse_inventory",
];
  • Step 4: Re-seed and verify
npx prisma db seed

Expected: 4 new warehouse permissions created, 3 new number sequence types seeded.

  • Step 5: Commit
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

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<typeof CreateCategorySchema>;
export type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>;

// === 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<typeof CreateSupplierSchema>;
export type UpdateSupplierInput = z.infer<typeof UpdateSupplierSchema>;

// === 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<typeof CreateLocationSchema>;
export type UpdateLocationInput = z.infer<typeof UpdateLocationSchema>;

// === 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<typeof UnitEnum>;

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<typeof CreateItemSchema>;
export type UpdateItemInput = z.infer<typeof UpdateItemSchema>;

// === 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<typeof CreateReceiptSchema>;
export type UpdateReceiptInput = z.infer<typeof UpdateReceiptSchema>;
export type ReceiptLineInput = z.infer<typeof ReceiptLineSchema>;

// === 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<typeof CreateIssueSchema>;
export type UpdateIssueInput = z.infer<typeof UpdateIssueSchema>;
export type IssueLineInput = z.infer<typeof IssueLineSchema>;

// === 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<typeof CreateReservationSchema>;

// === 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<typeof InventoryLineSchema>;
  • Step 2: Verify schema compiles
npx tsc --noEmit src/schemas/warehouse.schema.ts

Expected: No type errors.

  • Step 3: Commit
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

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<number> {
  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<Array<{ batchId: number; qty: number }>> {
  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<number> {
  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<number> {
  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
npx tsc --noEmit src/services/warehouse.service.ts

Expected: No type errors.

  • Step 3: Commit
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.

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<void> {

  // ==============================
  // 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<string, unknown>;
    const { page, limit, skip, search } = parsePagination(query);
    const where: Record<string, unknown> = {};
    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<string, unknown>;
    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<string, unknown> = { 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
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:

// ==============================
// RECEIPTS
// ==============================

fastify.get(
  "/receipts",
  { preHandler: requirePermission("warehouse.view") },
  async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    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<string, unknown> = {};
    if (statusFilter) where.status = statusFilter;
    if (supplierFilter) where.supplier_id = supplierFilter;
    if (dateFrom || dateTo) {
      const createdAt: Record<string, unknown> = {};
      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:

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
// ==============================
// ISSUES
// ==============================

fastify.get(
  "/issues",
  { preHandler: requirePermission("warehouse.view") },
  async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    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<string, unknown> = {};
    if (statusFilter) where.status = statusFilter;
    if (projectFilter) where.project_id = projectFilter;
    if (dateFrom || dateTo) {
      const createdAt: Record<string, unknown> = {};
      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
// ==============================
// RESERVATIONS
// ==============================

fastify.get(
  "/reservations",
  { preHandler: requirePermission("warehouse.view") },
  async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    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<string, unknown> = {};
    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
// ==============================
// INVENTORY
// ==============================

fastify.get(
  "/inventory-sessions",
  { preHandler: requirePermission("warehouse.inventory") },
  async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    const { page, limit, skip } = parsePagination(query);
    const statusFilter = query.status as string | undefined;

    const where: Record<string, unknown> = {};
    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
  // ==============================
  // REPORTS
  // ==============================

  fastify.get("/reports/stock-status", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    const categoryId = query.category_id ? Number(query.category_id) : undefined;

    const where: Record<string, unknown> = { 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<string, unknown>;
    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<string, unknown> = { status: "CONFIRMED" };
    if (projectId) where.project_id = projectId;
    if (dateFrom || dateTo) {
      const createdAt: Record<string, unknown> = {};
      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<string, { item_id: number; item_name: string; project_id: number; project_name: string; total_qty: number; total_value: number }> = {};
    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<string, unknown>;
    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<Record<string, unknown>> = [];

    if (!typeFilter || typeFilter === "receipt") {
      const receiptWhere: Record<string, unknown> = { status: "CONFIRMED" };
      if (dateFrom || dateTo) {
        const createdAt: Record<string, unknown> = {};
        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<string, unknown> = { status: "CONFIRMED" };
      if (dateFrom || dateTo) {
        const createdAt: Record<string, unknown> = {};
        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
npx tsc --noEmit src/routes/admin/warehouse.ts

Expected: No type errors.

  • Step 7: Commit
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:

import warehouseRoutes from "./routes/admin/warehouse";

Add the registration alongside existing route registrations:

await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
  • Step 2: Verify server compiles
npx tsc --noEmit src/server.ts

Expected: No type errors.

  • Step 3: Commit
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

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<WarehouseItem>(
        `/api/admin/warehouse/items${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseItemDetailOptions = (id: string | undefined) =>
  queryOptions({
    queryKey: ["warehouse", "items", id],
    queryFn: () => jsonQuery<WarehouseItem>(`/api/admin/warehouse/items/${id}`),
    enabled: !!id,
  });

export const warehouseCategoryListOptions = () =>
  queryOptions({
    queryKey: ["warehouse", "categories"],
    queryFn: () =>
      jsonQuery<WarehouseCategory[]>("/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<WarehouseSupplier>(
        `/api/admin/warehouse/suppliers${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseLocationListOptions = () =>
  queryOptions({
    queryKey: ["warehouse", "locations"],
    queryFn: () =>
      jsonQuery<WarehouseLocation[]>("/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<WarehouseReceipt>(
        `/api/admin/warehouse/receipts${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseReceiptDetailOptions = (id: string | undefined) =>
  queryOptions({
    queryKey: ["warehouse", "receipts", id],
    queryFn: () =>
      jsonQuery<WarehouseReceipt>(`/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<WarehouseIssue>(
        `/api/admin/warehouse/issues${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseIssueDetailOptions = (id: string | undefined) =>
  queryOptions({
    queryKey: ["warehouse", "issues", id],
    queryFn: () =>
      jsonQuery<WarehouseIssue>(`/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<WarehouseReservation>(
        `/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<WarehouseInventorySession>(
        `/api/admin/warehouse/inventory-sessions${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseInventoryDetailOptions = (id: string | undefined) =>
  queryOptions({
    queryKey: ["warehouse", "inventory", id],
    queryFn: () =>
      jsonQuery<WarehouseInventorySession>(
        `/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<WarehouseItem[]>(
        `/api/admin/warehouse/reports/stock-status${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseBelowMinimumOptions = () =>
  queryOptions({
    queryKey: ["warehouse", "reports", "below-minimum"],
    queryFn: () =>
      jsonQuery<WarehouseItem[]>("/api/admin/warehouse/reports/below-minimum"),
  });
  • Step 2: Commit
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

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 (
    <div className="admin-form-group">
      <input
        type="text"
        className="admin-form-input"
        placeholder="Hledat položku..."
        value={search}
        onChange={(e) => {
          setSearch(e.target.value);
          setOpen(true);
        }}
        onFocus={() => setOpen(true)}
      />
      {open && items.length > 0 && (
        <ul className="admin-item-picker-list">
          {items.map((item) => (
            <li
              key={item.id}
              className={`admin-item-picker-item ${value === item.id ? "active" : ""}`}
              onClick={() => {
                onChange(item.id);
                setOpen(false);
                setSearch(item.name);
              }}
            >
              <span className="admin-item-picker-name">{item.name}</span>
              {item.item_number && (
                <span className="admin-item-picker-number">
                  {item.item_number}
                </span>
              )}
              {item.available_quantity !== undefined && (
                <span className="admin-item-picker-qty">
                  {item.available_quantity} {item.unit}
                </span>
              )}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
  • Step 2: Create LocationSelect component
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 (
    <select
      className="admin-form-select"
      value={value ?? ""}
      onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
    >
      <option value="">-- bez lokace --</option>
      {locations?.map((loc) => (
        <option key={loc.id} value={loc.id}>
          {loc.code} - {loc.name}
        </option>
      ))}
    </select>
  );
}
  • Step 3: Create SupplierSelect component
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 (
    <select
      className="admin-form-select"
      value={value ?? ""}
      onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
    >
      <option value="">-- bez dodavatele --</option>
      {suppliers.map((s) => (
        <option key={s.id} value={s.id}>
          {s.name}
          {s.ico ? ` (${s.ico})` : ""}
        </option>
      ))}
    </select>
  );
}
  • Step 4: Create BatchPicker component
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<Batch[]>(`/api/admin/warehouse/items/${itemId}/batches`),
    enabled: !!itemId,
  });

  return (
    <select
      className="admin-form-select"
      value={value ?? ""}
      onChange={(e) => {
        const batchId = Number(e.target.value);
        const batch = batches?.find((b) => b.id === batchId);
        if (batch) onChange(batch.id, Number(batch.unit_price));
      }}
    >
      <option value="">-- auto FIFO --</option>
      {batches?.map((b) => (
        <option key={b.id} value={b.id}>
          {new Date(b.received_at).toLocaleDateString("cs-CZ")} | {b.quantity}{" "}
          ks | {Number(b.unit_price).toFixed(2)} 
        </option>
      ))}
    </select>
  );
}
  • Step 5: Create WarehouseMovementTable component

This is the multi-line editor used in both receipt and issue forms:

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 (
    <div className="admin-warehouse-movement-table">
      <table className="admin-table">
        <thead>
          <tr>
            <th>Položka</th>
            {mode === "issue" && <th>Šarže</th>}
            <th>Množství</th>
            <th>Cena/ks</th>
            <th>Lokace</th>
            <th>Poznámka</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {lines.map((line, index) => (
            <tr key={line.key}>
              <td>
                <ItemPicker
                  value={line.item_id}
                  onChange={(id) => updateLine(index, "item_id", id)}
                />
              </td>
              {mode === "issue" && (
                <td>
                  <BatchPicker
                    itemId={line.item_id}
                    value={line.batch_id}
                    onChange={(batchId, unitPrice) => {
                      updateLine(index, "batch_id", batchId);
                      updateLine(index, "unit_price", unitPrice);
                    }}
                  />
                </td>
              )}
              <td>
                <input
                  type="number"
                  className="admin-form-input"
                  value={line.quantity || ""}
                  onChange={(e) =>
                    updateLine(index, "quantity", Number(e.target.value))
                  }
                  min="0"
                  step="0.001"
                />
              </td>
              <td>
                <input
                  type="number"
                  className="admin-form-input"
                  value={line.unit_price || ""}
                  onChange={(e) =>
                    updateLine(index, "unit_price", Number(e.target.value))
                  }
                  min="0"
                  step="0.01"
                  disabled={mode === "issue"}
                />
              </td>
              <td>
                <LocationSelect
                  value={line.location_id}
                  onChange={(id) => updateLine(index, "location_id", id)}
                />
              </td>
              <td>
                <input
                  type="text"
                  className="admin-form-input"
                  value={line.notes ?? ""}
                  onChange={(e) => updateLine(index, "notes", e.target.value)}
                />
              </td>
              <td>
                <button
                  type="button"
                  className="admin-btn-danger-sm"
                  onClick={() => removeLine(index)}
                >
                  
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <button type="button" className="admin-btn-secondary" onClick={addLine}>
        + Přidat řádek
      </button>
    </div>
  );
}
  • Step 6: Commit
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:

{
  path: "/warehouse",
  label: "Sklad",
  permission: "warehouse.view",
  matchPrefix: "/warehouse",
  icon: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
      <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
      <line x1="12" y1="22.08" x2="12" y2="12" />
    </svg>
  ),
},
  • Step 2: Add lazy imports and routes to AdminApp.tsx

In src/admin/AdminApp.tsx, add lazy imports:

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 <Route element={<AdminLayout />}> section:

<Route path="warehouse" element={<Warehouse />} />
<Route path="warehouse/items" element={<WarehouseItems />} />
<Route path="warehouse/items/:id" element={<WarehouseItemDetail />} />
<Route path="warehouse/receipts" element={<WarehouseReceipts />} />
<Route path="warehouse/receipts/new" element={<WarehouseReceiptForm />} />
<Route path="warehouse/receipts/:id" element={<WarehouseReceiptDetail />} />
<Route path="warehouse/receipts/:id/edit" element={<WarehouseReceiptForm />} />
<Route path="warehouse/issues" element={<WarehouseIssues />} />
<Route path="warehouse/issues/new" element={<WarehouseIssueForm />} />
<Route path="warehouse/issues/:id" element={<WarehouseIssueDetail />} />
<Route path="warehouse/issues/:id/edit" element={<WarehouseIssueForm />} />
<Route path="warehouse/reservations" element={<WarehouseReservations />} />
<Route path="warehouse/inventory" element={<WarehouseInventory />} />
<Route path="warehouse/inventory/new" element={<WarehouseInventoryForm />} />
<Route path="warehouse/inventory/:id" element={<WarehouseInventoryDetail />} />
<Route path="warehouse/reports" element={<WarehouseReports />} />
<Route path="warehouse/suppliers" element={<WarehouseSuppliers />} />
<Route path="warehouse/locations" element={<WarehouseLocations />} />
<Route path="warehouse/categories" element={<WarehouseCategories />} />
  • Step 3: Commit
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
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
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
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
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

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

npm test -- --reporter=verbose src/__tests__/warehouse.test.ts

Expected: All tests pass.

  • Step 3: Commit
git add src/__tests__/warehouse.test.ts
git commit -m "test(warehouse): add integration tests"

Task 17: Build Verification

  • Step 1: Run full build
npm run build

Expected: No errors, dist/ and dist-client/ generated successfully.

  • Step 2: Run full test suite
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.