--- phase: 04-inventory-expenses-and-financial-reports plan: 01 type: execute wave: 1 depends_on: [] files_modified: - prisma/schema.prisma - src/lib/services/inventory-service.ts - src/lib/accounting/chart-of-accounts.ts - src/lib/tenant.ts - src/app/api/inventory/items/route.ts - src/app/api/inventory/items/[id]/route.ts - src/app/api/inventory/items/[id]/movements/route.ts - src/app/api/inventory/stock-levels/route.ts - src/lib/__tests__/inventory-service.test.ts autonomous: true must_haves: truths: - "Staff can register a hardware item with type, model, serial number, purchase cost, purchase date, and warranty expiry" - "Staff can record immutable stock movements (RECEIVED, ISSUED, RETURNED, DISPOSED, TRANSFERRED)" - "Current stock levels are derived from movement history — no mutable quantity column" - "Every RECEIVED movement posts a journal entry (DR 1200 Equipment Inventory, CR 2010 AP)" - "Consumables (cables, connectors) are tracked by type+quantity batch; serialized items tracked individually" artifacts: - path: "prisma/schema.prisma" provides: "InventoryItem, StockMovement, ItemType enums and models" contains: "model InventoryItem" - path: "src/lib/services/inventory-service.ts" provides: "registerItem, recordMovement, getStockLevels, getItemMovements" exports: ["InventoryService"] - path: "src/lib/__tests__/inventory-service.test.ts" provides: "Tests for registration, movements, stock derivation, JE posting" min_lines: 100 key_links: - from: "src/lib/services/inventory-service.ts" to: "src/lib/accounting/journal-entry-service.ts" via: "JournalEntryService.createEntry for RECEIVED movements" pattern: "JournalEntryService\\.createEntry" - from: "src/app/api/inventory/items/route.ts" to: "src/lib/services/inventory-service.ts" via: "API routes calling InventoryService methods" pattern: "InventoryService\\." --- Build the inventory event-ledger foundation: hardware item registration with dual tracking (serialized + batch), immutable stock movement records, derived stock level computation, and automatic journal entry posting for receiving movements. Purpose: This is the core inventory data model that all asset management (04-02) builds on. The immutable movement ledger is a locked architectural decision — no mutable quantity columns. Output: InventoryItem/StockMovement schema, InventoryService with full CRUD+movements, API routes, and tests. @C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md @C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/04-inventory-expenses-and-financial-reports/04-CONTEXT.md @prisma/schema.prisma @src/lib/accounting/journal-entry-service.ts @src/lib/accounting/chart-of-accounts.ts @src/lib/tenant.ts @src/lib/services/collector-service.ts (reference for service pattern with JE posting) Task 1: Schema — InventoryItem, StockMovement models and enums prisma/schema.prisma Add the following enums and models to the Prisma schema: **Enums:** - `ItemTrackingType`: `SERIALIZED`, `BATCH` — serialized items have unique serial numbers; batch items are tracked by quantity - `ItemCondition`: `NEW`, `REFURBISHED`, `USED`, `DAMAGED` — condition at time of movement - `MovementType`: `RECEIVED`, `ISSUED`, `RETURNED`, `DISPOSED`, `TRANSFERRED` - `LocationType`: `WAREHOUSE`, `TECHNICIAN`, `SUBSCRIBER` — where the item is **InventoryItem model:** - id (UUID), tenantId (String), name (String), itemType (String — e.g., "Router", "ONU", "Cable", "Connector") - model (String? — brand/model for serialized items), serialNumber (String? — null for batch items) - trackingType (ItemTrackingType) - purchaseCost (Decimal? @db.Decimal(10,2)), purchaseDate (DateTime?), warrantyExpiry (DateTime?) - isActive (Boolean @default(true)) - createdAt, updatedAt - Relation: movements StockMovement[] - @@unique([tenantId, serialNumber]) — only enforced when serialNumber is not null (Prisma handles this: unique constraint on nullable field only applies to non-null values) - @@index([tenantId]), @@index([tenantId, itemType]), @@index([tenantId, trackingType]) **StockMovement model:** - id (UUID), tenantId (String) - inventoryItemId (String) — FK to InventoryItem - movementType (MovementType) - quantity (Int @default(1)) — always 1 for serialized, variable for batch - condition (ItemCondition? — condition at time of movement) - fromLocationType (LocationType?), fromLocationId (String?) — null for RECEIVED - toLocationType (LocationType?), toLocationId (String?) — null for DISPOSED - notes (String?) - journalEntryId (String?) — JE for RECEIVED movements - performedById (String) — FK to User who recorded the movement - performedBy relation to User - createdAt DateTime @default(now()) — immutable, no updatedAt - @@index([tenantId]), @@index([inventoryItemId]), @@index([tenantId, movementType]) Add `recordedMovements StockMovement[] @relation("MovementPerformedBy")` to User model. Run `npx prisma generate` after schema changes. Do NOT run migrate yet (test will handle that). npx prisma validate passes with no errors InventoryItem and StockMovement models exist in schema with all fields, enums, indexes, and relations Task 2: InventoryService, API routes, migration, and tests src/lib/services/inventory-service.ts src/app/api/inventory/items/route.ts src/app/api/inventory/items/[id]/route.ts src/app/api/inventory/items/[id]/movements/route.ts src/app/api/inventory/stock-levels/route.ts src/lib/__tests__/inventory-service.test.ts **InventoryService** (`src/lib/services/inventory-service.ts`): - Static class following existing service patterns (see collector-service.ts, payment-service.ts) - `registerItem(tenantPrisma, tenantId, data)` — creates InventoryItem. Validates: serialNumber required if trackingType=SERIALIZED, serialNumber must be null/undefined for BATCH. Returns created item. - `recordMovement(tenantPrisma, tenantId, data)` — creates immutable StockMovement. Validates: - RECEIVED: toLocationType required (must be WAREHOUSE), fromLocationType must be null - ISSUED: fromLocationType+toLocationType required - RETURNED: fromLocationType+toLocationType required, toLocationType must be WAREHOUSE - DISPOSED: fromLocationType required, toLocationType must be null - TRANSFERRED: both from+to required - For SERIALIZED items: quantity must be 1 - For RECEIVED movements: auto-create JE via JournalEntryService.createEntry (DR 1200 Equipment Inventory, CR 2010 Accounts Payable) using purchaseCost or movement amount. Source=SYSTEM, referenceType="StockMovement". - `getStockLevels(tenantPrisma, filters?)` — derives current stock by aggregating movements: - RECEIVED/RETURNED add to stock at toLocation - ISSUED/TRANSFERRED remove from fromLocation, add to toLocation - DISPOSED removes from fromLocation - Group by itemType and location. Return array of {itemId?, itemType, locationName, locationType, quantity} - For serialized items, return individual item status (current location derived from latest movement) - `getItemMovements(tenantPrisma, itemId)` — returns chronological movement history for an item - `listItems(tenantPrisma, filters?)` — list items with optional filters (itemType, trackingType, isActive) **API Routes:** - `POST /api/inventory/items` — register new item (ADMIN, OFFICE_STAFF) - `GET /api/inventory/items` — list items with filters (ADMIN, OFFICE_STAFF, TECHNICIAN) - `GET /api/inventory/items/[id]` — get item detail (ADMIN, OFFICE_STAFF, TECHNICIAN) - `POST /api/inventory/items/[id]/movements` — record movement (ADMIN, OFFICE_STAFF) - `GET /api/inventory/items/[id]/movements` — get item movement history (ADMIN, OFFICE_STAFF, TECHNICIAN) - `GET /api/inventory/stock-levels` — get derived stock levels (ADMIN, OFFICE_STAFF) All routes use withPermission() HOF pattern. Follow existing route patterns (e.g., collections route.ts). **Migration:** Apply migration using the Docker exec psql + prisma migrate resolve --applied pattern established in prior phases. Migration name: `add_inventory_models`. **Tests** (`src/lib/__tests__/inventory-service.test.ts`): - Register serialized item (with serial number) - Register batch item (without serial number) - Reject serialized item without serial number - Record RECEIVED movement creates StockMovement + JE (verify JE: DR 1200, CR 2010) - Record ISSUED movement (warehouse to technician) - Record RETURNED movement (technician to warehouse) - Record DISPOSED movement - Derive stock levels from movement history (receive 10, issue 3 = 7 in warehouse) - Serialized item: derive current location from latest movement - Get movement history returns chronological order Follow existing test patterns: createTenant for setup, explicit cleanup order in afterAll. Cleanup order: stockMovements -> inventoryItems -> journalEntryLines -> null reversesEntryId -> journalEntries -> accountingPeriods -> accounts -> users -> tenant. npx jest inventory-service --verbose passes all tests InventoryService handles registration, all 5 movement types, stock level derivation, and JE posting for RECEIVED. All API routes respond correctly. All tests pass. - `npx prisma validate` passes - `npx jest inventory-service --verbose` — all tests pass - Stock levels are derived (no quantity column on InventoryItem) - RECEIVED movement creates balanced JE (DR 1200, CR 2010) - Serialized items enforce serial number uniqueness per tenant - Staff can register hardware items (serialized with serial number, batch without) - All 5 movement types (RECEIVED, ISSUED, RETURNED, DISPOSED, TRANSFERRED) create immutable records - Stock levels derived from movement aggregation — no mutable quantity column exists - RECEIVED movements auto-post journal entries to the ledger - All tests pass After completion, create `.planning/phases/04-inventory-expenses-and-financial-reports/04-01-SUMMARY.md`