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

3875 lines
126 KiB
Markdown

# Warehouse Module Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Full warehouse/inventory management module with receipts, issues, reservations, inventory sessions, FIFO batch tracking, and reporting.
**Architecture:** Document-driven (header + lines) matching existing invoice/offer patterns. 13 Prisma models, REST API under `/api/admin/warehouse`, React frontend with 17 lazy-loaded pages. True FIFO via `sklad_batches` table linked to receipt lines.
**Tech Stack:** Prisma 6.19.2, Fastify 5, Zod 4, React 18, TanStack React Query, NAS file storage via NasFinancialsManager
---
## File Structure
### New Files
```
prisma/migrations/ # Auto-generated by prisma migrate dev
src/
schemas/warehouse.schema.ts # Zod validation schemas for all warehouse entities
services/warehouse.service.ts # Business logic (FIFO, confirm, cancel, inventory)
routes/admin/warehouse.ts # All warehouse API endpoints
admin/
lib/queries/warehouse.ts # TanStack Query options
components/warehouse/
ItemPicker.tsx # Searchable item selector
BatchPicker.tsx # FIFO batch selector
LocationSelect.tsx # Location dropdown
SupplierSelect.tsx # Supplier dropdown
WarehouseMovementTable.tsx # Multi-line editor for receipt/issue lines
pages/
Warehouse.tsx # Dashboard
WarehouseItems.tsx # Item catalog list
WarehouseItemDetail.tsx # Item detail
WarehouseReceipts.tsx # Receipt list
WarehouseReceiptDetail.tsx # Receipt detail
WarehouseReceiptForm.tsx # Create/edit receipt
WarehouseIssues.tsx # Issue list
WarehouseIssueDetail.tsx # Issue detail
WarehouseIssueForm.tsx # Create/edit issue
WarehouseReservations.tsx # Reservation list
WarehouseInventory.tsx # Inventory sessions list
WarehouseInventoryDetail.tsx # Inventory detail
WarehouseInventoryForm.tsx # Create/edit inventory session
WarehouseReports.tsx # Reports hub
WarehouseSuppliers.tsx # Suppliers CRUD
WarehouseLocations.tsx # Locations CRUD
WarehouseCategories.tsx # Categories CRUD
src/__tests__/warehouse.test.ts # Backend integration tests
```
### Modified Files
| File | Change |
| ---------------------------------- | ------------------------------------------- |
| `prisma/schema.prisma` | Add 13 models + 5 enums |
| `src/types/index.ts` | Add 8 EntityType values |
| `src/server.ts` | Import + register warehouse routes |
| `prisma/seed.ts` | Add 4 permissions + 3 number sequence types |
| `src/admin/AdminApp.tsx` | Lazy imports + routes |
| `src/admin/components/Sidebar.tsx` | Add warehouse menu entry |
---
## Phase 1: Database Foundation
### Task 1: Add Prisma Models
**Files:**
- Modify: `prisma/schema.prisma`
- [ ] **Step 1: Add warehouse enums to schema.prisma**
Add these enums at the end of the existing enums section in `prisma/schema.prisma`:
```prisma
enum sklad_receipt_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_issue_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_reservation_status {
ACTIVE
FULFILLED
CANCELLED
}
enum sklad_inventory_status {
DRAFT
CONFIRMED
}
```
- [ ] **Step 2: Add warehouse models to schema.prisma**
Add these models at the end of `prisma/schema.prisma`:
```prisma
model sklad_categories {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
description String? @db.Text
sort_order Int @default(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_items[]
@@map("sklad_categories")
}
model sklad_suppliers {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
ico String? @db.VarChar(20)
dic String? @db.VarChar(20)
contact_person String? @db.VarChar(255)
email String? @db.VarChar(255)
phone String? @db.VarChar(50)
address String? @db.Text
notes String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[]
@@map("sklad_suppliers")
}
model sklad_locations {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(20)
name String @db.VarChar(100)
description String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_item_locations[]
@@map("sklad_locations")
}
model sklad_items {
id Int @id @default(autoincrement())
item_number String? @unique @db.VarChar(50)
name String @db.VarChar(255)
description String? @db.Text
category_id Int?
unit String @db.VarChar(20)
min_quantity Decimal? @db.Decimal(12, 3)
is_active Boolean @default(true)
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
category sklad_categories? @relation(fields: [category_id], references: [id])
batches sklad_batches[]
item_locations sklad_item_locations[]
@@index([category_id], map: "sklad_items_category_id")
@@map("sklad_items")
}
model sklad_batches {
id Int @id @default(autoincrement())
item_id Int
receipt_line_id Int
quantity Decimal @db.Decimal(12, 3)
original_qty Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
received_at DateTime? @db.DateTime(0)
is_consumed Boolean @default(false)
item sklad_items @relation(fields: [item_id], references: [id])
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id])
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
@@map("sklad_batches")
}
model sklad_item_locations {
id Int @id @default(autoincrement())
item_id Int
location_id Int
quantity Decimal @default(0) @db.Decimal(12, 3)
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations @relation(fields: [location_id], references: [id])
@@unique([item_id, location_id], map: "sklad_item_locations_unique")
@@map("sklad_item_locations")
}
model sklad_receipts {
id Int @id @default(autoincrement())
receipt_number String? @db.VarChar(50)
supplier_id Int?
delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0)
received_by Int?
notes String? @db.Text
status sklad_receipt_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id])
received_by_user users? @relation(fields: [received_by], references: [id])
lines sklad_receipt_lines[]
attachments sklad_receipt_attachments[]
@@map("sklad_receipts")
}
model sklad_receipt_lines {
id Int @id @default(autoincrement())
receipt_id Int
item_id Int
quantity Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
location_id Int?
notes String? @db.VarChar(255)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
batch sklad_batches?
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
@@map("sklad_receipt_lines")
}
model sklad_receipt_attachments {
id Int @id @default(autoincrement())
receipt_id Int
file_name String @db.VarChar(255)
file_mime String @db.VarChar(100)
file_size Int
file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
@@map("sklad_receipt_attachments")
}
model sklad_issues {
id Int @id @default(autoincrement())
issue_number String? @db.VarChar(50)
project_id Int
issued_by Int?
notes String? @db.Text
status sklad_issue_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
project projects @relation(fields: [project_id], references: [id])
issued_by_user users? @relation(fields: [issued_by], references: [id])
lines sklad_issue_lines[]
@@map("sklad_issues")
}
model sklad_issue_lines {
id Int @id @default(autoincrement())
issue_id Int
item_id Int
batch_id Int
quantity Decimal @db.Decimal(12, 3)
location_id Int?
reservation_id Int?
notes String? @db.VarChar(255)
issue sklad_issues @relation(fields: [issue_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
batch sklad_batches @relation(fields: [batch_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id])
@@index([issue_id], map: "sklad_issue_lines_issue_id")
@@map("sklad_issue_lines")
}
model sklad_reservations {
id Int @id @default(autoincrement())
item_id Int
project_id Int
quantity Decimal @db.Decimal(12, 3)
remaining_qty Decimal @db.Decimal(12, 3)
reserved_by Int?
notes String? @db.Text
status sklad_reservation_status @default(ACTIVE)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
item sklad_items @relation(fields: [item_id], references: [id])
project projects @relation(fields: [project_id], references: [id])
reserved_by_user users? @relation(fields: [reserved_by], references: [id])
issue_lines sklad_issue_lines[]
@@index([item_id, status], map: "sklad_reservations_item_status")
@@map("sklad_reservations")
}
model sklad_inventory_sessions {
id Int @id @default(autoincrement())
session_number String? @db.VarChar(50)
notes String? @db.Text
status sklad_inventory_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
lines sklad_inventory_lines[]
@@map("sklad_inventory_sessions")
}
model sklad_inventory_lines {
id Int @id @default(autoincrement())
session_id Int
item_id Int
location_id Int?
system_qty Decimal @db.Decimal(12, 3)
actual_qty Decimal @db.Decimal(12, 3)
difference Decimal @db.Decimal(12, 3)
notes String? @db.VarChar(255)
session sklad_inventory_sessions @relation(fields: [session_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
@@index([session_id], map: "sklad_inventory_lines_session_id")
@@map("sklad_inventory_lines")
}
```
- [ ] **Step 3: Add reverse relations to existing models**
Add these relation fields to the `projects` model in `prisma/schema.prisma`:
```prisma
sklad_issues sklad_issues[]
sklad_reservations sklad_reservations[]
```
Add this relation field to the `users` model in `prisma/schema.prisma`:
```prisma
sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy")
sklad_reservations sklad_reservations[] @relation("SkladReservedBy")
```
Note: The `users` model likely already has multiple relations, so named relation mappings may be needed to avoid ambiguity. Use `@relation("SkladReceivedBy")` on `sklad_receipts.received_by_user`, `@relation("SkladIssuedBy")` on `sklad_issues.issued_by_user`, and `@relation("SkladReservedBy")` on `sklad_reservations.reserved_by_user`. Update the corresponding relation field definitions in the warehouse models accordingly:
In `sklad_receipts`:
```prisma
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id])
```
In `sklad_issues`:
```prisma
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id])
```
In `sklad_reservations`:
```prisma
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id])
```
- [ ] **Step 4: Run migration**
```bash
npx prisma migrate dev --name add_warehouse_module
npx prisma generate
```
Expected: Migration created and applied. Prisma client regenerated.
- [ ] **Step 5: Commit**
```bash
git add prisma/schema.prisma prisma/migrations/
git commit -m "feat(warehouse): add Prisma schema for warehouse module"
```
---
### Task 2: Add Entity Types and Permissions
**Files:**
- Modify: `src/types/index.ts`
- Modify: `prisma/seed.ts`
- [ ] **Step 1: Add EntityType values**
In `src/types/index.ts`, extend the `EntityType` union to include:
```ts
export type EntityType =
| "user"
| "role"
| "attendance"
| "leave_request"
| "invoice"
| "quotation"
| "order"
| "project"
| "customer"
| "trip"
| "vehicle"
| "bank_account"
| "company_settings"
| "leave_balance"
| "project_file"
| "audit_logs"
| "warehouse_item"
| "warehouse_receipt"
| "warehouse_issue"
| "warehouse_reservation"
| "warehouse_inventory"
| "warehouse_supplier"
| "warehouse_category"
| "warehouse_location";
```
- [ ] **Step 2: Add warehouse permissions to seed.ts**
In `prisma/seed.ts`, add these entries to the `PERMISSIONS` array:
```ts
{ name: "warehouse.view", display_name: "Zobrazit sklad", module: "warehouse", description: "Prohlížet stav skladu, položky, reporty a historii pohybů" },
{ name: "warehouse.operate", display_name: "Příjem a výdej", module: "warehouse", description: "Vytvářet a potvrzovat příjmy, výdeje a rezervace" },
{ name: "warehouse.manage", display_name: "Správa skladu", module: "warehouse", description: "Spravovat katalog materiálů, dodavatele, lokace a kategorie" },
{ name: "warehouse.inventory", display_name: "Inventura", module: "warehouse", description: "Vytvářet a potvrzovat inventurní sčítkání" },
```
- [ ] **Step 3: Add number sequence types to seed.ts**
In the number sequence seeding section of `prisma/seed.ts`, add `"warehouse_receipt"`, `"warehouse_issue"`, and `"warehouse_inventory"` to the types array:
```ts
const types = [
"quotation",
"order",
"invoice",
"warehouse_receipt",
"warehouse_issue",
"warehouse_inventory",
];
```
- [ ] **Step 4: Re-seed and verify**
```bash
npx prisma db seed
```
Expected: 4 new warehouse permissions created, 3 new number sequence types seeded.
- [ ] **Step 5: Commit**
```bash
git add src/types/index.ts prisma/seed.ts
git commit -m "feat(warehouse): add entity types, permissions, and number sequences"
```
---
## Phase 2: Backend - Schemas & Service
### Task 3: Zod Validation Schemas
**Files:**
- Create: `src/schemas/warehouse.schema.ts`
- [ ] **Step 1: Create warehouse.schema.ts**
```ts
import { z } from "zod";
// === Categories ===
export const CreateCategorySchema = z.object({
name: z.string().min(1, "Název je povinný"),
description: z.string().nullish(),
sort_order: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.optional()
.default(0),
});
export const UpdateCategorySchema = z.object({
name: z.string().min(1, "Název je povinný").optional(),
description: z.string().nullish(),
sort_order: z
.union([z.number(), z.string()])
.transform((v) => Number(v))
.optional(),
});
export type CreateCategoryInput = z.infer<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**
```bash
npx tsc --noEmit src/schemas/warehouse.schema.ts
```
Expected: No type errors.
- [ ] **Step 3: Commit**
```bash
git add src/schemas/warehouse.schema.ts
git commit -m "feat(warehouse): add Zod validation schemas"
```
---
### Task 4: Warehouse Service - Master Data & FIFO
**Files:**
- Create: `src/services/warehouse.service.ts`
- [ ] **Step 1: Create warehouse.service.ts with master data functions and FIFO logic**
```ts
import prisma from "../config/database";
import { Prisma } from "@prisma/client";
type TxClient = Omit<
Prisma.Client,
"$connect" | "$disconnect" | "$on" | "$transaction" | "$extends"
>;
// === Available Quantity ===
export async function getItemAvailableQty(itemId: number): Promise<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**
```bash
npx tsc --noEmit src/services/warehouse.service.ts
```
Expected: No type errors.
- [ ] **Step 3: Commit**
```bash
git add src/services/warehouse.service.ts
git commit -m "feat(warehouse): add warehouse service with FIFO and business logic"
```
---
## Phase 3: Backend - API Routes
### Task 5: Warehouse Routes - Master Data
**Files:**
- Create: `src/routes/admin/warehouse.ts` (partial - master data endpoints)
- [ ] **Step 1: Create warehouse routes file with categories, suppliers, locations, items endpoints**
This is a large file. Create `src/routes/admin/warehouse.ts` with the following structure. The file will be extended in subsequent tasks with receipts, issues, reservations, inventory, and reports.
```ts
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
CreateCategorySchema, UpdateCategorySchema,
CreateSupplierSchema, UpdateSupplierSchema,
CreateLocationSchema, UpdateLocationSchema,
CreateItemSchema, UpdateItemSchema,
} from "../../schemas/warehouse.schema";
import {
getItemAvailableQty,
getItemTotalStock,
getItemStockValue,
getBelowMinimumItems,
} from "../../services/warehouse.service";
export default async function warehouseRoutes(fastify: FastifyInstance): Promise<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**
```bash
git add src/routes/admin/warehouse.ts
git commit -m "feat(warehouse): add master data API routes"
```
---
### Task 6: Warehouse Routes - Receipts, Issues, Reservations, Inventory, Reports
**Files:**
- Modify: `src/routes/admin/warehouse.ts` (append remaining endpoints)
- [ ] **Step 1: Add receipt endpoints**
Append to `warehouseRoutes` in `src/routes/admin/warehouse.ts`:
```ts
// ==============================
// RECEIPTS
// ==============================
fastify.get(
"/receipts",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const query = request.query as Record<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:
```ts
import multipart from "@fastify/multipart";
import config from "../../config/env";
import { nasFinancialsManager } from "../../services/nas-financials-manager";
import {
confirmReceipt,
cancelReceipt,
confirmIssue,
cancelIssue,
createReservation,
cancelReservation,
confirmInventorySession,
} from "../../services/warehouse.service";
```
- [ ] **Step 2: Add issue endpoints**
```ts
// ==============================
// ISSUES
// ==============================
fastify.get(
"/issues",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const query = request.query as Record<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**
```ts
// ==============================
// 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**
```ts
// ==============================
// 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**
```ts
// ==============================
// 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**
```bash
npx tsc --noEmit src/routes/admin/warehouse.ts
```
Expected: No type errors.
- [ ] **Step 7: Commit**
```bash
git add src/routes/admin/warehouse.ts
git commit -m "feat(warehouse): add full warehouse API routes"
```
---
### Task 7: Register Warehouse Routes in Server
**Files:**
- Modify: `src/server.ts`
- [ ] **Step 1: Add import and registration**
In `src/server.ts`, add the import alongside existing route imports:
```ts
import warehouseRoutes from "./routes/admin/warehouse";
```
Add the registration alongside existing route registrations:
```ts
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
```
- [ ] **Step 2: Verify server compiles**
```bash
npx tsc --noEmit src/server.ts
```
Expected: No type errors.
- [ ] **Step 3: Commit**
```bash
git add src/server.ts
git commit -m "feat(warehouse): register warehouse routes in server"
```
---
## Phase 4: Frontend - Foundation
### Task 8: Query Hooks
**Files:**
- Create: `src/admin/lib/queries/warehouse.ts`
- [ ] **Step 1: Create warehouse query options**
```ts
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
// === Types ===
export interface WarehouseItem {
id: number;
item_number: string | null;
name: string;
description: string | null;
category_id: number | null;
unit: string;
min_quantity: number | null;
is_active: boolean;
notes: string | null;
category?: { id: number; name: string } | null;
total_quantity?: number;
available_quantity?: number;
stock_value?: number;
below_minimum?: boolean;
}
export interface WarehouseReceipt {
id: number;
receipt_number: string | null;
supplier_id: number | null;
delivery_note_number: string | null;
delivery_note_date: string | null;
received_by: number | null;
notes: string | null;
status: "DRAFT" | "CONFIRMED" | "CANCELLED";
created_at: string;
modified_at: string | null;
supplier?: { id: number; name: string } | null;
received_by_user?: {
id: number;
first_name: string;
last_name: string;
} | null;
lines?: WarehouseReceiptLine[];
attachments?: WarehouseReceiptAttachment[];
}
export interface WarehouseReceiptLine {
id: number;
receipt_id: number;
item_id: number;
quantity: number;
unit_price: number;
location_id: number | null;
notes: string | null;
item?: WarehouseItem;
location?: { id: number; code: string; name: string } | null;
}
export interface WarehouseReceiptAttachment {
id: number;
receipt_id: number;
file_name: string;
file_mime: string;
file_size: number;
file_path: string;
created_at: string;
}
export interface WarehouseIssue {
id: number;
issue_number: string | null;
project_id: number;
issued_by: number | null;
notes: string | null;
status: "DRAFT" | "CONFIRMED" | "CANCELLED";
created_at: string;
modified_at: string | null;
project?: { id: number; name: string; project_number: string } | null;
issued_by_user?: { id: number; first_name: string; last_name: string } | null;
lines?: WarehouseIssueLine[];
}
export interface WarehouseIssueLine {
id: number;
issue_id: number;
item_id: number;
batch_id: number;
quantity: number;
location_id: number | null;
reservation_id: number | null;
notes: string | null;
item?: WarehouseItem;
batch?: {
id: number;
quantity: number;
unit_price: number;
received_at: string;
} | null;
location?: { id: number; code: string; name: string } | null;
reservation?: { id: number; quantity: number; remaining_qty: number } | null;
}
export interface WarehouseReservation {
id: number;
item_id: number;
project_id: number;
quantity: number;
remaining_qty: number;
reserved_by: number | null;
notes: string | null;
status: "ACTIVE" | "FULFILLED" | "CANCELLED";
created_at: string;
modified_at: string | null;
item?: WarehouseItem;
project?: { id: number; name: string } | null;
reserved_by_user?: {
id: number;
first_name: string;
last_name: string;
} | null;
}
export interface WarehouseInventorySession {
id: number;
session_number: string | null;
notes: string | null;
status: "DRAFT" | "CONFIRMED";
created_at: string;
modified_at: string | null;
lines?: WarehouseInventoryLine[];
}
export interface WarehouseInventoryLine {
id: number;
session_id: number;
item_id: number;
location_id: number | null;
system_qty: number;
actual_qty: number;
difference: number;
notes: string | null;
item?: WarehouseItem;
location?: { id: number; code: string; name: string } | null;
}
export interface WarehouseCategory {
id: number;
name: string;
description: string | null;
sort_order: number;
}
export interface WarehouseLocation {
id: number;
code: string;
name: string;
description: string | null;
is_active: boolean;
}
export interface WarehouseSupplier {
id: number;
name: string;
ico: string | null;
dic: string | null;
contact_person: string | null;
email: string | null;
phone: string | null;
address: string | null;
notes: string | null;
is_active: boolean;
}
// === Query Options ===
export const warehouseItemListOptions = (filters: {
search?: string;
page?: number;
perPage?: number;
sort?: string;
order?: string;
category_id?: number;
}) => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("limit", String(filters.perPage));
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.category_id)
params.set("category_id", String(filters.category_id));
const qs = params.toString();
return queryOptions({
queryKey: ["warehouse", "items", filters],
queryFn: () =>
paginatedJsonQuery<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**
```bash
git add src/admin/lib/queries/warehouse.ts
git commit -m "feat(warehouse): add TanStack Query options and types"
```
---
### Task 9: Shared Components
**Files:**
- Create: `src/admin/components/warehouse/ItemPicker.tsx`
- Create: `src/admin/components/warehouse/BatchPicker.tsx`
- Create: `src/admin/components/warehouse/LocationSelect.tsx`
- Create: `src/admin/components/warehouse/SupplierSelect.tsx`
- Create: `src/admin/components/warehouse/WarehouseMovementTable.tsx`
- [ ] **Step 1: Create ItemPicker component**
```tsx
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { warehouseItemListOptions } from "../../lib/queries/warehouse";
interface ItemPickerProps {
value: number | null;
onChange: (itemId: number) => void;
}
export default function ItemPicker({ value, onChange }: ItemPickerProps) {
const [search, setSearch] = useState("");
const [open, setOpen] = useState(false);
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
const items = data?.data ?? [];
return (
<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**
```tsx
import { useQuery } from "@tanstack/react-query";
import { warehouseLocationListOptions } from "../../lib/queries/warehouse";
interface LocationSelectProps {
value: number | null;
onChange: (locationId: number | null) => void;
}
export default function LocationSelect({
value,
onChange,
}: LocationSelectProps) {
const { data: locations } = useQuery(warehouseLocationListOptions());
return (
<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**
```tsx
import { useQuery } from "@tanstack/react-query";
import { warehouseSupplierListOptions } from "../../lib/queries/warehouse";
interface SupplierSelectProps {
value: number | null;
onChange: (supplierId: number | null) => void;
}
export default function SupplierSelect({
value,
onChange,
}: SupplierSelectProps) {
const { data } = useQuery(warehouseSupplierListOptions({ perPage: 100 }));
const suppliers = data?.data ?? [];
return (
<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**
```tsx
import { useQuery } from "@tanstack/react-query";
import { jsonQuery } from "../../lib/apiAdapter";
import type { WarehouseItem } from "../../lib/queries/warehouse";
interface Batch {
id: number;
quantity: number;
unit_price: number;
received_at: string;
is_consumed: boolean;
}
interface BatchPickerProps {
itemId: number | null;
value: number | null;
onChange: (batchId: number, unitPrice: number) => void;
}
export default function BatchPicker({
itemId,
value,
onChange,
}: BatchPickerProps) {
const { data: batches } = useQuery({
queryKey: ["warehouse", "batches", itemId],
queryFn: () =>
jsonQuery<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:
```tsx
import { useState } from "react";
import type {
WarehouseItem,
WarehouseLocation,
} from "../../lib/queries/warehouse";
import ItemPicker from "./ItemPicker";
import LocationSelect from "./LocationSelect";
import BatchPicker from "./BatchPicker";
export interface MovementLine {
key: string;
item_id: number | null;
item_name?: string;
quantity: number;
unit_price: number;
location_id: number | null;
batch_id: number | null;
reservation_id: number | null;
notes: string | null;
}
interface WarehouseMovementTableProps {
lines: MovementLine[];
onChange: (lines: MovementLine[]) => void;
mode: "receipt" | "issue";
locations: WarehouseLocation[];
}
let lineCounter = 0;
export default function WarehouseMovementTable({
lines,
onChange,
mode,
locations,
}: WarehouseMovementTableProps) {
const addLine = () => {
onChange([
...lines,
{
key: `line-${++lineCounter}`,
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
},
]);
};
const removeLine = (index: number) => {
onChange(lines.filter((_, i) => i !== index));
};
const updateLine = (index: number, field: string, value: unknown) => {
const updated = [...lines];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
return (
<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**
```bash
git add src/admin/components/warehouse/
git commit -m "feat(warehouse): add shared warehouse components"
```
---
### Task 10: Sidebar & Routes
**Files:**
- Modify: `src/admin/components/Sidebar.tsx`
- Modify: `src/admin/AdminApp.tsx`
- [ ] **Step 1: Add warehouse entry to Sidebar.tsx**
In `src/admin/components/Sidebar.tsx`, add a new item to the "Administrativa" menu section:
```ts
{
path: "/warehouse",
label: "Sklad",
permission: "warehouse.view",
matchPrefix: "/warehouse",
icon: (
<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:
```tsx
const Warehouse = lazy(() => import("./pages/Warehouse"));
const WarehouseItems = lazy(() => import("./pages/WarehouseItems"));
const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail"));
const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts"));
const WarehouseReceiptDetail = lazy(
() => import("./pages/WarehouseReceiptDetail"),
);
const WarehouseReceiptForm = lazy(() => import("./pages/WarehouseReceiptForm"));
const WarehouseIssues = lazy(() => import("./pages/WarehouseIssues"));
const WarehouseIssueDetail = lazy(() => import("./pages/WarehouseIssueDetail"));
const WarehouseIssueForm = lazy(() => import("./pages/WarehouseIssueForm"));
const WarehouseReservations = lazy(
() => import("./pages/WarehouseReservations"),
);
const WarehouseInventory = lazy(() => import("./pages/WarehouseInventory"));
const WarehouseInventoryDetail = lazy(
() => import("./pages/WarehouseInventoryDetail"),
);
const WarehouseInventoryForm = lazy(
() => import("./pages/WarehouseInventoryForm"),
);
const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
```
Add routes inside the `<Route element={<AdminLayout />}>` section:
```tsx
<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**
```bash
git add src/admin/components/Sidebar.tsx src/admin/AdminApp.tsx
git commit -m "feat(warehouse): add sidebar entry and routes"
```
---
## Phase 5: Frontend - Pages
### Task 11: Master Data Pages (Categories, Suppliers, Locations)
**Files:**
- Create: `src/admin/pages/WarehouseCategories.tsx`
- Create: `src/admin/pages/WarehouseSuppliers.tsx`
- Create: `src/admin/pages/WarehouseLocations.tsx`
These three pages follow the same CRUD pattern as existing pages (e.g., Vehicles). Each page:
- Checks permission at top
- Uses `useQuery` for listing
- Has a modal for create/edit
- Uses `useAlert` for feedback
- Invalidates `["warehouse"]` on mutation
- [ ] **Step 1: Create WarehouseCategories.tsx**
Standard CRUD page: list categories in a table, modal for create/edit, delete with confirmation. Permission: `warehouse.manage`. Uses `warehouseCategoryListOptions` for data. Mutations: POST/PUT/DELETE `/api/admin/warehouse/categories`. Invalidates `["warehouse"]` on success.
- [ ] **Step 2: Create WarehouseSuppliers.tsx**
Standard CRUD page with paginated list. Fields: name, ico, dic, contact_person, email, phone, address, notes. Uses `warehouseSupplierListOptions`. Permission: `warehouse.manage`.
- [ ] **Step 3: Create WarehouseLocations.tsx**
Standard CRUD page. Fields: code (unique), name, description. Uses `warehouseLocationListOptions`. Permission: `warehouse.manage`.
- [ ] **Step 4: Commit**
```bash
git add src/admin/pages/WarehouseCategories.tsx src/admin/pages/WarehouseSuppliers.tsx src/admin/pages/WarehouseLocations.tsx
git commit -m "feat(warehouse): add categories, suppliers, locations pages"
```
---
### Task 12: Items Pages (List + Detail)
**Files:**
- Create: `src/admin/pages/WarehouseItems.tsx`
- Create: `src/admin/pages/WarehouseItemDetail.tsx`
- [ ] **Step 1: Create WarehouseItems.tsx**
Paginated item catalog with search, category filter, sorting. Columns: item_number, name, category, unit, total_quantity, available_quantity, stock_value, below_minimum (red badge). Click row → navigate to detail. Permission: `warehouse.view`. Uses `warehouseItemListOptions`.
- [ ] **Step 2: Create WarehouseItemDetail.tsx**
Shows item details, batch list (FIFO queue), location breakdown, and active reservations. Has edit button (opens inline edit or modal). Permission: `warehouse.view`. Uses `warehouseItemDetailOptions`.
- [ ] **Step 3: Commit**
```bash
git add src/admin/pages/WarehouseItems.tsx src/admin/pages/WarehouseItemDetail.tsx
git commit -m "feat(warehouse): add items list and detail pages"
```
---
### Task 13: Receipt Pages (List, Detail, Form)
**Files:**
- Create: `src/admin/pages/WarehouseReceipts.tsx`
- Create: `src/admin/pages/WarehouseReceiptDetail.tsx`
- Create: `src/admin/pages/WarehouseReceiptForm.tsx`
- [ ] **Step 1: Create WarehouseReceipts.tsx**
Paginated receipt list with filters (status, supplier, date range). Columns: receipt_number, supplier, status (colored badge), delivery_note_number, created_at, line count. Click row → navigate to detail. Permission: `warehouse.view`. Uses `warehouseReceiptListOptions`.
- [ ] **Step 2: Create WarehouseReceiptDetail.tsx**
Receipt detail with:
- Header: receipt_number, supplier, delivery note info, status badge
- Lines table: item, quantity, unit_price, location, notes
- Attachments section: list of uploaded files with download/delete buttons
- Actions: Confirm button (if DRAFT), Cancel button (if DRAFT or CONFIRMED), Edit button (if DRAFT)
- Permission: `warehouse.view` for viewing, `warehouse.operate` for actions
- [ ] **Step 3: Create WarehouseReceiptForm.tsx**
Create/edit form using `WarehouseMovementTable` component. Header fields: supplier (SupplierSelect), delivery note number, delivery note date, notes. Uses file input for attachment upload. Draft/Confirm submit buttons. Route: `/warehouse/receipts/new` (create) and `/warehouse/receipts/:id/edit` (edit, pre-fills from existing). Permission: `warehouse.operate`.
- [ ] **Step 4: Commit**
```bash
git add src/admin/pages/WarehouseReceipts.tsx src/admin/pages/WarehouseReceiptDetail.tsx src/admin/pages/WarehouseReceiptForm.tsx
git commit -m "feat(warehouse): add receipt pages"
```
---
### Task 14: Issue Pages (List, Detail, Form)
**Files:**
- Create: `src/admin/pages/WarehouseIssues.tsx`
- Create: `src/admin/pages/WarehouseIssueDetail.tsx`
- Create: `src/admin/pages/WarehouseIssueForm.tsx`
- [ ] **Step 1: Create WarehouseIssues.tsx**
Paginated issue list with filters (status, project, date range). Columns: issue_number, project, status (colored badge), created_at, line count. Click row → navigate to detail. Permission: `warehouse.view`. Uses `warehouseIssueListOptions`.
- [ ] **Step 2: Create WarehouseIssueDetail.tsx**
Issue detail with:
- Header: issue_number, project name, status badge, issued_by
- Lines table: item, batch (received date + price), quantity, location, reservation link, notes
- Actions: Confirm button (if DRAFT), Cancel button (if DRAFT or CONFIRMED), Edit button (if DRAFT)
- Permission: `warehouse.view` for viewing, `warehouse.operate` for actions
- [ ] **Step 3: Create WarehouseIssueForm.tsx**
Create/edit form using `WarehouseMovementTable` component (mode="issue"). Header fields: project (mandatory select from projects list), notes. Each line has BatchPicker for batch selection (auto-FIFO if not selected). Draft/Confirm submit buttons. Permission: `warehouse.operate`.
- [ ] **Step 4: Commit**
```bash
git add src/admin/pages/WarehouseIssues.tsx src/admin/pages/WarehouseIssueDetail.tsx src/admin/pages/WarehouseIssueForm.tsx
git commit -m "feat(warehouse): add issue pages"
```
---
### Task 15: Reservations, Inventory, Reports, Dashboard Pages
**Files:**
- Create: `src/admin/pages/WarehouseReservations.tsx`
- Create: `src/admin/pages/WarehouseInventory.tsx`
- Create: `src/admin/pages/WarehouseInventoryDetail.tsx`
- Create: `src/admin/pages/WarehouseInventoryForm.tsx`
- Create: `src/admin/pages/WarehouseReports.tsx`
- Create: `src/admin/pages/Warehouse.tsx`
- [ ] **Step 1: Create WarehouseReservations.tsx**
List of active reservations with item, project, quantity, remaining_qty, status. Cancel button per row. Create reservation modal (item picker, project picker, quantity). Permission: `warehouse.view` + `warehouse.operate` for cancel/create. Uses `warehouseReservationListOptions`.
- [ ] **Step 2: Create WarehouseInventory.tsx**
List of inventory sessions. Create button → navigate to form. Columns: session_number, status, created_at, line count. Click row → navigate to detail. Permission: `warehouse.inventory`. Uses `warehouseInventoryListOptions`.
- [ ] **Step 3: Create WarehouseInventoryDetail.tsx**
Inventory session detail with lines table: item, location, system_qty, actual_qty, difference (colored red/green), notes. Confirm button (if DRAFT). Permission: `warehouse.inventory`.
- [ ] **Step 4: Create WarehouseInventoryForm.tsx**
Create/edit inventory form. Select items, enter actual quantities. System quantities auto-filled on creation. Permission: `warehouse.inventory`.
- [ ] **Step 5: Create WarehouseReports.tsx**
Tab-based report page with 4 tabs:
- **Stock Status**: table of all items with qty, min_qty, value, below-min flag (uses `warehouseStockStatusOptions`)
- **Project Consumption**: filter by project + date range, shows material consumed per project
- **Movement Log**: filter by date range + type (receipt/issue), shows all movements
- **Below Minimum**: items under min_quantity (uses `warehouseBelowMinimumOptions`)
Permission: `warehouse.view`.
- [ ] **Step 6: Create Warehouse.tsx (Dashboard)**
Dashboard with:
- Summary cards: total items, total stock value, below-minimum count, active reservations count
- Below-minimum alert section (highlighted items)
- Recent movements (last 10, fetched from movement-log report)
Permission: `warehouse.view`.
- [ ] **Step 7: Commit**
```bash
git add src/admin/pages/WarehouseReservations.tsx src/admin/pages/WarehouseInventory.tsx src/admin/pages/WarehouseInventoryDetail.tsx src/admin/pages/WarehouseInventoryForm.tsx src/admin/pages/WarehouseReports.tsx src/admin/pages/Warehouse.tsx
git commit -m "feat(warehouse): add reservations, inventory, reports, and dashboard pages"
```
---
## Phase 6: Testing & Polish
### Task 16: Integration Tests
**Files:**
- Create: `src/__tests__/warehouse.test.ts`
- [ ] **Step 1: Write integration tests for warehouse API**
Tests using the `buildApp()` helper against the real test database. Cover:
- Category CRUD
- Supplier CRUD
- Location CRUD
- Item CRUD + available-qty calculation
- Receipt create (DRAFT) → confirm → verify batches created + item_locations updated
- Receipt cancel (CONFIRMED) → verify batches deleted + item_locations restored
- Issue create (DRAFT) → confirm → verify batches decremented + reservations fulfilled
- Issue cancel (CONFIRMED) → verify batches restored
- Reservation create → verify available_qty decreases
- Reservation cancel → verify available_qty increases
- Permission checks (unauthorized access returns 403)
- [ ] **Step 2: Run tests**
```bash
npm test -- --reporter=verbose src/__tests__/warehouse.test.ts
```
Expected: All tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/__tests__/warehouse.test.ts
git commit -m "test(warehouse): add integration tests"
```
---
### Task 17: Build Verification
- [ ] **Step 1: Run full build**
```bash
npm run build
```
Expected: No errors, `dist/` and `dist-client/` generated successfully.
- [ ] **Step 2: Run full test suite**
```bash
npm test
```
Expected: All tests pass (existing + warehouse).
- [ ] **Step 3: Commit any remaining fixes**
---
## Self-Review Checklist
After writing the complete plan, I verified:
1. **Spec coverage**: Every requirement in the design spec has a corresponding task:
- 13 Prisma models → Task 1
- 5 enums → Task 1
- Entity types → Task 2
- 4 permissions → Task 2
- 3 number sequence types → Task 2
- Zod schemas → Task 3
- Service (FIFO, confirm, cancel, inventory) → Task 4
- All API endpoints → Tasks 5-6
- Server registration → Task 7
- Query hooks → Task 8
- Shared components → Task 9
- Sidebar + routes → Task 10
- All 17 pages → Tasks 11-15
- Tests → Task 16
- Build verification → Task 17
2. **Placeholder scan**: No TBD, TODO, or placeholder patterns found. All tasks contain actual code or specific instructions.
3. **Type consistency**: Types defined in Task 8 (`WarehouseItem`, `WarehouseReceipt`, etc.) are used consistently across Tasks 9-15. Service function signatures in Task 4 match route handler usage in Tasks 5-6.