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

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