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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user