- 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>
3875 lines
126 KiB
Markdown
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)} Kč
|
|
</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.
|