docs(03): create phase plan

Phase 03: Operational Modules
- 5 plans in 3 waves
- Wave 1: 03-01 (zones), 03-03 (tickets) — parallel
- Wave 2: 03-02 (collector collections), 03-04 (job orders) — parallel
- Wave 3: 03-05 (technician compensation)
- Ready for execution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 07:11:44 +08:00
parent 9af86c54c3
commit d54b517e2e
5 changed files with 748 additions and 630 deletions

View File

@@ -8,6 +8,8 @@ files_modified:
- prisma/schema.prisma
- src/lib/prisma-tenant.ts
- src/lib/services/zone-service.ts
- src/lib/casl/types.ts
- src/lib/casl/permissions.ts
- src/app/api/zones/route.ts
- src/app/api/zones/[id]/route.ts
- src/app/api/zones/[id]/subscribers/route.ts
@@ -17,40 +19,44 @@ autonomous: true
must_haves:
truths:
- "Admin can create, update, and deactivate zones for their tenant"
- "Admin can assign subscribers to a zone"
- "Admin can assign a collector user to a zone"
- "A collector can only see subscribers assigned to their zone(s)"
- "Admin can create, read, update zones with name and description"
- "Admin can assign subscribers to zones via zoneId FK"
- "Admin can assign collectors to zones via ZoneAssignment join"
- "Collector can only query subscribers within their assigned zones"
- "Zone data is tenant-scoped — Tenant B cannot see Tenant A zones"
artifacts:
- path: "prisma/schema.prisma"
provides: "Zone model with name, description, isActive; ZoneAssignment linking collector users to zones"
provides: "Zone and ZoneAssignment models, Subscriber.zoneId FK replacing zone String?"
contains: "model Zone"
- path: "src/lib/services/zone-service.ts"
provides: "Zone CRUD, subscriber zone assignment, collector zone assignment, getCollectorSubscribers"
exports: ["ZoneService"]
- path: "src/app/api/zones/route.ts"
provides: "GET list zones, POST create zone"
exports: ["GET", "POST"]
provides: "Zone CRUD, subscriber assignment, collector zone scoping"
exports: ["createZone", "updateZone", "listZones", "assignSubscriberToZone", "getCollectorSubscribers"]
- path: "src/lib/prisma-tenant.ts"
provides: "Tenant-scoped query blocks for Zone and ZoneAssignment"
contains: "zone"
- path: "src/lib/__tests__/zone-service.test.ts"
provides: "Integration tests for zone CRUD, assignment, collector scoping"
min_lines: 80
min_lines: 100
key_links:
- from: "src/lib/services/zone-service.ts"
to: "prisma/schema.prisma"
via: "Prisma client queries on Zone and ZoneAssignment"
pattern: "prisma\\.zone\\."
via: "tenantPrisma.zone and tenantPrisma.zoneAssignment queries"
pattern: "tenantPrisma\\.zone\\."
- from: "src/app/api/zones/route.ts"
to: "src/lib/services/zone-service.ts"
via: "withPermission HOF wrapping service calls"
pattern: "withPermission.*Zone"
- from: "src/app/api/collectors/[id]/subscribers/route.ts"
to: "src/lib/services/zone-service.ts"
via: "getCollectorSubscribers returns only zone-scoped subscribers"
via: "getCollectorSubscribers for zone-scoped subscriber list"
pattern: "getCollectorSubscribers"
---
<objective>
Create the zone/territory system that scopes collectors to specific subscriber groups.
Create the zone management system: Zone model, ZoneAssignment model (collector-to-zone), replace Subscriber.zone String? with Subscriber.zoneId FK, zone CRUD API, subscriber-to-zone assignment, and collector-scoped subscriber queries.
Purpose: Zones are the foundation for collector workflow — a collector can only collect from subscribers in their assigned zones. This must exist before collector field collection (03-02) can enforce proper scoping.
Output: Zone Prisma model, zone CRUD service and APIs, collector-to-zone and subscriber-to-zone assignment, scoped subscriber list for collectors, integration tests.
Purpose: Zones are the foundation for collector routing — collectors can only collect from subscribers in their assigned zones. This is a security boundary enforced at the data layer.
Output: Zone/ZoneAssignment models, zone-service.ts, 5 API routes, integration tests.
</objective>
<execution_context>
@@ -63,123 +69,136 @@ Output: Zone Prisma model, zone CRUD service and APIs, collector-to-zone and sub
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-operational-modules/03-CONTEXT.md
@.planning/phases/03-operational-modules/03-RESEARCH.md
@prisma/schema.prisma
@src/lib/prisma-tenant.ts
@src/lib/services/subscriber-service.ts
@src/lib/middleware/with-permission.ts
@src/lib/casl/types.ts
@src/lib/casl/permissions.ts
@src/lib/services/payment-service.ts (pattern reference for tenant-scoped service functions)
@src/lib/__tests__/payment.test.ts (pattern reference for integration test setup/cleanup)
</context>
<tasks>
<task type="auto">
<name>Task 1: Zone and ZoneAssignment Prisma models + migration</name>
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts</files>
<name>Task 1: Zone schema, migration, and tenant scoping</name>
<files>
prisma/schema.prisma
src/lib/prisma-tenant.ts
src/lib/casl/types.ts
src/lib/casl/permissions.ts
</files>
<action>
Add two new models to Prisma schema:
1. Add Zone model to schema.prisma:
- id (uuid), tenantId, name (String), description (String?), isActive (Boolean default true), createdAt, updatedAt
- @@unique([tenantId, name]), @@index([tenantId])
1. **Zone model:**
- id (uuid PK), tenantId, name (String), description (String?), isActive (Boolean default true)
- createdAt, updatedAt
- @@unique([tenantId, name]) — zone names unique per tenant
- @@index([tenantId])
- Relation: subscribers Subscriber[] (via Subscriber.zoneId — update Subscriber to add zoneId optional FK)
- Relation: assignments ZoneAssignment[]
2. Add ZoneAssignment model (collector-to-zone join):
- id (uuid), tenantId, userId (String — the collector user), zoneId (String — FK to Zone)
- Relations: user -> User, zone -> Zone
- @@unique([tenantId, userId, zoneId]), @@index([tenantId]), @@index([userId]), @@index([zoneId])
2. **ZoneAssignment model:**
- id (uuid PK), tenantId, zoneId (FK to Zone), userId (FK to User — the collector)
- createdAt
- @@unique([tenantId, zoneId, userId]) — prevent duplicate assignments
- @@index([tenantId]), @@index([userId]), @@index([zoneId])
3. **Update Subscriber model:**
- The Subscriber already has `zone String?` field. Replace it with a proper FK:
3. Replace Subscriber.zone String? with Subscriber.zoneId String? (FK to Zone):
- Remove `zone String?` field
- Add `zoneId String?` and `zone Zone? @relation(fields: [zoneId], references: [id])`
- Remove the old `zone String?` field (it was a placeholder for Phase 3)
- Add @@index([tenantId, zoneId])
4. **Update User model:**
- Add relation: `zoneAssignments ZoneAssignment[]`
4. Add reverse relations on Zone: `subscribers Subscriber[]`, `assignments ZoneAssignment[]`
Add reverse relation on User: `zoneAssignments ZoneAssignment[]`
5. **Add "zone" and "zoneAssignment" to TENANT_SCOPED_MODELS** in `src/lib/prisma-tenant.ts` and extend the withTenantContext() $extends block following the existing pattern.
5. Run `npx prisma migrate dev --name add-zones` to create migration.
6. Run `npx prisma migrate dev --name add-zones` to create the migration.
6. Add "Zone" to AppSubjects in types.ts (it is not currently listed). Add ZoneAssignment does NOT need its own subject — managed through Zone.
Important: The old `zone String?` on Subscriber is being replaced with `zoneId String?` (FK). The migration needs to handle this — drop the old column, add new column. No data migration needed (no production data).
7. Update permissions.ts:
- ADMIN: already has `manage all`
- OFFICE_STAFF: add `can("manage", "Zone")`
- COLLECTOR: add `can("read", "Zone")` (can see zones they are assigned to)
- TECHNICIAN/CLIENT: no zone access
8. Add Zone and ZoneAssignment to TENANT_SCOPED_MODELS in prisma-tenant.ts with FULL 12-operation extension blocks (copy from subscriber block pattern — findMany, findFirst, findFirstOrThrow, findUnique, findUniqueOrThrow, create, createMany, update, updateMany, delete, deleteMany, upsert, count, aggregate, groupBy). Missing any operation is a security hole.
</action>
<verify>
- `npx prisma migrate dev` completes without errors
- `npx prisma generate` succeeds
- Schema has Zone, ZoneAssignment models
- Subscriber has zoneId FK instead of zone String
- `npx prisma migrate dev` succeeds with no errors
- `npx tsc --noEmit` passes (no TypeScript errors)
- Grep prisma-tenant.ts confirms both "zone" and "zoneAssignment" appear in TENANT_SCOPED_MODELS
- Grep types.ts confirms "Zone" in AppSubjects
</verify>
<done>Zone and ZoneAssignment models exist in schema, Subscriber.zoneId replaces zone String, TENANT_SCOPED_MODELS updated, migration applied.</done>
<done>Zone and ZoneAssignment models exist in schema, migration applied, tenant scoping configured, CASL subjects and permissions updated.</done>
</task>
<task type="auto">
<name>Task 2: ZoneService + API routes + integration tests</name>
<files>src/lib/services/zone-service.ts, src/app/api/zones/route.ts, src/app/api/zones/[id]/route.ts, src/app/api/zones/[id]/subscribers/route.ts, src/app/api/collectors/[id]/subscribers/route.ts, src/lib/__tests__/zone-service.test.ts</files>
<name>Task 2: Zone service, API routes, and integration tests</name>
<files>
src/lib/services/zone-service.ts
src/app/api/zones/route.ts
src/app/api/zones/[id]/route.ts
src/app/api/zones/[id]/subscribers/route.ts
src/app/api/collectors/[id]/subscribers/route.ts
src/lib/__tests__/zone-service.test.ts
</files>
<action>
**ZoneService** (`src/lib/services/zone-service.ts`):
- `createZone(db, { name, description })` — creates zone, returns zone
- `updateZone(db, zoneId, { name?, description?, isActive? })` — updates zone
- `listZones(db)` — returns all zones for tenant (active and inactive)
- `assignSubscriberToZone(db, subscriberId, zoneId)` — updates subscriber.zoneId
- `removeSubscriberFromZone(db, subscriberId)` — sets subscriber.zoneId to null
- `assignCollectorToZone(db, userId, zoneId)` — creates ZoneAssignment (validates user has COLLECTOR role)
- `removeCollectorFromZone(db, userId, zoneId)`deletes ZoneAssignment
- `getCollectorZones(db, userId)` — returns zones assigned to a collector
- `getCollectorSubscribers(db, userId)` — returns subscribers in all zones assigned to this collector (the key scoping query). Include subscriber status and outstanding invoice count for the collector's field view.
- `getZoneSubscribers(db, zoneId)` — returns subscribers in a specific zone
1. Create src/lib/services/zone-service.ts with pure functions (tenantPrisma as first arg, tenantId as second for transactions):
- `createZone(tenantPrisma, tenantId, { name, description })` — create zone, return zone
- `updateZone(tenantPrisma, zoneId, { name?, description?, isActive? })` — update zone
- `listZones(tenantPrisma)` — return all zones with subscriber count and assigned collector count
- `getZone(tenantPrisma, zoneId)` — single zone with relations
- `assignSubscriberToZone(tenantPrisma, subscriberId, zoneId)` — update subscriber.zoneId
- `removeSubscriberFromZone(tenantPrisma, subscriberId)` — set subscriber.zoneId to null
- `assignCollectorToZone(tenantPrisma, tenantId, userId, zoneId)`create ZoneAssignment (validate user has COLLECTOR role)
- `removeCollectorFromZone(tenantPrisma, tenantId, userId, zoneId)` — delete ZoneAssignment
- `getCollectorSubscribers(tenantPrisma, tenantId, collectorUserId)` — find all zones assigned to collector, then find all subscribers in those zones. THROW error if collector has no zone assignments (security boundary per RESEARCH.md). Return subscribers with basic info (id, accountNumber, firstName, lastName, address, zone name).
- `getCollectorZones(tenantPrisma, collectorUserId)` — return zones assigned to a collector
Follow existing service patterns: take tenantPrisma client as first arg (same as PaymentService, SubscriberService). Use `as any` cast pattern for tenantId injection (documented in 02-03 decision).
2. Create API routes following withPermission pattern:
- GET /api/zones: withPermission("read", "Zone") -> listZones
- POST /api/zones: withPermission("create", "Zone") -> createZone, validate name required
- GET /api/zones/[id]: withPermission("read", "Zone") -> getZone
- PUT /api/zones/[id]: withPermission("update", "Zone") -> updateZone
- POST /api/zones/[id]/subscribers: withPermission("update", "Zone") -> assignSubscriberToZone (body: { subscriberId })
- DELETE /api/zones/[id]/subscribers: withPermission("update", "Zone") -> removeSubscriberFromZone (body: { subscriberId })
- GET /api/collectors/[id]/subscribers: withPermission("read", "Subscriber") -> getCollectorSubscribers (collector can only query their own; admin/staff can query any collector)
**API Routes:**
- `GET /api/zones` — list zones (ADMIN, OFFICE_STAFF, COLLECTOR can read)
- `POST /api/zones` — create zone (ADMIN only)
- `GET /api/zones/[id]` — get zone detail with subscriber count
- `PUT /api/zones/[id]` — update zone (ADMIN only)
- `POST /api/zones/[id]/subscribers` — assign subscriber to zone, body: { subscriberId }. ADMIN, OFFICE_STAFF.
- `DELETE /api/zones/[id]/subscribers` — remove subscriber from zone, body: { subscriberId }. ADMIN, OFFICE_STAFF.
- `GET /api/collectors/[id]/subscribers` — get subscribers for a specific collector (scoped by zone assignments). ADMIN, OFFICE_STAFF can query any collector; COLLECTOR can only query self.
Use the dynamic route handler pattern from 02-04: `export async function GET(req, { params }) { return withPermission(...)(async (req, { user }) => { const { id } = params; ... })(req); }`
Use withPermission() HOF pattern from existing API routes. For dynamic [id] routes, use the closure pattern documented in 02-01 decision (withPermission doesn't support dynamic params directly).
3. Create src/lib/__tests__/zone-service.test.ts integration tests:
- Setup: create tenant, admin user, collector user, seed COA, create service plan, create 3 subscribers, create 2 zones
- Test createZone: creates zone with name/description
- Test createZone duplicate name: throws on duplicate name within tenant
- Test updateZone: updates name, description, isActive
- Test listZones: returns zones with counts
- Test assignSubscriberToZone: subscriber.zoneId updated
- Test assignCollectorToZone: ZoneAssignment created, validates COLLECTOR role
- Test getCollectorSubscribers: returns only subscribers in collector's assigned zones
- Test getCollectorSubscribers with no zones: throws error
- Test cross-tenant isolation: zone created in Tenant A is invisible to Tenant B query
- Cleanup afterAll in reverse order: zoneAssignments -> subscribers -> servicePlans -> zones -> users -> tenant (extend the established cleanup pattern)
**Integration Tests** (`src/lib/__tests__/zone-service.test.ts`):
- Zone CRUD (create, update, list, deactivate)
- Zone name uniqueness within tenant
- Subscriber zone assignment and removal
- Collector zone assignment and removal
- getCollectorSubscribers returns only subscribers in collector's zones
- getCollectorSubscribers returns empty for collector with no zone assignments
- Collector cannot be assigned to zone if they don't have COLLECTOR role
- Cross-tenant isolation (zone from tenant A not visible to tenant B)
Follow existing test patterns: beforeAll creates tenant+user+accounts, afterAll cleans up in correct order. Add Zone and ZoneAssignment to cleanup order.
Follow the exact test setup pattern from payment.test.ts — use TS = Date.now() suffix, create via raw prisma for setup, test via tenantPrisma.
</action>
<verify>
- `npx vitest run src/lib/__tests__/zone-service.test.ts` — all tests pass
- `npx vitest run` — full suite passes (no regressions)
- `npx tsc --noEmit` passes
</verify>
<done>ZoneService with zone CRUD, subscriber/collector assignment, and collector-scoped subscriber queries all working. API routes enforce RBAC. Integration tests prove zone scoping and tenant isolation.</done>
<done>Zone CRUD works, subscribers can be assigned to zones, collectors can be assigned to zones, collector-scoped subscriber queries enforce zone boundary, cross-tenant isolation verified. All tests pass.</done>
</task>
</tasks>
<verification>
- Zone CRUD: create, update, deactivate zones
- Subscriber assignment: assign/remove subscriber to/from zone
- Collector assignment: assign/remove collector to/from zone
- Collector scoping: collector sees only their zone's subscribers
- Tenant isolation: zones are tenant-scoped
- All existing tests still pass (no regressions from Subscriber.zone -> zoneId migration)
- `npx prisma migrate dev` succeeds (schema valid)
- `npx tsc --noEmit` passes (no TypeScript errors)
- `npx vitest run src/lib/__tests__/zone-service.test.ts` — all tests green
- Zone CRUD, subscriber assignment, collector scoping, and tenant isolation verified
</verification>
<success_criteria>
- Zone and ZoneAssignment models in Prisma schema with migration applied
- ZoneService handles zone CRUD, subscriber assignment, collector assignment, and scoped queries
- API routes enforce RBAC (admin creates zones, collectors query their subscribers)
- Integration tests prove collector can only see subscribers in their assigned zones
- Full test suite passes with no regressions
- Zone and ZoneAssignment models exist with proper tenant scoping
- Subscriber.zone String? replaced with Subscriber.zoneId FK
- Zone CRUD API routes work with withPermission enforcement
- Collectors can only query subscribers in their assigned zones
- Cross-tenant isolation proven by test
- All integration tests pass
</success_criteria>
<output>

View File

@@ -7,11 +7,13 @@ depends_on: ["03-01"]
files_modified:
- prisma/schema.prisma
- src/lib/prisma-tenant.ts
- src/lib/accounting/chart-of-accounts.ts
- src/lib/services/collector-service.ts
- src/lib/services/remittance-service.ts
- src/lib/services/collection-report-service.ts
- src/app/api/collections/route.ts
- src/app/api/collections/[id]/route.ts
- src/app/api/collections/[id]/void/route.ts
- src/app/api/remittances/route.ts
- src/app/api/remittances/[id]/verify/route.ts
- src/app/api/reports/collections/route.ts
@@ -21,45 +23,56 @@ autonomous: true
must_haves:
truths:
- "A collector can log a cash payment against a subscriber in the field and the system applies FIFO allocation to outstanding invoices"
- "Total collected and total remitted per collector are derived from the transaction log — no stored balance field"
- "Office staff can verify a remittance by entering their own counted total; variance is recorded but does not block completion"
- "Verified remittance creates a double-entry journal entry (DR Cash on Hand, CR Cash in Transit)"
- "Daily collection summary shows totals per collector: collected, remitted, variance, number of collections"
- "Collector can log a cash collection against a subscriber (lump sum, FIFO allocation)"
- "Collection creates JE: DR 1030 Cash in Transit, CR 1100 AR"
- "Office staff can verify a remittance by entering their counted total"
- "Remittance verification creates JE: DR 1010 Cash on Hand, CR 1030 Cash in Transit"
- "Variance between collector total and staff count is recorded but does NOT block remittance"
- "Collector can only collect from subscribers in their assigned zones"
- "Daily collection summary shows per-collector totals (collected, remitted, variance)"
- "Collector balances are derived from transactions, never stored"
artifacts:
- path: "prisma/schema.prisma"
provides: "Collection and Remittance models"
contains: "model Collection"
- path: "src/lib/accounting/chart-of-accounts.ts"
provides: "Account 1030 Cash in Transit"
contains: "1030"
- path: "src/lib/services/collector-service.ts"
provides: "Field collection logging with FIFO allocation reusing PaymentService pattern"
exports: ["CollectorService"]
provides: "Collection recording with FIFO allocation and zone enforcement"
exports: ["recordCollection", "voidCollection", "getCollectionHistory"]
- path: "src/lib/services/remittance-service.ts"
provides: "Remittance creation and two-party verification with JE posting"
exports: ["RemittanceService"]
provides: "Remittance creation and verification with JE"
exports: ["createRemittance", "verifyRemittance"]
- path: "src/lib/services/collection-report-service.ts"
provides: "Daily collection summary per collector"
exports: ["CollectionReportService"]
provides: "Daily collection summary report"
exports: ["getDailyCollectionSummary"]
- path: "src/lib/__tests__/collector-service.test.ts"
provides: "Collection tests including FIFO, zone enforcement, JE verification"
min_lines: 150
- path: "src/lib/__tests__/remittance-service.test.ts"
provides: "Remittance tests including variance, JE verification"
min_lines: 100
key_links:
- from: "src/lib/services/collector-service.ts"
to: "src/lib/services/payment-service.ts"
via: "Reuses FIFO allocation pattern for invoice payment"
pattern: "PaymentService|recordPayment"
to: "src/lib/accounting/journal-entry-service.ts"
via: "JournalEntryService.createEntry for collection JE (DR 1030, CR 1100)"
pattern: "JournalEntryService\\.createEntry"
- from: "src/lib/services/remittance-service.ts"
to: "src/lib/accounting/journal-entry-service.ts"
via: "Creates JE on verified remittance (DR 1010 Cash on Hand, CR 1030 Cash in Transit)"
pattern: "JournalEntryService|createEntry"
- from: "src/lib/services/collection-report-service.ts"
to: "prisma/schema.prisma"
via: "Aggregates Collection and Remittance records for daily summary"
pattern: "collection\\.(findMany|aggregate)"
via: "JournalEntryService.createEntry for remittance verification JE (DR 1010, CR 1030)"
pattern: "JournalEntryService\\.createEntry"
- from: "src/lib/services/collector-service.ts"
to: "src/lib/services/zone-service.ts"
via: "zone scoping — validates subscriber is in collector's zones before collection"
pattern: "zone"
---
<objective>
Build the collector field collection and remittance workflow with audit trail.
Create the collector field collection and remittance system: Collection model (lump sum with FIFO allocation), Remittance model (two-party verification), accounting journal entries for both events, zone-scoped collection enforcement, daily collection summary report.
Purpose: This is the core collector workflow — collectors log cash received in the field, the system tracks what they've collected, office staff verify remittances with independent counts, and the accounting ledger records verified cash movements. The daily summary report gives management visibility into collection operations.
Output: Collection model (lump-sum field payment with FIFO allocation), Remittance model (two-party verification), journal entry on verified remittance, daily collection summary report, integration tests.
Purpose: This is the core cash flow tracking for field operations — collectors log what they receive, management verifies what they remit, and every peso is traceable through the double-entry ledger.
Output: Collection/Remittance models, 3 service files, 6 API routes, integration tests.
</objective>
<execution_context>
@@ -72,180 +85,198 @@ Output: Collection model (lump-sum field payment with FIFO allocation), Remittan
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-operational-modules/03-CONTEXT.md
@.planning/phases/03-operational-modules/03-RESEARCH.md
@.planning/phases/03-operational-modules/03-01-SUMMARY.md
@prisma/schema.prisma
@src/lib/services/payment-service.ts
@src/lib/accounting/journal-entry-service.ts
@src/lib/prisma-tenant.ts
@src/lib/accounting/chart-of-accounts.ts
@src/lib/accounting/journal-entry-service.ts
@src/lib/services/payment-service.ts (FIFO pattern reference — adapt for 1030 account)
@src/lib/services/zone-service.ts (zone scoping reference)
@src/lib/__tests__/payment.test.ts (test pattern reference)
</context>
<tasks>
<task type="auto">
<name>Task 1: Collection and Remittance Prisma models + new COA account</name>
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts, src/lib/accounting/chart-of-accounts.ts, src/lib/accounting/seed-coa.ts</files>
<name>Task 1: Collection/Remittance schema, 1030 account, migration, tenant scoping</name>
<files>
prisma/schema.prisma
src/lib/prisma-tenant.ts
src/lib/accounting/chart-of-accounts.ts
</files>
<action>
**New enum:**
- `RemittanceStatus { PENDING, VERIFIED }`
- `CollectionStatus { COMPLETED, VOIDED }` (mirroring PaymentStatus pattern)
1. Add 1030 Cash in Transit to ISP_CHART_OF_ACCOUNTS in chart-of-accounts.ts:
```
{ code: "1030", name: "Cash in Transit", accountType: "ASSET", normalBalance: "DEBIT", parentCode: "1000" }
```
Insert AFTER 1020 Cash in Bank and BEFORE 1100 Accounts Receivable to maintain code order.
**Collection model** — a field cash collection logged by a collector:
- id (uuid PK), tenantId
- collectorId (FK to User — the collector who collected)
- subscriberId (FK to Subscriber)
- amount (Decimal 10,2) — lump sum received from subscriber
- collectionDate (DateTime) — when cash was received in the field
- notes (String?)
- status (CollectionStatus default COMPLETED)
- remittanceId (String? FK to Remittance — linked when included in a remittance batch)
- paymentId (String? FK to Payment — the underlying Payment record created via PaymentService)
- createdAt, updatedAt
- @@index([tenantId]), @@index([tenantId, collectorId]), @@index([tenantId, collectionDate])
2. Add enums to schema.prisma:
- `enum CollectionStatus { COMPLETED VOIDED }`
- `enum RemittanceStatus { PENDING VERIFIED }`
**Remittance model** — a batch handoff of collected cash from collector to office:
- id (uuid PK), tenantId
- collectorId (FK to User)
- remittanceDate (DateTime) — when the collector handed over cash
- collectedTotal (Decimal 10,2) — sum of Collection amounts in this batch (system-calculated)
- verifiedTotal (Decimal 10,2?) — amount counted by office staff (null until verified)
- variance (Decimal 10,2?) — collectedTotal - verifiedTotal (system-calculated on verification)
- status (RemittanceStatus default PENDING)
- verifiedById (String? FK to User — office staff who verified)
- verifiedAt (DateTime?)
- journalEntryId (String?) — JE created on verification
- notes (String?)
- createdAt, updatedAt
- @@index([tenantId]), @@index([tenantId, collectorId]), @@index([tenantId, remittanceDate])
3. Add Collection model:
- id (uuid), tenantId
- collectorId (String, FK to User — the collector who made the collection)
- subscriberId (String, FK to Subscriber)
- amount (Decimal @db.Decimal(10,2))lump sum received from subscriber
- collectionDate (DateTime) — when collected in the field
- status (CollectionStatus, default COMPLETED)
- notes (String?)
- journalEntryId (String?) — JE created on collection (DR 1030, CR 1100)
- voidedAt (DateTime?), voidedById (String?), voidJournalEntryId (String?)
- createdAt, updatedAt
- Relations: collector -> User, subscriber -> Subscriber
- Add PaymentAllocation relation: collectionAllocations PaymentAllocation[] (reuse PaymentAllocation or create CollectionAllocation — prefer creating CollectionAllocation to avoid polluting PaymentAllocation with nullable fields)
- Actually, create CollectionAllocation as a separate model (same structure as PaymentAllocation but for collections):
id, tenantId, collectionId, invoiceId, amount, createdAt, @@index([collectionId]), @@index([invoiceId]), @@index([tenantId])
- @@unique([tenantId, collectorId, subscriberId, collectionDate]) — prevent double-recording same subscriber same day same collector
- @@index([tenantId]), @@index([tenantId, collectorId]), @@index([tenantId, collectionDate])
**Update relations:**
- User: add `collections Collection[]`, `remittancesAsCollector Remittance[] @relation("RemittanceCollector")`, `remittancesVerified Remittance[] @relation("RemittanceVerifiedBy")`
- Subscriber: add `collections Collection[]`
4. Add Remittance model:
- id (uuid), tenantId
- collectorId (String, FK to User)
- remittanceDate (DateTime) — the date of remittance
- collectedTotal (Decimal @db.Decimal(10,2)) — sum of collector's collections for the period (derived at creation time, stored for audit trail)
- verifiedTotal (Decimal? @db.Decimal(10,2)) — amount counted by office staff (null until verified)
- variance (Decimal? @db.Decimal(10,2)) — collectedTotal - verifiedTotal (null until verified)
- status (RemittanceStatus, default PENDING)
- verifiedById (String?, FK to User)
- verifiedAt (DateTime?)
- journalEntryId (String?) — JE created on verification (DR 1010, CR 1030)
- notes (String?)
- createdAt, updatedAt
- Relations: collector -> User, verifiedBy -> User
- @@index([tenantId]), @@index([tenantId, collectorId]), @@index([tenantId, remittanceDate])
**New COA account:**
- Add account 1030 "Cash in Transit" (ASSET, DEBIT normal balance) to ISP_CHART_OF_ACCOUNTS in chart-of-accounts.ts
- This is a child of 1000 (Cash and Cash Equivalents)
- Update seed-coa.ts if needed to include it
- Purpose: When collector collects cash, it's in transit until verified remittance moves it to Cash on Hand (1010)
5. Add reverse relations on User: `collections Collection[]`, `verifiedRemittances Remittance[]`
Add reverse relation on Subscriber: `collections Collection[]`
**Add to TENANT_SCOPED_MODELS:** "collection", "remittance"
6. Run `npx prisma migrate dev --name add-collections-remittances`
Run `npx prisma migrate dev --name add-collections-remittances`
**Journal Entry Pattern for Collection:**
When collector logs a collection, it creates a Payment via PaymentService (reusing FIFO allocation) AND creates a Collection record linking the collector. The Payment JE is: DR 1030 Cash in Transit, CR 1100 AR. Note: use 1030 (not 1010) because cash is with the collector, not yet in the office.
**Journal Entry Pattern for Verified Remittance:**
DR 1010 Cash on Hand (verified amount)
CR 1030 Cash in Transit (verified amount)
This moves the cash from "in transit" to "on hand" upon office verification.
IMPORTANT: The collector collection payment must debit 1030 Cash in Transit (not 1010 Cash on Hand). This means CollectorService needs to create the Payment with a custom account override, or create its own JE pattern. The cleanest approach: CollectorService creates the Payment record directly (reusing the FIFO allocation logic from PaymentService but with 1030 as the debit account instead of 1010/1020). Extract the FIFO allocation logic into a shared helper if needed, or have CollectorService call PaymentService.recordPayment with a parameter indicating collector collection (which uses 1030 instead of 1010).
7. Add Collection, CollectionAllocation, and Remittance to TENANT_SCOPED_MODELS in prisma-tenant.ts with FULL 12-operation extension blocks.
</action>
<verify>
- `npx prisma migrate dev` completes without errors
- `npx prisma generate` succeeds
- Schema has Collection, Remittance models with correct relations
- chart-of-accounts.ts includes 1030 Cash in Transit
- `npx prisma migrate dev` succeeds
- `npx tsc --noEmit` passes
- Grep chart-of-accounts.ts confirms "1030" exists
- Grep prisma-tenant.ts confirms "collection", "collectionAllocation", "remittance" in TENANT_SCOPED_MODELS
</verify>
<done>Collection and Remittance models exist, 1030 Cash in Transit added to COA, TENANT_SCOPED_MODELS updated, migration applied.</done>
<done>Collection, CollectionAllocation, and Remittance models exist, 1030 Cash in Transit added to COA, migration applied, tenant scoping configured.</done>
</task>
<task type="auto">
<name>Task 2: CollectorService, RemittanceService, CollectionReportService + APIs + tests</name>
<files>src/lib/services/collector-service.ts, src/lib/services/remittance-service.ts, src/lib/services/collection-report-service.ts, src/app/api/collections/route.ts, src/app/api/collections/[id]/route.ts, src/app/api/remittances/route.ts, src/app/api/remittances/[id]/verify/route.ts, src/app/api/reports/collections/route.ts, src/lib/__tests__/collector-service.test.ts, src/lib/__tests__/remittance-service.test.ts</files>
<name>Task 2: Collector service, remittance service, report service, APIs, and tests</name>
<files>
src/lib/services/collector-service.ts
src/lib/services/remittance-service.ts
src/lib/services/collection-report-service.ts
src/app/api/collections/route.ts
src/app/api/collections/[id]/route.ts
src/app/api/collections/[id]/void/route.ts
src/app/api/remittances/route.ts
src/app/api/remittances/[id]/verify/route.ts
src/app/api/reports/collections/route.ts
src/lib/__tests__/collector-service.test.ts
src/lib/__tests__/remittance-service.test.ts
</files>
<action>
**CollectorService** (`src/lib/services/collector-service.ts`):
- `recordCollection(db, { collectorId, subscriberId, amount, collectionDate, notes })`:
1. Verify collector is assigned to subscriber's zone (via ZoneService.getCollectorSubscribers or direct zone check)
2. Create a Payment record using PaymentService.recordPayment (or equivalent FIFO logic) — but with 1030 Cash in Transit as debit account instead of 1010/1020. Generate idempotencyKey as `coll-{collectorId}-{subscriberId}-{timestamp}`.
3. Create a Collection record linking collectorId, subscriberId, paymentId
4. Return the collection with payment allocation details
- `getCollectorCollections(db, collectorId, { dateFrom, dateTo })` — list collections for a collector in date range
- `voidCollection(db, collectionId)` — void the collection and its underlying payment (via PaymentService.voidPayment)
- `getUnremittedCollections(db, collectorId)` — collections not yet linked to a remittance
1. Create src/lib/services/collector-service.ts:
- `recordCollection(tenantPrisma, tenantId, { collectorId, subscriberId, amount, collectionDate, notes? })`:
a. ZONE ENFORCEMENT: Query collector's zone assignments. Query subscriber's zoneId. If subscriber's zone is NOT in collector's assigned zones, throw error "Subscriber not in your assigned zones" (security boundary).
b. FIFO allocation: Query subscriber's unpaid invoices ordered by dueDate ASC (same pattern as PaymentService). Allocate lump sum amount across invoices. Create CollectionAllocation records for each invoice touched. Update invoice.amountPaid and invoice.status atomically.
c. Create JE via JournalEntryService.createEntry: DR 1030 Cash in Transit, CR 1100 Accounts Receivable. Look up accounts by code "1030" and "1100". Use referenceType "Collection", referenceId = collection.id.
d. Handle overpayment: any excess after all invoices paid goes to subscriber.creditBalance (same pattern as PaymentService).
e. All inside a $transaction. Pass tenantId explicitly in all create/update data.
f. Return collection record with allocations.
- `voidCollection(tenantPrisma, tenantId, collectionId, voidedById)`:
- Reverse the JE via JournalEntryService (same pattern as voidPayment)
- Reverse invoice.amountPaid and status for each allocation
- Reverse subscriber.creditBalance if overpayment was applied
- Mark collection as VOIDED
- `getCollectionHistory(tenantPrisma, { collectorId?, subscriberId?, dateFrom?, dateTo?, page?, limit? })` — filtered list
**RemittanceService** (`src/lib/services/remittance-service.ts`):
- `createRemittance(db, { collectorId, collectionIds, remittanceDate, notes })`:
1. Validate all collectionIds belong to this collector and are unremitted
2. Calculate collectedTotal as sum of collection amounts
3. Create Remittance record with status PENDING
4. Link collections to remittance (update collection.remittanceId)
5. Return remittance
- `verifyRemittance(db, { remittanceId, verifiedById, verifiedTotal, notes })`:
1. Load remittance, verify status is PENDING
2. Calculate variance = collectedTotal - verifiedTotal
3. Create journal entry via JournalEntryService: DR 1010 Cash on Hand (verifiedTotal), CR 1030 Cash in Transit (verifiedTotal). Reference type "Remittance".
4. Update remittance: verifiedTotal, variance, verifiedById, verifiedAt, journalEntryId, status = VERIFIED
5. Variance is recorded but does NOT block — remittance completes regardless
6. Return remittance with variance info
- `getRemittances(db, { collectorId?, dateFrom?, dateTo?, status? })` — list remittances with filters
2. Create src/lib/services/remittance-service.ts:
- `createRemittance(tenantPrisma, tenantId, { collectorId, remittanceDate })`:
- Calculate collectedTotal: sum of all COMPLETED collections by this collector for this date (or unremitted collections if date-based grouping is complex — use all COMPLETED collections by collector where no remittance has been created yet). Simpler approach: sum COMPLETED collections by collectorId where collectionDate = remittanceDate.
- Create Remittance with status PENDING, collectedTotal set.
- `verifyRemittance(tenantPrisma, tenantId, { remittanceId, verifiedTotal, verifiedById, notes? })`:
- Load remittance, validate status is PENDING
- Calculate variance: collectedTotal - verifiedTotal (positive = collector short, negative = collector over)
- Create JE via JournalEntryService.createEntry: DR 1010 Cash on Hand (verifiedTotal), CR 1030 Cash in Transit (verifiedTotal). Note: JE is for verified amount, NOT collected total. Variance does NOT block.
- Update remittance: verifiedTotal, variance, verifiedById, verifiedAt, journalEntryId, status = VERIFIED
- `listRemittances(tenantPrisma, { collectorId?, status?, dateFrom?, dateTo? })` — filtered list
**CollectionReportService** (`src/lib/services/collection-report-service.ts`):
- `getDailyCollectionSummary(db, { date, collectorId? })`:
1. Query collections for the date (or all collectors if no collectorId)
2. Query remittances for the date
3. Return per-collector summary: { collectorId, collectorName, totalCollected, totalRemitted, variance, collectionCount }
4. Include drill-down data: per-subscriber detail (subscriberName, amount, collectionDate)
3. Create src/lib/services/collection-report-service.ts:
- `getDailyCollectionSummary(tenantPrisma, { date, collectorId? })`:
- For each collector active on the given date:
- collectedTotal: sum of COMPLETED collections on that date
- remittedTotal: sum of VERIFIED remittance verifiedTotal on that date
- variance: collectedTotal - remittedTotal
- collectionCount: count of collections
- Return array of { collectorId, collectorName, collectedTotal, remittedTotal, variance, collectionCount }
- `getCollectorCollectionDetail(tenantPrisma, { collectorId, date })`:
- Per-subscriber breakdown for drill-down: subscriber name, amount, collection time
**API Routes:**
- `POST /api/collections` — collector logs a collection. COLLECTOR role. Body: { subscriberId, amount, collectionDate, notes? }. Extracts collectorId from session.
- `GET /api/collections` — list collections. COLLECTOR sees own; ADMIN/OFFICE_STAFF see all or filter by collectorId query param.
- `GET /api/collections/[id]` — get collection detail with payment allocation info
- `POST /api/collections/[id]/void` void a collection. ADMIN, OFFICE_STAFF.
- `POST /api/remittances` — create remittance batch. COLLECTOR role. Body: { collectionIds, remittanceDate, notes? }
- `GET /api/remittances` — list remittances with filters. ADMIN, OFFICE_STAFF, COLLECTOR (own only).
- `POST /api/remittances/[id]/verify` — verify remittance. ADMIN, OFFICE_STAFF only. Body: { verifiedTotal, notes? }
- `GET /api/reports/collections` — daily collection summary. ADMIN, OFFICE_STAFF. Query params: date, collectorId?
4. Create API routes:
- POST /api/collections: withPermission("create", "Payment") -> recordCollection (collectors have "create" Payment permission)
- GET /api/collections: withPermission("read", "Payment") -> getCollectionHistory with query param filters
- GET /api/collections/[id]: withPermission("read", "Payment") -> single collection detail
- POST /api/collections/[id]/void: withPermission("manage", "Payment") -> voidCollection (admin/staff only)
- POST /api/remittances: withPermission("manage", "Payment") -> createRemittance (staff initiates)
- POST /api/remittances/[id]/verify: withPermission("manage", "Payment") -> verifyRemittance (staff verifies)
- GET /api/reports/collections: withPermission("read", "Report") -> getDailyCollectionSummary with date query param
**Integration Tests:**
5. Create src/lib/__tests__/collector-service.test.ts:
- Setup: create tenant (seeds COA including 1030 now), admin user, collector user, create 2 zones, assign collector to zone 1, create service plan, create 2 subscribers in zone 1, create 1 subscriber in zone 2, generate invoices for subscribers
- Test: recordCollection succeeds for subscriber in collector's zone
- Test: recordCollection FIFO allocates to oldest invoice first
- Test: recordCollection throws for subscriber NOT in collector's zones
- Test: collection creates JE with DR 1030, CR 1100 (verify journal entry lines)
- Test: overpayment adds to subscriber.creditBalance
- Test: voidCollection reverses JE and invoice allocations
- Test: cross-tenant isolation
- Cleanup: collectionAllocations -> collections -> invoiceLines -> invoices -> journalEntryLines -> null reversesEntryId -> journalEntries -> zoneAssignments -> subscribers -> servicePlans -> zones -> tenantSettings -> accountingPeriods -> accounts -> ticketCategories -> users -> tenant
`collector-service.test.ts`:
- Collector can log collection against subscriber in their zone
- Collector cannot collect from subscriber outside their zone
- Collection creates Payment with FIFO allocation (reuses PaymentService pattern)
- Collection uses 1030 Cash in Transit (not 1010)
- Void collection voids underlying payment
- getUnremittedCollections returns only collections not linked to remittance
- Collector balances derived from transactions (no stored balance field) — query collections sum vs remittances sum
`remittance-service.test.ts`:
- Create remittance batch from unremitted collections
- Cannot include already-remitted collections
- Verify remittance with matching total (variance = 0)
- Verify remittance with different total (variance recorded, not blocking)
- Verification creates JE: DR 1010, CR 1030
- Cannot verify already-verified remittance
- Daily collection summary returns correct totals per collector
6. Create src/lib/__tests__/remittance-service.test.ts:
- Setup: reuse similar setup, record some collections first
- Test: createRemittance calculates correct collectedTotal
- Test: verifyRemittance with matching amount (zero variance)
- Test: verifyRemittance with different amount (non-zero variance, still completes)
- Test: verification creates JE with DR 1010, CR 1030 for verifiedTotal
- Test: cannot verify already-verified remittance
- Cleanup: remittances -> collectionAllocations -> collections -> (same chain as above)
</action>
<verify>
- `npx vitest run src/lib/__tests__/collector-service.test.ts` — all tests pass
- `npx vitest run src/lib/__tests__/remittance-service.test.ts` — all tests pass
- `npx vitest run` — full suite passes (no regressions)
- `npx tsc --noEmit` passes
</verify>
<done>Collectors can log field collections with FIFO allocation via 1030 Cash in Transit, create remittance batches, office staff verify with independent count, variance tracked, JE posted on verification, daily collection summary report working. All derived from transaction log — no stored balance fields.</done>
<done>Collectors can record zone-scoped collections with FIFO allocation, collections create correct JEs (DR 1030 CR 1100), remittance verification creates correct JEs (DR 1010 CR 1030), variance recorded but non-blocking, daily summary report works, all tests pass.</done>
</task>
</tasks>
<verification>
- Collector logs collection -> Payment created with FIFO allocation -> debit goes to 1030 Cash in Transit
- Collector creates remittance batch from unremitted collections
- Office staff verifies remittance -> JE posted (DR 1010 Cash on Hand, CR 1030 Cash in Transit)
- Variance tracked but does not block verification
- Daily summary shows per-collector totals: collected, remitted, variance, count
- Collector balances are DERIVED (sum of collections minus sum of verified remittances) — no stored balance
- Zone scoping enforced (collector can only collect from their assigned subscribers)
- All existing tests pass (no regressions)
- `npx prisma migrate dev` succeeds
- `npx tsc --noEmit` passes
- `npx vitest run src/lib/__tests__/collector-service.test.ts` — all green
- `npx vitest run src/lib/__tests__/remittance-service.test.ts` — all green
- Collection JEs use account 1030 (NOT 1010) — verified by test assertions on JE lines
- Remittance verification JEs use DR 1010, CR 1030
- Variance does not block remittance completion
</verification>
<success_criteria>
- Collection and Remittance models with proper relations and migration
- 1030 Cash in Transit account added to COA
- CollectorService handles field collection with FIFO and zone scoping
- RemittanceService handles batch creation and two-party verification with JE
- CollectionReportService produces daily summary per collector
- Integration tests prove the full collection-to-remittance-to-JE flow
- No stored balance fields — all collector totals derived from transaction log
- Collection model with FIFO allocation (same pattern as PaymentService but with 1030)
- Zone enforcement on collections (collector can only collect from their zones)
- Remittance two-party verification (collector collects, staff counts and verifies)
- Correct accounting chain: Collection DR 1030/CR 1100, Remittance DR 1010/CR 1030
- Variance recorded but non-blocking
- Daily collection summary report with per-collector totals
- Collector balances derived (no stored balance field)
- All integration tests pass
</success_criteria>
<output>

View File

@@ -9,6 +9,7 @@ files_modified:
- src/lib/prisma-tenant.ts
- src/lib/services/ticket-service.ts
- src/lib/services/ticket-category-service.ts
- src/lib/tenant.ts
- src/app/api/tickets/route.ts
- src/app/api/tickets/[id]/route.ts
- src/app/api/tickets/[id]/status/route.ts
@@ -19,41 +20,49 @@ autonomous: true
must_haves:
truths:
- "Staff can create a support ticket from a client call with issue description, priority, and category"
- "Tickets follow a lifecycle: OPEN -> ASSIGNED -> RESOLVED -> CLOSED"
- "Admin can configure ticket categories per tenant (create, edit, deactivate)"
- "Default ticket categories are seeded at tenant creation"
- "Ticket model supports both staff and subscriber as source types from the start"
- "Staff can create a support ticket with subject, description, priority, and category"
- "Tickets follow lifecycle: OPEN -> ASSIGNED -> RESOLVED -> CLOSED"
- "Invalid status transitions are rejected (e.g., CLOSED -> OPEN is invalid)"
- "Admin can create, update, and deactivate ticket categories"
- "Default ISP categories are seeded at tenant creation"
- "Tickets with a deactivated category cannot be created"
- "Ticket data is tenant-scoped"
artifacts:
- path: "prisma/schema.prisma"
provides: "Ticket and TicketCategory models"
provides: "Ticket, TicketCategory models with enums"
contains: "model Ticket"
- path: "src/lib/services/ticket-service.ts"
provides: "Ticket CRUD, status transitions, search/filter"
exports: ["TicketService"]
provides: "Ticket CRUD and status transitions"
exports: ["createTicket", "updateTicket", "getTicket", "listTickets", "transitionTicketStatus"]
- path: "src/lib/services/ticket-category-service.ts"
provides: "TicketCategory CRUD with default seeding"
exports: ["TicketCategoryService"]
provides: "Category CRUD"
exports: ["createCategory", "updateCategory", "listCategories"]
- path: "src/lib/tenant.ts"
provides: "Default ticket category seeding in createTenant"
contains: "ticketCategory"
- path: "src/lib/__tests__/ticket-service.test.ts"
provides: "Integration tests for ticket lifecycle and category management"
min_lines: 100
provides: "Integration tests for ticket lifecycle, categories, transitions"
min_lines: 150
key_links:
- from: "src/lib/services/ticket-service.ts"
to: "src/lib/services/ticket-category-service.ts"
via: "validates category isActive before ticket creation"
pattern: "isActive"
- from: "src/lib/tenant.ts"
to: "prisma/schema.prisma"
via: "Prisma queries on Ticket model"
pattern: "prisma\\.ticket\\."
- from: "src/lib/services/ticket-category-service.ts"
to: "src/lib/tenant.ts"
via: "Categories seeded during tenant creation"
pattern: "seedTicketCategories|createTenant"
via: "seeds default TicketCategory records in createTenant transaction"
pattern: "ticketCategory\\.createMany"
- from: "src/app/api/tickets/[id]/status/route.ts"
to: "src/lib/services/ticket-service.ts"
via: "transitionTicketStatus with guard map"
pattern: "transitionTicketStatus"
---
<objective>
Build the ticketing system for tracking customer support issues.
Create the ticketing system: Ticket and TicketCategory models, ticket lifecycle (OPEN -> ASSIGNED -> RESOLVED -> CLOSED), admin-configurable categories with ISP default seeds, ticket CRUD API, status transition API with guard validation.
Purpose: Tickets are how customer issues enter the system — staff creates a ticket from a client call, the ticket flows through a lifecycle, and in 03-04 tickets get converted to job orders. The model also supports subscriber-created tickets (Phase 5 portal) from the start to avoid rework.
Output: Ticket and TicketCategory Prisma models, ticket CRUD service with lifecycle management, admin-configurable categories with default seeds, API routes, integration tests.
Purpose: Tickets are the intake mechanism for customer issues. They must exist before job orders (03-04) can be created from them. Category configurability avoids hardcoded enums.
Output: Ticket/TicketCategory models, ticket-service.ts, ticket-category-service.ts, updated tenant.ts, 5 API routes, integration tests.
</objective>
<execution_context>
@@ -66,153 +75,172 @@ Output: Ticket and TicketCategory Prisma models, ticket CRUD service with lifecy
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-operational-modules/03-CONTEXT.md
@.planning/phases/03-operational-modules/03-RESEARCH.md
@prisma/schema.prisma
@src/lib/prisma-tenant.ts
@src/lib/tenant.ts
@src/lib/services/subscriber-service.ts
@src/lib/casl/types.ts
@src/lib/casl/permissions.ts
@src/lib/services/payment-service.ts (pattern reference)
@src/lib/__tests__/payment.test.ts (pattern reference)
</context>
<tasks>
<task type="auto">
<name>Task 1: Ticket and TicketCategory Prisma models + category seeding</name>
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts, src/lib/tenant.ts, src/lib/services/ticket-category-service.ts</files>
<name>Task 1: Ticket schema, categories, migration, and tenant scoping</name>
<files>
prisma/schema.prisma
src/lib/prisma-tenant.ts
src/lib/tenant.ts
</files>
<action>
**New enums:**
- `TicketStatus { OPEN, ASSIGNED, RESOLVED, CLOSED }`
- `TicketPriority { LOW, MEDIUM, HIGH, URGENT }`
- `TicketSource { STAFF, SUBSCRIBER }` supports both sources from day one
1. Add enums to schema.prisma:
- `enum TicketStatus { OPEN ASSIGNED RESOLVED CLOSED }`
- `enum TicketPriority { LOW MEDIUM HIGH URGENT }`
- `enum TicketSource { STAFF SUBSCRIBER }` (supports Phase 5 subscriber portal)
**TicketCategory model:**
- id (uuid PK), tenantId
- name (String) — e.g., "No Connection", "Slow Speed"
- description (String?)
- isActive (Boolean default true) — soft delete for deactivation
- createdAt, updatedAt
- @@unique([tenantId, name])
- @@index([tenantId])
- Relation: tickets Ticket[]
2. Add TicketCategory model:
- id (uuid), tenantId, name (String), description (String?), isActive (Boolean default true), createdAt, updatedAt
- @@unique([tenantId, name]), @@index([tenantId])
**Ticket model:**
- id (uuid PK), tenantId
- ticketNumber (String) — auto-generated sequential per tenant, e.g., "TKT-0001"
- subscriberId (FK to Subscriber) — the affected subscriber
- categoryId (FK to TicketCategory)
- source (TicketSource default STAFF)
- createdById (FK to User) — staff who created, or subscriber user in Phase 5
- assignedToId (String? FK to User) — assigned staff member (set when status -> ASSIGNED)
- subject (String) — brief issue summary
- description (String) — detailed issue description
- priority (TicketPriority default MEDIUM)
- status (TicketStatus default OPEN)
- resolvedAt (DateTime?) — when auto-resolved (all job orders completed)
- closedAt (DateTime?) — when staff manually closes after confirming resolution
- closedById (String? FK to User)
- notes (String?) — internal notes
- createdAt, updatedAt
- @@unique([tenantId, ticketNumber])
- @@index([tenantId]), @@index([tenantId, status]), @@index([tenantId, subscriberId]), @@index([tenantId, categoryId])
3. Add Ticket model:
- id (uuid), tenantId
- ticketNumber (String) — auto-generated TKT-NNNN
- subject (String), description (String)
- categoryId (String, FK to TicketCategory)
- priority (TicketPriority, default MEDIUM)
- status (TicketStatus, default OPEN)
- source (TicketSource, default STAFF)
- subscriberId (String?, FK to Subscriber — which subscriber this ticket is about)
- createdById (String, FK to User — staff or subscriber who created it)
- resolvedAt (DateTime?), closedAt (DateTime?)
- notes (String?) — internal notes
- createdAt, updatedAt
- Relations: category -> TicketCategory, subscriber -> Subscriber, createdBy -> User
- @@unique([tenantId, ticketNumber]), @@index([tenantId]), @@index([tenantId, status]), @@index([tenantId, categoryId]), @@index([subscriberId])
- Add reverse relations: Subscriber.tickets Ticket[], User.createdTickets Ticket[], TicketCategory.tickets Ticket[]
**Update relations:**
- Subscriber: add `tickets Ticket[]`
- User: add appropriate ticket relations (createdTickets, assignedTickets, closedTickets)
4. Run `npx prisma migrate dev --name add-tickets`
**Add to TENANT_SCOPED_MODELS:** "ticket", "ticketCategory"
5. Add Ticket and TicketCategory to TENANT_SCOPED_MODELS in prisma-tenant.ts with FULL extension blocks (all 12 operations each — copy from subscriber block).
**TicketCategoryService** (`src/lib/services/ticket-category-service.ts`):
- `seedDefaultCategories(db, tenantId)` — creates default categories: No Connection, Slow Speed, Billing Inquiry, New Installation, Equipment Issue, Other
- `createCategory(db, { name, description })` — admin creates custom category
- `updateCategory(db, categoryId, { name?, description?, isActive? })` — admin edits/deactivates
- `listCategories(db, { includeInactive? })` — list categories
**Update tenant creation** in `src/lib/tenant.ts`:
- After seedChartOfAccounts in the createTenant $transaction, call seedDefaultCategories to provision default ticket categories for new tenants.
Run `npx prisma migrate dev --name add-tickets`
6. Update src/lib/tenant.ts createTenant function: after seedChartOfAccounts, add seed for default ticket categories within the same transaction:
```
const defaultCategories = [
{ name: "No Connection", description: "Subscriber has no internet connection", tenantId: tenant.id },
{ name: "Slow Speed", description: "Connection speed below expected plan speed", tenantId: tenant.id },
{ name: "Billing Inquiry", description: "Questions about bills or payments", tenantId: tenant.id },
{ name: "New Installation", description: "Request for new service installation", tenantId: tenant.id },
{ name: "Equipment Issue", description: "Router, ONU, or cable problems", tenantId: tenant.id },
{ name: "Other", description: "Other issues not covered by categories above", tenantId: tenant.id },
];
await tx.ticketCategory.createMany({ data: defaultCategories });
```
IMPORTANT: This is inside the $transaction callback, so use `tx` (raw client) and include tenantId explicitly.
</action>
<verify>
- `npx prisma migrate dev` completes without errors
- `npx prisma generate` succeeds
- Schema has Ticket and TicketCategory models
- Creating a new tenant seeds 6 default ticket categories
- `npx prisma migrate dev` succeeds
- `npx tsc --noEmit` passes
- Grep prisma-tenant.ts confirms "ticket" and "ticketCategory" in TENANT_SCOPED_MODELS
- Grep tenant.ts confirms "ticketCategory" seeding
</verify>
<done>Ticket and TicketCategory models exist, default categories seeded at tenant creation, TENANT_SCOPED_MODELS updated, migration applied.</done>
<done>Ticket and TicketCategory models exist, migration applied, tenant scoping configured, default categories seeded in tenant creation.</done>
</task>
<task type="auto">
<name>Task 2: TicketService + API routes + integration tests</name>
<files>src/lib/services/ticket-service.ts, src/app/api/tickets/route.ts, src/app/api/tickets/[id]/route.ts, src/app/api/tickets/[id]/status/route.ts, src/app/api/ticket-categories/route.ts, src/app/api/ticket-categories/[id]/route.ts, src/lib/__tests__/ticket-service.test.ts</files>
<name>Task 2: Ticket service, category service, API routes, and integration tests</name>
<files>
src/lib/services/ticket-service.ts
src/lib/services/ticket-category-service.ts
src/app/api/tickets/route.ts
src/app/api/tickets/[id]/route.ts
src/app/api/tickets/[id]/status/route.ts
src/app/api/ticket-categories/route.ts
src/app/api/ticket-categories/[id]/route.ts
src/lib/__tests__/ticket-service.test.ts
</files>
<action>
**TicketService** (`src/lib/services/ticket-service.ts`):
- `createTicket(db, { subscriberId, categoryId, subject, description, priority, source, createdById })`:
1. Auto-generate ticketNumber (pattern: TKT-NNNN, sequential per tenant — same approach as INV/JE numbers)
2. Create ticket with status OPEN
3. Return ticket with subscriber and category info
- `updateTicket(db, ticketId, { subject?, description?, priority?, categoryId?, notes? })` — update editable fields (not status — status has dedicated transitions)
- `assignTicket(db, ticketId, assignedToId)` — set assignedToId, transition status OPEN -> ASSIGNED
- `resolveTicket(db, ticketId)` — transition to RESOLVED (called by job order completion sync in 03-04, or manually). Set resolvedAt.
- `closeTicket(db, ticketId, closedById)` — transition RESOLVED -> CLOSED. Set closedAt, closedById. This is the manual confirmation step.
- `reopenTicket(db, ticketId)` — RESOLVED -> OPEN (if issue not actually fixed). Clear resolvedAt.
- `getTicket(db, ticketId)` — get ticket with subscriber, category, creator, assignee, and job orders (empty array until 03-04)
- `listTickets(db, filters)` — list with filters: status, priority, categoryId, subscriberId, assignedToId, dateFrom, dateTo. Pagination (skip/take). Sort by createdAt DESC.
1. Create src/lib/services/ticket-category-service.ts:
- `createCategory(tenantPrisma, tenantId, { name, description })` — create category
- `updateCategory(tenantPrisma, categoryId, { name?, description?, isActive? })` — update/deactivate
- `listCategories(tenantPrisma, { activeOnly?: boolean })` — list with optional active filter
**Status transition rules (enforce in service):**
- OPEN -> ASSIGNED (requires assignedToId)
- OPEN -> CLOSED (cancel without resolving)
- ASSIGNED -> OPEN (unassign)
- ASSIGNED -> RESOLVED (direct resolve without job order)
- RESOLVED -> CLOSED (staff confirmation)
- RESOLVED -> OPEN (reopen)
- All other transitions: throw error
2. Create src/lib/services/ticket-service.ts:
- Sequential number generation: `generateTicketNumber(tenantPrisma)` — same pattern as invoice/JE numbers. Query `ticket.findMany({ where: { ticketNumber: { startsWith: "TKT-" } }, orderBy: { ticketNumber: "desc" }, take: 1 })`, parse last 4 digits, increment, pad to 4.
- `createTicket(tenantPrisma, tenantId, { subject, description, categoryId, priority?, subscriberId?, createdById, source? })`:
- Validate category exists AND isActive=true — throw if deactivated
- Generate ticketNumber
- Create ticket with status OPEN
- `updateTicket(tenantPrisma, ticketId, { subject?, description?, categoryId?, priority?, notes? })` — update metadata only (NOT status)
- `getTicket(tenantPrisma, ticketId)` — include category, subscriber, createdBy, jobOrders (empty array for now, relation added in 03-04)
- `listTickets(tenantPrisma, { status?, categoryId?, priority?, subscriberId?, page?, limit? })` — filtered list with pagination, include category and subscriber
- `transitionTicketStatus(tenantPrisma, ticketId, newStatus)`:
- Define VALID_TICKET_TRANSITIONS map:
OPEN -> [ASSIGNED, CLOSED]
ASSIGNED -> [OPEN, RESOLVED]
RESOLVED -> [CLOSED, OPEN]
CLOSED -> [] (terminal)
- Load current ticket, validate transition is allowed, update status
- Set resolvedAt on transition to RESOLVED, closedAt on transition to CLOSED
- NOTE: OPEN -> ASSIGNED is triggered by job order creation (03-04), not manually
- NOTE: ASSIGNED -> RESOLVED is triggered by auto-resolve (03-04) when all jobs complete
- Both can also be called manually by staff via API
- `resolveTicket(tenantPrisma, ticketId)` — convenience wrapper around transitionTicketStatus that is IDEMPOTENT: if ticket is already RESOLVED, return silently (prevents race condition per RESEARCH.md pitfall 5)
**API Routes:**
- `GET /api/tickets` — list tickets with filters. ADMIN, OFFICE_STAFF see all. TECHNICIAN sees assigned only.
- `POST /api/tickets` — create ticket. ADMIN, OFFICE_STAFF. Body: { subscriberId, categoryId, subject, description, priority? }
- `GET /api/tickets/[id]` — get ticket detail
- `PUT /api/tickets/[id]` — update ticket fields. ADMIN, OFFICE_STAFF.
- `POST /api/tickets/[id]/status` — change ticket status. Body: { status, assignedToId? }. ADMIN, OFFICE_STAFF.
- `GET /api/ticket-categories` — list categories. All authenticated users.
- `POST /api/ticket-categories` create category. ADMIN only.
- `PUT /api/ticket-categories/[id]` — update/deactivate category. ADMIN only.
3. Create API routes:
- GET /api/tickets: withPermission("read", "Ticket") -> listTickets with query param filters
- POST /api/tickets: withPermission("create", "Ticket") -> createTicket (body: { subject, description, categoryId, priority?, subscriberId? })
- GET /api/tickets/[id]: withPermission("read", "Ticket") -> getTicket (dynamic route pattern)
- PUT /api/tickets/[id]: withPermission("update", "Ticket") -> updateTicket (dynamic route pattern)
- POST /api/tickets/[id]/status: withPermission("update", "Ticket") -> transitionTicketStatus (body: { status })
- GET /api/ticket-categories: withPermission("read", "Ticket") -> listCategories
- POST /api/ticket-categories: withPermission("manage", "Ticket") -> createCategory (admin/staff only via manage check)
- PUT /api/ticket-categories/[id]: withPermission("manage", "Ticket") -> updateCategory
**Integration Tests** (`src/lib/__tests__/ticket-service.test.ts`):
- Create ticket with auto-generated ticket number
- Ticket number sequential within tenant (TKT-0001, TKT-0002...)
- Status transitions: OPEN -> ASSIGNED -> RESOLVED -> CLOSED (happy path)
- Invalid transition rejected (e.g., OPEN -> RESOLVED without assignment — actually allowed per rules above, test the invalid ones: CLOSED -> OPEN)
- Reopen ticket (RESOLVED -> OPEN)
- List tickets with filters (status, priority, category)
- Category CRUD (create, update, deactivate)
- Deactivated category cannot be used for new tickets
- Default categories seeded on tenant creation
- Cross-tenant isolation
4. Create src/lib/__tests__/ticket-service.test.ts:
- Setup: create tenant (this now auto-seeds categories via updated tenant.ts), admin user
- Test: default categories are seeded on tenant creation (6 categories)
- Test: createCategory adds a new category
- Test: updateCategory deactivates a category (isActive=false)
- Test: createTicket with valid category succeeds, returns TKT-0001
- Test: createTicket with deactivated category throws
- Test: second ticket gets TKT-0002
- Test: transitionTicketStatus OPEN -> CLOSED succeeds
- Test: transitionTicketStatus CLOSED -> OPEN throws (terminal state)
- Test: transitionTicketStatus OPEN -> RESOLVED throws (invalid)
- Test: resolveTicket is idempotent (calling on RESOLVED ticket does not throw)
- Test: listTickets with status filter returns correct subset
- Test: cross-tenant isolation (Tenant B cannot see Tenant A tickets)
- Cleanup: tickets -> ticketCategories -> subscribers -> users -> tenant
NOTE: Since tenant creation now seeds ticketCategories, cleanup must delete them. Use tenantId filter.
</action>
<verify>
- `npx vitest run src/lib/__tests__/ticket-service.test.ts` — all tests pass
- `npx vitest run` — full suite passes (no regressions)
- `npx tsc --noEmit` passes
</verify>
<done>TicketService handles ticket lifecycle (OPEN -> ASSIGNED -> RESOLVED -> CLOSED) with proper status transition enforcement. Ticket categories are admin-configurable with ISP defaults seeded at tenant creation. API routes enforce RBAC. Integration tests prove lifecycle and tenant isolation.</done>
<done>Ticket CRUD works with sequential numbering, status transitions enforce guard map, categories are admin-configurable with ISP defaults, deactivated categories rejected, idempotent resolve, cross-tenant isolation verified.</done>
</task>
</tasks>
<verification>
- Ticket CRUD: create, update, get, list with filters
- Status lifecycle: OPEN -> ASSIGNED -> RESOLVED -> CLOSED with proper guards
- Reopen: RESOLVED -> OPEN works
- Category management: CRUD with deactivation
- Default categories seeded at tenant creation (6 ISP-relevant categories)
- Ticket numbers are sequential per tenant
- Source field supports STAFF and SUBSCRIBER (Phase 5 ready)
- All existing tests pass (no regressions)
- `npx prisma migrate dev` succeeds
- `npx tsc --noEmit` passes
- `npx vitest run src/lib/__tests__/ticket-service.test.ts` — all tests green
- Ticket lifecycle (OPEN -> ASSIGNED -> RESOLVED -> CLOSED) enforced
- Default categories seeded on tenant creation
</verification>
<success_criteria>
- Ticket and TicketCategory models with migration applied
- TicketService handles full ticket lifecycle with enforced state transitions
- Admin-configurable categories with 6 defaults seeded at tenant creation
- Ticket model supports both staff and subscriber source types
- API routes enforce RBAC (staff creates, technician views assigned)
- Integration tests prove lifecycle, filtering, and tenant isolation
- Ticket and TicketCategory models with tenant scoping
- Status transition guard map rejects invalid transitions
- Default 6 ISP categories seeded at tenant creation
- Deactivated categories cannot be used for new tickets
- Sequential ticket numbering (TKT-NNNN)
- resolveTicket is idempotent
- Cross-tenant isolation verified by test
- All integration tests pass
</success_criteria>
<output>

View File

@@ -8,10 +8,11 @@ files_modified:
- prisma/schema.prisma
- src/lib/prisma-tenant.ts
- src/lib/services/job-order-service.ts
- src/lib/services/ticket-service.ts
- src/app/api/tickets/[id]/job-orders/route.ts
- src/app/api/job-orders/route.ts
- src/app/api/job-orders/[id]/route.ts
- src/app/api/job-orders/[id]/status/route.ts
- src/app/api/tickets/[id]/job-orders/route.ts
- src/lib/__tests__/job-order-service.test.ts
autonomous: true
@@ -19,37 +20,41 @@ must_haves:
truths:
- "Staff can convert a ticket into a job order assigned to a technician"
- "One ticket can have multiple job orders (1:many)"
- "Technician can view their assigned job orders and update status (PENDING -> IN_PROGRESS -> COMPLETED)"
- "Job completion includes outcome notes, completion date"
- "When ALL job orders on a ticket are completed, ticket auto-moves to RESOLVED"
- "Staff manually closes ticket after confirming resolution (two-step: auto-resolve then close)"
- "Job orders follow lifecycle: PENDING -> IN_PROGRESS -> COMPLETED (or CANCELLED)"
- "Technician can update status of their own assigned job orders"
- "When ALL non-cancelled job orders on a ticket are COMPLETED, ticket auto-resolves"
- "When ALL job orders on a ticket are CANCELLED, ticket reverts to OPEN"
- "Creating first job order on OPEN ticket transitions ticket to ASSIGNED"
- "Job completion includes outcome notes and completion date"
artifacts:
- path: "prisma/schema.prisma"
provides: "JobOrder model with status lifecycle and ticket relation"
provides: "JobOrder model with status enum and ticket relation"
contains: "model JobOrder"
- path: "src/lib/services/job-order-service.ts"
provides: "Job order CRUD, status transitions, ticket-job synchronization"
exports: ["JobOrderService"]
provides: "Job order CRUD, status transitions, ticket synchronization"
exports: ["createJobOrder", "updateJobOrderStatus", "getJobOrder", "listJobOrders"]
- path: "src/lib/services/ticket-service.ts"
provides: "Updated with resolveTicket and revertToOpen calls from job order service"
contains: "resolveTicket"
- path: "src/lib/__tests__/job-order-service.test.ts"
provides: "Integration tests for job order lifecycle and ticket sync"
min_lines: 100
min_lines: 150
key_links:
- from: "src/lib/services/job-order-service.ts"
to: "src/lib/services/ticket-service.ts"
via: "Auto-resolves ticket when all job orders completed"
pattern: "TicketService|resolveTicket"
- from: "src/app/api/tickets/[id]/job-orders/route.ts"
via: "checkTicketAutoResolve and checkTicketRevertToOpen after status changes"
pattern: "resolveTicket|transitionTicketStatus"
- from: "src/app/api/job-orders/[id]/status/route.ts"
to: "src/lib/services/job-order-service.ts"
via: "POST creates job order from ticket"
pattern: "createJobOrder"
via: "updateJobOrderStatus with technician self-service"
pattern: "updateJobOrderStatus"
---
<objective>
Build the job order workflow that converts tickets into assignable technician work.
Create the job order workflow: JobOrder model linked to Ticket (1:many), job order lifecycle (PENDING -> IN_PROGRESS -> COMPLETED/CANCELLED), technician assignment, ticket-job status synchronization (auto-resolve on all complete, revert to OPEN on all cancelled), and technician self-service status updates.
Purpose: Job orders are how tickets become actionable work for technicians. A ticket can spawn multiple job orders (different visits or skill types). The critical synchronization rule: when all job orders on a ticket complete, the ticket auto-resolves, and staff then manually closes after confirming with the subscriber.
Output: JobOrder Prisma model, job order CRUD with status lifecycle, ticket-to-job conversion, auto-resolution sync, technician self-service status updates, integration tests.
Purpose: Job orders are the execution units that technicians work on. The bidirectional sync with tickets ensures the ticket lifecycle stays accurate as work progresses.
Output: JobOrder model, job-order-service.ts, updated ticket-service.ts, 4 API routes, integration tests.
</objective>
<execution_context>
@@ -62,143 +67,163 @@ Output: JobOrder Prisma model, job order CRUD with status lifecycle, ticket-to-j
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-operational-modules/03-CONTEXT.md
@.planning/phases/03-operational-modules/03-RESEARCH.md
@.planning/phases/03-operational-modules/03-03-SUMMARY.md
@prisma/schema.prisma
@src/lib/prisma-tenant.ts
@src/lib/services/ticket-service.ts
@src/lib/casl/permissions.ts
@src/lib/__tests__/payment.test.ts (test pattern reference)
</context>
<tasks>
<task type="auto">
<name>Task 1: JobOrder Prisma model + migration</name>
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts</files>
<name>Task 1: JobOrder schema, migration, and tenant scoping</name>
<files>
prisma/schema.prisma
src/lib/prisma-tenant.ts
</files>
<action>
**New enums:**
- `JobOrderStatus { PENDING, IN_PROGRESS, COMPLETED, CANCELLED }`
- `JobType { INSTALLATION, REPAIR, MAINTENANCE, RELOCATION, DISCONNECTION, OTHER }`
1. Add enum to schema.prisma:
- `enum JobOrderStatus { PENDING IN_PROGRESS COMPLETED CANCELLED }`
**JobOrder model:**
- id (uuid PK), tenantId
- orderNumber (String) — auto-generated sequential per tenant, e.g., "JO-0001"
- ticketId (FK to Ticket) — parent ticket
- assignedToId (FK to User) — the technician
- jobType (JobType)
- description (String) — what needs to be done
- status (JobOrderStatus default PENDING)
- scheduledDate (DateTime?) — optional scheduled date
- startedAt (DateTime?) — when technician started work
- completedAt (DateTime?) — when work was completed
- outcomeNotes (String?) — technician fills in on completion
- cancelledAt (DateTime?)
- cancelReason (String?)
- createdById (FK to User) — staff who created the job order
- createdAt, updatedAt
- @@unique([tenantId, orderNumber])
- @@index([tenantId]), @@index([tenantId, assignedToId]), @@index([tenantId, status]), @@index([ticketId])
2. Add JobOrder model:
- id (uuid), tenantId
- orderNumber (String) — auto-generated JO-NNNN
- ticketId (String, FK to Ticket)
- jobType (String) — e.g., "Installation", "Repair", "Maintenance" (free-form, matches JobTypeRate in 03-05)
- description (String?) — specific instructions for this job
- assignedToId (String, FK to User — the technician)
- status (JobOrderStatus, default PENDING)
- scheduledDate (DateTime?) — when the job is scheduled
- startedAt (DateTime?) — when technician started work
- completedAt (DateTime?) — when job was completed
- outcomeNotes (String?) — technician's completion notes
- cancelledAt (DateTime?), cancelReason (String?)
- createdById (String, FK to User — staff who created the job order)
- createdAt, updatedAt
- Relations: ticket -> Ticket, assignedTo -> User, createdBy -> User
- @@unique([tenantId, orderNumber])
- @@index([tenantId]), @@index([tenantId, ticketId]), @@index([tenantId, assignedToId]), @@index([tenantId, status])
**Update relations:**
- Ticket: add `jobOrders JobOrder[]`
- User: add `assignedJobOrders JobOrder[] @relation("JobOrderAssignedTo")`, `createdJobOrders JobOrder[] @relation("JobOrderCreatedBy")`
3. Add reverse relations:
- Ticket: `jobOrders JobOrder[]`
- User: `assignedJobOrders JobOrder[] @relation("JobOrderAssignedTo")`, `createdJobOrders JobOrder[] @relation("JobOrderCreatedBy")`
**Add to TENANT_SCOPED_MODELS:** "jobOrder"
4. Run `npx prisma migrate dev --name add-job-orders`
Run `npx prisma migrate dev --name add-job-orders`
5. Add JobOrder to TENANT_SCOPED_MODELS in prisma-tenant.ts with FULL 12-operation extension block.
</action>
<verify>
- `npx prisma migrate dev` completes without errors
- `npx prisma generate` succeeds
- Schema has JobOrder model with correct enums and relations
- `npx prisma migrate dev` succeeds
- `npx tsc --noEmit` passes
- Grep prisma-tenant.ts confirms "jobOrder" in TENANT_SCOPED_MODELS
</verify>
<done>JobOrder model exists with status lifecycle, ticket relation (1:many), technician assignment, and job type classification. Migration applied.</done>
<done>JobOrder model exists with ticket relation, migration applied, tenant scoping configured.</done>
</task>
<task type="auto">
<name>Task 2: JobOrderService + API routes + integration tests</name>
<files>src/lib/services/job-order-service.ts, src/app/api/job-orders/route.ts, src/app/api/job-orders/[id]/route.ts, src/app/api/job-orders/[id]/status/route.ts, src/app/api/tickets/[id]/job-orders/route.ts, src/lib/__tests__/job-order-service.test.ts</files>
<name>Task 2: Job order service, ticket sync, API routes, and integration tests</name>
<files>
src/lib/services/job-order-service.ts
src/lib/services/ticket-service.ts
src/app/api/tickets/[id]/job-orders/route.ts
src/app/api/job-orders/route.ts
src/app/api/job-orders/[id]/route.ts
src/app/api/job-orders/[id]/status/route.ts
src/lib/__tests__/job-order-service.test.ts
</files>
<action>
**JobOrderService** (`src/lib/services/job-order-service.ts`):
- `createJobOrder(db, { ticketId, assignedToId, jobType, description, scheduledDate?, createdById })`:
1. Validate ticket exists and is not CLOSED
2. Validate assignedToId is a user with TECHNICIAN role
3. Auto-generate orderNumber (JO-NNNN per tenant)
4. Create job order with status PENDING
5. If ticket status is OPEN, auto-transition ticket to ASSIGNED (via TicketService.assignTicket with the first technician)
6. Return job order with ticket and technician info
1. Update src/lib/services/ticket-service.ts — add/export two helper functions:
- `checkTicketAutoResolve(tenantPrisma, ticketId)`:
Query all job orders for ticket where status != CANCELLED.
If count > 0 AND all have status COMPLETED, call resolveTicket (which is already idempotent).
If count == 0 (all cancelled), do NOT auto-resolve.
- `checkTicketRevertToOpen(tenantPrisma, ticketId)`:
Query all job orders for ticket.
If ALL are CANCELLED (none pending/in-progress/completed), and ticket.status is ASSIGNED, transition ticket to OPEN.
If ticket is already OPEN or has non-cancelled job orders, no-op.
- `updateJobOrder(db, jobOrderId, { description?, scheduledDate?, jobType? })` — update editable fields
2. Create src/lib/services/job-order-service.ts:
- Sequential number generation: `generateOrderNumber(tenantPrisma)` — JO-NNNN pattern (same as ticket numbering).
- `createJobOrder(tenantPrisma, tenantId, { ticketId, jobType, description?, assignedToId, scheduledDate?, createdById })`:
a. Validate ticket exists and is not CLOSED
b. Validate assignedToId user has TECHNICIAN role
c. Generate orderNumber
d. Create JobOrder with status PENDING
e. If ticket status is OPEN, auto-transition to ASSIGNED via transitionTicketStatus
f. Return job order
- `updateJobOrderStatus(tenantPrisma, tenantId, jobOrderId, { status, outcomeNotes?, cancelReason? })`:
Define VALID_JO_TRANSITIONS:
PENDING -> [IN_PROGRESS, CANCELLED]
IN_PROGRESS -> [COMPLETED, CANCELLED]
COMPLETED -> [] (terminal)
CANCELLED -> [] (terminal)
a. Validate transition
b. Update status with timestamps:
- IN_PROGRESS: set startedAt
- COMPLETED: set completedAt, require outcomeNotes (throw if missing)
- CANCELLED: set cancelledAt, cancelReason optional
c. After COMPLETED: call checkTicketAutoResolve(tenantPrisma, ticketId)
d. After CANCELLED: call checkTicketRevertToOpen(tenantPrisma, ticketId)
- `getJobOrder(tenantPrisma, jobOrderId)` — include ticket, assignedTo, createdBy
- `listJobOrders(tenantPrisma, { ticketId?, assignedToId?, status?, page?, limit? })` — filtered list
- `getMyJobOrders(tenantPrisma, technicianUserId, { status?, page?, limit? })` — convenience for technician self-service
- `updateStatus(db, jobOrderId, { status, outcomeNotes?, cancelReason? })`:
Status transitions:
- PENDING -> IN_PROGRESS: set startedAt
- PENDING -> CANCELLED: set cancelledAt, cancelReason
- IN_PROGRESS -> COMPLETED: set completedAt, outcomeNotes (required). Then call `checkTicketAutoResolve`.
- IN_PROGRESS -> CANCELLED: set cancelledAt, cancelReason
- All other transitions: throw error
3. Create API routes:
- POST /api/tickets/[id]/job-orders: withPermission("create", "JobOrder") -> createJobOrder (staff creates from ticket context; dynamic route pattern: ticketId from params)
- GET /api/job-orders: withPermission("read", "JobOrder") -> listJobOrders with query filters. For TECHNICIAN role: auto-filter to assignedToId = user.id
- GET /api/job-orders/[id]: withPermission("read", "JobOrder") -> getJobOrder
- PUT /api/job-orders/[id]: withPermission("update", "JobOrder") -> update job order metadata (description, scheduledDate)
- POST /api/job-orders/[id]/status: withPermission("update", "JobOrder") -> updateJobOrderStatus (body: { status, outcomeNotes?, cancelReason? })
- `checkTicketAutoResolve(db, ticketId)`:
1. Load all job orders for this ticket
2. If ALL non-cancelled job orders have status COMPLETED, auto-resolve the ticket via TicketService.resolveTicket
3. If there are only cancelled job orders (no completed ones), do NOT auto-resolve
- `reassignJobOrder(db, jobOrderId, newAssignedToId)` — reassign to different technician (only if PENDING or IN_PROGRESS)
- `getJobOrder(db, jobOrderId)` — get detail with ticket, subscriber, technician info
- `listJobOrders(db, filters)` — list with filters: assignedToId, status, jobType, ticketId, dateFrom, dateTo. Pagination. Sort by createdAt DESC.
- `getTechnicianJobOrders(db, technicianId, filters)` — convenience wrapper for technician self-service view
**API Routes:**
- `POST /api/tickets/[id]/job-orders` — create job order from ticket. ADMIN, OFFICE_STAFF. Body: { assignedToId, jobType, description, scheduledDate? }
- `GET /api/job-orders` — list job orders with filters. ADMIN, OFFICE_STAFF see all. TECHNICIAN sees assigned only.
- `GET /api/job-orders/[id]` — get job order detail
- `PUT /api/job-orders/[id]` — update job order fields. ADMIN, OFFICE_STAFF.
- `POST /api/job-orders/[id]/status` — update status. TECHNICIAN can update own (PENDING->IN_PROGRESS, IN_PROGRESS->COMPLETED). ADMIN, OFFICE_STAFF can do any valid transition.
Body: { status, outcomeNotes?, cancelReason? }
**Integration Tests** (`src/lib/__tests__/job-order-service.test.ts`):
- Create job order from ticket (auto-assigns ticket to ASSIGNED status)
- One ticket can have multiple job orders
- Status transitions: PENDING -> IN_PROGRESS -> COMPLETED (happy path)
- Completing last job order auto-resolves parent ticket
- Completing one of two job orders does NOT resolve ticket
- All job orders completed -> ticket auto-resolved -> staff closes ticket
- Cancelled job orders are excluded from auto-resolve check
- Cannot complete job order without outcomeNotes
- Invalid transitions rejected (e.g., COMPLETED -> IN_PROGRESS)
- Reassign job order to different technician
- Technician filter returns only their assigned orders
- Job order number sequential per tenant (JO-0001, JO-0002...)
- Cross-tenant isolation
4. Create src/lib/__tests__/job-order-service.test.ts:
- Setup: create tenant (auto-seeds categories), admin user, technician user (TECHNICIAN role), create subscriber, create ticket
- Test: createJobOrder succeeds, returns JO-0001
- Test: createJobOrder auto-transitions OPEN ticket to ASSIGNED
- Test: second job order gets JO-0002, ticket stays ASSIGNED
- Test: createJobOrder rejects non-TECHNICIAN assignee
- Test: createJobOrder rejects CLOSED ticket
- Test: updateJobOrderStatus PENDING -> IN_PROGRESS succeeds (sets startedAt)
- Test: updateJobOrderStatus IN_PROGRESS -> COMPLETED succeeds (sets completedAt, requires outcomeNotes)
- Test: COMPLETED without outcomeNotes throws
- Test: invalid transition (COMPLETED -> IN_PROGRESS) throws
- Test: auto-resolve: create 2 job orders on ticket, complete both -> ticket auto-resolves to RESOLVED
- Test: revert-to-open: create 1 job order, cancel it -> ticket reverts from ASSIGNED to OPEN
- Test: partial completion: 2 job orders, complete 1, cancel 1 -> ticket auto-resolves (all non-cancelled are completed)
- Test: all cancelled with none completed -> ticket reverts to OPEN, does NOT resolve
- Test: getMyJobOrders returns only technician's assigned orders
- Test: cross-tenant isolation
- Cleanup: jobOrders -> tickets -> ticketCategories -> subscribers -> servicePlans -> users -> tenant
</action>
<verify>
- `npx vitest run src/lib/__tests__/job-order-service.test.ts` — all tests pass
- `npx vitest run` — full suite passes (no regressions)
- `npx tsc --noEmit` passes
</verify>
<done>JobOrderService handles job order lifecycle with ticket auto-resolution sync. Technicians update their own work, staff manages assignments. All status transitions enforced. Integration tests prove the ticket-to-job-order-to-resolution flow.</done>
<done>Job orders can be created from tickets, assigned to technicians, progressed through lifecycle, auto-resolve and revert-to-open ticket sync works correctly, technicians can self-service their assigned orders, all tests pass.</done>
</task>
</tasks>
<verification>
- Create job order from ticket: ticket auto-transitions to ASSIGNED
- One ticket, multiple job orders: each tracks independently
- Status lifecycle: PENDING -> IN_PROGRESS -> COMPLETED with proper guards
- Completion requires outcomeNotes
- Auto-resolution: all non-cancelled job orders COMPLETED -> ticket RESOLVED
- Staff closes ticket (RESOLVED -> CLOSED) as separate manual step
- Technician sees only their assigned job orders
- Job order numbers sequential per tenant
- All existing tests pass (no regressions)
- `npx prisma migrate dev` succeeds
- `npx tsc --noEmit` passes
- `npx vitest run src/lib/__tests__/job-order-service.test.ts` — all green
- Ticket auto-resolve works when all non-cancelled jobs complete
- Ticket revert-to-open works when all jobs cancelled
- Technician can only see/update their own job orders
</verification>
<success_criteria>
- JobOrder model with status lifecycle, ticket 1:many relation, and technician assignment
- JobOrderService handles creation from ticket, status transitions, auto-resolution sync
- Technicians can update their own job orders (view assigned, update status)
- Ticket auto-resolves when all non-cancelled job orders complete
- API routes enforce RBAC (staff creates, technician updates own)
- Integration tests prove full ticket-to-job-to-resolution workflow
- JobOrder model with 1:many ticket relation
- Sequential numbering (JO-NNNN)
- Status transitions enforced by guard map
- Ticket auto-transitions: OPEN -> ASSIGNED on first job, auto-resolve on all complete, revert to OPEN on all cancelled
- Technicians can update their assigned job orders
- Completion requires outcome notes
- Cross-tenant isolation verified
- All integration tests pass
</success_criteria>
<output>

View File

@@ -20,41 +20,42 @@ autonomous: true
must_haves:
truths:
- "Admin can create technician profiles with contact info, skills, and assigned zone"
- "Admin can set flat compensation rates per job type at the tenant level"
- "Technician can have base monthly salary, per-job bonuses, or hybrid (both optional)"
- "Only completed job orders count toward per-job compensation"
- "System generates compensation summary per technician per period: total jobs, base salary, job bonuses, total"
- "Admin can create technician profiles with contact info, skills, zone, and compensation model"
- "Admin can configure flat per-job compensation rates by job type at tenant level"
- "System supports hybrid compensation: base salary PLUS per-job bonuses (both optional)"
- "Only COMPLETED job orders count toward per-job compensation"
- "Missing job type rate defaults to 0 bonus (not error)"
- "Compensation summary shows per-technician totals: base salary, job bonuses, total"
- "Compensation summary supports drill-down to job-by-job detail"
artifacts:
- path: "prisma/schema.prisma"
provides: "TechnicianProfile, JobTypeRate models"
provides: "TechnicianProfile and JobTypeRate models with compensation enums"
contains: "model TechnicianProfile"
- path: "src/lib/services/technician-service.ts"
provides: "Technician profile CRUD with zone and compensation config"
exports: ["TechnicianService"]
provides: "Technician profile CRUD"
exports: ["createTechnicianProfile", "updateTechnicianProfile", "getTechnicianProfile", "listTechnicians"]
- path: "src/lib/services/compensation-service.ts"
provides: "Period compensation calculation and summary report"
exports: ["CompensationService"]
provides: "Compensation calculation and summary report"
exports: ["getCompensationSummary", "getTechnicianCompensationDetail"]
- path: "src/lib/__tests__/compensation-service.test.ts"
provides: "Integration tests for compensation calculation across models"
min_lines: 80
provides: "Tests for all compensation models (per-job, salary, hybrid) and edge cases"
min_lines: 120
key_links:
- from: "src/lib/services/compensation-service.ts"
to: "prisma/schema.prisma"
via: "Queries completed JobOrders by technician and joins with JobTypeRate for per-job amounts"
via: "queries JobOrder (COMPLETED, in period) and JobTypeRate for rate lookup"
pattern: "jobOrder\\.findMany|jobTypeRate\\.findMany"
- from: "src/lib/services/compensation-service.ts"
to: "src/lib/services/technician-service.ts"
via: "Reads TechnicianProfile for base salary and compensation model"
pattern: "technicianProfile|monthlySalary"
via: "reads TechnicianProfile for compensation model and base salary"
pattern: "technicianProfile"
---
<objective>
Build technician management with hybrid compensation model.
Create the technician management system: TechnicianProfile model (skills, zone, compensation model), JobTypeRate model (per-job rates by type), CompensationService (hybrid salary + per-job calculation), and compensation summary report with drill-down.
Purpose: Technicians need profiles with skills and zone assignments for proper job routing. The compensation system supports the real-world ISP pattern where technicians can be paid per-job, monthly salary, or a hybrid of both. The compensation summary report gives management visibility into technician costs.
Output: TechnicianProfile model, JobTypeRate model (tenant-level rates), TechnicianService for profile management, CompensationService for period calculation, compensation summary report API, integration tests.
Purpose: Enables admin to track technician compensation across per-job, salary, and hybrid models. Only completed jobs count, missing rates default to zero, and the summary provides both overview and detail views.
Output: TechnicianProfile/JobTypeRate models, technician-service.ts, compensation-service.ts, 6 API routes, integration tests.
</objective>
<execution_context>
@@ -67,146 +68,160 @@ Output: TechnicianProfile model, JobTypeRate model (tenant-level rates), Technic
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-operational-modules/03-CONTEXT.md
@.planning/phases/03-operational-modules/03-RESEARCH.md
@.planning/phases/03-operational-modules/03-04-SUMMARY.md
@prisma/schema.prisma
@src/lib/prisma-tenant.ts
@src/lib/services/job-order-service.ts
@src/lib/__tests__/payment.test.ts (test pattern reference)
</context>
<tasks>
<task type="auto">
<name>Task 1: TechnicianProfile and JobTypeRate Prisma models + migration</name>
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts</files>
<name>Task 1: TechnicianProfile/JobTypeRate schema, migration, tenant scoping</name>
<files>
prisma/schema.prisma
src/lib/prisma-tenant.ts
</files>
<action>
**New enum:**
- `CompensationModel { PER_JOB, SALARY, HYBRID }`
1. Add enum to schema.prisma:
- `enum CompensationModel { PER_JOB SALARY HYBRID }`
**TechnicianProfile model** — extends User with technician-specific data:
- id (uuid PK), tenantId
- userId (FK to User, unique) — one profile per user
- phone (String?)
- skills (String[]) — array of skill tags, e.g., ["fiber splicing", "router config", "installation"]
- zoneId (String? FK to Zone) — primary assigned zone
- compensationModel (CompensationModel default PER_JOB)
- monthlySalary (Decimal? 10,2) — null if pure per-job
- isActive (Boolean default true)
- createdAt, updatedAt
- @@unique([tenantId, userId]) — one profile per user per tenant
- @@index([tenantId])
2. Add TechnicianProfile model:
- id (uuid), tenantId
- userId (String, FK to User — the technician user, @@unique with tenantId)
- phone (String?)
- skills (String[]) — PostgreSQL array, e.g., ["Installation", "Repair", "Fiber Splicing"]
- zoneId (String?, FK to Zone — primary assigned zone)
- compensationModel (CompensationModel, default PER_JOB)
- monthlySalary (Decimal? @db.Decimal(10,2)) — null for PER_JOB model, set for SALARY/HYBRID
- isActive (Boolean, default true)
- createdAt, updatedAt
- Relations: user -> User, zone -> Zone
- @@unique([tenantId, userId]) — one profile per user per tenant
- @@index([tenantId])
**JobTypeRate model** tenant-level flat rates per job type:
- id (uuid PK), tenantId
- jobType (JobType enum — reuse from 03-04)
- rate (Decimal 10,2) — flat amount per completed job of this type (e.g., 500.00 for INSTALLATION)
- description (String?) — e.g., "Standard installation rate"
- isActive (Boolean default true)
- createdAt, updatedAt
- @@unique([tenantId, jobType]) — one rate per job type per tenant
- @@index([tenantId])
3. Add JobTypeRate model (tenant-level rates, not per-technician):
- id (uuid), tenantId
- jobType (String) — e.g., "Installation", "Repair" — matches JobOrder.jobType
- rate (Decimal @db.Decimal(10,2)) — flat rate per completed job of this type
- description (String?)
- isActive (Boolean, default true)
- createdAt, updatedAt
- @@unique([tenantId, jobType])
- @@index([tenantId])
**Update relations:**
- User: add `technicianProfile TechnicianProfile?`
- Zone: add `technicianProfiles TechnicianProfile[]`
4. Add reverse relations:
- User: `technicianProfile TechnicianProfile?`
- Zone: `technicianProfiles TechnicianProfile[]`
**Add to TENANT_SCOPED_MODELS:** "technicianProfile", "jobTypeRate"
5. Run `npx prisma migrate dev --name add-technician-profiles`
Run `npx prisma migrate dev --name add-technician-compensation`
6. Add TechnicianProfile and JobTypeRate to TENANT_SCOPED_MODELS in prisma-tenant.ts with FULL 12-operation extension blocks.
</action>
<verify>
- `npx prisma migrate dev` completes without errors
- `npx prisma generate` succeeds
- Schema has TechnicianProfile and JobTypeRate models
- `npx prisma migrate dev` succeeds
- `npx tsc --noEmit` passes
- Grep prisma-tenant.ts confirms "technicianProfile" and "jobTypeRate" in TENANT_SCOPED_MODELS
</verify>
<done>TechnicianProfile and JobTypeRate models exist with proper relations, compensation model enum, and migration applied.</done>
<done>TechnicianProfile and JobTypeRate models exist, migration applied, tenant scoping configured.</done>
</task>
<task type="auto">
<name>Task 2: TechnicianService, CompensationService + APIs + tests</name>
<files>src/lib/services/technician-service.ts, src/lib/services/compensation-service.ts, src/app/api/technicians/route.ts, src/app/api/technicians/[id]/route.ts, src/app/api/technicians/[id]/compensation/route.ts, src/app/api/job-type-rates/route.ts, src/app/api/job-type-rates/[id]/route.ts, src/app/api/reports/compensation/route.ts, src/lib/__tests__/compensation-service.test.ts</files>
<name>Task 2: Technician service, compensation service, APIs, and integration tests</name>
<files>
src/lib/services/technician-service.ts
src/lib/services/compensation-service.ts
src/app/api/technicians/route.ts
src/app/api/technicians/[id]/route.ts
src/app/api/technicians/[id]/compensation/route.ts
src/app/api/job-type-rates/route.ts
src/app/api/job-type-rates/[id]/route.ts
src/app/api/reports/compensation/route.ts
src/lib/__tests__/compensation-service.test.ts
</files>
<action>
**TechnicianService** (`src/lib/services/technician-service.ts`):
- `createProfile(db, { userId, phone?, skills?, zoneId?, compensationModel, monthlySalary? })`:
1. Validate user has TECHNICIAN role
2. Validate monthlySalary is set if compensationModel is SALARY or HYBRID
3. Create TechnicianProfile
4. Return profile with user info
- `updateProfile(db, profileId, { phone?, skills?, zoneId?, compensationModel?, monthlySalary?, isActive? })`
- `getProfile(db, userId)` — get profile by user ID
- `listTechnicians(db, filters?)` — list all technician profiles for tenant. Filter by zoneId, isActive, compensationModel.
- `setJobTypeRate(db, { jobType, rate, description? })` — create or update rate for a job type (upsert on @@unique)
- `listJobTypeRates(db)` — list all job type rates for tenant
- `deactivateJobTypeRate(db, rateId)` — soft-delete
1. Create src/lib/services/technician-service.ts:
- `createTechnicianProfile(tenantPrisma, tenantId, { userId, phone?, skills?, zoneId?, compensationModel?, monthlySalary? })`:
- Validate user exists and has TECHNICIAN role
- Create TechnicianProfile
- `updateTechnicianProfile(tenantPrisma, profileId, { phone?, skills?, zoneId?, compensationModel?, monthlySalary?, isActive? })`:
- If compensationModel changes to PER_JOB, set monthlySalary to null
- If compensationModel is SALARY or HYBRID and monthlySalary is not provided, throw
- `getTechnicianProfile(tenantPrisma, profileId)` — include user, zone
- `getTechnicianProfileByUserId(tenantPrisma, userId)` — lookup by user
- `listTechnicians(tenantPrisma, { activeOnly?, zoneId? })` — list with optional filters, include user name, zone, compensation model
**CompensationService** (`src/lib/services/compensation-service.ts`):
- `calculateTechnicianCompensation(db, technicianUserId, { periodStart, periodEnd })`:
1. Load technician profile (for compensationModel and monthlySalary)
2. Query completed job orders assigned to this technician within date range
3. For each completed job order, look up JobTypeRate for the job type
4. Calculate:
- jobCount: number of completed jobs
- jobBonusTotal: sum of (rate for each job type * count of that type)
- baseSalary: monthlySalary if SALARY or HYBRID model, else 0
- totalCompensation: baseSalary + jobBonusTotal
5. Return: { technicianId, technicianName, compensationModel, baseSalary, jobCount, jobBonusTotal, totalCompensation, jobDetails: [{ jobOrderId, jobType, completedAt, rate }] }
2. Create src/lib/services/compensation-service.ts:
- `getCompensationSummary(tenantPrisma, { periodStart: Date, periodEnd: Date, technicianProfileId? })`:
a. Load all active technician profiles (or specific one if filtered)
b. For each technician:
- Load COMPLETED job orders where completedAt is between periodStart and periodEnd AND assignedToId = profile.userId
- Load all JobTypeRates for the tenant. Build rateMap: Map<string, Decimal>
- Calculate jobBonusTotal: for each completed job, look up rateMap.get(job.jobType) ?? 0 (missing rate = 0, not error per RESEARCH pitfall 6)
- Calculate baseSalary: if compensationModel is SALARY or HYBRID, use profile.monthlySalary ?? 0. If PER_JOB, baseSalary = 0.
- totalCompensation = baseSalary + jobBonusTotal
- completedJobCount = number of completed jobs
c. Return array of { technicianProfileId, technicianName, compensationModel, baseSalary, jobBonusTotal, totalCompensation, completedJobCount }
- `getCompensationSummary(db, { periodStart, periodEnd, technicianId? })`:
1. If technicianId provided, calculate for one technician
2. Otherwise, calculate for all active technicians
3. Return array of per-technician summaries (same structure as above)
4. Include grand totals: totalJobs, totalBaseSalary, totalBonuses, grandTotal
- `getTechnicianCompensationDetail(tenantPrisma, { technicianProfileId, periodStart: Date, periodEnd: Date })`:
a. Load technician profile with user
b. Load completed job orders in period for this technician
c. Load rate map
d. Return { profile info, baseSalary, jobs: [{ orderNumber, jobType, completedAt, rate (from map, 0 if missing), ticketNumber }], jobBonusTotal, totalCompensation }
**API Routes:**
- `GET /api/technicians` list technician profiles. ADMIN, OFFICE_STAFF.
- `POST /api/technicians` — create profile. ADMIN only. Body: { userId, phone?, skills?, zoneId?, compensationModel, monthlySalary? }
- `GET /api/technicians/[id]` — get profile detail. ADMIN, OFFICE_STAFF, TECHNICIAN (own only).
- `PUT /api/technicians/[id]` — update profile. ADMIN only.
- `GET /api/technicians/[id]/compensation` — get compensation for a technician for a period. ADMIN. Query params: periodStart, periodEnd.
- `GET /api/job-type-rates` list rates. ADMIN.
- `POST /api/job-type-rates` — set rate (upsert). ADMIN. Body: { jobType, rate, description? }
- `PUT /api/job-type-rates/[id]` update rate. ADMIN.
- `GET /api/reports/compensation` — compensation summary for all technicians. ADMIN. Query params: periodStart, periodEnd, technicianId?
3. Create API routes:
- GET /api/technicians: withPermission("read", "User") -> listTechnicians (admin/staff)
- POST /api/technicians: withPermission("manage", "User") -> createTechnicianProfile (admin only)
- GET /api/technicians/[id]: withPermission("read", "User") -> getTechnicianProfile
- PUT /api/technicians/[id]: withPermission("manage", "User") -> updateTechnicianProfile
- GET /api/technicians/[id]/compensation: withPermission("read", "Report") -> getTechnicianCompensationDetail (query: periodStart, periodEnd)
- GET /api/job-type-rates: withPermission("read", "Report") -> list all rates
- POST /api/job-type-rates: withPermission("manage", "User") -> create rate (admin)
- PUT /api/job-type-rates/[id]: withPermission("manage", "User") -> update rate
- GET /api/reports/compensation: withPermission("read", "Report") -> getCompensationSummary (query: periodStart, periodEnd, technicianProfileId?)
**Integration Tests** (`src/lib/__tests__/compensation-service.test.ts`):
- Create technician profile (validates TECHNICIAN role)
- PER_JOB model: 3 completed installations at 500 each = 1500 total
- SALARY model: monthly salary of 15000, job count tracked but no per-job bonus
- HYBRID model: 15000 salary + 3 installations at 500 = 16500 total
- Only COMPLETED job orders count (PENDING, IN_PROGRESS, CANCELLED excluded)
- Jobs outside date range excluded
- Job type with no configured rate: 0 bonus for that job (not an error)
- Compensation summary across multiple technicians with grand totals
- Drill-down detail: each job with type, date, rate
- Job type rate CRUD (create, update, upsert by jobType)
4. Create src/lib/__tests__/compensation-service.test.ts:
- Setup: create tenant, admin user, 2 technician users (tech1: HYBRID model with monthlySalary=10000, tech2: PER_JOB model), create technician profiles, create job type rates (Installation=500, Repair=300), create subscriber, create ticket, create job orders assigned to technicians, complete some job orders with different job types
- Test: PER_JOB technician — compensation = sum of rates for completed jobs only
- Test: SALARY technician — compensation = monthlySalary only (no job bonus)
- Test: HYBRID technician — compensation = monthlySalary + sum of rates
- Test: missing job type rate defaults to 0 (not error) — create completed job with job type "Custom" that has no rate entry
- Test: only COMPLETED jobs count — PENDING and IN_PROGRESS jobs excluded
- Test: CANCELLED jobs excluded from compensation
- Test: date range filter — only jobs completed within period
- Test: getCompensationSummary returns all technicians with correct totals
- Test: getTechnicianCompensationDetail returns job-by-job breakdown
- Test: technician with no completed jobs in period shows 0 job bonus
- Cleanup: jobOrders -> tickets -> ticketCategories -> jobTypeRates -> technicianProfiles -> zones -> subscribers -> servicePlans -> users -> tenant
</action>
<verify>
- `npx vitest run src/lib/__tests__/compensation-service.test.ts` — all tests pass
- `npx vitest run` — full suite passes (no regressions)
- `npx tsc --noEmit` passes
</verify>
<done>TechnicianService handles profiles with compensation config. CompensationService calculates per-period compensation for per-job, salary, and hybrid models. Summary report shows per-technician totals with job-by-job drill-down. All models tested.</done>
<done>Technician profiles created with compensation model config, job type rates configurable at tenant level, compensation calculation correct for PER_JOB/SALARY/HYBRID models, missing rates default to 0, only completed jobs count, summary and detail reports work, all tests pass.</done>
</task>
</tasks>
<verification>
- Technician profiles: CRUD with skills, zone, compensation model
- Job type rates: admin sets flat rates per job type
- PER_JOB compensation: sum of rates for completed jobs only
- SALARY compensation: monthly base only
- HYBRID compensation: base + per-job bonuses
- Period summary: per-technician totals + grand totals
- Drill-down: per-job detail (type, date, rate)
- Only completed jobs count — no partial credit
- All existing tests pass (no regressions)
- `npx prisma migrate dev` succeeds
- `npx tsc --noEmit` passes
- `npx vitest run src/lib/__tests__/compensation-service.test.ts` — all green
- All three compensation models produce correct results
- Missing rate edge case handled (0, not error)
</verification>
<success_criteria>
- TechnicianProfile and JobTypeRate models with migration applied
- TechnicianService manages profiles with hybrid compensation config
- CompensationService correctly calculates for PER_JOB, SALARY, and HYBRID models
- Period compensation summary with drill-down to individual jobs
- Job type rates are tenant-level and admin-configurable
- Integration tests prove all three compensation models with correct calculations
- Full test suite passes with no regressions
- TechnicianProfile with compensation model (PER_JOB, SALARY, HYBRID)
- JobTypeRate for tenant-level per-job rates
- CompensationService correctly calculates all three models
- Missing job type rate = 0 bonus (not error)
- Only COMPLETED jobs in date range count
- Summary and detail endpoints work
- Cross-tenant isolation
- All integration tests pass
</success_criteria>
<output>