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:
@@ -8,6 +8,8 @@ files_modified:
|
|||||||
- prisma/schema.prisma
|
- prisma/schema.prisma
|
||||||
- src/lib/prisma-tenant.ts
|
- src/lib/prisma-tenant.ts
|
||||||
- src/lib/services/zone-service.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/route.ts
|
||||||
- src/app/api/zones/[id]/route.ts
|
- src/app/api/zones/[id]/route.ts
|
||||||
- src/app/api/zones/[id]/subscribers/route.ts
|
- src/app/api/zones/[id]/subscribers/route.ts
|
||||||
@@ -17,40 +19,44 @@ autonomous: true
|
|||||||
|
|
||||||
must_haves:
|
must_haves:
|
||||||
truths:
|
truths:
|
||||||
- "Admin can create, update, and deactivate zones for their tenant"
|
- "Admin can create, read, update zones with name and description"
|
||||||
- "Admin can assign subscribers to a zone"
|
- "Admin can assign subscribers to zones via zoneId FK"
|
||||||
- "Admin can assign a collector user to a zone"
|
- "Admin can assign collectors to zones via ZoneAssignment join"
|
||||||
- "A collector can only see subscribers assigned to their zone(s)"
|
- "Collector can only query subscribers within their assigned zones"
|
||||||
|
- "Zone data is tenant-scoped — Tenant B cannot see Tenant A zones"
|
||||||
artifacts:
|
artifacts:
|
||||||
- path: "prisma/schema.prisma"
|
- 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"
|
contains: "model Zone"
|
||||||
- path: "src/lib/services/zone-service.ts"
|
- path: "src/lib/services/zone-service.ts"
|
||||||
provides: "Zone CRUD, subscriber zone assignment, collector zone assignment, getCollectorSubscribers"
|
provides: "Zone CRUD, subscriber assignment, collector zone scoping"
|
||||||
exports: ["ZoneService"]
|
exports: ["createZone", "updateZone", "listZones", "assignSubscriberToZone", "getCollectorSubscribers"]
|
||||||
- path: "src/app/api/zones/route.ts"
|
- path: "src/lib/prisma-tenant.ts"
|
||||||
provides: "GET list zones, POST create zone"
|
provides: "Tenant-scoped query blocks for Zone and ZoneAssignment"
|
||||||
exports: ["GET", "POST"]
|
contains: "zone"
|
||||||
- path: "src/lib/__tests__/zone-service.test.ts"
|
- path: "src/lib/__tests__/zone-service.test.ts"
|
||||||
provides: "Integration tests for zone CRUD, assignment, collector scoping"
|
provides: "Integration tests for zone CRUD, assignment, collector scoping"
|
||||||
min_lines: 80
|
min_lines: 100
|
||||||
key_links:
|
key_links:
|
||||||
- from: "src/lib/services/zone-service.ts"
|
- from: "src/lib/services/zone-service.ts"
|
||||||
to: "prisma/schema.prisma"
|
to: "prisma/schema.prisma"
|
||||||
via: "Prisma client queries on Zone and ZoneAssignment"
|
via: "tenantPrisma.zone and tenantPrisma.zoneAssignment queries"
|
||||||
pattern: "prisma\\.zone\\."
|
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"
|
- from: "src/app/api/collectors/[id]/subscribers/route.ts"
|
||||||
to: "src/lib/services/zone-service.ts"
|
to: "src/lib/services/zone-service.ts"
|
||||||
via: "getCollectorSubscribers returns only zone-scoped subscribers"
|
via: "getCollectorSubscribers for zone-scoped subscriber list"
|
||||||
pattern: "getCollectorSubscribers"
|
pattern: "getCollectorSubscribers"
|
||||||
---
|
---
|
||||||
|
|
||||||
<objective>
|
<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.
|
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.
|
||||||
Output: Zone Prisma model, zone CRUD service and APIs, collector-to-zone and subscriber-to-zone assignment, scoped subscriber list for collectors, integration tests.
|
|
||||||
</objective>
|
</objective>
|
||||||
|
|
||||||
<execution_context>
|
<execution_context>
|
||||||
@@ -63,123 +69,136 @@ Output: Zone Prisma model, zone CRUD service and APIs, collector-to-zone and sub
|
|||||||
@.planning/ROADMAP.md
|
@.planning/ROADMAP.md
|
||||||
@.planning/STATE.md
|
@.planning/STATE.md
|
||||||
@.planning/phases/03-operational-modules/03-CONTEXT.md
|
@.planning/phases/03-operational-modules/03-CONTEXT.md
|
||||||
|
@.planning/phases/03-operational-modules/03-RESEARCH.md
|
||||||
@prisma/schema.prisma
|
@prisma/schema.prisma
|
||||||
@src/lib/prisma-tenant.ts
|
@src/lib/prisma-tenant.ts
|
||||||
@src/lib/services/subscriber-service.ts
|
@src/lib/casl/types.ts
|
||||||
@src/lib/middleware/with-permission.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>
|
</context>
|
||||||
|
|
||||||
<tasks>
|
<tasks>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>Task 1: Zone and ZoneAssignment Prisma models + migration</name>
|
<name>Task 1: Zone schema, migration, and tenant scoping</name>
|
||||||
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts</files>
|
<files>
|
||||||
|
prisma/schema.prisma
|
||||||
|
src/lib/prisma-tenant.ts
|
||||||
|
src/lib/casl/types.ts
|
||||||
|
src/lib/casl/permissions.ts
|
||||||
|
</files>
|
||||||
<action>
|
<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:**
|
2. Add ZoneAssignment model (collector-to-zone join):
|
||||||
- id (uuid PK), tenantId, name (String), description (String?), isActive (Boolean default true)
|
- id (uuid), tenantId, userId (String — the collector user), zoneId (String — FK to Zone)
|
||||||
- createdAt, updatedAt
|
- Relations: user -> User, zone -> Zone
|
||||||
- @@unique([tenantId, name]) — zone names unique per tenant
|
- @@unique([tenantId, userId, zoneId]), @@index([tenantId]), @@index([userId]), @@index([zoneId])
|
||||||
- @@index([tenantId])
|
|
||||||
- Relation: subscribers Subscriber[] (via Subscriber.zoneId — update Subscriber to add zoneId optional FK)
|
|
||||||
- Relation: assignments ZoneAssignment[]
|
|
||||||
|
|
||||||
2. **ZoneAssignment model:**
|
3. Replace Subscriber.zone String? with Subscriber.zoneId String? (FK to Zone):
|
||||||
- id (uuid PK), tenantId, zoneId (FK to Zone), userId (FK to User — the collector)
|
- Remove `zone String?` field
|
||||||
- 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:
|
|
||||||
- Add `zoneId String?` and `zone Zone? @relation(fields: [zoneId], references: [id])`
|
- 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])
|
- Add @@index([tenantId, zoneId])
|
||||||
|
|
||||||
4. **Update User model:**
|
4. Add reverse relations on Zone: `subscribers Subscriber[]`, `assignments ZoneAssignment[]`
|
||||||
- Add relation: `zoneAssignments 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>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
- `npx prisma migrate dev` completes without errors
|
- `npx prisma migrate dev` succeeds with no errors
|
||||||
- `npx prisma generate` succeeds
|
- `npx tsc --noEmit` passes (no TypeScript errors)
|
||||||
- Schema has Zone, ZoneAssignment models
|
- Grep prisma-tenant.ts confirms both "zone" and "zoneAssignment" appear in TENANT_SCOPED_MODELS
|
||||||
- Subscriber has zoneId FK instead of zone String
|
- Grep types.ts confirms "Zone" in AppSubjects
|
||||||
</verify>
|
</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>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>Task 2: ZoneService + API routes + integration tests</name>
|
<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>
|
<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>
|
<action>
|
||||||
**ZoneService** (`src/lib/services/zone-service.ts`):
|
1. Create src/lib/services/zone-service.ts with pure functions (tenantPrisma as first arg, tenantId as second for transactions):
|
||||||
- `createZone(db, { name, description })` — creates zone, returns zone
|
- `createZone(tenantPrisma, tenantId, { name, description })` — create zone, return zone
|
||||||
- `updateZone(db, zoneId, { name?, description?, isActive? })` — updates zone
|
- `updateZone(tenantPrisma, zoneId, { name?, description?, isActive? })` — update zone
|
||||||
- `listZones(db)` — returns all zones for tenant (active and inactive)
|
- `listZones(tenantPrisma)` — return all zones with subscriber count and assigned collector count
|
||||||
- `assignSubscriberToZone(db, subscriberId, zoneId)` — updates subscriber.zoneId
|
- `getZone(tenantPrisma, zoneId)` — single zone with relations
|
||||||
- `removeSubscriberFromZone(db, subscriberId)` — sets subscriber.zoneId to null
|
- `assignSubscriberToZone(tenantPrisma, subscriberId, zoneId)` — update subscriber.zoneId
|
||||||
- `assignCollectorToZone(db, userId, zoneId)` — creates ZoneAssignment (validates user has COLLECTOR role)
|
- `removeSubscriberFromZone(tenantPrisma, subscriberId)` — set subscriber.zoneId to null
|
||||||
- `removeCollectorFromZone(db, userId, zoneId)` — deletes ZoneAssignment
|
- `assignCollectorToZone(tenantPrisma, tenantId, userId, zoneId)` — create ZoneAssignment (validate user has COLLECTOR role)
|
||||||
- `getCollectorZones(db, userId)` — returns zones assigned to a collector
|
- `removeCollectorFromZone(tenantPrisma, tenantId, userId, zoneId)` — delete ZoneAssignment
|
||||||
- `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.
|
- `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).
|
||||||
- `getZoneSubscribers(db, zoneId)` — returns subscribers in a specific zone
|
- `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:**
|
Use the dynamic route handler pattern from 02-04: `export async function GET(req, { params }) { return withPermission(...)(async (req, { user }) => { const { id } = params; ... })(req); }`
|
||||||
- `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 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`):
|
Follow the exact test setup pattern from payment.test.ts — use TS = Date.now() suffix, create via raw prisma for setup, test via tenantPrisma.
|
||||||
- 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.
|
|
||||||
</action>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
- `npx vitest run src/lib/__tests__/zone-service.test.ts` — all tests pass
|
- `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>
|
</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>
|
</task>
|
||||||
|
|
||||||
</tasks>
|
</tasks>
|
||||||
|
|
||||||
<verification>
|
<verification>
|
||||||
- Zone CRUD: create, update, deactivate zones
|
- `npx prisma migrate dev` succeeds (schema valid)
|
||||||
- Subscriber assignment: assign/remove subscriber to/from zone
|
- `npx tsc --noEmit` passes (no TypeScript errors)
|
||||||
- Collector assignment: assign/remove collector to/from zone
|
- `npx vitest run src/lib/__tests__/zone-service.test.ts` — all tests green
|
||||||
- Collector scoping: collector sees only their zone's subscribers
|
- Zone CRUD, subscriber assignment, collector scoping, and tenant isolation verified
|
||||||
- Tenant isolation: zones are tenant-scoped
|
|
||||||
- All existing tests still pass (no regressions from Subscriber.zone -> zoneId migration)
|
|
||||||
</verification>
|
</verification>
|
||||||
|
|
||||||
<success_criteria>
|
<success_criteria>
|
||||||
- Zone and ZoneAssignment models in Prisma schema with migration applied
|
- Zone and ZoneAssignment models exist with proper tenant scoping
|
||||||
- ZoneService handles zone CRUD, subscriber assignment, collector assignment, and scoped queries
|
- Subscriber.zone String? replaced with Subscriber.zoneId FK
|
||||||
- API routes enforce RBAC (admin creates zones, collectors query their subscribers)
|
- Zone CRUD API routes work with withPermission enforcement
|
||||||
- Integration tests prove collector can only see subscribers in their assigned zones
|
- Collectors can only query subscribers in their assigned zones
|
||||||
- Full test suite passes with no regressions
|
- Cross-tenant isolation proven by test
|
||||||
|
- All integration tests pass
|
||||||
</success_criteria>
|
</success_criteria>
|
||||||
|
|
||||||
<output>
|
<output>
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ depends_on: ["03-01"]
|
|||||||
files_modified:
|
files_modified:
|
||||||
- prisma/schema.prisma
|
- prisma/schema.prisma
|
||||||
- src/lib/prisma-tenant.ts
|
- src/lib/prisma-tenant.ts
|
||||||
|
- src/lib/accounting/chart-of-accounts.ts
|
||||||
- src/lib/services/collector-service.ts
|
- src/lib/services/collector-service.ts
|
||||||
- src/lib/services/remittance-service.ts
|
- src/lib/services/remittance-service.ts
|
||||||
- src/lib/services/collection-report-service.ts
|
- src/lib/services/collection-report-service.ts
|
||||||
- src/app/api/collections/route.ts
|
- src/app/api/collections/route.ts
|
||||||
- src/app/api/collections/[id]/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/route.ts
|
||||||
- src/app/api/remittances/[id]/verify/route.ts
|
- src/app/api/remittances/[id]/verify/route.ts
|
||||||
- src/app/api/reports/collections/route.ts
|
- src/app/api/reports/collections/route.ts
|
||||||
@@ -21,45 +23,56 @@ autonomous: true
|
|||||||
|
|
||||||
must_haves:
|
must_haves:
|
||||||
truths:
|
truths:
|
||||||
- "A collector can log a cash payment against a subscriber in the field and the system applies FIFO allocation to outstanding invoices"
|
- "Collector can log a cash collection against a subscriber (lump sum, FIFO allocation)"
|
||||||
- "Total collected and total remitted per collector are derived from the transaction log — no stored balance field"
|
- "Collection creates JE: DR 1030 Cash in Transit, CR 1100 AR"
|
||||||
- "Office staff can verify a remittance by entering their own counted total; variance is recorded but does not block completion"
|
- "Office staff can verify a remittance by entering their counted total"
|
||||||
- "Verified remittance creates a double-entry journal entry (DR Cash on Hand, CR Cash in Transit)"
|
- "Remittance verification creates JE: DR 1010 Cash on Hand, CR 1030 Cash in Transit"
|
||||||
- "Daily collection summary shows totals per collector: collected, remitted, variance, number of collections"
|
- "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:
|
artifacts:
|
||||||
- path: "prisma/schema.prisma"
|
- path: "prisma/schema.prisma"
|
||||||
provides: "Collection and Remittance models"
|
provides: "Collection and Remittance models"
|
||||||
contains: "model Collection"
|
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"
|
- path: "src/lib/services/collector-service.ts"
|
||||||
provides: "Field collection logging with FIFO allocation reusing PaymentService pattern"
|
provides: "Collection recording with FIFO allocation and zone enforcement"
|
||||||
exports: ["CollectorService"]
|
exports: ["recordCollection", "voidCollection", "getCollectionHistory"]
|
||||||
- path: "src/lib/services/remittance-service.ts"
|
- path: "src/lib/services/remittance-service.ts"
|
||||||
provides: "Remittance creation and two-party verification with JE posting"
|
provides: "Remittance creation and verification with JE"
|
||||||
exports: ["RemittanceService"]
|
exports: ["createRemittance", "verifyRemittance"]
|
||||||
- path: "src/lib/services/collection-report-service.ts"
|
- path: "src/lib/services/collection-report-service.ts"
|
||||||
provides: "Daily collection summary per collector"
|
provides: "Daily collection summary report"
|
||||||
exports: ["CollectionReportService"]
|
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:
|
key_links:
|
||||||
- from: "src/lib/services/collector-service.ts"
|
- from: "src/lib/services/collector-service.ts"
|
||||||
to: "src/lib/services/payment-service.ts"
|
to: "src/lib/accounting/journal-entry-service.ts"
|
||||||
via: "Reuses FIFO allocation pattern for invoice payment"
|
via: "JournalEntryService.createEntry for collection JE (DR 1030, CR 1100)"
|
||||||
pattern: "PaymentService|recordPayment"
|
pattern: "JournalEntryService\\.createEntry"
|
||||||
- from: "src/lib/services/remittance-service.ts"
|
- from: "src/lib/services/remittance-service.ts"
|
||||||
to: "src/lib/accounting/journal-entry-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)"
|
via: "JournalEntryService.createEntry for remittance verification JE (DR 1010, CR 1030)"
|
||||||
pattern: "JournalEntryService|createEntry"
|
pattern: "JournalEntryService\\.createEntry"
|
||||||
- from: "src/lib/services/collection-report-service.ts"
|
- from: "src/lib/services/collector-service.ts"
|
||||||
to: "prisma/schema.prisma"
|
to: "src/lib/services/zone-service.ts"
|
||||||
via: "Aggregates Collection and Remittance records for daily summary"
|
via: "zone scoping — validates subscriber is in collector's zones before collection"
|
||||||
pattern: "collection\\.(findMany|aggregate)"
|
pattern: "zone"
|
||||||
---
|
---
|
||||||
|
|
||||||
<objective>
|
<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.
|
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.
|
||||||
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.
|
|
||||||
</objective>
|
</objective>
|
||||||
|
|
||||||
<execution_context>
|
<execution_context>
|
||||||
@@ -72,180 +85,198 @@ Output: Collection model (lump-sum field payment with FIFO allocation), Remittan
|
|||||||
@.planning/ROADMAP.md
|
@.planning/ROADMAP.md
|
||||||
@.planning/STATE.md
|
@.planning/STATE.md
|
||||||
@.planning/phases/03-operational-modules/03-CONTEXT.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
|
@.planning/phases/03-operational-modules/03-01-SUMMARY.md
|
||||||
@prisma/schema.prisma
|
@prisma/schema.prisma
|
||||||
@src/lib/services/payment-service.ts
|
@src/lib/prisma-tenant.ts
|
||||||
@src/lib/accounting/journal-entry-service.ts
|
|
||||||
@src/lib/accounting/chart-of-accounts.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>
|
</context>
|
||||||
|
|
||||||
<tasks>
|
<tasks>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>Task 1: Collection and Remittance Prisma models + new COA account</name>
|
<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, src/lib/accounting/seed-coa.ts</files>
|
<files>
|
||||||
|
prisma/schema.prisma
|
||||||
|
src/lib/prisma-tenant.ts
|
||||||
|
src/lib/accounting/chart-of-accounts.ts
|
||||||
|
</files>
|
||||||
<action>
|
<action>
|
||||||
**New enum:**
|
1. Add 1030 Cash in Transit to ISP_CHART_OF_ACCOUNTS in chart-of-accounts.ts:
|
||||||
- `RemittanceStatus { PENDING, VERIFIED }`
|
```
|
||||||
- `CollectionStatus { COMPLETED, VOIDED }` (mirroring PaymentStatus pattern)
|
{ 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:
|
2. Add enums to schema.prisma:
|
||||||
- id (uuid PK), tenantId
|
- `enum CollectionStatus { COMPLETED VOIDED }`
|
||||||
- collectorId (FK to User — the collector who collected)
|
- `enum RemittanceStatus { PENDING VERIFIED }`
|
||||||
- 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])
|
|
||||||
|
|
||||||
**Remittance model** — a batch handoff of collected cash from collector to office:
|
3. Add Collection model:
|
||||||
- id (uuid PK), tenantId
|
- id (uuid), tenantId
|
||||||
- collectorId (FK to User)
|
- collectorId (String, FK to User — the collector who made the collection)
|
||||||
- remittanceDate (DateTime) — when the collector handed over cash
|
- subscriberId (String, FK to Subscriber)
|
||||||
- collectedTotal (Decimal 10,2) — sum of Collection amounts in this batch (system-calculated)
|
- amount (Decimal @db.Decimal(10,2)) — lump sum received from subscriber
|
||||||
- verifiedTotal (Decimal 10,2?) — amount counted by office staff (null until verified)
|
- collectionDate (DateTime) — when collected in the field
|
||||||
- variance (Decimal 10,2?) — collectedTotal - verifiedTotal (system-calculated on verification)
|
- status (CollectionStatus, default COMPLETED)
|
||||||
- status (RemittanceStatus default PENDING)
|
- notes (String?)
|
||||||
- verifiedById (String? FK to User — office staff who verified)
|
- journalEntryId (String?) — JE created on collection (DR 1030, CR 1100)
|
||||||
- verifiedAt (DateTime?)
|
- voidedAt (DateTime?), voidedById (String?), voidJournalEntryId (String?)
|
||||||
- journalEntryId (String?) — JE created on verification
|
- createdAt, updatedAt
|
||||||
- notes (String?)
|
- Relations: collector -> User, subscriber -> Subscriber
|
||||||
- createdAt, updatedAt
|
- Add PaymentAllocation relation: collectionAllocations PaymentAllocation[] (reuse PaymentAllocation or create CollectionAllocation — prefer creating CollectionAllocation to avoid polluting PaymentAllocation with nullable fields)
|
||||||
- @@index([tenantId]), @@index([tenantId, collectorId]), @@index([tenantId, remittanceDate])
|
- 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:**
|
4. Add Remittance model:
|
||||||
- User: add `collections Collection[]`, `remittancesAsCollector Remittance[] @relation("RemittanceCollector")`, `remittancesVerified Remittance[] @relation("RemittanceVerifiedBy")`
|
- id (uuid), tenantId
|
||||||
- Subscriber: add `collections Collection[]`
|
- 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:**
|
5. Add reverse relations on User: `collections Collection[]`, `verifiedRemittances Remittance[]`
|
||||||
- Add account 1030 "Cash in Transit" (ASSET, DEBIT normal balance) to ISP_CHART_OF_ACCOUNTS in chart-of-accounts.ts
|
Add reverse relation on Subscriber: `collections Collection[]`
|
||||||
- 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)
|
|
||||||
|
|
||||||
**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`
|
7. Add Collection, CollectionAllocation, and Remittance to TENANT_SCOPED_MODELS in prisma-tenant.ts with FULL 12-operation extension blocks.
|
||||||
|
|
||||||
**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).
|
|
||||||
</action>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
- `npx prisma migrate dev` completes without errors
|
- `npx prisma migrate dev` succeeds
|
||||||
- `npx prisma generate` succeeds
|
- `npx tsc --noEmit` passes
|
||||||
- Schema has Collection, Remittance models with correct relations
|
- Grep chart-of-accounts.ts confirms "1030" exists
|
||||||
- chart-of-accounts.ts includes 1030 Cash in Transit
|
- Grep prisma-tenant.ts confirms "collection", "collectionAllocation", "remittance" in TENANT_SCOPED_MODELS
|
||||||
</verify>
|
</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>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>Task 2: CollectorService, RemittanceService, CollectionReportService + APIs + tests</name>
|
<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/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>
|
<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>
|
<action>
|
||||||
**CollectorService** (`src/lib/services/collector-service.ts`):
|
1. Create src/lib/services/collector-service.ts:
|
||||||
- `recordCollection(db, { collectorId, subscriberId, amount, collectionDate, notes })`:
|
- `recordCollection(tenantPrisma, tenantId, { collectorId, subscriberId, amount, collectionDate, notes? })`:
|
||||||
1. Verify collector is assigned to subscriber's zone (via ZoneService.getCollectorSubscribers or direct zone check)
|
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).
|
||||||
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}`.
|
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.
|
||||||
3. Create a Collection record linking collectorId, subscriberId, paymentId
|
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.
|
||||||
4. Return the collection with payment allocation details
|
d. Handle overpayment: any excess after all invoices paid goes to subscriber.creditBalance (same pattern as PaymentService).
|
||||||
- `getCollectorCollections(db, collectorId, { dateFrom, dateTo })` — list collections for a collector in date range
|
e. All inside a $transaction. Pass tenantId explicitly in all create/update data.
|
||||||
- `voidCollection(db, collectionId)` — void the collection and its underlying payment (via PaymentService.voidPayment)
|
f. Return collection record with allocations.
|
||||||
- `getUnremittedCollections(db, collectorId)` — collections not yet linked to a remittance
|
- `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`):
|
2. Create src/lib/services/remittance-service.ts:
|
||||||
- `createRemittance(db, { collectorId, collectionIds, remittanceDate, notes })`:
|
- `createRemittance(tenantPrisma, tenantId, { collectorId, remittanceDate })`:
|
||||||
1. Validate all collectionIds belong to this collector and are unremitted
|
- 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.
|
||||||
2. Calculate collectedTotal as sum of collection amounts
|
- Create Remittance with status PENDING, collectedTotal set.
|
||||||
3. Create Remittance record with status PENDING
|
- `verifyRemittance(tenantPrisma, tenantId, { remittanceId, verifiedTotal, verifiedById, notes? })`:
|
||||||
4. Link collections to remittance (update collection.remittanceId)
|
- Load remittance, validate status is PENDING
|
||||||
5. Return remittance
|
- Calculate variance: collectedTotal - verifiedTotal (positive = collector short, negative = collector over)
|
||||||
- `verifyRemittance(db, { remittanceId, verifiedById, verifiedTotal, notes })`:
|
- 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.
|
||||||
1. Load remittance, verify status is PENDING
|
- Update remittance: verifiedTotal, variance, verifiedById, verifiedAt, journalEntryId, status = VERIFIED
|
||||||
2. Calculate variance = collectedTotal - verifiedTotal
|
- `listRemittances(tenantPrisma, { collectorId?, status?, dateFrom?, dateTo? })` — filtered list
|
||||||
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
|
|
||||||
|
|
||||||
**CollectionReportService** (`src/lib/services/collection-report-service.ts`):
|
3. Create src/lib/services/collection-report-service.ts:
|
||||||
- `getDailyCollectionSummary(db, { date, collectorId? })`:
|
- `getDailyCollectionSummary(tenantPrisma, { date, collectorId? })`:
|
||||||
1. Query collections for the date (or all collectors if no collectorId)
|
- For each collector active on the given date:
|
||||||
2. Query remittances for the date
|
- collectedTotal: sum of COMPLETED collections on that date
|
||||||
3. Return per-collector summary: { collectorId, collectorName, totalCollected, totalRemitted, variance, collectionCount }
|
- remittedTotal: sum of VERIFIED remittance verifiedTotal on that date
|
||||||
4. Include drill-down data: per-subscriber detail (subscriberName, amount, collectionDate)
|
- 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:**
|
4. Create API routes:
|
||||||
- `POST /api/collections` — collector logs a collection. COLLECTOR role. Body: { subscriberId, amount, collectionDate, notes? }. Extracts collectorId from session.
|
- POST /api/collections: withPermission("create", "Payment") -> recordCollection (collectors have "create" Payment permission)
|
||||||
- `GET /api/collections` — list collections. COLLECTOR sees own; ADMIN/OFFICE_STAFF see all or filter by collectorId query param.
|
- GET /api/collections: withPermission("read", "Payment") -> getCollectionHistory with query param filters
|
||||||
- `GET /api/collections/[id]` — get collection detail with payment allocation info
|
- GET /api/collections/[id]: withPermission("read", "Payment") -> single collection detail
|
||||||
- `POST /api/collections/[id]/void` — void a collection. ADMIN, OFFICE_STAFF.
|
- POST /api/collections/[id]/void: withPermission("manage", "Payment") -> voidCollection (admin/staff only)
|
||||||
- `POST /api/remittances` — create remittance batch. COLLECTOR role. Body: { collectionIds, remittanceDate, notes? }
|
- POST /api/remittances: withPermission("manage", "Payment") -> createRemittance (staff initiates)
|
||||||
- `GET /api/remittances` — list remittances with filters. ADMIN, OFFICE_STAFF, COLLECTOR (own only).
|
- POST /api/remittances/[id]/verify: withPermission("manage", "Payment") -> verifyRemittance (staff verifies)
|
||||||
- `POST /api/remittances/[id]/verify` — verify remittance. ADMIN, OFFICE_STAFF only. Body: { verifiedTotal, notes? }
|
- GET /api/reports/collections: withPermission("read", "Report") -> getDailyCollectionSummary with date query param
|
||||||
- `GET /api/reports/collections` — daily collection summary. ADMIN, OFFICE_STAFF. Query params: date, collectorId?
|
|
||||||
|
|
||||||
**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`:
|
6. Create src/lib/__tests__/remittance-service.test.ts:
|
||||||
- Collector can log collection against subscriber in their zone
|
- Setup: reuse similar setup, record some collections first
|
||||||
- Collector cannot collect from subscriber outside their zone
|
- Test: createRemittance calculates correct collectedTotal
|
||||||
- Collection creates Payment with FIFO allocation (reuses PaymentService pattern)
|
- Test: verifyRemittance with matching amount (zero variance)
|
||||||
- Collection uses 1030 Cash in Transit (not 1010)
|
- Test: verifyRemittance with different amount (non-zero variance, still completes)
|
||||||
- Void collection voids underlying payment
|
- Test: verification creates JE with DR 1010, CR 1030 for verifiedTotal
|
||||||
- getUnremittedCollections returns only collections not linked to remittance
|
- Test: cannot verify already-verified remittance
|
||||||
- Collector balances derived from transactions (no stored balance field) — query collections sum vs remittances sum
|
- Cleanup: remittances -> collectionAllocations -> collections -> (same chain as above)
|
||||||
|
|
||||||
`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
|
|
||||||
</action>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
- `npx vitest run src/lib/__tests__/collector-service.test.ts` — all tests pass
|
- `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 src/lib/__tests__/remittance-service.test.ts` — all tests pass
|
||||||
- `npx vitest run` — full suite passes (no regressions)
|
- `npx tsc --noEmit` passes
|
||||||
</verify>
|
</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>
|
</task>
|
||||||
|
|
||||||
</tasks>
|
</tasks>
|
||||||
|
|
||||||
<verification>
|
<verification>
|
||||||
- Collector logs collection -> Payment created with FIFO allocation -> debit goes to 1030 Cash in Transit
|
- `npx prisma migrate dev` succeeds
|
||||||
- Collector creates remittance batch from unremitted collections
|
- `npx tsc --noEmit` passes
|
||||||
- Office staff verifies remittance -> JE posted (DR 1010 Cash on Hand, CR 1030 Cash in Transit)
|
- `npx vitest run src/lib/__tests__/collector-service.test.ts` — all green
|
||||||
- Variance tracked but does not block verification
|
- `npx vitest run src/lib/__tests__/remittance-service.test.ts` — all green
|
||||||
- Daily summary shows per-collector totals: collected, remitted, variance, count
|
- Collection JEs use account 1030 (NOT 1010) — verified by test assertions on JE lines
|
||||||
- Collector balances are DERIVED (sum of collections minus sum of verified remittances) — no stored balance
|
- Remittance verification JEs use DR 1010, CR 1030
|
||||||
- Zone scoping enforced (collector can only collect from their assigned subscribers)
|
- Variance does not block remittance completion
|
||||||
- All existing tests pass (no regressions)
|
|
||||||
</verification>
|
</verification>
|
||||||
|
|
||||||
<success_criteria>
|
<success_criteria>
|
||||||
- Collection and Remittance models with proper relations and migration
|
- Collection model with FIFO allocation (same pattern as PaymentService but with 1030)
|
||||||
- 1030 Cash in Transit account added to COA
|
- Zone enforcement on collections (collector can only collect from their zones)
|
||||||
- CollectorService handles field collection with FIFO and zone scoping
|
- Remittance two-party verification (collector collects, staff counts and verifies)
|
||||||
- RemittanceService handles batch creation and two-party verification with JE
|
- Correct accounting chain: Collection DR 1030/CR 1100, Remittance DR 1010/CR 1030
|
||||||
- CollectionReportService produces daily summary per collector
|
- Variance recorded but non-blocking
|
||||||
- Integration tests prove the full collection-to-remittance-to-JE flow
|
- Daily collection summary report with per-collector totals
|
||||||
- No stored balance fields — all collector totals derived from transaction log
|
- Collector balances derived (no stored balance field)
|
||||||
|
- All integration tests pass
|
||||||
</success_criteria>
|
</success_criteria>
|
||||||
|
|
||||||
<output>
|
<output>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ files_modified:
|
|||||||
- src/lib/prisma-tenant.ts
|
- src/lib/prisma-tenant.ts
|
||||||
- src/lib/services/ticket-service.ts
|
- src/lib/services/ticket-service.ts
|
||||||
- src/lib/services/ticket-category-service.ts
|
- src/lib/services/ticket-category-service.ts
|
||||||
|
- src/lib/tenant.ts
|
||||||
- src/app/api/tickets/route.ts
|
- src/app/api/tickets/route.ts
|
||||||
- src/app/api/tickets/[id]/route.ts
|
- src/app/api/tickets/[id]/route.ts
|
||||||
- src/app/api/tickets/[id]/status/route.ts
|
- src/app/api/tickets/[id]/status/route.ts
|
||||||
@@ -19,41 +20,49 @@ autonomous: true
|
|||||||
|
|
||||||
must_haves:
|
must_haves:
|
||||||
truths:
|
truths:
|
||||||
- "Staff can create a support ticket from a client call with issue description, priority, and category"
|
- "Staff can create a support ticket with subject, description, priority, and category"
|
||||||
- "Tickets follow a lifecycle: OPEN -> ASSIGNED -> RESOLVED -> CLOSED"
|
- "Tickets follow lifecycle: OPEN -> ASSIGNED -> RESOLVED -> CLOSED"
|
||||||
- "Admin can configure ticket categories per tenant (create, edit, deactivate)"
|
- "Invalid status transitions are rejected (e.g., CLOSED -> OPEN is invalid)"
|
||||||
- "Default ticket categories are seeded at tenant creation"
|
- "Admin can create, update, and deactivate ticket categories"
|
||||||
- "Ticket model supports both staff and subscriber as source types from the start"
|
- "Default ISP categories are seeded at tenant creation"
|
||||||
|
- "Tickets with a deactivated category cannot be created"
|
||||||
|
- "Ticket data is tenant-scoped"
|
||||||
artifacts:
|
artifacts:
|
||||||
- path: "prisma/schema.prisma"
|
- path: "prisma/schema.prisma"
|
||||||
provides: "Ticket and TicketCategory models"
|
provides: "Ticket, TicketCategory models with enums"
|
||||||
contains: "model Ticket"
|
contains: "model Ticket"
|
||||||
- path: "src/lib/services/ticket-service.ts"
|
- path: "src/lib/services/ticket-service.ts"
|
||||||
provides: "Ticket CRUD, status transitions, search/filter"
|
provides: "Ticket CRUD and status transitions"
|
||||||
exports: ["TicketService"]
|
exports: ["createTicket", "updateTicket", "getTicket", "listTickets", "transitionTicketStatus"]
|
||||||
- path: "src/lib/services/ticket-category-service.ts"
|
- path: "src/lib/services/ticket-category-service.ts"
|
||||||
provides: "TicketCategory CRUD with default seeding"
|
provides: "Category CRUD"
|
||||||
exports: ["TicketCategoryService"]
|
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"
|
- path: "src/lib/__tests__/ticket-service.test.ts"
|
||||||
provides: "Integration tests for ticket lifecycle and category management"
|
provides: "Integration tests for ticket lifecycle, categories, transitions"
|
||||||
min_lines: 100
|
min_lines: 150
|
||||||
key_links:
|
key_links:
|
||||||
- from: "src/lib/services/ticket-service.ts"
|
- 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"
|
to: "prisma/schema.prisma"
|
||||||
via: "Prisma queries on Ticket model"
|
via: "seeds default TicketCategory records in createTenant transaction"
|
||||||
pattern: "prisma\\.ticket\\."
|
pattern: "ticketCategory\\.createMany"
|
||||||
- from: "src/lib/services/ticket-category-service.ts"
|
- from: "src/app/api/tickets/[id]/status/route.ts"
|
||||||
to: "src/lib/tenant.ts"
|
to: "src/lib/services/ticket-service.ts"
|
||||||
via: "Categories seeded during tenant creation"
|
via: "transitionTicketStatus with guard map"
|
||||||
pattern: "seedTicketCategories|createTenant"
|
pattern: "transitionTicketStatus"
|
||||||
---
|
---
|
||||||
|
|
||||||
<objective>
|
<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.
|
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.
|
||||||
Output: Ticket and TicketCategory Prisma models, ticket CRUD service with lifecycle management, admin-configurable categories with default seeds, API routes, integration tests.
|
|
||||||
</objective>
|
</objective>
|
||||||
|
|
||||||
<execution_context>
|
<execution_context>
|
||||||
@@ -66,153 +75,172 @@ Output: Ticket and TicketCategory Prisma models, ticket CRUD service with lifecy
|
|||||||
@.planning/ROADMAP.md
|
@.planning/ROADMAP.md
|
||||||
@.planning/STATE.md
|
@.planning/STATE.md
|
||||||
@.planning/phases/03-operational-modules/03-CONTEXT.md
|
@.planning/phases/03-operational-modules/03-CONTEXT.md
|
||||||
|
@.planning/phases/03-operational-modules/03-RESEARCH.md
|
||||||
@prisma/schema.prisma
|
@prisma/schema.prisma
|
||||||
|
@src/lib/prisma-tenant.ts
|
||||||
@src/lib/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>
|
</context>
|
||||||
|
|
||||||
<tasks>
|
<tasks>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>Task 1: Ticket and TicketCategory Prisma models + category seeding</name>
|
<name>Task 1: Ticket schema, categories, migration, and tenant scoping</name>
|
||||||
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts, src/lib/tenant.ts, src/lib/services/ticket-category-service.ts</files>
|
<files>
|
||||||
|
prisma/schema.prisma
|
||||||
|
src/lib/prisma-tenant.ts
|
||||||
|
src/lib/tenant.ts
|
||||||
|
</files>
|
||||||
<action>
|
<action>
|
||||||
**New enums:**
|
1. Add enums to schema.prisma:
|
||||||
- `TicketStatus { OPEN, ASSIGNED, RESOLVED, CLOSED }`
|
- `enum TicketStatus { OPEN ASSIGNED RESOLVED CLOSED }`
|
||||||
- `TicketPriority { LOW, MEDIUM, HIGH, URGENT }`
|
- `enum TicketPriority { LOW MEDIUM HIGH URGENT }`
|
||||||
- `TicketSource { STAFF, SUBSCRIBER }` — supports both sources from day one
|
- `enum TicketSource { STAFF SUBSCRIBER }` (supports Phase 5 subscriber portal)
|
||||||
|
|
||||||
**TicketCategory model:**
|
2. Add TicketCategory model:
|
||||||
- id (uuid PK), tenantId
|
- id (uuid), tenantId, name (String), description (String?), isActive (Boolean default true), createdAt, updatedAt
|
||||||
- name (String) — e.g., "No Connection", "Slow Speed"
|
- @@unique([tenantId, name]), @@index([tenantId])
|
||||||
- description (String?)
|
|
||||||
- isActive (Boolean default true) — soft delete for deactivation
|
|
||||||
- createdAt, updatedAt
|
|
||||||
- @@unique([tenantId, name])
|
|
||||||
- @@index([tenantId])
|
|
||||||
- Relation: tickets Ticket[]
|
|
||||||
|
|
||||||
**Ticket model:**
|
3. Add Ticket model:
|
||||||
- id (uuid PK), tenantId
|
- id (uuid), tenantId
|
||||||
- ticketNumber (String) — auto-generated sequential per tenant, e.g., "TKT-0001"
|
- ticketNumber (String) — auto-generated TKT-NNNN
|
||||||
- subscriberId (FK to Subscriber) — the affected subscriber
|
- subject (String), description (String)
|
||||||
- categoryId (FK to TicketCategory)
|
- categoryId (String, FK to TicketCategory)
|
||||||
- source (TicketSource default STAFF)
|
- priority (TicketPriority, default MEDIUM)
|
||||||
- createdById (FK to User) — staff who created, or subscriber user in Phase 5
|
- status (TicketStatus, default OPEN)
|
||||||
- assignedToId (String? FK to User) — assigned staff member (set when status -> ASSIGNED)
|
- source (TicketSource, default STAFF)
|
||||||
- subject (String) — brief issue summary
|
- subscriberId (String?, FK to Subscriber — which subscriber this ticket is about)
|
||||||
- description (String) — detailed issue description
|
- createdById (String, FK to User — staff or subscriber who created it)
|
||||||
- priority (TicketPriority default MEDIUM)
|
- resolvedAt (DateTime?), closedAt (DateTime?)
|
||||||
- status (TicketStatus default OPEN)
|
- notes (String?) — internal notes
|
||||||
- resolvedAt (DateTime?) — when auto-resolved (all job orders completed)
|
- createdAt, updatedAt
|
||||||
- closedAt (DateTime?) — when staff manually closes after confirming resolution
|
- Relations: category -> TicketCategory, subscriber -> Subscriber, createdBy -> User
|
||||||
- closedById (String? FK to User)
|
- @@unique([tenantId, ticketNumber]), @@index([tenantId]), @@index([tenantId, status]), @@index([tenantId, categoryId]), @@index([subscriberId])
|
||||||
- notes (String?) — internal notes
|
- Add reverse relations: Subscriber.tickets Ticket[], User.createdTickets Ticket[], TicketCategory.tickets Ticket[]
|
||||||
- createdAt, updatedAt
|
|
||||||
- @@unique([tenantId, ticketNumber])
|
|
||||||
- @@index([tenantId]), @@index([tenantId, status]), @@index([tenantId, subscriberId]), @@index([tenantId, categoryId])
|
|
||||||
|
|
||||||
**Update relations:**
|
4. Run `npx prisma migrate dev --name add-tickets`
|
||||||
- Subscriber: add `tickets Ticket[]`
|
|
||||||
- User: add appropriate ticket relations (createdTickets, assignedTickets, closedTickets)
|
|
||||||
|
|
||||||
**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`):
|
6. Update src/lib/tenant.ts createTenant function: after seedChartOfAccounts, add seed for default ticket categories within the same transaction:
|
||||||
- `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
|
const defaultCategories = [
|
||||||
- `updateCategory(db, categoryId, { name?, description?, isActive? })` — admin edits/deactivates
|
{ name: "No Connection", description: "Subscriber has no internet connection", tenantId: tenant.id },
|
||||||
- `listCategories(db, { includeInactive? })` — list categories
|
{ 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 },
|
||||||
**Update tenant creation** in `src/lib/tenant.ts`:
|
{ name: "New Installation", description: "Request for new service installation", tenantId: tenant.id },
|
||||||
- After seedChartOfAccounts in the createTenant $transaction, call seedDefaultCategories to provision default ticket categories for new tenants.
|
{ 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 },
|
||||||
Run `npx prisma migrate dev --name add-tickets`
|
];
|
||||||
|
await tx.ticketCategory.createMany({ data: defaultCategories });
|
||||||
|
```
|
||||||
|
IMPORTANT: This is inside the $transaction callback, so use `tx` (raw client) and include tenantId explicitly.
|
||||||
</action>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
- `npx prisma migrate dev` completes without errors
|
- `npx prisma migrate dev` succeeds
|
||||||
- `npx prisma generate` succeeds
|
- `npx tsc --noEmit` passes
|
||||||
- Schema has Ticket and TicketCategory models
|
- Grep prisma-tenant.ts confirms "ticket" and "ticketCategory" in TENANT_SCOPED_MODELS
|
||||||
- Creating a new tenant seeds 6 default ticket categories
|
- Grep tenant.ts confirms "ticketCategory" seeding
|
||||||
</verify>
|
</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>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>Task 2: TicketService + API routes + integration tests</name>
|
<name>Task 2: Ticket service, category service, API routes, and 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>
|
<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>
|
<action>
|
||||||
**TicketService** (`src/lib/services/ticket-service.ts`):
|
1. Create src/lib/services/ticket-category-service.ts:
|
||||||
- `createTicket(db, { subscriberId, categoryId, subject, description, priority, source, createdById })`:
|
- `createCategory(tenantPrisma, tenantId, { name, description })` — create category
|
||||||
1. Auto-generate ticketNumber (pattern: TKT-NNNN, sequential per tenant — same approach as INV/JE numbers)
|
- `updateCategory(tenantPrisma, categoryId, { name?, description?, isActive? })` — update/deactivate
|
||||||
2. Create ticket with status OPEN
|
- `listCategories(tenantPrisma, { activeOnly?: boolean })` — list with optional active filter
|
||||||
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.
|
|
||||||
|
|
||||||
**Status transition rules (enforce in service):**
|
2. Create src/lib/services/ticket-service.ts:
|
||||||
- OPEN -> ASSIGNED (requires assignedToId)
|
- 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.
|
||||||
- OPEN -> CLOSED (cancel without resolving)
|
- `createTicket(tenantPrisma, tenantId, { subject, description, categoryId, priority?, subscriberId?, createdById, source? })`:
|
||||||
- ASSIGNED -> OPEN (unassign)
|
- Validate category exists AND isActive=true — throw if deactivated
|
||||||
- ASSIGNED -> RESOLVED (direct resolve without job order)
|
- Generate ticketNumber
|
||||||
- RESOLVED -> CLOSED (staff confirmation)
|
- Create ticket with status OPEN
|
||||||
- RESOLVED -> OPEN (reopen)
|
- `updateTicket(tenantPrisma, ticketId, { subject?, description?, categoryId?, priority?, notes? })` — update metadata only (NOT status)
|
||||||
- All other transitions: throw error
|
- `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:**
|
3. Create API routes:
|
||||||
- `GET /api/tickets` — list tickets with filters. ADMIN, OFFICE_STAFF see all. TECHNICIAN sees assigned only.
|
- GET /api/tickets: withPermission("read", "Ticket") -> listTickets with query param filters
|
||||||
- `POST /api/tickets` — create ticket. ADMIN, OFFICE_STAFF. Body: { subscriberId, categoryId, subject, description, priority? }
|
- POST /api/tickets: withPermission("create", "Ticket") -> createTicket (body: { subject, description, categoryId, priority?, subscriberId? })
|
||||||
- `GET /api/tickets/[id]` — get ticket detail
|
- GET /api/tickets/[id]: withPermission("read", "Ticket") -> getTicket (dynamic route pattern)
|
||||||
- `PUT /api/tickets/[id]` — update ticket fields. ADMIN, OFFICE_STAFF.
|
- PUT /api/tickets/[id]: withPermission("update", "Ticket") -> updateTicket (dynamic route pattern)
|
||||||
- `POST /api/tickets/[id]/status` — change ticket status. Body: { status, assignedToId? }. ADMIN, OFFICE_STAFF.
|
- POST /api/tickets/[id]/status: withPermission("update", "Ticket") -> transitionTicketStatus (body: { status })
|
||||||
- `GET /api/ticket-categories` — list categories. All authenticated users.
|
- GET /api/ticket-categories: withPermission("read", "Ticket") -> listCategories
|
||||||
- `POST /api/ticket-categories` — create category. ADMIN only.
|
- POST /api/ticket-categories: withPermission("manage", "Ticket") -> createCategory (admin/staff only via manage check)
|
||||||
- `PUT /api/ticket-categories/[id]` — update/deactivate category. ADMIN only.
|
- PUT /api/ticket-categories/[id]: withPermission("manage", "Ticket") -> updateCategory
|
||||||
|
|
||||||
**Integration Tests** (`src/lib/__tests__/ticket-service.test.ts`):
|
4. Create src/lib/__tests__/ticket-service.test.ts:
|
||||||
- Create ticket with auto-generated ticket number
|
- Setup: create tenant (this now auto-seeds categories via updated tenant.ts), admin user
|
||||||
- Ticket number sequential within tenant (TKT-0001, TKT-0002...)
|
- Test: default categories are seeded on tenant creation (6 categories)
|
||||||
- Status transitions: OPEN -> ASSIGNED -> RESOLVED -> CLOSED (happy path)
|
- Test: createCategory adds a new category
|
||||||
- Invalid transition rejected (e.g., OPEN -> RESOLVED without assignment — actually allowed per rules above, test the invalid ones: CLOSED -> OPEN)
|
- Test: updateCategory deactivates a category (isActive=false)
|
||||||
- Reopen ticket (RESOLVED -> OPEN)
|
- Test: createTicket with valid category succeeds, returns TKT-0001
|
||||||
- List tickets with filters (status, priority, category)
|
- Test: createTicket with deactivated category throws
|
||||||
- Category CRUD (create, update, deactivate)
|
- Test: second ticket gets TKT-0002
|
||||||
- Deactivated category cannot be used for new tickets
|
- Test: transitionTicketStatus OPEN -> CLOSED succeeds
|
||||||
- Default categories seeded on tenant creation
|
- Test: transitionTicketStatus CLOSED -> OPEN throws (terminal state)
|
||||||
- Cross-tenant isolation
|
- 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>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
- `npx vitest run src/lib/__tests__/ticket-service.test.ts` — all tests pass
|
- `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>
|
</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>
|
</task>
|
||||||
|
|
||||||
</tasks>
|
</tasks>
|
||||||
|
|
||||||
<verification>
|
<verification>
|
||||||
- Ticket CRUD: create, update, get, list with filters
|
- `npx prisma migrate dev` succeeds
|
||||||
- Status lifecycle: OPEN -> ASSIGNED -> RESOLVED -> CLOSED with proper guards
|
- `npx tsc --noEmit` passes
|
||||||
- Reopen: RESOLVED -> OPEN works
|
- `npx vitest run src/lib/__tests__/ticket-service.test.ts` — all tests green
|
||||||
- Category management: CRUD with deactivation
|
- Ticket lifecycle (OPEN -> ASSIGNED -> RESOLVED -> CLOSED) enforced
|
||||||
- Default categories seeded at tenant creation (6 ISP-relevant categories)
|
- Default categories seeded on tenant creation
|
||||||
- Ticket numbers are sequential per tenant
|
|
||||||
- Source field supports STAFF and SUBSCRIBER (Phase 5 ready)
|
|
||||||
- All existing tests pass (no regressions)
|
|
||||||
</verification>
|
</verification>
|
||||||
|
|
||||||
<success_criteria>
|
<success_criteria>
|
||||||
- Ticket and TicketCategory models with migration applied
|
- Ticket and TicketCategory models with tenant scoping
|
||||||
- TicketService handles full ticket lifecycle with enforced state transitions
|
- Status transition guard map rejects invalid transitions
|
||||||
- Admin-configurable categories with 6 defaults seeded at tenant creation
|
- Default 6 ISP categories seeded at tenant creation
|
||||||
- Ticket model supports both staff and subscriber source types
|
- Deactivated categories cannot be used for new tickets
|
||||||
- API routes enforce RBAC (staff creates, technician views assigned)
|
- Sequential ticket numbering (TKT-NNNN)
|
||||||
- Integration tests prove lifecycle, filtering, and tenant isolation
|
- resolveTicket is idempotent
|
||||||
|
- Cross-tenant isolation verified by test
|
||||||
|
- All integration tests pass
|
||||||
</success_criteria>
|
</success_criteria>
|
||||||
|
|
||||||
<output>
|
<output>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ files_modified:
|
|||||||
- prisma/schema.prisma
|
- prisma/schema.prisma
|
||||||
- src/lib/prisma-tenant.ts
|
- src/lib/prisma-tenant.ts
|
||||||
- src/lib/services/job-order-service.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/route.ts
|
||||||
- src/app/api/job-orders/[id]/route.ts
|
- src/app/api/job-orders/[id]/route.ts
|
||||||
- src/app/api/job-orders/[id]/status/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
|
- src/lib/__tests__/job-order-service.test.ts
|
||||||
autonomous: true
|
autonomous: true
|
||||||
|
|
||||||
@@ -19,37 +20,41 @@ must_haves:
|
|||||||
truths:
|
truths:
|
||||||
- "Staff can convert a ticket into a job order assigned to a technician"
|
- "Staff can convert a ticket into a job order assigned to a technician"
|
||||||
- "One ticket can have multiple job orders (1:many)"
|
- "One ticket can have multiple job orders (1:many)"
|
||||||
- "Technician can view their assigned job orders and update status (PENDING -> IN_PROGRESS -> COMPLETED)"
|
- "Job orders follow lifecycle: PENDING -> IN_PROGRESS -> COMPLETED (or CANCELLED)"
|
||||||
- "Job completion includes outcome notes, completion date"
|
- "Technician can update status of their own assigned job orders"
|
||||||
- "When ALL job orders on a ticket are completed, ticket auto-moves to RESOLVED"
|
- "When ALL non-cancelled job orders on a ticket are COMPLETED, ticket auto-resolves"
|
||||||
- "Staff manually closes ticket after confirming resolution (two-step: auto-resolve then close)"
|
- "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:
|
artifacts:
|
||||||
- path: "prisma/schema.prisma"
|
- 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"
|
contains: "model JobOrder"
|
||||||
- path: "src/lib/services/job-order-service.ts"
|
- path: "src/lib/services/job-order-service.ts"
|
||||||
provides: "Job order CRUD, status transitions, ticket-job synchronization"
|
provides: "Job order CRUD, status transitions, ticket synchronization"
|
||||||
exports: ["JobOrderService"]
|
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"
|
- path: "src/lib/__tests__/job-order-service.test.ts"
|
||||||
provides: "Integration tests for job order lifecycle and ticket sync"
|
provides: "Integration tests for job order lifecycle and ticket sync"
|
||||||
min_lines: 100
|
min_lines: 150
|
||||||
key_links:
|
key_links:
|
||||||
- from: "src/lib/services/job-order-service.ts"
|
- from: "src/lib/services/job-order-service.ts"
|
||||||
to: "src/lib/services/ticket-service.ts"
|
to: "src/lib/services/ticket-service.ts"
|
||||||
via: "Auto-resolves ticket when all job orders completed"
|
via: "checkTicketAutoResolve and checkTicketRevertToOpen after status changes"
|
||||||
pattern: "TicketService|resolveTicket"
|
pattern: "resolveTicket|transitionTicketStatus"
|
||||||
- from: "src/app/api/tickets/[id]/job-orders/route.ts"
|
- from: "src/app/api/job-orders/[id]/status/route.ts"
|
||||||
to: "src/lib/services/job-order-service.ts"
|
to: "src/lib/services/job-order-service.ts"
|
||||||
via: "POST creates job order from ticket"
|
via: "updateJobOrderStatus with technician self-service"
|
||||||
pattern: "createJobOrder"
|
pattern: "updateJobOrderStatus"
|
||||||
---
|
---
|
||||||
|
|
||||||
<objective>
|
<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.
|
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.
|
||||||
Output: JobOrder Prisma model, job order CRUD with status lifecycle, ticket-to-job conversion, auto-resolution sync, technician self-service status updates, integration tests.
|
|
||||||
</objective>
|
</objective>
|
||||||
|
|
||||||
<execution_context>
|
<execution_context>
|
||||||
@@ -62,143 +67,163 @@ Output: JobOrder Prisma model, job order CRUD with status lifecycle, ticket-to-j
|
|||||||
@.planning/ROADMAP.md
|
@.planning/ROADMAP.md
|
||||||
@.planning/STATE.md
|
@.planning/STATE.md
|
||||||
@.planning/phases/03-operational-modules/03-CONTEXT.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
|
@.planning/phases/03-operational-modules/03-03-SUMMARY.md
|
||||||
@prisma/schema.prisma
|
@prisma/schema.prisma
|
||||||
|
@src/lib/prisma-tenant.ts
|
||||||
@src/lib/services/ticket-service.ts
|
@src/lib/services/ticket-service.ts
|
||||||
|
@src/lib/casl/permissions.ts
|
||||||
|
@src/lib/__tests__/payment.test.ts (test pattern reference)
|
||||||
</context>
|
</context>
|
||||||
|
|
||||||
<tasks>
|
<tasks>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>Task 1: JobOrder Prisma model + migration</name>
|
<name>Task 1: JobOrder schema, migration, and tenant scoping</name>
|
||||||
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts</files>
|
<files>
|
||||||
|
prisma/schema.prisma
|
||||||
|
src/lib/prisma-tenant.ts
|
||||||
|
</files>
|
||||||
<action>
|
<action>
|
||||||
**New enums:**
|
1. Add enum to schema.prisma:
|
||||||
- `JobOrderStatus { PENDING, IN_PROGRESS, COMPLETED, CANCELLED }`
|
- `enum JobOrderStatus { PENDING IN_PROGRESS COMPLETED CANCELLED }`
|
||||||
- `JobType { INSTALLATION, REPAIR, MAINTENANCE, RELOCATION, DISCONNECTION, OTHER }`
|
|
||||||
|
|
||||||
**JobOrder model:**
|
2. Add JobOrder model:
|
||||||
- id (uuid PK), tenantId
|
- id (uuid), tenantId
|
||||||
- orderNumber (String) — auto-generated sequential per tenant, e.g., "JO-0001"
|
- orderNumber (String) — auto-generated JO-NNNN
|
||||||
- ticketId (FK to Ticket) — parent ticket
|
- ticketId (String, FK to Ticket)
|
||||||
- assignedToId (FK to User) — the technician
|
- jobType (String) — e.g., "Installation", "Repair", "Maintenance" (free-form, matches JobTypeRate in 03-05)
|
||||||
- jobType (JobType)
|
- description (String?) — specific instructions for this job
|
||||||
- description (String) — what needs to be done
|
- assignedToId (String, FK to User — the technician)
|
||||||
- status (JobOrderStatus default PENDING)
|
- status (JobOrderStatus, default PENDING)
|
||||||
- scheduledDate (DateTime?) — optional scheduled date
|
- scheduledDate (DateTime?) — when the job is scheduled
|
||||||
- startedAt (DateTime?) — when technician started work
|
- startedAt (DateTime?) — when technician started work
|
||||||
- completedAt (DateTime?) — when work was completed
|
- completedAt (DateTime?) — when job was completed
|
||||||
- outcomeNotes (String?) — technician fills in on completion
|
- outcomeNotes (String?) — technician's completion notes
|
||||||
- cancelledAt (DateTime?)
|
- cancelledAt (DateTime?), cancelReason (String?)
|
||||||
- cancelReason (String?)
|
- createdById (String, FK to User — staff who created the job order)
|
||||||
- createdById (FK to User) — staff who created the job order
|
- createdAt, updatedAt
|
||||||
- createdAt, updatedAt
|
- Relations: ticket -> Ticket, assignedTo -> User, createdBy -> User
|
||||||
- @@unique([tenantId, orderNumber])
|
- @@unique([tenantId, orderNumber])
|
||||||
- @@index([tenantId]), @@index([tenantId, assignedToId]), @@index([tenantId, status]), @@index([ticketId])
|
- @@index([tenantId]), @@index([tenantId, ticketId]), @@index([tenantId, assignedToId]), @@index([tenantId, status])
|
||||||
|
|
||||||
**Update relations:**
|
3. Add reverse relations:
|
||||||
- Ticket: add `jobOrders JobOrder[]`
|
- Ticket: `jobOrders JobOrder[]`
|
||||||
- User: add `assignedJobOrders JobOrder[] @relation("JobOrderAssignedTo")`, `createdJobOrders JobOrder[] @relation("JobOrderCreatedBy")`
|
- 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>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
- `npx prisma migrate dev` completes without errors
|
- `npx prisma migrate dev` succeeds
|
||||||
- `npx prisma generate` succeeds
|
- `npx tsc --noEmit` passes
|
||||||
- Schema has JobOrder model with correct enums and relations
|
- Grep prisma-tenant.ts confirms "jobOrder" in TENANT_SCOPED_MODELS
|
||||||
</verify>
|
</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>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>Task 2: JobOrderService + API routes + integration tests</name>
|
<name>Task 2: Job order service, ticket sync, API routes, and 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>
|
<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>
|
<action>
|
||||||
**JobOrderService** (`src/lib/services/job-order-service.ts`):
|
1. Update src/lib/services/ticket-service.ts — add/export two helper functions:
|
||||||
- `createJobOrder(db, { ticketId, assignedToId, jobType, description, scheduledDate?, createdById })`:
|
- `checkTicketAutoResolve(tenantPrisma, ticketId)`:
|
||||||
1. Validate ticket exists and is not CLOSED
|
Query all job orders for ticket where status != CANCELLED.
|
||||||
2. Validate assignedToId is a user with TECHNICIAN role
|
If count > 0 AND all have status COMPLETED, call resolveTicket (which is already idempotent).
|
||||||
3. Auto-generate orderNumber (JO-NNNN per tenant)
|
If count == 0 (all cancelled), do NOT auto-resolve.
|
||||||
4. Create job order with status PENDING
|
- `checkTicketRevertToOpen(tenantPrisma, ticketId)`:
|
||||||
5. If ticket status is OPEN, auto-transition ticket to ASSIGNED (via TicketService.assignTicket with the first technician)
|
Query all job orders for ticket.
|
||||||
6. Return job order with ticket and technician info
|
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? })`:
|
3. Create API routes:
|
||||||
Status transitions:
|
- POST /api/tickets/[id]/job-orders: withPermission("create", "JobOrder") -> createJobOrder (staff creates from ticket context; dynamic route pattern: ticketId from params)
|
||||||
- PENDING -> IN_PROGRESS: set startedAt
|
- GET /api/job-orders: withPermission("read", "JobOrder") -> listJobOrders with query filters. For TECHNICIAN role: auto-filter to assignedToId = user.id
|
||||||
- PENDING -> CANCELLED: set cancelledAt, cancelReason
|
- GET /api/job-orders/[id]: withPermission("read", "JobOrder") -> getJobOrder
|
||||||
- IN_PROGRESS -> COMPLETED: set completedAt, outcomeNotes (required). Then call `checkTicketAutoResolve`.
|
- PUT /api/job-orders/[id]: withPermission("update", "JobOrder") -> update job order metadata (description, scheduledDate)
|
||||||
- IN_PROGRESS -> CANCELLED: set cancelledAt, cancelReason
|
- POST /api/job-orders/[id]/status: withPermission("update", "JobOrder") -> updateJobOrderStatus (body: { status, outcomeNotes?, cancelReason? })
|
||||||
- All other transitions: throw error
|
|
||||||
|
|
||||||
- `checkTicketAutoResolve(db, ticketId)`:
|
4. Create src/lib/__tests__/job-order-service.test.ts:
|
||||||
1. Load all job orders for this ticket
|
- Setup: create tenant (auto-seeds categories), admin user, technician user (TECHNICIAN role), create subscriber, create ticket
|
||||||
2. If ALL non-cancelled job orders have status COMPLETED, auto-resolve the ticket via TicketService.resolveTicket
|
- Test: createJobOrder succeeds, returns JO-0001
|
||||||
3. If there are only cancelled job orders (no completed ones), do NOT auto-resolve
|
- Test: createJobOrder auto-transitions OPEN ticket to ASSIGNED
|
||||||
|
- Test: second job order gets JO-0002, ticket stays ASSIGNED
|
||||||
- `reassignJobOrder(db, jobOrderId, newAssignedToId)` — reassign to different technician (only if PENDING or IN_PROGRESS)
|
- Test: createJobOrder rejects non-TECHNICIAN assignee
|
||||||
|
- Test: createJobOrder rejects CLOSED ticket
|
||||||
- `getJobOrder(db, jobOrderId)` — get detail with ticket, subscriber, technician info
|
- Test: updateJobOrderStatus PENDING -> IN_PROGRESS succeeds (sets startedAt)
|
||||||
|
- Test: updateJobOrderStatus IN_PROGRESS -> COMPLETED succeeds (sets completedAt, requires outcomeNotes)
|
||||||
- `listJobOrders(db, filters)` — list with filters: assignedToId, status, jobType, ticketId, dateFrom, dateTo. Pagination. Sort by createdAt DESC.
|
- Test: COMPLETED without outcomeNotes throws
|
||||||
|
- Test: invalid transition (COMPLETED -> IN_PROGRESS) throws
|
||||||
- `getTechnicianJobOrders(db, technicianId, filters)` — convenience wrapper for technician self-service view
|
- 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
|
||||||
**API Routes:**
|
- Test: partial completion: 2 job orders, complete 1, cancel 1 -> ticket auto-resolves (all non-cancelled are completed)
|
||||||
- `POST /api/tickets/[id]/job-orders` — create job order from ticket. ADMIN, OFFICE_STAFF. Body: { assignedToId, jobType, description, scheduledDate? }
|
- Test: all cancelled with none completed -> ticket reverts to OPEN, does NOT resolve
|
||||||
- `GET /api/job-orders` — list job orders with filters. ADMIN, OFFICE_STAFF see all. TECHNICIAN sees assigned only.
|
- Test: getMyJobOrders returns only technician's assigned orders
|
||||||
- `GET /api/job-orders/[id]` — get job order detail
|
- Test: cross-tenant isolation
|
||||||
- `PUT /api/job-orders/[id]` — update job order fields. ADMIN, OFFICE_STAFF.
|
- Cleanup: jobOrders -> tickets -> ticketCategories -> subscribers -> servicePlans -> users -> tenant
|
||||||
- `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
|
|
||||||
</action>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
- `npx vitest run src/lib/__tests__/job-order-service.test.ts` — all tests pass
|
- `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>
|
</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>
|
</task>
|
||||||
|
|
||||||
</tasks>
|
</tasks>
|
||||||
|
|
||||||
<verification>
|
<verification>
|
||||||
- Create job order from ticket: ticket auto-transitions to ASSIGNED
|
- `npx prisma migrate dev` succeeds
|
||||||
- One ticket, multiple job orders: each tracks independently
|
- `npx tsc --noEmit` passes
|
||||||
- Status lifecycle: PENDING -> IN_PROGRESS -> COMPLETED with proper guards
|
- `npx vitest run src/lib/__tests__/job-order-service.test.ts` — all green
|
||||||
- Completion requires outcomeNotes
|
- Ticket auto-resolve works when all non-cancelled jobs complete
|
||||||
- Auto-resolution: all non-cancelled job orders COMPLETED -> ticket RESOLVED
|
- Ticket revert-to-open works when all jobs cancelled
|
||||||
- Staff closes ticket (RESOLVED -> CLOSED) as separate manual step
|
- Technician can only see/update their own job orders
|
||||||
- Technician sees only their assigned job orders
|
|
||||||
- Job order numbers sequential per tenant
|
|
||||||
- All existing tests pass (no regressions)
|
|
||||||
</verification>
|
</verification>
|
||||||
|
|
||||||
<success_criteria>
|
<success_criteria>
|
||||||
- JobOrder model with status lifecycle, ticket 1:many relation, and technician assignment
|
- JobOrder model with 1:many ticket relation
|
||||||
- JobOrderService handles creation from ticket, status transitions, auto-resolution sync
|
- Sequential numbering (JO-NNNN)
|
||||||
- Technicians can update their own job orders (view assigned, update status)
|
- Status transitions enforced by guard map
|
||||||
- Ticket auto-resolves when all non-cancelled job orders complete
|
- Ticket auto-transitions: OPEN -> ASSIGNED on first job, auto-resolve on all complete, revert to OPEN on all cancelled
|
||||||
- API routes enforce RBAC (staff creates, technician updates own)
|
- Technicians can update their assigned job orders
|
||||||
- Integration tests prove full ticket-to-job-to-resolution workflow
|
- Completion requires outcome notes
|
||||||
|
- Cross-tenant isolation verified
|
||||||
|
- All integration tests pass
|
||||||
</success_criteria>
|
</success_criteria>
|
||||||
|
|
||||||
<output>
|
<output>
|
||||||
|
|||||||
@@ -20,41 +20,42 @@ autonomous: true
|
|||||||
|
|
||||||
must_haves:
|
must_haves:
|
||||||
truths:
|
truths:
|
||||||
- "Admin can create technician profiles with contact info, skills, and assigned zone"
|
- "Admin can create technician profiles with contact info, skills, zone, and compensation model"
|
||||||
- "Admin can set flat compensation rates per job type at the tenant level"
|
- "Admin can configure flat per-job compensation rates by job type at tenant level"
|
||||||
- "Technician can have base monthly salary, per-job bonuses, or hybrid (both optional)"
|
- "System supports hybrid compensation: base salary PLUS per-job bonuses (both optional)"
|
||||||
- "Only completed job orders count toward per-job compensation"
|
- "Only COMPLETED job orders count toward per-job compensation"
|
||||||
- "System generates compensation summary per technician per period: total jobs, base salary, job bonuses, total"
|
- "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:
|
artifacts:
|
||||||
- path: "prisma/schema.prisma"
|
- path: "prisma/schema.prisma"
|
||||||
provides: "TechnicianProfile, JobTypeRate models"
|
provides: "TechnicianProfile and JobTypeRate models with compensation enums"
|
||||||
contains: "model TechnicianProfile"
|
contains: "model TechnicianProfile"
|
||||||
- path: "src/lib/services/technician-service.ts"
|
- path: "src/lib/services/technician-service.ts"
|
||||||
provides: "Technician profile CRUD with zone and compensation config"
|
provides: "Technician profile CRUD"
|
||||||
exports: ["TechnicianService"]
|
exports: ["createTechnicianProfile", "updateTechnicianProfile", "getTechnicianProfile", "listTechnicians"]
|
||||||
- path: "src/lib/services/compensation-service.ts"
|
- path: "src/lib/services/compensation-service.ts"
|
||||||
provides: "Period compensation calculation and summary report"
|
provides: "Compensation calculation and summary report"
|
||||||
exports: ["CompensationService"]
|
exports: ["getCompensationSummary", "getTechnicianCompensationDetail"]
|
||||||
- path: "src/lib/__tests__/compensation-service.test.ts"
|
- path: "src/lib/__tests__/compensation-service.test.ts"
|
||||||
provides: "Integration tests for compensation calculation across models"
|
provides: "Tests for all compensation models (per-job, salary, hybrid) and edge cases"
|
||||||
min_lines: 80
|
min_lines: 120
|
||||||
key_links:
|
key_links:
|
||||||
- from: "src/lib/services/compensation-service.ts"
|
- from: "src/lib/services/compensation-service.ts"
|
||||||
to: "prisma/schema.prisma"
|
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"
|
pattern: "jobOrder\\.findMany|jobTypeRate\\.findMany"
|
||||||
- from: "src/lib/services/compensation-service.ts"
|
- from: "src/lib/services/compensation-service.ts"
|
||||||
to: "src/lib/services/technician-service.ts"
|
to: "src/lib/services/technician-service.ts"
|
||||||
via: "Reads TechnicianProfile for base salary and compensation model"
|
via: "reads TechnicianProfile for compensation model and base salary"
|
||||||
pattern: "technicianProfile|monthlySalary"
|
pattern: "technicianProfile"
|
||||||
---
|
---
|
||||||
|
|
||||||
<objective>
|
<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.
|
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.
|
||||||
Output: TechnicianProfile model, JobTypeRate model (tenant-level rates), TechnicianService for profile management, CompensationService for period calculation, compensation summary report API, integration tests.
|
|
||||||
</objective>
|
</objective>
|
||||||
|
|
||||||
<execution_context>
|
<execution_context>
|
||||||
@@ -67,146 +68,160 @@ Output: TechnicianProfile model, JobTypeRate model (tenant-level rates), Technic
|
|||||||
@.planning/ROADMAP.md
|
@.planning/ROADMAP.md
|
||||||
@.planning/STATE.md
|
@.planning/STATE.md
|
||||||
@.planning/phases/03-operational-modules/03-CONTEXT.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
|
@.planning/phases/03-operational-modules/03-04-SUMMARY.md
|
||||||
@prisma/schema.prisma
|
@prisma/schema.prisma
|
||||||
|
@src/lib/prisma-tenant.ts
|
||||||
@src/lib/services/job-order-service.ts
|
@src/lib/services/job-order-service.ts
|
||||||
|
@src/lib/__tests__/payment.test.ts (test pattern reference)
|
||||||
</context>
|
</context>
|
||||||
|
|
||||||
<tasks>
|
<tasks>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>Task 1: TechnicianProfile and JobTypeRate Prisma models + migration</name>
|
<name>Task 1: TechnicianProfile/JobTypeRate schema, migration, tenant scoping</name>
|
||||||
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts</files>
|
<files>
|
||||||
|
prisma/schema.prisma
|
||||||
|
src/lib/prisma-tenant.ts
|
||||||
|
</files>
|
||||||
<action>
|
<action>
|
||||||
**New enum:**
|
1. Add enum to schema.prisma:
|
||||||
- `CompensationModel { PER_JOB, SALARY, HYBRID }`
|
- `enum CompensationModel { PER_JOB SALARY HYBRID }`
|
||||||
|
|
||||||
**TechnicianProfile model** — extends User with technician-specific data:
|
2. Add TechnicianProfile model:
|
||||||
- id (uuid PK), tenantId
|
- id (uuid), tenantId
|
||||||
- userId (FK to User, unique) — one profile per user
|
- userId (String, FK to User — the technician user, @@unique with tenantId)
|
||||||
- phone (String?)
|
- phone (String?)
|
||||||
- skills (String[]) — array of skill tags, e.g., ["fiber splicing", "router config", "installation"]
|
- skills (String[]) — PostgreSQL array, e.g., ["Installation", "Repair", "Fiber Splicing"]
|
||||||
- zoneId (String? FK to Zone) — primary assigned zone
|
- zoneId (String?, FK to Zone — primary assigned zone)
|
||||||
- compensationModel (CompensationModel default PER_JOB)
|
- compensationModel (CompensationModel, default PER_JOB)
|
||||||
- monthlySalary (Decimal? 10,2) — null if pure per-job
|
- monthlySalary (Decimal? @db.Decimal(10,2)) — null for PER_JOB model, set for SALARY/HYBRID
|
||||||
- isActive (Boolean default true)
|
- isActive (Boolean, default true)
|
||||||
- createdAt, updatedAt
|
- createdAt, updatedAt
|
||||||
- @@unique([tenantId, userId]) — one profile per user per tenant
|
- Relations: user -> User, zone -> Zone
|
||||||
- @@index([tenantId])
|
- @@unique([tenantId, userId]) — one profile per user per tenant
|
||||||
|
- @@index([tenantId])
|
||||||
|
|
||||||
**JobTypeRate model** — tenant-level flat rates per job type:
|
3. Add JobTypeRate model (tenant-level rates, not per-technician):
|
||||||
- id (uuid PK), tenantId
|
- id (uuid), tenantId
|
||||||
- jobType (JobType enum — reuse from 03-04)
|
- jobType (String) — e.g., "Installation", "Repair" — matches JobOrder.jobType
|
||||||
- rate (Decimal 10,2) — flat amount per completed job of this type (e.g., 500.00 for INSTALLATION)
|
- rate (Decimal @db.Decimal(10,2)) — flat rate per completed job of this type
|
||||||
- description (String?) — e.g., "Standard installation rate"
|
- description (String?)
|
||||||
- isActive (Boolean default true)
|
- isActive (Boolean, default true)
|
||||||
- createdAt, updatedAt
|
- createdAt, updatedAt
|
||||||
- @@unique([tenantId, jobType]) — one rate per job type per tenant
|
- @@unique([tenantId, jobType])
|
||||||
- @@index([tenantId])
|
- @@index([tenantId])
|
||||||
|
|
||||||
**Update relations:**
|
4. Add reverse relations:
|
||||||
- User: add `technicianProfile TechnicianProfile?`
|
- User: `technicianProfile TechnicianProfile?`
|
||||||
- Zone: add `technicianProfiles 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>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
- `npx prisma migrate dev` completes without errors
|
- `npx prisma migrate dev` succeeds
|
||||||
- `npx prisma generate` succeeds
|
- `npx tsc --noEmit` passes
|
||||||
- Schema has TechnicianProfile and JobTypeRate models
|
- Grep prisma-tenant.ts confirms "technicianProfile" and "jobTypeRate" in TENANT_SCOPED_MODELS
|
||||||
</verify>
|
</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>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
<name>Task 2: TechnicianService, CompensationService + APIs + tests</name>
|
<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>
|
<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>
|
<action>
|
||||||
**TechnicianService** (`src/lib/services/technician-service.ts`):
|
1. Create src/lib/services/technician-service.ts:
|
||||||
- `createProfile(db, { userId, phone?, skills?, zoneId?, compensationModel, monthlySalary? })`:
|
- `createTechnicianProfile(tenantPrisma, tenantId, { userId, phone?, skills?, zoneId?, compensationModel?, monthlySalary? })`:
|
||||||
1. Validate user has TECHNICIAN role
|
- Validate user exists and has TECHNICIAN role
|
||||||
2. Validate monthlySalary is set if compensationModel is SALARY or HYBRID
|
- Create TechnicianProfile
|
||||||
3. Create TechnicianProfile
|
- `updateTechnicianProfile(tenantPrisma, profileId, { phone?, skills?, zoneId?, compensationModel?, monthlySalary?, isActive? })`:
|
||||||
4. Return profile with user info
|
- If compensationModel changes to PER_JOB, set monthlySalary to null
|
||||||
- `updateProfile(db, profileId, { phone?, skills?, zoneId?, compensationModel?, monthlySalary?, isActive? })`
|
- If compensationModel is SALARY or HYBRID and monthlySalary is not provided, throw
|
||||||
- `getProfile(db, userId)` — get profile by user ID
|
- `getTechnicianProfile(tenantPrisma, profileId)` — include user, zone
|
||||||
- `listTechnicians(db, filters?)` — list all technician profiles for tenant. Filter by zoneId, isActive, compensationModel.
|
- `getTechnicianProfileByUserId(tenantPrisma, userId)` — lookup by user
|
||||||
- `setJobTypeRate(db, { jobType, rate, description? })` — create or update rate for a job type (upsert on @@unique)
|
- `listTechnicians(tenantPrisma, { activeOnly?, zoneId? })` — list with optional filters, include user name, zone, compensation model
|
||||||
- `listJobTypeRates(db)` — list all job type rates for tenant
|
|
||||||
- `deactivateJobTypeRate(db, rateId)` — soft-delete
|
|
||||||
|
|
||||||
**CompensationService** (`src/lib/services/compensation-service.ts`):
|
2. Create src/lib/services/compensation-service.ts:
|
||||||
- `calculateTechnicianCompensation(db, technicianUserId, { periodStart, periodEnd })`:
|
- `getCompensationSummary(tenantPrisma, { periodStart: Date, periodEnd: Date, technicianProfileId? })`:
|
||||||
1. Load technician profile (for compensationModel and monthlySalary)
|
a. Load all active technician profiles (or specific one if filtered)
|
||||||
2. Query completed job orders assigned to this technician within date range
|
b. For each technician:
|
||||||
3. For each completed job order, look up JobTypeRate for the job type
|
- Load COMPLETED job orders where completedAt is between periodStart and periodEnd AND assignedToId = profile.userId
|
||||||
4. Calculate:
|
- Load all JobTypeRates for the tenant. Build rateMap: Map<string, Decimal>
|
||||||
- jobCount: number of completed jobs
|
- Calculate jobBonusTotal: for each completed job, look up rateMap.get(job.jobType) ?? 0 (missing rate = 0, not error per RESEARCH pitfall 6)
|
||||||
- jobBonusTotal: sum of (rate for each job type * count of that type)
|
- Calculate baseSalary: if compensationModel is SALARY or HYBRID, use profile.monthlySalary ?? 0. If PER_JOB, baseSalary = 0.
|
||||||
- baseSalary: monthlySalary if SALARY or HYBRID model, else 0
|
- totalCompensation = baseSalary + jobBonusTotal
|
||||||
- totalCompensation: baseSalary + jobBonusTotal
|
- completedJobCount = number of completed jobs
|
||||||
5. Return: { technicianId, technicianName, compensationModel, baseSalary, jobCount, jobBonusTotal, totalCompensation, jobDetails: [{ jobOrderId, jobType, completedAt, rate }] }
|
c. Return array of { technicianProfileId, technicianName, compensationModel, baseSalary, jobBonusTotal, totalCompensation, completedJobCount }
|
||||||
|
|
||||||
- `getCompensationSummary(db, { periodStart, periodEnd, technicianId? })`:
|
- `getTechnicianCompensationDetail(tenantPrisma, { technicianProfileId, periodStart: Date, periodEnd: Date })`:
|
||||||
1. If technicianId provided, calculate for one technician
|
a. Load technician profile with user
|
||||||
2. Otherwise, calculate for all active technicians
|
b. Load completed job orders in period for this technician
|
||||||
3. Return array of per-technician summaries (same structure as above)
|
c. Load rate map
|
||||||
4. Include grand totals: totalJobs, totalBaseSalary, totalBonuses, grandTotal
|
d. Return { profile info, baseSalary, jobs: [{ orderNumber, jobType, completedAt, rate (from map, 0 if missing), ticketNumber }], jobBonusTotal, totalCompensation }
|
||||||
|
|
||||||
**API Routes:**
|
3. Create API routes:
|
||||||
- `GET /api/technicians` — list technician profiles. ADMIN, OFFICE_STAFF.
|
- GET /api/technicians: withPermission("read", "User") -> listTechnicians (admin/staff)
|
||||||
- `POST /api/technicians` — create profile. ADMIN only. Body: { userId, phone?, skills?, zoneId?, compensationModel, monthlySalary? }
|
- POST /api/technicians: withPermission("manage", "User") -> createTechnicianProfile (admin only)
|
||||||
- `GET /api/technicians/[id]` — get profile detail. ADMIN, OFFICE_STAFF, TECHNICIAN (own only).
|
- GET /api/technicians/[id]: withPermission("read", "User") -> getTechnicianProfile
|
||||||
- `PUT /api/technicians/[id]` — update profile. ADMIN only.
|
- PUT /api/technicians/[id]: withPermission("manage", "User") -> updateTechnicianProfile
|
||||||
- `GET /api/technicians/[id]/compensation` — get compensation for a technician for a period. ADMIN. Query params: periodStart, periodEnd.
|
- GET /api/technicians/[id]/compensation: withPermission("read", "Report") -> getTechnicianCompensationDetail (query: periodStart, periodEnd)
|
||||||
- `GET /api/job-type-rates` — list rates. ADMIN.
|
- GET /api/job-type-rates: withPermission("read", "Report") -> list all rates
|
||||||
- `POST /api/job-type-rates` — set rate (upsert). ADMIN. Body: { jobType, rate, description? }
|
- POST /api/job-type-rates: withPermission("manage", "User") -> create rate (admin)
|
||||||
- `PUT /api/job-type-rates/[id]` — update rate. ADMIN.
|
- PUT /api/job-type-rates/[id]: withPermission("manage", "User") -> update rate
|
||||||
- `GET /api/reports/compensation` — compensation summary for all technicians. ADMIN. Query params: periodStart, periodEnd, technicianId?
|
- GET /api/reports/compensation: withPermission("read", "Report") -> getCompensationSummary (query: periodStart, periodEnd, technicianProfileId?)
|
||||||
|
|
||||||
**Integration Tests** (`src/lib/__tests__/compensation-service.test.ts`):
|
4. Create src/lib/__tests__/compensation-service.test.ts:
|
||||||
- Create technician profile (validates TECHNICIAN role)
|
- 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
|
||||||
- PER_JOB model: 3 completed installations at 500 each = 1500 total
|
- Test: PER_JOB technician — compensation = sum of rates for completed jobs only
|
||||||
- SALARY model: monthly salary of 15000, job count tracked but no per-job bonus
|
- Test: SALARY technician — compensation = monthlySalary only (no job bonus)
|
||||||
- HYBRID model: 15000 salary + 3 installations at 500 = 16500 total
|
- Test: HYBRID technician — compensation = monthlySalary + sum of rates
|
||||||
- Only COMPLETED job orders count (PENDING, IN_PROGRESS, CANCELLED excluded)
|
- Test: missing job type rate defaults to 0 (not error) — create completed job with job type "Custom" that has no rate entry
|
||||||
- Jobs outside date range excluded
|
- Test: only COMPLETED jobs count — PENDING and IN_PROGRESS jobs excluded
|
||||||
- Job type with no configured rate: 0 bonus for that job (not an error)
|
- Test: CANCELLED jobs excluded from compensation
|
||||||
- Compensation summary across multiple technicians with grand totals
|
- Test: date range filter — only jobs completed within period
|
||||||
- Drill-down detail: each job with type, date, rate
|
- Test: getCompensationSummary returns all technicians with correct totals
|
||||||
- Job type rate CRUD (create, update, upsert by jobType)
|
- 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>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
- `npx vitest run src/lib/__tests__/compensation-service.test.ts` — all tests pass
|
- `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>
|
</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>
|
</task>
|
||||||
|
|
||||||
</tasks>
|
</tasks>
|
||||||
|
|
||||||
<verification>
|
<verification>
|
||||||
- Technician profiles: CRUD with skills, zone, compensation model
|
- `npx prisma migrate dev` succeeds
|
||||||
- Job type rates: admin sets flat rates per job type
|
- `npx tsc --noEmit` passes
|
||||||
- PER_JOB compensation: sum of rates for completed jobs only
|
- `npx vitest run src/lib/__tests__/compensation-service.test.ts` — all green
|
||||||
- SALARY compensation: monthly base only
|
- All three compensation models produce correct results
|
||||||
- HYBRID compensation: base + per-job bonuses
|
- Missing rate edge case handled (0, not error)
|
||||||
- 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)
|
|
||||||
</verification>
|
</verification>
|
||||||
|
|
||||||
<success_criteria>
|
<success_criteria>
|
||||||
- TechnicianProfile and JobTypeRate models with migration applied
|
- TechnicianProfile with compensation model (PER_JOB, SALARY, HYBRID)
|
||||||
- TechnicianService manages profiles with hybrid compensation config
|
- JobTypeRate for tenant-level per-job rates
|
||||||
- CompensationService correctly calculates for PER_JOB, SALARY, and HYBRID models
|
- CompensationService correctly calculates all three models
|
||||||
- Period compensation summary with drill-down to individual jobs
|
- Missing job type rate = 0 bonus (not error)
|
||||||
- Job type rates are tenant-level and admin-configurable
|
- Only COMPLETED jobs in date range count
|
||||||
- Integration tests prove all three compensation models with correct calculations
|
- Summary and detail endpoints work
|
||||||
- Full test suite passes with no regressions
|
- Cross-tenant isolation
|
||||||
|
- All integration tests pass
|
||||||
</success_criteria>
|
</success_criteria>
|
||||||
|
|
||||||
<output>
|
<output>
|
||||||
|
|||||||
Reference in New Issue
Block a user