docs(03): create phase plan

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

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

View File

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