Files
NetForge/.planning/phases/03-operational-modules/03-05-PLAN.md
kevin-asprec d54b517e2e 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>
2026-03-05 07:11:44 +08:00

230 lines
12 KiB
Markdown

---
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, 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 and JobTypeRate models with compensation enums"
contains: "model TechnicianProfile"
- path: "src/lib/services/technician-service.ts"
provides: "Technician profile CRUD"
exports: ["createTechnicianProfile", "updateTechnicianProfile", "getTechnicianProfile", "listTechnicians"]
- path: "src/lib/services/compensation-service.ts"
provides: "Compensation calculation and summary report"
exports: ["getCompensationSummary", "getTechnicianCompensationDetail"]
- path: "src/lib/__tests__/compensation-service.test.ts"
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 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 compensation model and base salary"
pattern: "technicianProfile"
---
<objective>
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: 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>
@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-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/JobTypeRate schema, migration, tenant scoping</name>
<files>
prisma/schema.prisma
src/lib/prisma-tenant.ts
</files>
<action>
1. Add enum to schema.prisma:
- `enum CompensationModel { PER_JOB SALARY HYBRID }`
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])
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])
4. Add reverse relations:
- User: `technicianProfile TechnicianProfile?`
- Zone: `technicianProfiles TechnicianProfile[]`
5. Run `npx prisma migrate dev --name add-technician-profiles`
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` succeeds
- `npx tsc --noEmit` passes
- Grep prisma-tenant.ts confirms "technicianProfile" and "jobTypeRate" in TENANT_SCOPED_MODELS
</verify>
<done>TechnicianProfile and JobTypeRate models exist, migration applied, tenant scoping configured.</done>
</task>
<task type="auto">
<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>
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
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 }
- `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 }
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?)
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 tsc --noEmit` passes
</verify>
<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>
- `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 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>
After completion, create `.planning/phases/03-operational-modules/03-05-SUMMARY.md`
</output>