Files
kevin-asprec b5f2f3946b docs(04): create phase plan — Inventory, Expenses, and Financial Reports
Phase 04: 5 plans in 2 waves
- Wave 1: 04-01 (inventory event-ledger), 04-03 (expense tracking), 04-05 (financial reports) — parallel
- Wave 2: 04-02 (asset management), 04-04 (expense reports + audit trail) — sequential
- Ready for execution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:00:55 +08:00

172 lines
8.9 KiB
Markdown

---
phase: 04-inventory-expenses-and-financial-reports
plan: 02
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- prisma/schema.prisma
- src/lib/services/asset-service.ts
- src/lib/services/inventory-service.ts
- src/app/api/inventory/items/[id]/assign/route.ts
- src/app/api/inventory/items/[id]/return/route.ts
- src/app/api/inventory/items/[id]/dispose/route.ts
- src/app/api/inventory/items/[id]/history/route.ts
- src/lib/__tests__/asset-service.test.ts
autonomous: true
must_haves:
truths:
- "An asset can be assigned to a subscriber with condition tracking"
- "An asset can be assigned to a technician for field work"
- "An assigned asset shows full location history (chronological timeline)"
- "Returning an asset records condition at return time"
- "Disposal requires admin role — non-admin users get 403"
- "Disposal creates a write-off JE (DR 5030 Equipment Expense, CR 1200 Equipment Inventory)"
artifacts:
- path: "src/lib/services/asset-service.ts"
provides: "assignToSubscriber, assignToTechnician, returnAsset, disposeAsset, getAssetHistory"
exports: ["AssetService"]
- path: "src/lib/__tests__/asset-service.test.ts"
provides: "Tests for assignment, return, disposal, history, authorization"
min_lines: 80
key_links:
- from: "src/lib/services/asset-service.ts"
to: "src/lib/services/inventory-service.ts"
via: "recordMovement for ISSUED/RETURNED/DISPOSED movements"
pattern: "InventoryService\\.recordMovement"
- from: "src/lib/services/asset-service.ts"
to: "src/lib/accounting/journal-entry-service.ts"
via: "JournalEntryService.createEntry for disposal write-off"
pattern: "JournalEntryService\\.createEntry"
---
<objective>
Build asset lifecycle management on top of the inventory event-ledger: assign serialized items to subscribers and technicians, track condition at assignment and return, enforce admin-only disposal with write-off JE, and provide chronological location history.
Purpose: Enables ISP staff to track where every piece of equipment is — from warehouse to technician to subscriber and back. The location history is the key differentiator for accountability.
Output: AssetService with assign/return/dispose/history, API routes, and tests.
</objective>
<execution_context>
@C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-inventory-expenses-and-financial-reports/04-CONTEXT.md
@.planning/phases/04-inventory-expenses-and-financial-reports/04-01-SUMMARY.md
@prisma/schema.prisma
@src/lib/services/inventory-service.ts
@src/lib/accounting/journal-entry-service.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: AssetService — assignment, return, disposal, and history</name>
<files>
src/lib/services/asset-service.ts
src/app/api/inventory/items/[id]/assign/route.ts
src/app/api/inventory/items/[id]/return/route.ts
src/app/api/inventory/items/[id]/dispose/route.ts
src/app/api/inventory/items/[id]/history/route.ts
</files>
<action>
**AssetService** (`src/lib/services/asset-service.ts`):
Static class building on InventoryService.recordMovement:
- `assignToSubscriber(tenantPrisma, tenantId, { itemId, subscriberId, condition, performedById, notes? })`:
- Validate item exists and is SERIALIZED (batch items cannot be individually assigned to subscribers)
- Determine current location from latest movement (must be in WAREHOUSE or with a TECHNICIAN — cannot assign from subscriber to subscriber)
- Call InventoryService.recordMovement with movementType=ISSUED, fromLocation=current location, toLocationType=SUBSCRIBER, toLocationId=subscriberId, condition
- Return the created movement
- `assignToTechnician(tenantPrisma, tenantId, { itemId, technicianUserId, condition, performedById, notes? })`:
- Validate item is SERIALIZED
- Current location must be WAREHOUSE
- Call InventoryService.recordMovement with movementType=ISSUED, fromLocationType=WAREHOUSE, toLocationType=TECHNICIAN, toLocationId=technicianUserId, condition
- `returnAsset(tenantPrisma, tenantId, { itemId, condition, performedById, notes? })`:
- Validate item is SERIALIZED
- Current location must be with SUBSCRIBER or TECHNICIAN (not already in warehouse)
- Call InventoryService.recordMovement with movementType=RETURNED, from=current location, toLocationType=WAREHOUSE, condition (captures condition at return — NEW, USED, DAMAGED, REFURBISHED)
- `disposeAsset(tenantPrisma, tenantId, { itemId, performedById, notes?, userRoles })`:
- Validate item is SERIALIZED
- Validate userRoles includes ADMIN — disposal requires admin approval per CONTEXT.md
- Current location must be WAREHOUSE (cannot dispose from field)
- Create write-off JE: DR 5030 Equipment Expense, CR 1200 Equipment Inventory for the item's purchaseCost. Source=SYSTEM, referenceType="StockMovement", description="Disposal write-off: {item.name} SN:{item.serialNumber}"
- Call InventoryService.recordMovement with movementType=DISPOSED, fromLocationType=WAREHOUSE, journalEntryId from JE
- `getAssetHistory(tenantPrisma, itemId)`:
- Fetch item with all movements ordered by createdAt ASC
- Return formatted timeline: each entry has { movementType, date, fromLocation (type+name), toLocation (type+name), condition, performedBy (user name), notes }
- Resolve location names: WAREHOUSE="Warehouse", SUBSCRIBER=subscriber name, TECHNICIAN=user name
- Helper: `getCurrentLocation(tenantPrisma, itemId)` — returns { locationType, locationId } from latest movement's to-fields (or null if disposed)
**API Routes:**
- `POST /api/inventory/items/[id]/assign` — body: { assigneeType: "SUBSCRIBER"|"TECHNICIAN", assigneeId, condition, notes? }. ADMIN, OFFICE_STAFF.
- `POST /api/inventory/items/[id]/return` — body: { condition, notes? }. ADMIN, OFFICE_STAFF.
- `POST /api/inventory/items/[id]/dispose` — body: { notes? }. ADMIN only.
- `GET /api/inventory/items/[id]/history` — returns chronological timeline. ADMIN, OFFICE_STAFF, TECHNICIAN.
Use withPermission() HOF and dynamic route handler pattern from prior phases.
</action>
<verify>npx prisma validate passes; API route files exist and export correct HTTP methods</verify>
<done>AssetService handles subscriber/technician assignment, return with condition, admin-only disposal with write-off JE, and chronological history</done>
</task>
<task type="auto">
<name>Task 2: Asset service tests</name>
<files>src/lib/__tests__/asset-service.test.ts</files>
<action>
Write comprehensive tests for AssetService:
**Setup:** createTenant, create admin user + office_staff user + technician user, create subscriber, create serialized InventoryItem, record initial RECEIVED movement (so item is in warehouse).
**Test cases:**
1. Assign item to subscriber — creates ISSUED movement with SUBSCRIBER location
2. Assign item to technician — creates ISSUED movement with TECHNICIAN location
3. Return item from subscriber — creates RETURNED movement back to WAREHOUSE with condition
4. Return item from technician — creates RETURNED movement back to WAREHOUSE
5. Reject assignment of item already with a subscriber (must return first)
6. Reject assignment of batch item to subscriber (only SERIALIZED allowed)
7. Dispose item — ADMIN role creates DISPOSED movement + write-off JE (verify DR 5030, CR 1200)
8. Reject disposal by non-admin (OFFICE_STAFF user gets authorization error)
9. Reject disposal of item not in warehouse
10. Get asset history — returns chronological timeline with resolved location names
11. Full lifecycle: RECEIVED -> ISSUED to tech -> RETURNED -> ISSUED to subscriber -> RETURNED -> DISPOSED — history shows all 6 entries
**Cleanup order:** stockMovements -> inventoryItems -> journalEntryLines -> null reversesEntryId -> journalEntries -> subscribers -> servicePlans -> accountingPeriods -> accounts -> users -> tenant
</action>
<verify>npx jest asset-service --verbose passes all tests</verify>
<done>All 11 test cases pass covering assignment, return, disposal authorization, history, and full lifecycle</done>
</task>
</tasks>
<verification>
- `npx jest asset-service --verbose` — all tests pass
- Subscriber assignment creates correct movement record
- Technician assignment creates correct movement record
- Disposal enforces admin-only and creates write-off JE
- History returns chronological timeline with location names
</verification>
<success_criteria>
- Assets can be assigned to subscribers and technicians
- Condition is captured at assignment and return
- Disposal requires admin role and posts write-off JE (DR 5030, CR 1200)
- Full location history available as chronological timeline
- All tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/04-inventory-expenses-and-financial-reports/04-02-SUMMARY.md`
</output>