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) — sequential
- Ready for execution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 06:05:55 +08:00
parent 4b4f7bce3b
commit ef0150654b
6 changed files with 1086 additions and 6 deletions

View File

@@ -72,14 +72,14 @@ Plans:
3. Staff can create a support ticket from a client call, convert it to a job order, assign it to a technician, and the technician can mark it complete with outcome notes — all status transitions are synchronized between ticket and job order
4. The system generates a daily collection summary report per collector showing payments collected, remitted, and any variance
5. Admin can configure per-job compensation rates by job type; the system generates a compensation summary per technician per period correctly for both per-job and monthly-salary models
**Plans**: TBD
**Plans**: 5 plans
Plans:
- [ ] 03-01: Collector zone management zone/territory setup, subscriber-to-collector assignment, collector role scoping (COLL-03, AUTH-03)
- [ ] 03-02: Collector field collection and remittance payment logging by collector, two-party remittance verification, journal entry on verified remittance, daily summary report (COLL-01, COLL-02, COLL-04, COLL-05, COLL-06)
- [ ] 03-03: Ticketing system ticket creation from staff or client call, priority and category, ticket lifecycle (open → assigned → resolved → closed), client portal ticket submission (TICK-01, TICK-05)
- [ ] 03-04: Job order workflow ticket-to-job-order conversion, technician assignment, status tracking (pending → in progress → completed), outcome notes, ticket status synchronization (TICK-02, TICK-03, TICK-04)
- [ ] 03-05: Technician management profiles, compensation model configuration (per-job rates by type, monthly salary), CompensationService, period compensation summary (TECH-01, TECH-02, TECH-03, TECH-04)
- [ ] 03-01-PLAN.md — Zone management: zone CRUD, subscriber-to-zone assignment, collector-to-zone assignment, collector-scoped subscriber queries (COLL-03, AUTH-03)
- [ ] 03-02-PLAN.md — Collector field collection and remittance: FIFO payment via Cash in Transit, two-party remittance verification with JE, daily collection summary report (COLL-01, COLL-02, COLL-04, COLL-05, COLL-06)
- [ ] 03-03-PLAN.md — Ticketing system: ticket CRUD with lifecycle (OPEN/ASSIGNED/RESOLVED/CLOSED), admin-configurable categories with ISP defaults, priority levels (TICK-01, TICK-05)
- [ ] 03-04-PLAN.md — Job order workflow: ticket-to-job conversion (1:many), technician assignment, status lifecycle, auto-resolve ticket when all jobs complete (TICK-02, TICK-03, TICK-04)
- [ ] 03-05-PLAN.md — Technician management: profiles with skills/zone, hybrid compensation model (per-job + salary), CompensationService, period summary report (TECH-01, TECH-02, TECH-03, TECH-04)
---

View File

@@ -0,0 +1,187 @@
---
phase: 03-operational-modules
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
- prisma/schema.prisma
- src/lib/prisma-tenant.ts
- 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
autonomous: true
must_haves:
truths:
- "Admin can create, update, and deactivate zones for their tenant"
- "Admin can assign subscribers to a zone"
- "Admin can assign a collector user to a zone"
- "A collector can only see subscribers assigned to their zone(s)"
artifacts:
- path: "prisma/schema.prisma"
provides: "Zone model with name, description, isActive; ZoneAssignment linking collector users to zones"
contains: "model Zone"
- path: "src/lib/services/zone-service.ts"
provides: "Zone CRUD, subscriber zone assignment, collector zone assignment, getCollectorSubscribers"
exports: ["ZoneService"]
- path: "src/app/api/zones/route.ts"
provides: "GET list zones, POST create zone"
exports: ["GET", "POST"]
- path: "src/lib/__tests__/zone-service.test.ts"
provides: "Integration tests for zone CRUD, assignment, collector scoping"
min_lines: 80
key_links:
- from: "src/lib/services/zone-service.ts"
to: "prisma/schema.prisma"
via: "Prisma client queries on Zone and ZoneAssignment"
pattern: "prisma\\.zone\\."
- from: "src/app/api/collectors/[id]/subscribers/route.ts"
to: "src/lib/services/zone-service.ts"
via: "getCollectorSubscribers returns only zone-scoped subscribers"
pattern: "getCollectorSubscribers"
---
<objective>
Create the zone/territory system that scopes collectors to specific subscriber groups.
Purpose: Zones are the foundation for collector workflow — a collector can only collect from subscribers in their assigned zones. This must exist before collector field collection (03-02) can enforce proper scoping.
Output: Zone Prisma model, zone CRUD service and APIs, collector-to-zone and subscriber-to-zone assignment, scoped subscriber list for collectors, integration tests.
</objective>
<execution_context>
@C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-operational-modules/03-CONTEXT.md
@prisma/schema.prisma
@src/lib/prisma-tenant.ts
@src/lib/services/subscriber-service.ts
@src/lib/middleware/with-permission.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Zone and ZoneAssignment Prisma models + migration</name>
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts</files>
<action>
Add two new models to Prisma schema:
1. **Zone model:**
- id (uuid PK), tenantId, name (String), description (String?), isActive (Boolean default true)
- createdAt, updatedAt
- @@unique([tenantId, name]) — zone names unique per tenant
- @@index([tenantId])
- Relation: subscribers Subscriber[] (via Subscriber.zoneId — update Subscriber to add zoneId optional FK)
- Relation: assignments ZoneAssignment[]
2. **ZoneAssignment model:**
- id (uuid PK), tenantId, zoneId (FK to Zone), userId (FK to User — the collector)
- createdAt
- @@unique([tenantId, zoneId, userId]) — prevent duplicate assignments
- @@index([tenantId]), @@index([userId]), @@index([zoneId])
3. **Update Subscriber model:**
- The Subscriber already has `zone String?` field. Replace it with a proper FK:
- Add `zoneId String?` and `zone Zone? @relation(fields: [zoneId], references: [id])`
- Remove the old `zone String?` field (it was a placeholder for Phase 3)
- Add @@index([tenantId, zoneId])
4. **Update User model:**
- Add relation: `zoneAssignments ZoneAssignment[]`
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.
6. Run `npx prisma migrate dev --name add-zones` to create the migration.
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).
</action>
<verify>
- `npx prisma migrate dev` completes without errors
- `npx prisma generate` succeeds
- Schema has Zone, ZoneAssignment models
- Subscriber has zoneId FK instead of zone String
</verify>
<done>Zone and ZoneAssignment models exist in schema, Subscriber.zoneId replaces zone String, TENANT_SCOPED_MODELS updated, migration applied.</done>
</task>
<task type="auto">
<name>Task 2: ZoneService + API routes + integration tests</name>
<files>src/lib/services/zone-service.ts, src/app/api/zones/route.ts, src/app/api/zones/[id]/route.ts, src/app/api/zones/[id]/subscribers/route.ts, src/app/api/collectors/[id]/subscribers/route.ts, src/lib/__tests__/zone-service.test.ts</files>
<action>
**ZoneService** (`src/lib/services/zone-service.ts`):
- `createZone(db, { name, description })` — creates zone, returns zone
- `updateZone(db, zoneId, { name?, description?, isActive? })` — updates zone
- `listZones(db)` — returns all zones for tenant (active and inactive)
- `assignSubscriberToZone(db, subscriberId, zoneId)` — updates subscriber.zoneId
- `removeSubscriberFromZone(db, subscriberId)` — sets subscriber.zoneId to null
- `assignCollectorToZone(db, userId, zoneId)` — creates ZoneAssignment (validates user has COLLECTOR role)
- `removeCollectorFromZone(db, userId, zoneId)` — deletes ZoneAssignment
- `getCollectorZones(db, userId)` — returns zones assigned to a collector
- `getCollectorSubscribers(db, userId)` — returns subscribers in all zones assigned to this collector (the key scoping query). Include subscriber status and outstanding invoice count for the collector's field view.
- `getZoneSubscribers(db, zoneId)` — returns subscribers in a specific zone
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).
**API Routes:**
- `GET /api/zones` — list zones (ADMIN, OFFICE_STAFF, COLLECTOR can read)
- `POST /api/zones` — create zone (ADMIN only)
- `GET /api/zones/[id]` — get zone detail with subscriber count
- `PUT /api/zones/[id]` — update zone (ADMIN only)
- `POST /api/zones/[id]/subscribers` — assign subscriber to zone, body: { subscriberId }. ADMIN, OFFICE_STAFF.
- `DELETE /api/zones/[id]/subscribers` — remove subscriber from zone, body: { subscriberId }. ADMIN, OFFICE_STAFF.
- `GET /api/collectors/[id]/subscribers` — get subscribers for a specific collector (scoped by zone assignments). ADMIN, OFFICE_STAFF can query any collector; COLLECTOR can only query self.
Use 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).
**Integration Tests** (`src/lib/__tests__/zone-service.test.ts`):
- Zone CRUD (create, update, list, deactivate)
- Zone name uniqueness within tenant
- Subscriber zone assignment and removal
- Collector zone assignment and removal
- getCollectorSubscribers returns only subscribers in collector's zones
- getCollectorSubscribers returns empty for collector with no zone assignments
- Collector cannot be assigned to zone if they don't have COLLECTOR role
- Cross-tenant isolation (zone from tenant A not visible to tenant B)
Follow existing test patterns: beforeAll creates tenant+user+accounts, afterAll cleans up in correct order. Add Zone and ZoneAssignment to cleanup order.
</action>
<verify>
- `npx vitest run src/lib/__tests__/zone-service.test.ts` — all tests pass
- `npx vitest run` — full suite passes (no regressions)
</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>
</task>
</tasks>
<verification>
- Zone CRUD: create, update, deactivate zones
- Subscriber assignment: assign/remove subscriber to/from zone
- Collector assignment: assign/remove collector to/from zone
- Collector scoping: collector sees only their zone's subscribers
- Tenant isolation: zones are tenant-scoped
- All existing tests still pass (no regressions from Subscriber.zone -> zoneId migration)
</verification>
<success_criteria>
- Zone and ZoneAssignment models in Prisma schema with migration applied
- ZoneService handles zone CRUD, subscriber assignment, collector assignment, and scoped queries
- API routes enforce RBAC (admin creates zones, collectors query their subscribers)
- Integration tests prove collector can only see subscribers in their assigned zones
- Full test suite passes with no regressions
</success_criteria>
<output>
After completion, create `.planning/phases/03-operational-modules/03-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,253 @@
---
phase: 03-operational-modules
plan: "02"
type: execute
wave: 2
depends_on: ["03-01"]
files_modified:
- prisma/schema.prisma
- src/lib/prisma-tenant.ts
- src/lib/services/collector-service.ts
- src/lib/services/remittance-service.ts
- src/lib/services/collection-report-service.ts
- src/app/api/collections/route.ts
- src/app/api/collections/[id]/route.ts
- src/app/api/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
autonomous: true
must_haves:
truths:
- "A collector can log a cash payment against a subscriber in the field and the system applies FIFO allocation to outstanding invoices"
- "Total collected and total remitted per collector are derived from the transaction log — no stored balance field"
- "Office staff can verify a remittance by entering their own counted total; variance is recorded but does not block completion"
- "Verified remittance creates a double-entry journal entry (DR Cash on Hand, CR Cash in Transit)"
- "Daily collection summary shows totals per collector: collected, remitted, variance, number of collections"
artifacts:
- path: "prisma/schema.prisma"
provides: "Collection and Remittance models"
contains: "model Collection"
- path: "src/lib/services/collector-service.ts"
provides: "Field collection logging with FIFO allocation reusing PaymentService pattern"
exports: ["CollectorService"]
- path: "src/lib/services/remittance-service.ts"
provides: "Remittance creation and two-party verification with JE posting"
exports: ["RemittanceService"]
- path: "src/lib/services/collection-report-service.ts"
provides: "Daily collection summary per collector"
exports: ["CollectionReportService"]
key_links:
- from: "src/lib/services/collector-service.ts"
to: "src/lib/services/payment-service.ts"
via: "Reuses FIFO allocation pattern for invoice payment"
pattern: "PaymentService|recordPayment"
- from: "src/lib/services/remittance-service.ts"
to: "src/lib/accounting/journal-entry-service.ts"
via: "Creates JE on verified remittance (DR 1010 Cash on Hand, CR 1030 Cash in Transit)"
pattern: "JournalEntryService|createEntry"
- from: "src/lib/services/collection-report-service.ts"
to: "prisma/schema.prisma"
via: "Aggregates Collection and Remittance records for daily summary"
pattern: "collection\\.(findMany|aggregate)"
---
<objective>
Build the collector field collection and remittance workflow with audit trail.
Purpose: This is the core collector workflow — collectors log cash received in the field, the system tracks what they've collected, office staff verify remittances with independent counts, and the accounting ledger records verified cash movements. The daily summary report gives management visibility into collection operations.
Output: Collection model (lump-sum field payment with FIFO allocation), Remittance model (two-party verification), journal entry on verified remittance, daily collection summary report, integration tests.
</objective>
<execution_context>
@C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-operational-modules/03-CONTEXT.md
@.planning/phases/03-operational-modules/03-01-SUMMARY.md
@prisma/schema.prisma
@src/lib/services/payment-service.ts
@src/lib/accounting/journal-entry-service.ts
@src/lib/accounting/chart-of-accounts.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Collection and Remittance Prisma models + new COA account</name>
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts, src/lib/accounting/chart-of-accounts.ts, src/lib/accounting/seed-coa.ts</files>
<action>
**New enum:**
- `RemittanceStatus { PENDING, VERIFIED }`
- `CollectionStatus { COMPLETED, VOIDED }` (mirroring PaymentStatus pattern)
**Collection model** — a field cash collection logged by a collector:
- id (uuid PK), tenantId
- collectorId (FK to User — the collector who collected)
- subscriberId (FK to Subscriber)
- amount (Decimal 10,2) — lump sum received from subscriber
- collectionDate (DateTime) — when cash was received in the field
- notes (String?)
- status (CollectionStatus default COMPLETED)
- remittanceId (String? FK to Remittance — linked when included in a remittance batch)
- paymentId (String? FK to Payment — the underlying Payment record created via PaymentService)
- createdAt, updatedAt
- @@index([tenantId]), @@index([tenantId, collectorId]), @@index([tenantId, collectionDate])
**Remittance model** — a batch handoff of collected cash from collector to office:
- id (uuid PK), tenantId
- collectorId (FK to User)
- remittanceDate (DateTime) — when the collector handed over cash
- collectedTotal (Decimal 10,2) — sum of Collection amounts in this batch (system-calculated)
- verifiedTotal (Decimal 10,2?) — amount counted by office staff (null until verified)
- variance (Decimal 10,2?) — collectedTotal - verifiedTotal (system-calculated on verification)
- status (RemittanceStatus default PENDING)
- verifiedById (String? FK to User — office staff who verified)
- verifiedAt (DateTime?)
- journalEntryId (String?) — JE created on verification
- notes (String?)
- createdAt, updatedAt
- @@index([tenantId]), @@index([tenantId, collectorId]), @@index([tenantId, remittanceDate])
**Update relations:**
- User: add `collections Collection[]`, `remittancesAsCollector Remittance[] @relation("RemittanceCollector")`, `remittancesVerified Remittance[] @relation("RemittanceVerifiedBy")`
- Subscriber: add `collections Collection[]`
**New COA account:**
- Add account 1030 "Cash in Transit" (ASSET, DEBIT normal balance) to ISP_CHART_OF_ACCOUNTS in chart-of-accounts.ts
- This is a child of 1000 (Cash and Cash Equivalents)
- Update seed-coa.ts if needed to include it
- Purpose: When collector collects cash, it's in transit until verified remittance moves it to Cash on Hand (1010)
**Add to TENANT_SCOPED_MODELS:** "collection", "remittance"
Run `npx prisma migrate dev --name add-collections-remittances`
**Journal Entry Pattern for Collection:**
When collector logs a collection, it creates a Payment via PaymentService (reusing FIFO allocation) AND creates a Collection record linking the collector. The Payment JE is: DR 1030 Cash in Transit, CR 1100 AR. Note: use 1030 (not 1010) because cash is with the collector, not yet in the office.
**Journal Entry Pattern for Verified Remittance:**
DR 1010 Cash on Hand (verified amount)
CR 1030 Cash in Transit (verified amount)
This moves the cash from "in transit" to "on hand" upon office verification.
IMPORTANT: The collector collection payment must debit 1030 Cash in Transit (not 1010 Cash on Hand). This means CollectorService needs to create the Payment with a custom account override, or create its own JE pattern. The cleanest approach: CollectorService creates the Payment record directly (reusing the FIFO allocation logic from PaymentService but with 1030 as the debit account instead of 1010/1020). Extract the FIFO allocation logic into a shared helper if needed, or have CollectorService call PaymentService.recordPayment with a parameter indicating collector collection (which uses 1030 instead of 1010).
</action>
<verify>
- `npx prisma migrate dev` completes without errors
- `npx prisma generate` succeeds
- Schema has Collection, Remittance models with correct relations
- chart-of-accounts.ts includes 1030 Cash in Transit
</verify>
<done>Collection and Remittance models exist, 1030 Cash in Transit added to COA, TENANT_SCOPED_MODELS updated, migration applied.</done>
</task>
<task type="auto">
<name>Task 2: CollectorService, RemittanceService, CollectionReportService + APIs + tests</name>
<files>src/lib/services/collector-service.ts, src/lib/services/remittance-service.ts, src/lib/services/collection-report-service.ts, src/app/api/collections/route.ts, src/app/api/collections/[id]/route.ts, src/app/api/remittances/route.ts, src/app/api/remittances/[id]/verify/route.ts, src/app/api/reports/collections/route.ts, src/lib/__tests__/collector-service.test.ts, src/lib/__tests__/remittance-service.test.ts</files>
<action>
**CollectorService** (`src/lib/services/collector-service.ts`):
- `recordCollection(db, { collectorId, subscriberId, amount, collectionDate, notes })`:
1. Verify collector is assigned to subscriber's zone (via ZoneService.getCollectorSubscribers or direct zone check)
2. Create a Payment record using PaymentService.recordPayment (or equivalent FIFO logic) — but with 1030 Cash in Transit as debit account instead of 1010/1020. Generate idempotencyKey as `coll-{collectorId}-{subscriberId}-{timestamp}`.
3. Create a Collection record linking collectorId, subscriberId, paymentId
4. Return the collection with payment allocation details
- `getCollectorCollections(db, collectorId, { dateFrom, dateTo })` — list collections for a collector in date range
- `voidCollection(db, collectionId)` — void the collection and its underlying payment (via PaymentService.voidPayment)
- `getUnremittedCollections(db, collectorId)` — collections not yet linked to a remittance
**RemittanceService** (`src/lib/services/remittance-service.ts`):
- `createRemittance(db, { collectorId, collectionIds, remittanceDate, notes })`:
1. Validate all collectionIds belong to this collector and are unremitted
2. Calculate collectedTotal as sum of collection amounts
3. Create Remittance record with status PENDING
4. Link collections to remittance (update collection.remittanceId)
5. Return remittance
- `verifyRemittance(db, { remittanceId, verifiedById, verifiedTotal, notes })`:
1. Load remittance, verify status is PENDING
2. Calculate variance = collectedTotal - verifiedTotal
3. Create journal entry via JournalEntryService: DR 1010 Cash on Hand (verifiedTotal), CR 1030 Cash in Transit (verifiedTotal). Reference type "Remittance".
4. Update remittance: verifiedTotal, variance, verifiedById, verifiedAt, journalEntryId, status = VERIFIED
5. Variance is recorded but does NOT block — remittance completes regardless
6. Return remittance with variance info
- `getRemittances(db, { collectorId?, dateFrom?, dateTo?, status? })` — list remittances with filters
**CollectionReportService** (`src/lib/services/collection-report-service.ts`):
- `getDailyCollectionSummary(db, { date, collectorId? })`:
1. Query collections for the date (or all collectors if no collectorId)
2. Query remittances for the date
3. Return per-collector summary: { collectorId, collectorName, totalCollected, totalRemitted, variance, collectionCount }
4. Include drill-down data: per-subscriber detail (subscriberName, amount, collectionDate)
**API Routes:**
- `POST /api/collections` — collector logs a collection. COLLECTOR role. Body: { subscriberId, amount, collectionDate, notes? }. Extracts collectorId from session.
- `GET /api/collections` — list collections. COLLECTOR sees own; ADMIN/OFFICE_STAFF see all or filter by collectorId query param.
- `GET /api/collections/[id]` — get collection detail with payment allocation info
- `POST /api/collections/[id]/void` — void a collection. ADMIN, OFFICE_STAFF.
- `POST /api/remittances` — create remittance batch. COLLECTOR role. Body: { collectionIds, remittanceDate, notes? }
- `GET /api/remittances` — list remittances with filters. ADMIN, OFFICE_STAFF, COLLECTOR (own only).
- `POST /api/remittances/[id]/verify` — verify remittance. ADMIN, OFFICE_STAFF only. Body: { verifiedTotal, notes? }
- `GET /api/reports/collections` — daily collection summary. ADMIN, OFFICE_STAFF. Query params: date, collectorId?
**Integration Tests:**
`collector-service.test.ts`:
- Collector can log collection against subscriber in their zone
- Collector cannot collect from subscriber outside their zone
- Collection creates Payment with FIFO allocation (reuses PaymentService pattern)
- Collection uses 1030 Cash in Transit (not 1010)
- Void collection voids underlying payment
- getUnremittedCollections returns only collections not linked to remittance
- Collector balances derived from transactions (no stored balance field) — query collections sum vs remittances sum
`remittance-service.test.ts`:
- Create remittance batch from unremitted collections
- Cannot include already-remitted collections
- Verify remittance with matching total (variance = 0)
- Verify remittance with different total (variance recorded, not blocking)
- Verification creates JE: DR 1010, CR 1030
- Cannot verify already-verified remittance
- Daily collection summary returns correct totals per collector
</action>
<verify>
- `npx vitest run src/lib/__tests__/collector-service.test.ts` — all tests pass
- `npx vitest run src/lib/__tests__/remittance-service.test.ts` — all tests pass
- `npx vitest run` — full suite passes (no regressions)
</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>
</task>
</tasks>
<verification>
- Collector logs collection -> Payment created with FIFO allocation -> debit goes to 1030 Cash in Transit
- Collector creates remittance batch from unremitted collections
- Office staff verifies remittance -> JE posted (DR 1010 Cash on Hand, CR 1030 Cash in Transit)
- Variance tracked but does not block verification
- Daily summary shows per-collector totals: collected, remitted, variance, count
- Collector balances are DERIVED (sum of collections minus sum of verified remittances) — no stored balance
- Zone scoping enforced (collector can only collect from their assigned subscribers)
- All existing tests pass (no regressions)
</verification>
<success_criteria>
- Collection and Remittance models with proper relations and migration
- 1030 Cash in Transit account added to COA
- CollectorService handles field collection with FIFO and zone scoping
- RemittanceService handles batch creation and two-party verification with JE
- CollectionReportService produces daily summary per collector
- Integration tests prove the full collection-to-remittance-to-JE flow
- No stored balance fields — all collector totals derived from transaction log
</success_criteria>
<output>
After completion, create `.planning/phases/03-operational-modules/03-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,220 @@
---
phase: 03-operational-modules
plan: "03"
type: execute
wave: 1
depends_on: []
files_modified:
- prisma/schema.prisma
- src/lib/prisma-tenant.ts
- 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
autonomous: true
must_haves:
truths:
- "Staff can create a support ticket from a client call with issue description, priority, and category"
- "Tickets follow a lifecycle: OPEN -> ASSIGNED -> RESOLVED -> CLOSED"
- "Admin can configure ticket categories per tenant (create, edit, deactivate)"
- "Default ticket categories are seeded at tenant creation"
- "Ticket model supports both staff and subscriber as source types from the start"
artifacts:
- path: "prisma/schema.prisma"
provides: "Ticket and TicketCategory models"
contains: "model Ticket"
- path: "src/lib/services/ticket-service.ts"
provides: "Ticket CRUD, status transitions, search/filter"
exports: ["TicketService"]
- path: "src/lib/services/ticket-category-service.ts"
provides: "TicketCategory CRUD with default seeding"
exports: ["TicketCategoryService"]
- path: "src/lib/__tests__/ticket-service.test.ts"
provides: "Integration tests for ticket lifecycle and category management"
min_lines: 100
key_links:
- from: "src/lib/services/ticket-service.ts"
to: "prisma/schema.prisma"
via: "Prisma queries on Ticket model"
pattern: "prisma\\.ticket\\."
- from: "src/lib/services/ticket-category-service.ts"
to: "src/lib/tenant.ts"
via: "Categories seeded during tenant creation"
pattern: "seedTicketCategories|createTenant"
---
<objective>
Build the ticketing system for tracking customer support issues.
Purpose: Tickets are how customer issues enter the system — staff creates a ticket from a client call, the ticket flows through a lifecycle, and in 03-04 tickets get converted to job orders. The model also supports subscriber-created tickets (Phase 5 portal) from the start to avoid rework.
Output: Ticket and TicketCategory Prisma models, ticket CRUD service with lifecycle management, admin-configurable categories with default seeds, API routes, integration tests.
</objective>
<execution_context>
@C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-operational-modules/03-CONTEXT.md
@prisma/schema.prisma
@src/lib/tenant.ts
@src/lib/services/subscriber-service.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Ticket and TicketCategory Prisma models + category seeding</name>
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts, src/lib/tenant.ts, src/lib/services/ticket-category-service.ts</files>
<action>
**New enums:**
- `TicketStatus { OPEN, ASSIGNED, RESOLVED, CLOSED }`
- `TicketPriority { LOW, MEDIUM, HIGH, URGENT }`
- `TicketSource { STAFF, SUBSCRIBER }` — supports both sources from day one
**TicketCategory model:**
- id (uuid PK), tenantId
- name (String) — e.g., "No Connection", "Slow Speed"
- description (String?)
- isActive (Boolean default true) — soft delete for deactivation
- createdAt, updatedAt
- @@unique([tenantId, name])
- @@index([tenantId])
- Relation: tickets Ticket[]
**Ticket model:**
- id (uuid PK), tenantId
- ticketNumber (String) — auto-generated sequential per tenant, e.g., "TKT-0001"
- subscriberId (FK to Subscriber) — the affected subscriber
- categoryId (FK to TicketCategory)
- source (TicketSource default STAFF)
- createdById (FK to User) — staff who created, or subscriber user in Phase 5
- assignedToId (String? FK to User) — assigned staff member (set when status -> ASSIGNED)
- subject (String) — brief issue summary
- description (String) — detailed issue description
- priority (TicketPriority default MEDIUM)
- status (TicketStatus default OPEN)
- resolvedAt (DateTime?) — when auto-resolved (all job orders completed)
- closedAt (DateTime?) — when staff manually closes after confirming resolution
- closedById (String? FK to User)
- notes (String?) — internal notes
- createdAt, updatedAt
- @@unique([tenantId, ticketNumber])
- @@index([tenantId]), @@index([tenantId, status]), @@index([tenantId, subscriberId]), @@index([tenantId, categoryId])
**Update relations:**
- Subscriber: add `tickets Ticket[]`
- User: add appropriate ticket relations (createdTickets, assignedTickets, closedTickets)
**Add to TENANT_SCOPED_MODELS:** "ticket", "ticketCategory"
**TicketCategoryService** (`src/lib/services/ticket-category-service.ts`):
- `seedDefaultCategories(db, tenantId)` — creates default categories: No Connection, Slow Speed, Billing Inquiry, New Installation, Equipment Issue, Other
- `createCategory(db, { name, description })` — admin creates custom category
- `updateCategory(db, categoryId, { name?, description?, isActive? })` — admin edits/deactivates
- `listCategories(db, { includeInactive? })` — list categories
**Update tenant creation** in `src/lib/tenant.ts`:
- After seedChartOfAccounts in the createTenant $transaction, call seedDefaultCategories to provision default ticket categories for new tenants.
Run `npx prisma migrate dev --name add-tickets`
</action>
<verify>
- `npx prisma migrate dev` completes without errors
- `npx prisma generate` succeeds
- Schema has Ticket and TicketCategory models
- Creating a new tenant seeds 6 default ticket categories
</verify>
<done>Ticket and TicketCategory models exist, default categories seeded at tenant creation, TENANT_SCOPED_MODELS updated, migration applied.</done>
</task>
<task type="auto">
<name>Task 2: TicketService + API routes + integration tests</name>
<files>src/lib/services/ticket-service.ts, src/app/api/tickets/route.ts, src/app/api/tickets/[id]/route.ts, src/app/api/tickets/[id]/status/route.ts, src/app/api/ticket-categories/route.ts, src/app/api/ticket-categories/[id]/route.ts, src/lib/__tests__/ticket-service.test.ts</files>
<action>
**TicketService** (`src/lib/services/ticket-service.ts`):
- `createTicket(db, { subscriberId, categoryId, subject, description, priority, source, createdById })`:
1. Auto-generate ticketNumber (pattern: TKT-NNNN, sequential per tenant — same approach as INV/JE numbers)
2. Create ticket with status OPEN
3. Return ticket with subscriber and category info
- `updateTicket(db, ticketId, { subject?, description?, priority?, categoryId?, notes? })` — update editable fields (not status — status has dedicated transitions)
- `assignTicket(db, ticketId, assignedToId)` — set assignedToId, transition status OPEN -> ASSIGNED
- `resolveTicket(db, ticketId)` — transition to RESOLVED (called by job order completion sync in 03-04, or manually). Set resolvedAt.
- `closeTicket(db, ticketId, closedById)` — transition RESOLVED -> CLOSED. Set closedAt, closedById. This is the manual confirmation step.
- `reopenTicket(db, ticketId)` — RESOLVED -> OPEN (if issue not actually fixed). Clear resolvedAt.
- `getTicket(db, ticketId)` — get ticket with subscriber, category, creator, assignee, and job orders (empty array until 03-04)
- `listTickets(db, filters)` — list with filters: status, priority, categoryId, subscriberId, assignedToId, dateFrom, dateTo. Pagination (skip/take). Sort by createdAt DESC.
**Status transition rules (enforce in service):**
- OPEN -> ASSIGNED (requires assignedToId)
- OPEN -> CLOSED (cancel without resolving)
- ASSIGNED -> OPEN (unassign)
- ASSIGNED -> RESOLVED (direct resolve without job order)
- RESOLVED -> CLOSED (staff confirmation)
- RESOLVED -> OPEN (reopen)
- All other transitions: throw error
**API Routes:**
- `GET /api/tickets` — list tickets with filters. ADMIN, OFFICE_STAFF see all. TECHNICIAN sees assigned only.
- `POST /api/tickets` — create ticket. ADMIN, OFFICE_STAFF. Body: { subscriberId, categoryId, subject, description, priority? }
- `GET /api/tickets/[id]` — get ticket detail
- `PUT /api/tickets/[id]` — update ticket fields. ADMIN, OFFICE_STAFF.
- `POST /api/tickets/[id]/status` — change ticket status. Body: { status, assignedToId? }. ADMIN, OFFICE_STAFF.
- `GET /api/ticket-categories` — list categories. All authenticated users.
- `POST /api/ticket-categories` — create category. ADMIN only.
- `PUT /api/ticket-categories/[id]` — update/deactivate category. ADMIN only.
**Integration Tests** (`src/lib/__tests__/ticket-service.test.ts`):
- Create ticket with auto-generated ticket number
- Ticket number sequential within tenant (TKT-0001, TKT-0002...)
- Status transitions: OPEN -> ASSIGNED -> RESOLVED -> CLOSED (happy path)
- Invalid transition rejected (e.g., OPEN -> RESOLVED without assignment — actually allowed per rules above, test the invalid ones: CLOSED -> OPEN)
- Reopen ticket (RESOLVED -> OPEN)
- List tickets with filters (status, priority, category)
- Category CRUD (create, update, deactivate)
- Deactivated category cannot be used for new tickets
- Default categories seeded on tenant creation
- Cross-tenant isolation
</action>
<verify>
- `npx vitest run src/lib/__tests__/ticket-service.test.ts` — all tests pass
- `npx vitest run` — full suite passes (no regressions)
</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>
</task>
</tasks>
<verification>
- Ticket CRUD: create, update, get, list with filters
- Status lifecycle: OPEN -> ASSIGNED -> RESOLVED -> CLOSED with proper guards
- Reopen: RESOLVED -> OPEN works
- Category management: CRUD with deactivation
- Default categories seeded at tenant creation (6 ISP-relevant categories)
- Ticket numbers are sequential per tenant
- Source field supports STAFF and SUBSCRIBER (Phase 5 ready)
- All existing tests pass (no regressions)
</verification>
<success_criteria>
- Ticket and TicketCategory models with migration applied
- TicketService handles full ticket lifecycle with enforced state transitions
- Admin-configurable categories with 6 defaults seeded at tenant creation
- Ticket model supports both staff and subscriber source types
- API routes enforce RBAC (staff creates, technician views assigned)
- Integration tests prove lifecycle, filtering, and tenant isolation
</success_criteria>
<output>
After completion, create `.planning/phases/03-operational-modules/03-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,206 @@
---
phase: 03-operational-modules
plan: "04"
type: execute
wave: 2
depends_on: ["03-03"]
files_modified:
- prisma/schema.prisma
- src/lib/prisma-tenant.ts
- 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
autonomous: true
must_haves:
truths:
- "Staff can convert a ticket into a job order assigned to a technician"
- "One ticket can have multiple job orders (1:many)"
- "Technician can view their assigned job orders and update status (PENDING -> IN_PROGRESS -> COMPLETED)"
- "Job completion includes outcome notes, completion date"
- "When ALL job orders on a ticket are completed, ticket auto-moves to RESOLVED"
- "Staff manually closes ticket after confirming resolution (two-step: auto-resolve then close)"
artifacts:
- path: "prisma/schema.prisma"
provides: "JobOrder model with status lifecycle and ticket relation"
contains: "model JobOrder"
- path: "src/lib/services/job-order-service.ts"
provides: "Job order CRUD, status transitions, ticket-job synchronization"
exports: ["JobOrderService"]
- path: "src/lib/__tests__/job-order-service.test.ts"
provides: "Integration tests for job order lifecycle and ticket sync"
min_lines: 100
key_links:
- from: "src/lib/services/job-order-service.ts"
to: "src/lib/services/ticket-service.ts"
via: "Auto-resolves ticket when all job orders completed"
pattern: "TicketService|resolveTicket"
- from: "src/app/api/tickets/[id]/job-orders/route.ts"
to: "src/lib/services/job-order-service.ts"
via: "POST creates job order from ticket"
pattern: "createJobOrder"
---
<objective>
Build the job order workflow that converts tickets into assignable technician work.
Purpose: Job orders are how tickets become actionable work for technicians. A ticket can spawn multiple job orders (different visits or skill types). The critical synchronization rule: when all job orders on a ticket complete, the ticket auto-resolves, and staff then manually closes after confirming with the subscriber.
Output: JobOrder Prisma model, job order CRUD with status lifecycle, ticket-to-job conversion, auto-resolution sync, technician self-service status updates, integration tests.
</objective>
<execution_context>
@C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-operational-modules/03-CONTEXT.md
@.planning/phases/03-operational-modules/03-03-SUMMARY.md
@prisma/schema.prisma
@src/lib/services/ticket-service.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: JobOrder Prisma model + migration</name>
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts</files>
<action>
**New enums:**
- `JobOrderStatus { PENDING, IN_PROGRESS, COMPLETED, CANCELLED }`
- `JobType { INSTALLATION, REPAIR, MAINTENANCE, RELOCATION, DISCONNECTION, OTHER }`
**JobOrder model:**
- id (uuid PK), tenantId
- orderNumber (String) — auto-generated sequential per tenant, e.g., "JO-0001"
- ticketId (FK to Ticket) — parent ticket
- assignedToId (FK to User) — the technician
- jobType (JobType)
- description (String) — what needs to be done
- status (JobOrderStatus default PENDING)
- scheduledDate (DateTime?) — optional scheduled date
- startedAt (DateTime?) — when technician started work
- completedAt (DateTime?) — when work was completed
- outcomeNotes (String?) — technician fills in on completion
- cancelledAt (DateTime?)
- cancelReason (String?)
- createdById (FK to User) — staff who created the job order
- createdAt, updatedAt
- @@unique([tenantId, orderNumber])
- @@index([tenantId]), @@index([tenantId, assignedToId]), @@index([tenantId, status]), @@index([ticketId])
**Update relations:**
- Ticket: add `jobOrders JobOrder[]`
- User: add `assignedJobOrders JobOrder[] @relation("JobOrderAssignedTo")`, `createdJobOrders JobOrder[] @relation("JobOrderCreatedBy")`
**Add to TENANT_SCOPED_MODELS:** "jobOrder"
Run `npx prisma migrate dev --name add-job-orders`
</action>
<verify>
- `npx prisma migrate dev` completes without errors
- `npx prisma generate` succeeds
- Schema has JobOrder model with correct enums and relations
</verify>
<done>JobOrder model exists with status lifecycle, ticket relation (1:many), technician assignment, and job type classification. Migration applied.</done>
</task>
<task type="auto">
<name>Task 2: JobOrderService + API routes + integration tests</name>
<files>src/lib/services/job-order-service.ts, src/app/api/job-orders/route.ts, src/app/api/job-orders/[id]/route.ts, src/app/api/job-orders/[id]/status/route.ts, src/app/api/tickets/[id]/job-orders/route.ts, src/lib/__tests__/job-order-service.test.ts</files>
<action>
**JobOrderService** (`src/lib/services/job-order-service.ts`):
- `createJobOrder(db, { ticketId, assignedToId, jobType, description, scheduledDate?, createdById })`:
1. Validate ticket exists and is not CLOSED
2. Validate assignedToId is a user with TECHNICIAN role
3. Auto-generate orderNumber (JO-NNNN per tenant)
4. Create job order with status PENDING
5. If ticket status is OPEN, auto-transition ticket to ASSIGNED (via TicketService.assignTicket with the first technician)
6. Return job order with ticket and technician info
- `updateJobOrder(db, jobOrderId, { description?, scheduledDate?, jobType? })` — update editable fields
- `updateStatus(db, jobOrderId, { status, outcomeNotes?, cancelReason? })`:
Status transitions:
- PENDING -> IN_PROGRESS: set startedAt
- PENDING -> CANCELLED: set cancelledAt, cancelReason
- IN_PROGRESS -> COMPLETED: set completedAt, outcomeNotes (required). Then call `checkTicketAutoResolve`.
- IN_PROGRESS -> CANCELLED: set cancelledAt, cancelReason
- All other transitions: throw error
- `checkTicketAutoResolve(db, ticketId)`:
1. Load all job orders for this ticket
2. If ALL non-cancelled job orders have status COMPLETED, auto-resolve the ticket via TicketService.resolveTicket
3. If there are only cancelled job orders (no completed ones), do NOT auto-resolve
- `reassignJobOrder(db, jobOrderId, newAssignedToId)` — reassign to different technician (only if PENDING or IN_PROGRESS)
- `getJobOrder(db, jobOrderId)` — get detail with ticket, subscriber, technician info
- `listJobOrders(db, filters)` — list with filters: assignedToId, status, jobType, ticketId, dateFrom, dateTo. Pagination. Sort by createdAt DESC.
- `getTechnicianJobOrders(db, technicianId, filters)` — convenience wrapper for technician self-service view
**API Routes:**
- `POST /api/tickets/[id]/job-orders` — create job order from ticket. ADMIN, OFFICE_STAFF. Body: { assignedToId, jobType, description, scheduledDate? }
- `GET /api/job-orders` — list job orders with filters. ADMIN, OFFICE_STAFF see all. TECHNICIAN sees assigned only.
- `GET /api/job-orders/[id]` — get job order detail
- `PUT /api/job-orders/[id]` — update job order fields. ADMIN, OFFICE_STAFF.
- `POST /api/job-orders/[id]/status` — update status. TECHNICIAN can update own (PENDING->IN_PROGRESS, IN_PROGRESS->COMPLETED). ADMIN, OFFICE_STAFF can do any valid transition.
Body: { status, outcomeNotes?, cancelReason? }
**Integration Tests** (`src/lib/__tests__/job-order-service.test.ts`):
- Create job order from ticket (auto-assigns ticket to ASSIGNED status)
- One ticket can have multiple job orders
- Status transitions: PENDING -> IN_PROGRESS -> COMPLETED (happy path)
- Completing last job order auto-resolves parent ticket
- Completing one of two job orders does NOT resolve ticket
- All job orders completed -> ticket auto-resolved -> staff closes ticket
- Cancelled job orders are excluded from auto-resolve check
- Cannot complete job order without outcomeNotes
- Invalid transitions rejected (e.g., COMPLETED -> IN_PROGRESS)
- Reassign job order to different technician
- Technician filter returns only their assigned orders
- Job order number sequential per tenant (JO-0001, JO-0002...)
- Cross-tenant isolation
</action>
<verify>
- `npx vitest run src/lib/__tests__/job-order-service.test.ts` — all tests pass
- `npx vitest run` — full suite passes (no regressions)
</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>
</task>
</tasks>
<verification>
- Create job order from ticket: ticket auto-transitions to ASSIGNED
- One ticket, multiple job orders: each tracks independently
- Status lifecycle: PENDING -> IN_PROGRESS -> COMPLETED with proper guards
- Completion requires outcomeNotes
- Auto-resolution: all non-cancelled job orders COMPLETED -> ticket RESOLVED
- Staff closes ticket (RESOLVED -> CLOSED) as separate manual step
- Technician sees only their assigned job orders
- Job order numbers sequential per tenant
- All existing tests pass (no regressions)
</verification>
<success_criteria>
- JobOrder model with status lifecycle, ticket 1:many relation, and technician assignment
- JobOrderService handles creation from ticket, status transitions, auto-resolution sync
- Technicians can update their own job orders (view assigned, update status)
- Ticket auto-resolves when all non-cancelled job orders complete
- API routes enforce RBAC (staff creates, technician updates own)
- Integration tests prove full ticket-to-job-to-resolution workflow
</success_criteria>
<output>
After completion, create `.planning/phases/03-operational-modules/03-04-SUMMARY.md`
</output>

View File

@@ -0,0 +1,214 @@
---
phase: 03-operational-modules
plan: "05"
type: execute
wave: 3
depends_on: ["03-04"]
files_modified:
- prisma/schema.prisma
- src/lib/prisma-tenant.ts
- 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
autonomous: true
must_haves:
truths:
- "Admin can create technician profiles with contact info, skills, and assigned zone"
- "Admin can set flat compensation rates per job type at the tenant level"
- "Technician can have base monthly salary, per-job bonuses, or hybrid (both optional)"
- "Only completed job orders count toward per-job compensation"
- "System generates compensation summary per technician per period: total jobs, base salary, job bonuses, total"
artifacts:
- path: "prisma/schema.prisma"
provides: "TechnicianProfile, JobTypeRate models"
contains: "model TechnicianProfile"
- path: "src/lib/services/technician-service.ts"
provides: "Technician profile CRUD with zone and compensation config"
exports: ["TechnicianService"]
- path: "src/lib/services/compensation-service.ts"
provides: "Period compensation calculation and summary report"
exports: ["CompensationService"]
- path: "src/lib/__tests__/compensation-service.test.ts"
provides: "Integration tests for compensation calculation across models"
min_lines: 80
key_links:
- from: "src/lib/services/compensation-service.ts"
to: "prisma/schema.prisma"
via: "Queries completed JobOrders by technician and joins with JobTypeRate for per-job amounts"
pattern: "jobOrder\\.findMany|jobTypeRate\\.findMany"
- from: "src/lib/services/compensation-service.ts"
to: "src/lib/services/technician-service.ts"
via: "Reads TechnicianProfile for base salary and compensation model"
pattern: "technicianProfile|monthlySalary"
---
<objective>
Build technician management with hybrid compensation model.
Purpose: Technicians need profiles with skills and zone assignments for proper job routing. The compensation system supports the real-world ISP pattern where technicians can be paid per-job, monthly salary, or a hybrid of both. The compensation summary report gives management visibility into technician costs.
Output: TechnicianProfile model, JobTypeRate model (tenant-level rates), TechnicianService for profile management, CompensationService for period calculation, compensation summary report API, integration tests.
</objective>
<execution_context>
@C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md
@C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-operational-modules/03-CONTEXT.md
@.planning/phases/03-operational-modules/03-04-SUMMARY.md
@prisma/schema.prisma
@src/lib/services/job-order-service.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: TechnicianProfile and JobTypeRate Prisma models + migration</name>
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts</files>
<action>
**New enum:**
- `CompensationModel { PER_JOB, SALARY, HYBRID }`
**TechnicianProfile model** — extends User with technician-specific data:
- id (uuid PK), tenantId
- userId (FK to User, unique) — one profile per user
- phone (String?)
- skills (String[]) — array of skill tags, e.g., ["fiber splicing", "router config", "installation"]
- zoneId (String? FK to Zone) — primary assigned zone
- compensationModel (CompensationModel default PER_JOB)
- monthlySalary (Decimal? 10,2) — null if pure per-job
- isActive (Boolean default true)
- createdAt, updatedAt
- @@unique([tenantId, userId]) — one profile per user per tenant
- @@index([tenantId])
**JobTypeRate model** — tenant-level flat rates per job type:
- id (uuid PK), tenantId
- jobType (JobType enum — reuse from 03-04)
- rate (Decimal 10,2) — flat amount per completed job of this type (e.g., 500.00 for INSTALLATION)
- description (String?) — e.g., "Standard installation rate"
- isActive (Boolean default true)
- createdAt, updatedAt
- @@unique([tenantId, jobType]) — one rate per job type per tenant
- @@index([tenantId])
**Update relations:**
- User: add `technicianProfile TechnicianProfile?`
- Zone: add `technicianProfiles TechnicianProfile[]`
**Add to TENANT_SCOPED_MODELS:** "technicianProfile", "jobTypeRate"
Run `npx prisma migrate dev --name add-technician-compensation`
</action>
<verify>
- `npx prisma migrate dev` completes without errors
- `npx prisma generate` succeeds
- Schema has TechnicianProfile and JobTypeRate models
</verify>
<done>TechnicianProfile and JobTypeRate models exist with proper relations, compensation model enum, and migration applied.</done>
</task>
<task type="auto">
<name>Task 2: TechnicianService, CompensationService + APIs + tests</name>
<files>src/lib/services/technician-service.ts, src/lib/services/compensation-service.ts, src/app/api/technicians/route.ts, src/app/api/technicians/[id]/route.ts, src/app/api/technicians/[id]/compensation/route.ts, src/app/api/job-type-rates/route.ts, src/app/api/job-type-rates/[id]/route.ts, src/app/api/reports/compensation/route.ts, src/lib/__tests__/compensation-service.test.ts</files>
<action>
**TechnicianService** (`src/lib/services/technician-service.ts`):
- `createProfile(db, { userId, phone?, skills?, zoneId?, compensationModel, monthlySalary? })`:
1. Validate user has TECHNICIAN role
2. Validate monthlySalary is set if compensationModel is SALARY or HYBRID
3. Create TechnicianProfile
4. Return profile with user info
- `updateProfile(db, profileId, { phone?, skills?, zoneId?, compensationModel?, monthlySalary?, isActive? })`
- `getProfile(db, userId)` — get profile by user ID
- `listTechnicians(db, filters?)` — list all technician profiles for tenant. Filter by zoneId, isActive, compensationModel.
- `setJobTypeRate(db, { jobType, rate, description? })` — create or update rate for a job type (upsert on @@unique)
- `listJobTypeRates(db)` — list all job type rates for tenant
- `deactivateJobTypeRate(db, rateId)` — soft-delete
**CompensationService** (`src/lib/services/compensation-service.ts`):
- `calculateTechnicianCompensation(db, technicianUserId, { periodStart, periodEnd })`:
1. Load technician profile (for compensationModel and monthlySalary)
2. Query completed job orders assigned to this technician within date range
3. For each completed job order, look up JobTypeRate for the job type
4. Calculate:
- jobCount: number of completed jobs
- jobBonusTotal: sum of (rate for each job type * count of that type)
- baseSalary: monthlySalary if SALARY or HYBRID model, else 0
- totalCompensation: baseSalary + jobBonusTotal
5. Return: { technicianId, technicianName, compensationModel, baseSalary, jobCount, jobBonusTotal, totalCompensation, jobDetails: [{ jobOrderId, jobType, completedAt, rate }] }
- `getCompensationSummary(db, { periodStart, periodEnd, technicianId? })`:
1. If technicianId provided, calculate for one technician
2. Otherwise, calculate for all active technicians
3. Return array of per-technician summaries (same structure as above)
4. Include grand totals: totalJobs, totalBaseSalary, totalBonuses, grandTotal
**API Routes:**
- `GET /api/technicians` — list technician profiles. ADMIN, OFFICE_STAFF.
- `POST /api/technicians` — create profile. ADMIN only. Body: { userId, phone?, skills?, zoneId?, compensationModel, monthlySalary? }
- `GET /api/technicians/[id]` — get profile detail. ADMIN, OFFICE_STAFF, TECHNICIAN (own only).
- `PUT /api/technicians/[id]` — update profile. ADMIN only.
- `GET /api/technicians/[id]/compensation` — get compensation for a technician for a period. ADMIN. Query params: periodStart, periodEnd.
- `GET /api/job-type-rates` — list rates. ADMIN.
- `POST /api/job-type-rates` — set rate (upsert). ADMIN. Body: { jobType, rate, description? }
- `PUT /api/job-type-rates/[id]` — update rate. ADMIN.
- `GET /api/reports/compensation` — compensation summary for all technicians. ADMIN. Query params: periodStart, periodEnd, technicianId?
**Integration Tests** (`src/lib/__tests__/compensation-service.test.ts`):
- Create technician profile (validates TECHNICIAN role)
- PER_JOB model: 3 completed installations at 500 each = 1500 total
- SALARY model: monthly salary of 15000, job count tracked but no per-job bonus
- HYBRID model: 15000 salary + 3 installations at 500 = 16500 total
- Only COMPLETED job orders count (PENDING, IN_PROGRESS, CANCELLED excluded)
- Jobs outside date range excluded
- Job type with no configured rate: 0 bonus for that job (not an error)
- Compensation summary across multiple technicians with grand totals
- Drill-down detail: each job with type, date, rate
- Job type rate CRUD (create, update, upsert by jobType)
</action>
<verify>
- `npx vitest run src/lib/__tests__/compensation-service.test.ts` — all tests pass
- `npx vitest run` — full suite passes (no regressions)
</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>
</task>
</tasks>
<verification>
- Technician profiles: CRUD with skills, zone, compensation model
- Job type rates: admin sets flat rates per job type
- PER_JOB compensation: sum of rates for completed jobs only
- SALARY compensation: monthly base only
- HYBRID compensation: base + per-job bonuses
- Period summary: per-technician totals + grand totals
- Drill-down: per-job detail (type, date, rate)
- Only completed jobs count — no partial credit
- All existing tests pass (no regressions)
</verification>
<success_criteria>
- TechnicianProfile and JobTypeRate models with migration applied
- TechnicianService manages profiles with hybrid compensation config
- CompensationService correctly calculates for PER_JOB, SALARY, and HYBRID models
- Period compensation summary with drill-down to individual jobs
- Job type rates are tenant-level and admin-configurable
- Integration tests prove all three compensation models with correct calculations
- Full test suite passes with no regressions
</success_criteria>
<output>
After completion, create `.planning/phases/03-operational-modules/03-05-SUMMARY.md`
</output>