feat(03-05): technician service, compensation service, APIs, and 27 passing tests
- technician-service.ts: createTechnicianProfile, updateTechnicianProfile, getTechnicianProfile, getTechnicianProfileByUserId, listTechnicians - compensation-service.ts: getCompensationSummary (all 3 models), getTechnicianCompensationDetail (job-by-job) - 6 API routes: GET/POST /api/technicians, GET/PUT /api/technicians/[id], GET /api/technicians/[id]/compensation, GET/POST /api/job-type-rates, PUT /api/job-type-rates/[id], GET /api/reports/compensation - Added TechnicianProfile and JobTypeRate to CASL AppSubjects; OFFICE_STAFF read access - 27 integration tests: all 3 models, missing rate defaults to 0, only COMPLETED count, date range filter, summary+detail reports, cross-tenant isolation — all green
This commit is contained in:
75
src/app/api/job-type-rates/[id]/route.ts
Normal file
75
src/app/api/job-type-rates/[id]/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { withPermission } from "@/lib/middleware/authorize";
|
||||
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||
|
||||
/**
|
||||
* PUT /api/job-type-rates/[id]
|
||||
*
|
||||
* Update a job type rate.
|
||||
*
|
||||
* Accepts: { rate?, description?, isActive? }
|
||||
* Note: jobType is immutable (it's part of the unique key).
|
||||
*
|
||||
* Requires: update on JobTypeRate subject (ADMIN only).
|
||||
*
|
||||
* Response:
|
||||
* 200 OK — updated job type rate
|
||||
* 400 Bad Request — validation error
|
||||
* 404 Not Found — rate not found in tenant scope
|
||||
* 401 Unauthorized — no session
|
||||
* 403 Forbidden — insufficient role
|
||||
*/
|
||||
export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
return withPermission("update", "JobTypeRate")(
|
||||
async (req: NextRequest, { user }) => {
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No tenant context — super-admins must use the admin API" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rate, description, isActive } = body as Record<string, unknown>;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
if (rate !== undefined) {
|
||||
if (isNaN(Number(rate)) || Number(rate) < 0) {
|
||||
return NextResponse.json({ error: "rate must be a valid non-negative number" }, { status: 400 });
|
||||
}
|
||||
data.rate = Number(rate);
|
||||
}
|
||||
|
||||
if (description !== undefined) data.description = description ? String(description).trim() : null;
|
||||
if (isActive !== undefined) data.isActive = Boolean(isActive);
|
||||
|
||||
const tenantPrisma = withTenantContext(user.tenantId);
|
||||
|
||||
// Verify exists
|
||||
const existing = await tenantPrisma.jobTypeRate.findFirst({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Job type rate not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await tenantPrisma.jobTypeRate.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to update job type rate";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
)(req);
|
||||
}
|
||||
121
src/app/api/job-type-rates/route.ts
Normal file
121
src/app/api/job-type-rates/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { withPermission } from "@/lib/middleware/authorize";
|
||||
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||
|
||||
/**
|
||||
* GET /api/job-type-rates
|
||||
*
|
||||
* List all job type rates for the current tenant.
|
||||
*
|
||||
* Query params: activeOnly? (boolean)
|
||||
*
|
||||
* Requires: read on JobTypeRate subject.
|
||||
*
|
||||
* Response:
|
||||
* 200 OK — { rates, total }
|
||||
* 401 Unauthorized — no session
|
||||
* 403 Forbidden — insufficient role
|
||||
*/
|
||||
export function GET(req: NextRequest) {
|
||||
return withPermission("read", "JobTypeRate")(
|
||||
async (req: NextRequest, { user }) => {
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No tenant context — super-admins must use the admin API" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const activeOnly = url.searchParams.get("activeOnly") === "true";
|
||||
|
||||
const tenantPrisma = withTenantContext(user.tenantId);
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (activeOnly) where.isActive = true;
|
||||
|
||||
const [rates, total] = await Promise.all([
|
||||
tenantPrisma.jobTypeRate.findMany({
|
||||
where,
|
||||
orderBy: { jobType: "asc" },
|
||||
}),
|
||||
tenantPrisma.jobTypeRate.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ rates, total });
|
||||
}
|
||||
)(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/job-type-rates
|
||||
*
|
||||
* Create a new job type rate.
|
||||
*
|
||||
* Accepts: { jobType, rate, description? }
|
||||
*
|
||||
* Requires: create on JobTypeRate subject (ADMIN only).
|
||||
*
|
||||
* Response:
|
||||
* 201 Created — created job type rate
|
||||
* 400 Bad Request — validation error or duplicate jobType
|
||||
* 401 Unauthorized — no session
|
||||
* 403 Forbidden — insufficient role
|
||||
*/
|
||||
export function POST(req: NextRequest) {
|
||||
return withPermission("create", "JobTypeRate")(
|
||||
async (req: NextRequest, { user }) => {
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No tenant context — super-admins must use the admin API" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { jobType, rate, description } = body as Record<string, unknown>;
|
||||
|
||||
if (!jobType || typeof jobType !== "string" || !jobType.trim()) {
|
||||
return NextResponse.json({ error: "jobType is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (rate === undefined || rate === null || isNaN(Number(rate))) {
|
||||
return NextResponse.json({ error: "rate must be a valid number" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (Number(rate) < 0) {
|
||||
return NextResponse.json({ error: "rate must be >= 0" }, { status: 400 });
|
||||
}
|
||||
|
||||
const tenantPrisma = withTenantContext(user.tenantId);
|
||||
|
||||
try {
|
||||
const created = await tenantPrisma.jobTypeRate.create({
|
||||
data: {
|
||||
tenantId: user.tenantId,
|
||||
jobType: (jobType as string).trim(),
|
||||
rate: Number(rate),
|
||||
description: description ? String(description).trim() : null,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create job type rate";
|
||||
if (message.includes("Unique constraint") || message.includes("P2002")) {
|
||||
return NextResponse.json(
|
||||
{ error: `A rate for job type "${jobType}" already exists` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
)(req);
|
||||
}
|
||||
73
src/app/api/reports/compensation/route.ts
Normal file
73
src/app/api/reports/compensation/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { withPermission } from "@/lib/middleware/authorize";
|
||||
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||
import { getCompensationSummary } from "@/lib/services/compensation-service";
|
||||
|
||||
/**
|
||||
* GET /api/reports/compensation
|
||||
*
|
||||
* Get compensation summary report for all technicians in a period.
|
||||
*
|
||||
* Query params:
|
||||
* periodStart (required) — ISO date string (inclusive)
|
||||
* periodEnd (required) — ISO date string (inclusive)
|
||||
* technicianProfileId? — filter to a single technician
|
||||
*
|
||||
* Requires: read on TechnicianProfile subject (ADMIN, OFFICE_STAFF).
|
||||
*
|
||||
* Response:
|
||||
* 200 OK — { periodStart, periodEnd, technicians, grandTotal }
|
||||
* Each technician: { technicianProfileId, userId, firstName, lastName,
|
||||
* compensationModel, baseSalary, jobBonusTotal, totalCompensation, completedJobCount }
|
||||
* 400 Bad Request — missing/invalid period params
|
||||
* 401 Unauthorized — no session
|
||||
* 403 Forbidden — insufficient role
|
||||
*/
|
||||
export function GET(req: NextRequest) {
|
||||
return withPermission("read", "TechnicianProfile")(
|
||||
async (req: NextRequest, { user }) => {
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No tenant context — super-admins must use the admin API" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const periodStartParam = url.searchParams.get("periodStart");
|
||||
const periodEndParam = url.searchParams.get("periodEnd");
|
||||
const technicianProfileId = url.searchParams.get("technicianProfileId") ?? undefined;
|
||||
|
||||
if (!periodStartParam || !periodEndParam) {
|
||||
return NextResponse.json(
|
||||
{ error: "periodStart and periodEnd query params are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const periodStart = new Date(periodStartParam);
|
||||
const periodEnd = new Date(periodEndParam);
|
||||
|
||||
if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) {
|
||||
return NextResponse.json(
|
||||
{ error: "periodStart and periodEnd must be valid ISO date strings" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tenantPrisma = withTenantContext(user.tenantId);
|
||||
|
||||
try {
|
||||
const summary = await getCompensationSummary(tenantPrisma, {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
technicianProfileId,
|
||||
});
|
||||
return NextResponse.json(summary);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to generate compensation report";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
)(req);
|
||||
}
|
||||
75
src/app/api/technicians/[id]/compensation/route.ts
Normal file
75
src/app/api/technicians/[id]/compensation/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { withPermission } from "@/lib/middleware/authorize";
|
||||
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||
import { getTechnicianCompensationDetail } from "@/lib/services/compensation-service";
|
||||
|
||||
/**
|
||||
* GET /api/technicians/[id]/compensation
|
||||
*
|
||||
* Get compensation detail (job-by-job breakdown) for a specific technician.
|
||||
*
|
||||
* Query params:
|
||||
* periodStart (required) — ISO date string (inclusive)
|
||||
* periodEnd (required) — ISO date string (inclusive)
|
||||
*
|
||||
* Requires: read on TechnicianProfile subject.
|
||||
*
|
||||
* Response:
|
||||
* 200 OK — { technicianProfileId, userId, firstName, lastName, compensationModel,
|
||||
* periodStart, periodEnd, baseSalary, jobs, jobBonusTotal, totalCompensation }
|
||||
* 400 Bad Request — missing/invalid period params
|
||||
* 404 Not Found — profile not found in tenant scope
|
||||
* 401 Unauthorized — no session
|
||||
* 403 Forbidden — insufficient role
|
||||
*/
|
||||
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
return withPermission("read", "TechnicianProfile")(
|
||||
async (req: NextRequest, { user }) => {
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No tenant context — super-admins must use the admin API" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const url = new URL(req.url);
|
||||
const periodStartParam = url.searchParams.get("periodStart");
|
||||
const periodEndParam = url.searchParams.get("periodEnd");
|
||||
|
||||
if (!periodStartParam || !periodEndParam) {
|
||||
return NextResponse.json(
|
||||
{ error: "periodStart and periodEnd query params are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const periodStart = new Date(periodStartParam);
|
||||
const periodEnd = new Date(periodEndParam);
|
||||
|
||||
if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) {
|
||||
return NextResponse.json(
|
||||
{ error: "periodStart and periodEnd must be valid ISO date strings" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tenantPrisma = withTenantContext(user.tenantId);
|
||||
|
||||
try {
|
||||
const detail = await getTechnicianCompensationDetail(tenantPrisma, {
|
||||
technicianProfileId: id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
return NextResponse.json(detail);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to get compensation detail";
|
||||
if (message.includes("not found")) {
|
||||
return NextResponse.json({ error: message }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
)(req);
|
||||
}
|
||||
110
src/app/api/technicians/[id]/route.ts
Normal file
110
src/app/api/technicians/[id]/route.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { withPermission } from "@/lib/middleware/authorize";
|
||||
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||
import {
|
||||
getTechnicianProfile,
|
||||
updateTechnicianProfile,
|
||||
} from "@/lib/services/technician-service";
|
||||
import { CompensationModel } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* GET /api/technicians/[id]
|
||||
*
|
||||
* Get a single technician profile by ID.
|
||||
*
|
||||
* Requires: read on TechnicianProfile subject.
|
||||
*
|
||||
* Response:
|
||||
* 200 OK — technician profile with user and zone
|
||||
* 404 Not Found — profile not found in tenant scope
|
||||
* 401 Unauthorized — no session
|
||||
* 403 Forbidden — insufficient role
|
||||
*/
|
||||
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
return withPermission("read", "TechnicianProfile")(
|
||||
async (_req: NextRequest, { user }) => {
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No tenant context — super-admins must use the admin API" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const tenantPrisma = withTenantContext(user.tenantId);
|
||||
const profile = await getTechnicianProfile(tenantPrisma, id);
|
||||
|
||||
if (!profile) {
|
||||
return NextResponse.json({ error: "Technician profile not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(profile);
|
||||
}
|
||||
)(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/technicians/[id]
|
||||
*
|
||||
* Update a technician profile.
|
||||
*
|
||||
* Accepts: { phone?, skills?, zoneId?, compensationModel?, monthlySalary?, isActive? }
|
||||
*
|
||||
* Requires: update on TechnicianProfile subject (ADMIN only).
|
||||
*
|
||||
* Response:
|
||||
* 200 OK — updated technician profile
|
||||
* 400 Bad Request — validation error
|
||||
* 404 Not Found — profile not found
|
||||
* 401 Unauthorized — no session
|
||||
* 403 Forbidden — insufficient role
|
||||
*/
|
||||
export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
return withPermission("update", "TechnicianProfile")(
|
||||
async (req: NextRequest, { user }) => {
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No tenant context — super-admins must use the admin API" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { phone, skills, zoneId, compensationModel, monthlySalary, isActive } =
|
||||
body as Record<string, unknown>;
|
||||
|
||||
const tenantPrisma = withTenantContext(user.tenantId);
|
||||
|
||||
try {
|
||||
const profile = await updateTechnicianProfile(tenantPrisma, id, {
|
||||
phone: phone as string | undefined,
|
||||
skills: Array.isArray(skills) ? (skills as string[]) : undefined,
|
||||
zoneId: zoneId === null ? null : (zoneId as string | undefined),
|
||||
compensationModel: compensationModel as CompensationModel | undefined,
|
||||
monthlySalary:
|
||||
monthlySalary === null
|
||||
? null
|
||||
: monthlySalary !== undefined
|
||||
? Number(monthlySalary)
|
||||
: undefined,
|
||||
isActive: isActive !== undefined ? Boolean(isActive) : undefined,
|
||||
});
|
||||
return NextResponse.json(profile);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to update technician profile";
|
||||
if (message.includes("not found")) {
|
||||
return NextResponse.json({ error: message }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
)(req);
|
||||
}
|
||||
104
src/app/api/technicians/route.ts
Normal file
104
src/app/api/technicians/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { withPermission } from "@/lib/middleware/authorize";
|
||||
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||
import {
|
||||
listTechnicians,
|
||||
createTechnicianProfile,
|
||||
} from "@/lib/services/technician-service";
|
||||
import { CompensationModel } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* GET /api/technicians
|
||||
*
|
||||
* List technician profiles for the current tenant.
|
||||
*
|
||||
* Query params: activeOnly? (boolean), zoneId? (string)
|
||||
*
|
||||
* Requires: read on TechnicianProfile subject (ADMIN, OFFICE_STAFF).
|
||||
*
|
||||
* Response:
|
||||
* 200 OK — { profiles, total }
|
||||
* 401 Unauthorized — no session
|
||||
* 403 Forbidden — insufficient role
|
||||
*/
|
||||
export function GET(req: NextRequest) {
|
||||
return withPermission("read", "TechnicianProfile")(
|
||||
async (req: NextRequest, { user }) => {
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No tenant context — super-admins must use the admin API" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const activeOnly = url.searchParams.get("activeOnly") === "true";
|
||||
const zoneId = url.searchParams.get("zoneId") ?? undefined;
|
||||
|
||||
const tenantPrisma = withTenantContext(user.tenantId);
|
||||
const result = await listTechnicians(tenantPrisma, { activeOnly, zoneId });
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
)(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/technicians
|
||||
*
|
||||
* Create a new technician profile.
|
||||
*
|
||||
* Accepts: { userId, phone?, skills?, zoneId?, compensationModel?, monthlySalary? }
|
||||
*
|
||||
* Requires: create on TechnicianProfile subject (ADMIN only).
|
||||
*
|
||||
* Response:
|
||||
* 201 Created — created technician profile
|
||||
* 400 Bad Request — validation error
|
||||
* 401 Unauthorized — no session
|
||||
* 403 Forbidden — insufficient role
|
||||
*/
|
||||
export function POST(req: NextRequest) {
|
||||
return withPermission("create", "TechnicianProfile")(
|
||||
async (req: NextRequest, { user }) => {
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No tenant context — super-admins must use the admin API" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { userId, phone, skills, zoneId, compensationModel, monthlySalary } =
|
||||
body as Record<string, unknown>;
|
||||
|
||||
if (!userId || typeof userId !== "string") {
|
||||
return NextResponse.json({ error: "userId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const tenantPrisma = withTenantContext(user.tenantId);
|
||||
|
||||
try {
|
||||
const profile = await createTechnicianProfile(tenantPrisma, user.tenantId, {
|
||||
userId: userId as string,
|
||||
phone: phone as string | undefined,
|
||||
skills: Array.isArray(skills) ? (skills as string[]) : undefined,
|
||||
zoneId: zoneId as string | undefined,
|
||||
compensationModel: compensationModel as CompensationModel | undefined,
|
||||
monthlySalary:
|
||||
monthlySalary !== undefined ? Number(monthlySalary) : undefined,
|
||||
});
|
||||
return NextResponse.json(profile, { status: 201 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create technician profile";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
)(req);
|
||||
}
|
||||
826
src/lib/__tests__/compensation-service.test.ts
Normal file
826
src/lib/__tests__/compensation-service.test.ts
Normal file
@@ -0,0 +1,826 @@
|
||||
/**
|
||||
* Compensation Service Integration Tests
|
||||
*
|
||||
* Tests all three compensation models and edge cases:
|
||||
* - PER_JOB: sum of rates for completed jobs
|
||||
* - SALARY: monthlySalary only (no per-job bonuses)
|
||||
* - HYBRID: monthlySalary + sum of rates
|
||||
* - Missing job type rate defaults to 0 (not error)
|
||||
* - Only COMPLETED jobs count (PENDING, IN_PROGRESS, CANCELLED excluded)
|
||||
* - CANCELLED jobs excluded
|
||||
* - Date range filter (jobs outside period not counted)
|
||||
* - Summary returns all technicians with correct totals
|
||||
* - Detail returns job-by-job breakdown
|
||||
* - Technician with no completed jobs = 0 job bonus
|
||||
* - Cross-tenant isolation
|
||||
*
|
||||
* CLEANUP ORDER:
|
||||
* jobOrders -> tickets -> ticketCategories -> jobTypeRates -> technicianProfiles
|
||||
* -> zones -> subscribers -> servicePlans -> users -> tenant
|
||||
*
|
||||
* These tests require a live PostgreSQL database connection.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||
import { BillingType, CompensationModel, JobOrderStatus, TenantStatus } from "@prisma/client";
|
||||
import {
|
||||
createTechnicianProfile,
|
||||
listTechnicians,
|
||||
updateTechnicianProfile,
|
||||
getTechnicianProfile,
|
||||
getTechnicianProfileByUserId,
|
||||
} from "@/lib/services/technician-service";
|
||||
import {
|
||||
getCompensationSummary,
|
||||
getTechnicianCompensationDetail,
|
||||
} from "@/lib/services/compensation-service";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TS = Date.now();
|
||||
|
||||
let tenantId: string;
|
||||
let tenantBId: string;
|
||||
let adminUserId: string;
|
||||
|
||||
// Technician users
|
||||
let techAUserId: string; // HYBRID model
|
||||
let techBUserId: string; // PER_JOB model
|
||||
let techCUserId: string; // SALARY model
|
||||
|
||||
// Profiles
|
||||
let profileAId: string;
|
||||
let profileBId: string;
|
||||
let profileCId: string;
|
||||
|
||||
// Job type rates
|
||||
let installRateId: string;
|
||||
let repairRateId: string;
|
||||
|
||||
// Subscriber + ticket for creating job orders
|
||||
let subscriberId: string;
|
||||
let ticketId: string;
|
||||
let ticketCategoryId: string;
|
||||
let planId: string;
|
||||
let zoneId: string;
|
||||
|
||||
// Counters for unique order numbers
|
||||
let joCounter = 0;
|
||||
let ticketCounter = 0;
|
||||
|
||||
function tA() {
|
||||
return withTenantContext(tenantId);
|
||||
}
|
||||
|
||||
function tB() {
|
||||
return withTenantContext(tenantBId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createJobOrder(
|
||||
assignedToId: string,
|
||||
jobType: string,
|
||||
status: JobOrderStatus,
|
||||
completedAt?: Date
|
||||
) {
|
||||
joCounter++;
|
||||
const orderNumber = `JO-COMP-${TS}-${joCounter}`;
|
||||
|
||||
const jo = await prisma.jobOrder.create({
|
||||
data: {
|
||||
tenantId,
|
||||
orderNumber,
|
||||
ticketId,
|
||||
jobType,
|
||||
description: `Test job ${joCounter}`,
|
||||
assignedToId,
|
||||
createdById: adminUserId,
|
||||
status,
|
||||
scheduledDate: null,
|
||||
startedAt: status !== JobOrderStatus.PENDING ? new Date() : null,
|
||||
completedAt: completedAt ?? (status === JobOrderStatus.COMPLETED ? new Date() : null),
|
||||
cancelledAt: status === JobOrderStatus.CANCELLED ? new Date() : null,
|
||||
outcomeNotes: status === JobOrderStatus.COMPLETED ? "Work complete" : null,
|
||||
},
|
||||
});
|
||||
|
||||
return jo;
|
||||
}
|
||||
|
||||
async function createTicket(): Promise<string> {
|
||||
ticketCounter++;
|
||||
const t = await prisma.ticket.create({
|
||||
data: {
|
||||
tenantId,
|
||||
ticketNumber: `TKT-COMP-${TS}-${ticketCounter}`,
|
||||
subject: `Test Ticket ${ticketCounter}`,
|
||||
description: "Test",
|
||||
categoryId: ticketCategoryId,
|
||||
priority: "MEDIUM",
|
||||
status: "OPEN",
|
||||
source: "STAFF",
|
||||
createdById: adminUserId,
|
||||
},
|
||||
});
|
||||
return t.id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(async () => {
|
||||
// Tenant A
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
name: `Compensation Test Tenant ${TS}`,
|
||||
slug: `comp-${TS}`,
|
||||
ownerEmail: `comp-${TS}@test.example`,
|
||||
status: TenantStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
tenantId = tenant.id;
|
||||
|
||||
// Tenant B (for isolation)
|
||||
const tenantB = await prisma.tenant.create({
|
||||
data: {
|
||||
name: `Compensation Test Tenant B ${TS}`,
|
||||
slug: `comp-b-${TS}`,
|
||||
ownerEmail: `comp-b-${TS}@test.example`,
|
||||
status: TenantStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
tenantBId = tenantB.id;
|
||||
|
||||
// Admin user
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email: `comp-admin-${TS}@test.example`,
|
||||
passwordHash: "hashed",
|
||||
firstName: "Comp",
|
||||
lastName: "Admin",
|
||||
roles: ["ADMIN"],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
adminUserId = admin.id;
|
||||
|
||||
// Zone
|
||||
const zone = await prisma.zone.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: `Zone Comp ${TS}`,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
zoneId = zone.id;
|
||||
|
||||
// Service plan
|
||||
const plan = await prisma.servicePlan.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: `Comp Plan ${TS}`,
|
||||
speed: "50 Mbps",
|
||||
monthlyPrice: 49.99,
|
||||
billingType: BillingType.POSTPAID,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
planId = plan.id;
|
||||
|
||||
// Subscriber
|
||||
const sub = await prisma.subscriber.create({
|
||||
data: {
|
||||
tenantId,
|
||||
accountNumber: `COMP-SUB-${TS}`,
|
||||
firstName: "Comp",
|
||||
lastName: "Subscriber",
|
||||
address: "123 Comp St",
|
||||
servicePlanId: planId,
|
||||
status: "ACTIVE",
|
||||
billingDay: 15,
|
||||
creditBalance: 0,
|
||||
},
|
||||
});
|
||||
subscriberId = sub.id;
|
||||
|
||||
// Ticket category
|
||||
const cat = await prisma.ticketCategory.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: `Comp Category ${TS}`,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
ticketCategoryId = cat.id;
|
||||
|
||||
// Initial ticket
|
||||
ticketId = await createTicket();
|
||||
|
||||
// Technician users
|
||||
const techA = await prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email: `comp-tech-a-${TS}@test.example`,
|
||||
passwordHash: "hashed",
|
||||
firstName: "TechA",
|
||||
lastName: "Hybrid",
|
||||
roles: ["TECHNICIAN"],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
techAUserId = techA.id;
|
||||
|
||||
const techB = await prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email: `comp-tech-b-${TS}@test.example`,
|
||||
passwordHash: "hashed",
|
||||
firstName: "TechB",
|
||||
lastName: "PerJob",
|
||||
roles: ["TECHNICIAN"],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
techBUserId = techB.id;
|
||||
|
||||
const techC = await prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email: `comp-tech-c-${TS}@test.example`,
|
||||
passwordHash: "hashed",
|
||||
firstName: "TechC",
|
||||
lastName: "Salary",
|
||||
roles: ["TECHNICIAN"],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
techCUserId = techC.id;
|
||||
|
||||
// Create technician profiles
|
||||
const profileA = await createTechnicianProfile(tA(), tenantId, {
|
||||
userId: techAUserId,
|
||||
compensationModel: CompensationModel.HYBRID,
|
||||
monthlySalary: 2000,
|
||||
skills: ["fiber", "installation"],
|
||||
zoneId,
|
||||
});
|
||||
profileAId = profileA.id;
|
||||
|
||||
const profileB = await createTechnicianProfile(tA(), tenantId, {
|
||||
userId: techBUserId,
|
||||
compensationModel: CompensationModel.PER_JOB,
|
||||
skills: ["repair"],
|
||||
});
|
||||
profileBId = profileB.id;
|
||||
|
||||
const profileC = await createTechnicianProfile(tA(), tenantId, {
|
||||
userId: techCUserId,
|
||||
compensationModel: CompensationModel.SALARY,
|
||||
monthlySalary: 3500,
|
||||
});
|
||||
profileCId = profileC.id;
|
||||
|
||||
// Job type rates
|
||||
const installRate = await prisma.jobTypeRate.create({
|
||||
data: {
|
||||
tenantId,
|
||||
jobType: "Installation",
|
||||
rate: 150,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
installRateId = installRate.id;
|
||||
|
||||
const repairRate = await prisma.jobTypeRate.create({
|
||||
data: {
|
||||
tenantId,
|
||||
jobType: "Repair",
|
||||
rate: 75,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
repairRateId = repairRate.id;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
afterAll(async () => {
|
||||
for (const tid of [tenantId, tenantBId]) {
|
||||
if (!tid) continue;
|
||||
// 1. Job orders (FK to tickets and users)
|
||||
await prisma.jobOrder.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
||||
// 2. Tickets
|
||||
await prisma.ticket.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
||||
// 3. Ticket categories
|
||||
await prisma.ticketCategory.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
||||
// 4. Job type rates
|
||||
await prisma.jobTypeRate.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
||||
// 5. Technician profiles
|
||||
await prisma.technicianProfile.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
||||
// 6. Zone assignments
|
||||
await prisma.zoneAssignment.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
||||
// 7. Subscribers
|
||||
await prisma.subscriber.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
||||
// 8. Zones
|
||||
await prisma.zone.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
||||
// 9. Service plans
|
||||
await prisma.servicePlan.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
||||
// 10. Users
|
||||
await prisma.user.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
||||
// 11. Tenant
|
||||
await prisma.tenant.delete({ where: { id: tid } }).catch(() => {});
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// TECHNICIAN SERVICE TESTS
|
||||
// ===========================================================================
|
||||
|
||||
describe("createTechnicianProfile", () => {
|
||||
it("creates a profile for a TECHNICIAN user", async () => {
|
||||
const profile = await getTechnicianProfile(tA(), profileAId);
|
||||
expect(profile).not.toBeNull();
|
||||
expect(profile!.userId).toBe(techAUserId);
|
||||
expect(profile!.compensationModel).toBe(CompensationModel.HYBRID);
|
||||
expect(Number(profile!.monthlySalary)).toBe(2000);
|
||||
expect(profile!.skills).toContain("fiber");
|
||||
expect(profile!.zoneId).toBe(zoneId);
|
||||
});
|
||||
|
||||
it("throws if user does not have TECHNICIAN role", async () => {
|
||||
await expect(
|
||||
createTechnicianProfile(tA(), tenantId, {
|
||||
userId: adminUserId,
|
||||
compensationModel: CompensationModel.PER_JOB,
|
||||
})
|
||||
).rejects.toThrow(/TECHNICIAN role/i);
|
||||
});
|
||||
|
||||
it("throws if profile already exists for user", async () => {
|
||||
await expect(
|
||||
createTechnicianProfile(tA(), tenantId, {
|
||||
userId: techAUserId,
|
||||
compensationModel: CompensationModel.PER_JOB,
|
||||
})
|
||||
).rejects.toThrow(/already exists/i);
|
||||
});
|
||||
|
||||
it("throws if SALARY model has no monthlySalary", async () => {
|
||||
// Create a separate technician user for this test
|
||||
const extraTech = await prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email: `comp-extra-${TS}@test.example`,
|
||||
passwordHash: "hashed",
|
||||
firstName: "Extra",
|
||||
lastName: "Tech",
|
||||
roles: ["TECHNICIAN"],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
createTechnicianProfile(tA(), tenantId, {
|
||||
userId: extraTech.id,
|
||||
compensationModel: CompensationModel.SALARY,
|
||||
// No monthlySalary
|
||||
})
|
||||
).rejects.toThrow(/monthlySalary is required/i);
|
||||
|
||||
// Cleanup
|
||||
await prisma.user.delete({ where: { id: extraTech.id } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("listTechnicians", () => {
|
||||
it("returns all technicians", async () => {
|
||||
const result = await listTechnicians(tA());
|
||||
// Should have at least our 3 technicians
|
||||
expect(result.total).toBeGreaterThanOrEqual(3);
|
||||
const profileIds = result.profiles.map((p: { id: string }) => p.id);
|
||||
expect(profileIds).toContain(profileAId);
|
||||
expect(profileIds).toContain(profileBId);
|
||||
expect(profileIds).toContain(profileCId);
|
||||
});
|
||||
|
||||
it("filters by activeOnly", async () => {
|
||||
// All profiles are active by default
|
||||
const result = await listTechnicians(tA(), { activeOnly: true });
|
||||
expect(result.total).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("filters by zoneId", async () => {
|
||||
const result = await listTechnicians(tA(), { zoneId });
|
||||
const profileIds = result.profiles.map((p: { id: string }) => p.id);
|
||||
expect(profileIds).toContain(profileAId); // techA is in this zone
|
||||
expect(profileIds).not.toContain(profileBId); // techB has no zone
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTechnicianProfile", () => {
|
||||
it("updates phone and skills", async () => {
|
||||
const updated = await updateTechnicianProfile(tA(), profileBId, {
|
||||
phone: "555-1234",
|
||||
skills: ["repair", "maintenance"],
|
||||
});
|
||||
expect(updated.phone).toBe("555-1234");
|
||||
expect(updated.skills).toContain("maintenance");
|
||||
});
|
||||
|
||||
it("deactivates a profile", async () => {
|
||||
await updateTechnicianProfile(tA(), profileBId, { isActive: false });
|
||||
const profile = await getTechnicianProfile(tA(), profileBId);
|
||||
expect(profile!.isActive).toBe(false);
|
||||
// Reactivate for other tests
|
||||
await updateTechnicianProfile(tA(), profileBId, { isActive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTechnicianProfileByUserId", () => {
|
||||
it("finds profile by userId", async () => {
|
||||
const profile = await getTechnicianProfileByUserId(tA(), techCUserId);
|
||||
expect(profile).not.toBeNull();
|
||||
expect(profile!.id).toBe(profileCId);
|
||||
expect(profile!.compensationModel).toBe(CompensationModel.SALARY);
|
||||
});
|
||||
|
||||
it("returns null for user without profile", async () => {
|
||||
const profile = await getTechnicianProfileByUserId(tA(), adminUserId);
|
||||
expect(profile).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// COMPENSATION SERVICE TESTS
|
||||
// ===========================================================================
|
||||
|
||||
describe("PER_JOB compensation model", () => {
|
||||
it("sums rates for all completed jobs in period", async () => {
|
||||
// TechB (PER_JOB): 2x Installation ($150 each) + 1x Repair ($75)
|
||||
const periodStart = new Date("2026-02-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
||||
|
||||
await createJobOrder(techBUserId, "Installation", JobOrderStatus.COMPLETED, new Date("2026-02-10T10:00:00Z"));
|
||||
await createJobOrder(techBUserId, "Installation", JobOrderStatus.COMPLETED, new Date("2026-02-15T10:00:00Z"));
|
||||
await createJobOrder(techBUserId, "Repair", JobOrderStatus.COMPLETED, new Date("2026-02-20T10:00:00Z"));
|
||||
|
||||
const summary = await getCompensationSummary(tA(), { periodStart, periodEnd, technicianProfileId: profileBId });
|
||||
const techSummary = summary.technicians.find((t: { technicianProfileId: string }) => t.technicianProfileId === profileBId);
|
||||
|
||||
expect(techSummary).toBeDefined();
|
||||
expect(Number(techSummary!.baseSalary)).toBe(0);
|
||||
expect(Number(techSummary!.jobBonusTotal)).toBe(375); // 150 + 150 + 75
|
||||
expect(Number(techSummary!.totalCompensation)).toBe(375);
|
||||
expect(techSummary!.completedJobCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SALARY compensation model", () => {
|
||||
it("returns monthlySalary only, regardless of completed jobs", async () => {
|
||||
const periodStart = new Date("2026-02-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
||||
|
||||
// Create a completed job for techC — but SALARY model should ignore it
|
||||
await createJobOrder(techCUserId, "Installation", JobOrderStatus.COMPLETED, new Date("2026-02-10T10:00:00Z"));
|
||||
|
||||
const summary = await getCompensationSummary(tA(), { periodStart, periodEnd, technicianProfileId: profileCId });
|
||||
const techSummary = summary.technicians.find((t: { technicianProfileId: string }) => t.technicianProfileId === profileCId);
|
||||
|
||||
expect(techSummary).toBeDefined();
|
||||
expect(Number(techSummary!.baseSalary)).toBe(3500);
|
||||
expect(Number(techSummary!.jobBonusTotal)).toBe(0); // SALARY model has no per-job bonuses
|
||||
expect(Number(techSummary!.totalCompensation)).toBe(3500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HYBRID compensation model", () => {
|
||||
it("sums base salary + per-job rates", async () => {
|
||||
const periodStart = new Date("2026-02-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
||||
|
||||
// TechA (HYBRID, $2000 salary): 1x Installation ($150)
|
||||
await createJobOrder(techAUserId, "Installation", JobOrderStatus.COMPLETED, new Date("2026-02-12T10:00:00Z"));
|
||||
|
||||
const summary = await getCompensationSummary(tA(), { periodStart, periodEnd, technicianProfileId: profileAId });
|
||||
const techSummary = summary.technicians.find((t: { technicianProfileId: string }) => t.technicianProfileId === profileAId);
|
||||
|
||||
expect(techSummary).toBeDefined();
|
||||
expect(Number(techSummary!.baseSalary)).toBe(2000);
|
||||
expect(Number(techSummary!.jobBonusTotal)).toBeGreaterThanOrEqual(150); // at least 1 installation
|
||||
expect(Number(techSummary!.totalCompensation)).toBe(
|
||||
Number(techSummary!.baseSalary) + Number(techSummary!.jobBonusTotal)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Missing job type rate defaults to 0", () => {
|
||||
it("returns 0 bonus for unknown job type (not an error)", async () => {
|
||||
const periodStart = new Date("2026-03-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-03-31T23:59:59Z");
|
||||
|
||||
// Create job with a type that has NO rate configured
|
||||
await createJobOrder(techBUserId, "UnknownJobType-no-rate", JobOrderStatus.COMPLETED, new Date("2026-03-05T10:00:00Z"));
|
||||
|
||||
const summary = await getCompensationSummary(tA(), { periodStart, periodEnd, technicianProfileId: profileBId });
|
||||
const techSummary = summary.technicians.find((t: { technicianProfileId: string }) => t.technicianProfileId === profileBId);
|
||||
|
||||
// Should not throw; unknown job type = 0 bonus
|
||||
expect(techSummary).toBeDefined();
|
||||
// The unknown job type contributes $0
|
||||
const unknownJobDetail = await getTechnicianCompensationDetail(tA(), {
|
||||
technicianProfileId: profileBId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
const unknownJob = unknownJobDetail.jobs.find((j: { jobType: string }) => j.jobType === "UnknownJobType-no-rate");
|
||||
expect(unknownJob).toBeDefined();
|
||||
expect(Number(unknownJob!.rate)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Only COMPLETED jobs count", () => {
|
||||
it("excludes PENDING, IN_PROGRESS, and CANCELLED jobs", async () => {
|
||||
const periodStart = new Date("2026-03-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-03-31T23:59:59Z");
|
||||
|
||||
// Only the COMPLETED job at the start (from previous test) and explicitly add more here
|
||||
// Add PENDING, IN_PROGRESS, CANCELLED jobs — none should count
|
||||
await createJobOrder(techBUserId, "Installation", JobOrderStatus.PENDING);
|
||||
await createJobOrder(techBUserId, "Installation", JobOrderStatus.IN_PROGRESS);
|
||||
await createJobOrder(techBUserId, "Installation", JobOrderStatus.CANCELLED);
|
||||
|
||||
const summary = await getCompensationSummary(tA(), { periodStart, periodEnd, technicianProfileId: profileBId });
|
||||
const techSummary = summary.technicians.find((t: { technicianProfileId: string }) => t.technicianProfileId === profileBId);
|
||||
|
||||
// completedJobCount should only include COMPLETED jobs (the unknown type one from previous test)
|
||||
// PENDING/IN_PROGRESS/CANCELLED are excluded
|
||||
expect(techSummary).toBeDefined();
|
||||
// Verify the count equals the number of COMPLETED jobs only
|
||||
const allJobsInPeriod = await prisma.jobOrder.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
assignedToId: techBUserId,
|
||||
completedAt: { gte: periodStart, lte: periodEnd },
|
||||
status: JobOrderStatus.COMPLETED,
|
||||
},
|
||||
});
|
||||
expect(techSummary!.completedJobCount).toBe(allJobsInPeriod.length);
|
||||
});
|
||||
|
||||
it("CANCELLED jobs do not generate bonuses", async () => {
|
||||
const periodStart = new Date("2026-03-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-03-31T23:59:59Z");
|
||||
|
||||
const detail = await getTechnicianCompensationDetail(tA(), {
|
||||
technicianProfileId: profileBId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
|
||||
// All jobs in detail should be COMPLETED (by definition, as we only fetch COMPLETED)
|
||||
for (const job of detail.jobs) {
|
||||
expect(job.completedAt).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Date range filter", () => {
|
||||
it("excludes jobs outside the period", async () => {
|
||||
// Jobs from Feb 2026 (already created above)
|
||||
// Check March period only returns March jobs
|
||||
const marchStart = new Date("2026-03-01T00:00:00Z");
|
||||
const marchEnd = new Date("2026-03-31T23:59:59Z");
|
||||
|
||||
const detail = await getTechnicianCompensationDetail(tA(), {
|
||||
technicianProfileId: profileBId,
|
||||
periodStart: marchStart,
|
||||
periodEnd: marchEnd,
|
||||
});
|
||||
|
||||
// All returned jobs should be within March
|
||||
for (const job of detail.jobs) {
|
||||
expect(job.completedAt.getTime()).toBeGreaterThanOrEqual(marchStart.getTime());
|
||||
expect(job.completedAt.getTime()).toBeLessThanOrEqual(marchEnd.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it("February jobs not included in March report", async () => {
|
||||
const marchStart = new Date("2026-03-01T00:00:00Z");
|
||||
const marchEnd = new Date("2026-03-31T23:59:59Z");
|
||||
|
||||
const detail = await getTechnicianCompensationDetail(tA(), {
|
||||
technicianProfileId: profileBId,
|
||||
periodStart: marchStart,
|
||||
periodEnd: marchEnd,
|
||||
});
|
||||
|
||||
// Feb jobs (completedAt in Feb) should not appear
|
||||
const febJobs = detail.jobs.filter((j: { completedAt: Date }) => j.completedAt < marchStart);
|
||||
expect(febJobs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Summary report returns all technicians", () => {
|
||||
it("includes all technicians with correct totals", async () => {
|
||||
const periodStart = new Date("2026-02-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
||||
|
||||
const summary = await getCompensationSummary(tA(), { periodStart, periodEnd });
|
||||
|
||||
// Should include all 3 profiles
|
||||
const profileIds = summary.technicians.map((t: { technicianProfileId: string }) => t.technicianProfileId);
|
||||
expect(profileIds).toContain(profileAId);
|
||||
expect(profileIds).toContain(profileBId);
|
||||
expect(profileIds).toContain(profileCId);
|
||||
|
||||
// grandTotal should be sum of all technician totals
|
||||
const sumOfTotals = summary.technicians.reduce(
|
||||
(sum: number, t: { totalCompensation: { toNumber: () => number } }) => sum + t.totalCompensation.toNumber(),
|
||||
0
|
||||
);
|
||||
expect(Number(summary.grandTotal)).toBeCloseTo(sumOfTotals, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Detail report returns job-by-job breakdown", () => {
|
||||
it("returns each completed job with rate", async () => {
|
||||
const periodStart = new Date("2026-02-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
||||
|
||||
const detail = await getTechnicianCompensationDetail(tA(), {
|
||||
technicianProfileId: profileBId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
|
||||
// Should include job-by-job detail
|
||||
expect(detail.jobs.length).toBeGreaterThan(0);
|
||||
for (const job of detail.jobs) {
|
||||
expect(job.jobOrderId).toBeDefined();
|
||||
expect(job.orderNumber).toBeDefined();
|
||||
expect(job.jobType).toBeDefined();
|
||||
expect(job.completedAt).toBeDefined();
|
||||
expect(job.rate).toBeDefined();
|
||||
expect(job.ticketId).toBeDefined();
|
||||
expect(job.ticketNumber).toBeDefined();
|
||||
}
|
||||
|
||||
// Verify totals consistent
|
||||
const sumRates = detail.jobs.reduce(
|
||||
(sum: number, j: { rate: { toNumber: () => number } }) => sum + j.rate.toNumber(),
|
||||
0
|
||||
);
|
||||
expect(Number(detail.jobBonusTotal)).toBeCloseTo(sumRates, 2);
|
||||
});
|
||||
|
||||
it("throws if technician profile not found", async () => {
|
||||
await expect(
|
||||
getTechnicianCompensationDetail(tA(), {
|
||||
technicianProfileId: "non-existent-profile-id",
|
||||
periodStart: new Date("2026-02-01"),
|
||||
periodEnd: new Date("2026-02-28"),
|
||||
})
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Technician with no completed jobs", () => {
|
||||
it("returns 0 job bonus for technician with no completed jobs in period", async () => {
|
||||
// Create a brand new technician with no jobs
|
||||
const newTech = await prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email: `comp-new-tech-${TS}@test.example`,
|
||||
passwordHash: "hashed",
|
||||
firstName: "NoJobs",
|
||||
lastName: "Tech",
|
||||
roles: ["TECHNICIAN"],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const newProfile = await createTechnicianProfile(tA(), tenantId, {
|
||||
userId: newTech.id,
|
||||
compensationModel: CompensationModel.PER_JOB,
|
||||
});
|
||||
|
||||
const periodStart = new Date("2026-02-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
||||
|
||||
const summary = await getCompensationSummary(tA(), {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
technicianProfileId: newProfile.id,
|
||||
});
|
||||
|
||||
const techSummary = summary.technicians.find(
|
||||
(t: { technicianProfileId: string }) => t.technicianProfileId === newProfile.id
|
||||
);
|
||||
|
||||
expect(techSummary).toBeDefined();
|
||||
expect(Number(techSummary!.jobBonusTotal)).toBe(0);
|
||||
expect(Number(techSummary!.baseSalary)).toBe(0);
|
||||
expect(Number(techSummary!.totalCompensation)).toBe(0);
|
||||
expect(techSummary!.completedJobCount).toBe(0);
|
||||
|
||||
// Cleanup
|
||||
await prisma.technicianProfile.delete({ where: { id: newProfile.id } }).catch(() => {});
|
||||
await prisma.user.delete({ where: { id: newTech.id } }).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cross-tenant isolation", () => {
|
||||
it("Tenant B technicians do not appear in Tenant A summary", async () => {
|
||||
// Create a technician in Tenant B
|
||||
const techB_user = await prisma.user.create({
|
||||
data: {
|
||||
tenantId: tenantBId,
|
||||
email: `comp-tech-b-isolation-${TS}@test.example`,
|
||||
passwordHash: "hashed",
|
||||
firstName: "TenantB",
|
||||
lastName: "Tech",
|
||||
roles: ["TECHNICIAN"],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const profileB = await createTechnicianProfile(tB(), tenantBId, {
|
||||
userId: techB_user.id,
|
||||
compensationModel: CompensationModel.PER_JOB,
|
||||
});
|
||||
|
||||
const periodStart = new Date("2026-02-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
||||
|
||||
const summaryA = await getCompensationSummary(tA(), { periodStart, periodEnd });
|
||||
|
||||
// Tenant B profile should not appear in Tenant A's report
|
||||
const profileIds = summaryA.technicians.map((t: { technicianProfileId: string }) => t.technicianProfileId);
|
||||
expect(profileIds).not.toContain(profileB.id);
|
||||
|
||||
// Cleanup Tenant B technician
|
||||
await prisma.technicianProfile.delete({ where: { id: profileB.id } }).catch(() => {});
|
||||
await prisma.user.delete({ where: { id: techB_user.id } }).catch(() => {});
|
||||
});
|
||||
|
||||
it("job type rates are tenant-scoped", async () => {
|
||||
// Tenant B should not see Tenant A's rates
|
||||
const tenantBRates = await tB().jobTypeRate.findMany({});
|
||||
const tenantAInstallRate = await tA().jobTypeRate.findFirst({ where: { id: installRateId } });
|
||||
|
||||
// Tenant B has no rates (we only created rates for Tenant A)
|
||||
const tenantBRateIds = tenantBRates.map((r: { id: string }) => r.id);
|
||||
expect(tenantBRateIds).not.toContain(installRateId);
|
||||
expect(tenantAInstallRate).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Compensation detail baseSalary and jobBonusTotal", () => {
|
||||
it("HYBRID detail includes both baseSalary and job breakdown", async () => {
|
||||
const periodStart = new Date("2026-02-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
||||
|
||||
const detail = await getTechnicianCompensationDetail(tA(), {
|
||||
technicianProfileId: profileAId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
|
||||
expect(detail.compensationModel).toBe(CompensationModel.HYBRID);
|
||||
expect(Number(detail.baseSalary)).toBe(2000);
|
||||
expect(detail.jobs.length).toBeGreaterThan(0);
|
||||
expect(Number(detail.totalCompensation)).toBe(
|
||||
Number(detail.baseSalary) + Number(detail.jobBonusTotal)
|
||||
);
|
||||
});
|
||||
|
||||
it("SALARY detail shows salary with no jobs counted for bonus", async () => {
|
||||
const periodStart = new Date("2026-02-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
||||
|
||||
const detail = await getTechnicianCompensationDetail(tA(), {
|
||||
technicianProfileId: profileCId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
|
||||
expect(detail.compensationModel).toBe(CompensationModel.SALARY);
|
||||
expect(Number(detail.baseSalary)).toBe(3500);
|
||||
expect(Number(detail.jobBonusTotal)).toBe(0);
|
||||
// Jobs array may have entries, but their rate should be 0 for SALARY model
|
||||
for (const job of detail.jobs) {
|
||||
expect(Number(job.rate)).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,11 @@ export function definePermissionsFor(
|
||||
can("manage", "Ticket");
|
||||
// Job order management
|
||||
can("manage", "JobOrder");
|
||||
// Technician profile management (read/update, not create — admin only for compensation config)
|
||||
can("read", "TechnicianProfile");
|
||||
can("update", "TechnicianProfile");
|
||||
// Job type rates (read-only for office staff — admin configures rates)
|
||||
can("read", "JobTypeRate");
|
||||
// View financial reports (read-only)
|
||||
can("read", "Report");
|
||||
// View accounting (read-only, cannot modify Chart of Accounts)
|
||||
|
||||
@@ -16,6 +16,8 @@ export type AppSubjects =
|
||||
| "Zone"
|
||||
| "Ticket"
|
||||
| "JobOrder"
|
||||
| "TechnicianProfile"
|
||||
| "JobTypeRate"
|
||||
| "Inventory"
|
||||
| "Expense"
|
||||
| "Account"
|
||||
|
||||
314
src/lib/services/compensation-service.ts
Normal file
314
src/lib/services/compensation-service.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* CompensationService — Compensation calculation and summary report.
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* Three compensation models:
|
||||
* PER_JOB: jobBonusTotal = sum of rates for COMPLETED jobs in period
|
||||
* baseSalary = 0
|
||||
* SALARY: baseSalary = monthlySalary (prorated for partial periods if needed — currently full month)
|
||||
* jobBonusTotal = 0
|
||||
* HYBRID: baseSalary = monthlySalary + jobBonusTotal = sum of rates
|
||||
*
|
||||
* RULES:
|
||||
* - Only COMPLETED job orders count (PENDING, IN_PROGRESS, CANCELLED are excluded)
|
||||
* - Missing job type rate defaults to 0 bonus (not an error)
|
||||
* - Date range is inclusive on both ends: completedAt >= periodStart AND completedAt <= periodEnd
|
||||
*
|
||||
* OUTPUTS:
|
||||
* - getCompensationSummary: per-technician totals (baseSalary, jobBonusTotal, totalCompensation)
|
||||
* - getTechnicianCompensationDetail: job-by-job breakdown for one technician
|
||||
*/
|
||||
|
||||
import { CompensationModel, JobOrderStatus, Prisma } from "@prisma/client";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type TenantPrismaClient = any;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input/output types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CompensationPeriod {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
technicianProfileId?: string;
|
||||
}
|
||||
|
||||
export interface TechnicianCompensationSummary {
|
||||
technicianProfileId: string;
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
compensationModel: CompensationModel;
|
||||
baseSalary: Prisma.Decimal;
|
||||
jobBonusTotal: Prisma.Decimal;
|
||||
totalCompensation: Prisma.Decimal;
|
||||
completedJobCount: number;
|
||||
}
|
||||
|
||||
export interface CompensationSummaryResult {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
technicians: TechnicianCompensationSummary[];
|
||||
grandTotal: Prisma.Decimal;
|
||||
}
|
||||
|
||||
export interface JobCompensationDetail {
|
||||
jobOrderId: string;
|
||||
orderNumber: string;
|
||||
jobType: string;
|
||||
completedAt: Date;
|
||||
rate: Prisma.Decimal;
|
||||
ticketId: string;
|
||||
ticketNumber: string;
|
||||
}
|
||||
|
||||
export interface TechnicianCompensationDetailResult {
|
||||
technicianProfileId: string;
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
compensationModel: CompensationModel;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
baseSalary: Prisma.Decimal;
|
||||
jobs: JobCompensationDetail[];
|
||||
jobBonusTotal: Prisma.Decimal;
|
||||
totalCompensation: Prisma.Decimal;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a rate lookup map from JobTypeRate records.
|
||||
* jobType -> Decimal rate
|
||||
* Missing job types default to 0 in calculation (not an error).
|
||||
*/
|
||||
function buildRateMap(
|
||||
rates: Array<{ jobType: string; rate: Prisma.Decimal }>
|
||||
): Map<string, Prisma.Decimal> {
|
||||
const map = new Map<string, Prisma.Decimal>();
|
||||
for (const r of rates) {
|
||||
map.set(r.jobType, new Prisma.Decimal(r.rate));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate job bonus for a list of completed jobs using the rate map.
|
||||
* Missing rates default to 0.
|
||||
*/
|
||||
function calculateJobBonus(
|
||||
completedJobs: Array<{ jobType: string }>,
|
||||
rateMap: Map<string, Prisma.Decimal>
|
||||
): Prisma.Decimal {
|
||||
return completedJobs.reduce((sum, job) => {
|
||||
const rate = rateMap.get(job.jobType) ?? new Prisma.Decimal(0);
|
||||
return sum.plus(rate);
|
||||
}, new Prisma.Decimal(0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCompensationSummary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get compensation summary for all technicians (or a specific one) in a period.
|
||||
*
|
||||
* For each technician:
|
||||
* 1. Load their COMPLETED job orders in the period
|
||||
* 2. Load the tenant's JobTypeRates for the relevant job types
|
||||
* 3. Calculate jobBonusTotal (missing rate = 0)
|
||||
* 4. Calculate baseSalary (SALARY/HYBRID only)
|
||||
* 5. Sum totalCompensation
|
||||
*
|
||||
* @param tenantPrisma - Tenant-scoped Prisma client
|
||||
* @param params - Period and optional technicianProfileId filter
|
||||
*/
|
||||
export async function getCompensationSummary(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
params: CompensationPeriod
|
||||
): Promise<CompensationSummaryResult> {
|
||||
const { periodStart, periodEnd, technicianProfileId } = params;
|
||||
|
||||
// Load technician profiles (optionally filtered)
|
||||
const profileWhere: Record<string, unknown> = { isActive: true };
|
||||
if (technicianProfileId) {
|
||||
profileWhere.id = technicianProfileId;
|
||||
}
|
||||
|
||||
const profiles = await tenantPrisma.technicianProfile.findMany({
|
||||
where: profileWhere,
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Load all tenant job type rates (active only)
|
||||
const allRates = await tenantPrisma.jobTypeRate.findMany({
|
||||
where: { isActive: true },
|
||||
select: { jobType: true, rate: true },
|
||||
});
|
||||
const rateMap = buildRateMap(allRates);
|
||||
|
||||
const technicians: TechnicianCompensationSummary[] = [];
|
||||
let grandTotal = new Prisma.Decimal(0);
|
||||
|
||||
for (const profile of profiles) {
|
||||
// Load COMPLETED job orders in period assigned to this technician
|
||||
const completedJobs = await tenantPrisma.jobOrder.findMany({
|
||||
where: {
|
||||
assignedToId: profile.userId,
|
||||
status: JobOrderStatus.COMPLETED,
|
||||
completedAt: {
|
||||
gte: periodStart,
|
||||
lte: periodEnd,
|
||||
},
|
||||
},
|
||||
select: { id: true, jobType: true, completedAt: true, orderNumber: true },
|
||||
});
|
||||
|
||||
const model = profile.compensationModel as CompensationModel;
|
||||
|
||||
const baseSalary =
|
||||
model === CompensationModel.SALARY || model === CompensationModel.HYBRID
|
||||
? new Prisma.Decimal(profile.monthlySalary ?? 0)
|
||||
: new Prisma.Decimal(0);
|
||||
|
||||
const jobBonusTotal =
|
||||
model === CompensationModel.PER_JOB || model === CompensationModel.HYBRID
|
||||
? calculateJobBonus(completedJobs, rateMap)
|
||||
: new Prisma.Decimal(0);
|
||||
|
||||
const totalCompensation = baseSalary.plus(jobBonusTotal);
|
||||
grandTotal = grandTotal.plus(totalCompensation);
|
||||
|
||||
technicians.push({
|
||||
technicianProfileId: profile.id,
|
||||
userId: profile.user.id,
|
||||
firstName: profile.user.firstName,
|
||||
lastName: profile.user.lastName,
|
||||
compensationModel: model,
|
||||
baseSalary,
|
||||
jobBonusTotal,
|
||||
totalCompensation,
|
||||
completedJobCount: completedJobs.length,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
technicians,
|
||||
grandTotal,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getTechnicianCompensationDetail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get job-by-job compensation breakdown for a single technician in a period.
|
||||
*
|
||||
* Returns:
|
||||
* - Each COMPLETED job order with rate lookup
|
||||
* - baseSalary for the period
|
||||
* - jobBonusTotal and totalCompensation
|
||||
*
|
||||
* @throws Error if TechnicianProfile not found
|
||||
*/
|
||||
export async function getTechnicianCompensationDetail(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
params: { technicianProfileId: string; periodStart: Date; periodEnd: Date }
|
||||
): Promise<TechnicianCompensationDetailResult> {
|
||||
const { technicianProfileId, periodStart, periodEnd } = params;
|
||||
|
||||
const profile = await tenantPrisma.technicianProfile.findFirst({
|
||||
where: { id: technicianProfileId },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
throw new Error(`TechnicianProfile not found: ${technicianProfileId}`);
|
||||
}
|
||||
|
||||
// Load COMPLETED job orders with ticket info for this technician in period
|
||||
const completedJobs = await tenantPrisma.jobOrder.findMany({
|
||||
where: {
|
||||
assignedToId: profile.userId,
|
||||
status: JobOrderStatus.COMPLETED,
|
||||
completedAt: {
|
||||
gte: periodStart,
|
||||
lte: periodEnd,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
ticket: {
|
||||
select: { id: true, ticketNumber: true },
|
||||
},
|
||||
},
|
||||
orderBy: { completedAt: "asc" },
|
||||
});
|
||||
|
||||
// Load all active job type rates
|
||||
const allRates = await tenantPrisma.jobTypeRate.findMany({
|
||||
where: { isActive: true },
|
||||
select: { jobType: true, rate: true },
|
||||
});
|
||||
const rateMap = buildRateMap(allRates);
|
||||
|
||||
const model = profile.compensationModel as CompensationModel;
|
||||
|
||||
const baseSalary =
|
||||
model === CompensationModel.SALARY || model === CompensationModel.HYBRID
|
||||
? new Prisma.Decimal(profile.monthlySalary ?? 0)
|
||||
: new Prisma.Decimal(0);
|
||||
|
||||
// Build job-by-job detail
|
||||
let jobBonusTotal = new Prisma.Decimal(0);
|
||||
const jobs: JobCompensationDetail[] = [];
|
||||
|
||||
for (const jo of completedJobs) {
|
||||
const rate =
|
||||
model === CompensationModel.PER_JOB || model === CompensationModel.HYBRID
|
||||
? (rateMap.get(jo.jobType) ?? new Prisma.Decimal(0))
|
||||
: new Prisma.Decimal(0);
|
||||
|
||||
jobBonusTotal = jobBonusTotal.plus(rate);
|
||||
|
||||
jobs.push({
|
||||
jobOrderId: jo.id,
|
||||
orderNumber: jo.orderNumber,
|
||||
jobType: jo.jobType,
|
||||
completedAt: jo.completedAt,
|
||||
rate,
|
||||
ticketId: jo.ticket.id,
|
||||
ticketNumber: jo.ticket.ticketNumber,
|
||||
});
|
||||
}
|
||||
|
||||
const totalCompensation = baseSalary.plus(jobBonusTotal);
|
||||
|
||||
return {
|
||||
technicianProfileId: profile.id,
|
||||
userId: profile.user.id,
|
||||
firstName: profile.user.firstName,
|
||||
lastName: profile.user.lastName,
|
||||
compensationModel: model,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
baseSalary,
|
||||
jobs,
|
||||
jobBonusTotal,
|
||||
totalCompensation,
|
||||
};
|
||||
}
|
||||
296
src/lib/services/technician-service.ts
Normal file
296
src/lib/services/technician-service.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* TechnicianService — Technician profile CRUD.
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* A TechnicianProfile holds ISP-specific attributes for a user with the TECHNICIAN role.
|
||||
* Each profile is uniquely tied to one user per tenant (@@unique([tenantId, userId])).
|
||||
*
|
||||
* COMPENSATION MODEL:
|
||||
* PER_JOB: earns rate per completed job type (no base salary)
|
||||
* SALARY: earns fixed monthly salary only (no per-job bonuses)
|
||||
* HYBRID: earns fixed monthly salary + per-job bonuses
|
||||
*
|
||||
* VALIDATION:
|
||||
* - userId must belong to a user with TECHNICIAN role
|
||||
* - One profile per user per tenant (enforced at DB via @@unique)
|
||||
* - monthlySalary required for SALARY and HYBRID models
|
||||
*/
|
||||
|
||||
import { CompensationModel } from "@prisma/client";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type TenantPrismaClient = any;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateTechnicianProfileInput {
|
||||
userId: string;
|
||||
phone?: string;
|
||||
skills?: string[];
|
||||
zoneId?: string;
|
||||
compensationModel?: CompensationModel;
|
||||
monthlySalary?: number;
|
||||
}
|
||||
|
||||
export interface UpdateTechnicianProfileInput {
|
||||
phone?: string;
|
||||
skills?: string[];
|
||||
zoneId?: string | null;
|
||||
compensationModel?: CompensationModel;
|
||||
monthlySalary?: number | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ListTechniciansOptions {
|
||||
activeOnly?: boolean;
|
||||
zoneId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createTechnicianProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new TechnicianProfile for a user.
|
||||
*
|
||||
* Validates:
|
||||
* - userId references an existing user with TECHNICIAN role
|
||||
* - No existing profile for this user in this tenant
|
||||
* - monthlySalary provided when compensationModel is SALARY or HYBRID
|
||||
*
|
||||
* @throws Error if user not found, lacks TECHNICIAN role, profile already exists,
|
||||
* or salary missing for salary-based model
|
||||
*/
|
||||
export async function createTechnicianProfile(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
tenantId: string,
|
||||
input: CreateTechnicianProfileInput
|
||||
) {
|
||||
const {
|
||||
userId,
|
||||
phone,
|
||||
skills = [],
|
||||
zoneId,
|
||||
compensationModel = CompensationModel.PER_JOB,
|
||||
monthlySalary,
|
||||
} = input;
|
||||
|
||||
// Validate user exists and has TECHNICIAN role
|
||||
const user = await tenantPrisma.user.findFirst({
|
||||
where: { id: userId },
|
||||
select: { id: true, roles: true, firstName: true, lastName: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
if (!user.roles.includes("TECHNICIAN")) {
|
||||
throw new Error(
|
||||
`User ${user.firstName} ${user.lastName} does not have TECHNICIAN role`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate salary for salary-based models
|
||||
if (
|
||||
(compensationModel === CompensationModel.SALARY ||
|
||||
compensationModel === CompensationModel.HYBRID) &&
|
||||
(monthlySalary === undefined || monthlySalary === null)
|
||||
) {
|
||||
throw new Error(
|
||||
`monthlySalary is required for compensation model ${compensationModel}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for existing profile
|
||||
const existing = await tenantPrisma.technicianProfile.findFirst({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
`TechnicianProfile already exists for user ${userId} in this tenant`
|
||||
);
|
||||
}
|
||||
|
||||
return tenantPrisma.technicianProfile.create({
|
||||
data: {
|
||||
tenantId,
|
||||
userId,
|
||||
phone: phone ?? null,
|
||||
skills,
|
||||
zoneId: zoneId ?? null,
|
||||
compensationModel,
|
||||
monthlySalary: monthlySalary !== undefined ? monthlySalary : null,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true, email: true, roles: true },
|
||||
},
|
||||
zone: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateTechnicianProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update a TechnicianProfile's fields.
|
||||
* Does not allow changing userId (identity of the profile).
|
||||
*
|
||||
* @throws Error if profile not found
|
||||
*/
|
||||
export async function updateTechnicianProfile(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
profileId: string,
|
||||
input: UpdateTechnicianProfileInput
|
||||
) {
|
||||
const { phone, skills, zoneId, compensationModel, monthlySalary, isActive } = input;
|
||||
|
||||
// Validate salary for salary-based models when changing model
|
||||
if (compensationModel !== undefined) {
|
||||
const currentProfile = await tenantPrisma.technicianProfile.findFirst({
|
||||
where: { id: profileId },
|
||||
select: { monthlySalary: true },
|
||||
});
|
||||
|
||||
const effectiveSalary =
|
||||
monthlySalary !== undefined ? monthlySalary : currentProfile?.monthlySalary;
|
||||
|
||||
if (
|
||||
(compensationModel === CompensationModel.SALARY ||
|
||||
compensationModel === CompensationModel.HYBRID) &&
|
||||
(effectiveSalary === undefined || effectiveSalary === null)
|
||||
) {
|
||||
throw new Error(
|
||||
`monthlySalary is required for compensation model ${compensationModel}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (phone !== undefined) data.phone = phone ?? null;
|
||||
if (skills !== undefined) data.skills = skills;
|
||||
if (zoneId !== undefined) data.zoneId = zoneId;
|
||||
if (compensationModel !== undefined) data.compensationModel = compensationModel;
|
||||
if (monthlySalary !== undefined) data.monthlySalary = monthlySalary;
|
||||
if (isActive !== undefined) data.isActive = isActive;
|
||||
|
||||
try {
|
||||
return await tenantPrisma.technicianProfile.update({
|
||||
where: { id: profileId },
|
||||
data,
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true, email: true, roles: true },
|
||||
},
|
||||
zone: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("Record to update not found") || message.includes("P2025")) {
|
||||
throw new Error(`TechnicianProfile not found: ${profileId}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getTechnicianProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a single TechnicianProfile by ID.
|
||||
* Returns null if not found in tenant scope.
|
||||
*/
|
||||
export async function getTechnicianProfile(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
profileId: string
|
||||
) {
|
||||
return tenantPrisma.technicianProfile.findFirst({
|
||||
where: { id: profileId },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true, email: true, roles: true },
|
||||
},
|
||||
zone: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getTechnicianProfileByUserId
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a TechnicianProfile by the technician's userId.
|
||||
* Returns null if not found in tenant scope.
|
||||
*/
|
||||
export async function getTechnicianProfileByUserId(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
userId: string
|
||||
) {
|
||||
return tenantPrisma.technicianProfile.findFirst({
|
||||
where: { userId },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true, email: true, roles: true },
|
||||
},
|
||||
zone: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listTechnicians
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List TechnicianProfiles with optional filters.
|
||||
* Ordered by user lastName, firstName.
|
||||
*/
|
||||
export async function listTechnicians(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
options: ListTechniciansOptions = {}
|
||||
) {
|
||||
const { activeOnly = false, zoneId } = options;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (activeOnly) where.isActive = true;
|
||||
if (zoneId !== undefined) where.zoneId = zoneId;
|
||||
|
||||
const [profiles, total] = await Promise.all([
|
||||
tenantPrisma.technicianProfile.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true, email: true, roles: true },
|
||||
},
|
||||
zone: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ user: { lastName: "asc" } },
|
||||
{ user: { firstName: "asc" } },
|
||||
],
|
||||
}),
|
||||
tenantPrisma.technicianProfile.count({ where }),
|
||||
]);
|
||||
|
||||
return { profiles, total };
|
||||
}
|
||||
Reference in New Issue
Block a user