feature/IO-3587-Commision-Cut - Additional Testing / Test Harness improvements

This commit is contained in:
Dave
2026-03-18 16:50:57 -04:00
parent 782fa8a1c7
commit 3f03157834
13 changed files with 2227 additions and 45 deletions

View File

@@ -5,6 +5,7 @@ const logMock = vi.fn();
let payAllModule;
let claimTaskModule;
let calculateTotalsModule;
const buildBaseJob = (overrides = {}) => ({
id: "job-1",
@@ -62,6 +63,7 @@ beforeEach(() => {
mockRequire("../utils/logger", { log: logMock });
payAllModule = require("./pay-all");
claimTaskModule = require("./claim-task");
calculateTotalsModule = require("./calculate-totals");
});
describe("payroll payout helpers", () => {
@@ -174,6 +176,104 @@ describe("payroll payout helpers", () => {
)
).toThrow("Missing hourly payout rate for John Smith on labor type LAB.");
});
it("supports commission boundary values of zero and one hundred percent", () => {
const zeroPercent = payAllModule.BuildPayoutDetails(
{
rate_laa: 123.45
},
{
payout_method: "commission",
commission_rates: {
LAA: 0
},
employee: {
id: "emp-1"
}
},
"LAA"
);
const fullPercent = payAllModule.BuildPayoutDetails(
{
rate_laa: 123.45
},
{
payout_method: "commission",
commission_rates: {
LAA: 100
},
employee: {
id: "emp-1"
}
},
"LAA"
);
expect(zeroPercent.effectiveRate).toBe(0);
expect(zeroPercent.payoutContext.cut_percent_applied).toBe(0);
expect(fullPercent.effectiveRate).toBe(123.45);
expect(fullPercent.payoutContext.cut_percent_applied).toBe(100);
});
it("throws a useful error when the sale rate for a commission labor type is missing", () => {
expect(() =>
payAllModule.BuildPayoutDetails(
{},
{
payout_method: "commission",
commission_rates: {
LAA: 35
},
employee: {
first_name: "Sam",
last_name: "Painter"
}
},
"LAA"
)
).toThrow("Missing sale rate rate_laa for labor type LAA.");
});
it("rejects commission percentages outside the allowed zero-to-one-hundred range", () => {
expect(() =>
payAllModule.BuildPayoutDetails(
{
rate_laa: 100
},
{
payout_method: "commission",
commission_rates: {
LAA: -5
},
employee: {
first_name: "Alex",
last_name: "Painter"
}
},
"LAA"
)
).toThrow("Commission percent for Alex Painter on labor type LAA must be between 0 and 100.");
expect(() =>
payAllModule.BuildPayoutDetails(
{
rate_laa: 100
},
{
payout_method: "commission",
commission_rates: {
LAA: 105
},
employee: {
first_name: "Alex",
last_name: "Painter"
}
},
"LAA"
)
).toThrow("Commission percent for Alex Painter on labor type LAA must be between 0 and 100.");
});
});
describe("payroll routes", () => {
@@ -277,6 +377,344 @@ describe("payroll routes", () => {
expect(res.json).toHaveBeenCalledWith(insertedTickets);
});
it("returns a validation failure when job lines still have unassigned hours", async () => {
const job = buildBaseJob({
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 3.5,
assigned_team: null,
convertedtolbr: false
}
]
});
const { client, req, res } = buildReqRes({ job });
await payAllModule.payall(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Not all hours have been assigned."
});
});
it("creates separate pay-all tickets for mixed hourly and commission team members across labor types", async () => {
const job = buildBaseJob({
rate_laa: 100,
rate_lab: 80,
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body",
LAB: "Refinish"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 50,
payout_method: "hourly",
labor_rates: {
LAA: 30,
LAB: 25
},
employee: {
id: "emp-hourly",
first_name: "Hourly",
last_name: "Tech"
}
},
{
percentage: 50,
payout_method: "commission",
commission_rates: {
LAA: 40,
LAB: 50
},
labor_rates: {
LAA: 0,
LAB: 0
},
employee: {
id: "emp-commission",
first_name: "Commission",
last_name: "Tech"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 4,
assigned_team: "team-1",
convertedtolbr: false
},
{
mod_lbr_ty: "LAB",
mod_lb_hrs: 2,
assigned_team: "team-1",
convertedtolbr: false
}
]
});
const { client, req, res } = buildReqRes({ job });
client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 4 } });
await payAllModule.payall(req, res);
expect(client.request).toHaveBeenCalledTimes(2);
const insertedTickets = client.request.mock.calls[1][1].timetickets;
expect(insertedTickets).toHaveLength(4);
expect(insertedTickets).toEqual(
expect.arrayContaining([
expect.objectContaining({
employeeid: "emp-hourly",
ciecacode: "LAA",
productivehrs: 2,
rate: 30,
cost_center: "Body",
payout_context: expect.objectContaining({
payout_method: "hourly",
payout_type: "hourly",
effective_rate: 30
})
}),
expect.objectContaining({
employeeid: "emp-hourly",
ciecacode: "LAB",
productivehrs: 1,
rate: 25,
cost_center: "Refinish",
payout_context: expect.objectContaining({
payout_method: "hourly",
payout_type: "hourly",
effective_rate: 25
})
}),
expect.objectContaining({
employeeid: "emp-commission",
ciecacode: "LAA",
productivehrs: 2,
rate: 40,
cost_center: "Body",
payout_context: expect.objectContaining({
payout_method: "commission",
payout_type: "cut",
cut_percent_applied: 40,
source_labor_rate: 100,
effective_rate: 40
})
}),
expect.objectContaining({
employeeid: "emp-commission",
ciecacode: "LAB",
productivehrs: 1,
rate: 40,
cost_center: "Refinish",
payout_context: expect.objectContaining({
payout_method: "commission",
payout_type: "cut",
cut_percent_applied: 50,
source_labor_rate: 80,
effective_rate: 40
})
})
])
);
expect(res.json).toHaveBeenCalledWith(insertedTickets);
});
it("creates a negative pay-all adjustment at the current commission rate when the remaining expected hours drop below prior claimed hours", async () => {
const job = buildBaseJob({
rate_laa: 120,
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 100,
payout_method: "commission",
commission_rates: {
LAA: 40
},
employee: {
id: "emp-1",
first_name: "Current",
last_name: "Tech"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 4,
assigned_team: "team-1",
convertedtolbr: false
}
],
timetickets: [
{
employeeid: "emp-1",
ciecacode: "LAA",
productivehrs: 6,
rate: 30,
payout_context: {
payout_method: "hourly",
payout_type: "hourly",
effective_rate: 30
}
}
]
});
const { client, req, res } = buildReqRes({ job });
client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } });
await payAllModule.payall(req, res);
const insertedTickets = client.request.mock.calls[1][1].timetickets;
expect(insertedTickets).toHaveLength(1);
expect(insertedTickets[0]).toEqual(
expect.objectContaining({
employeeid: "emp-1",
productivehrs: -2,
rate: 48,
ciecacode: "LAA",
memo: "Adjust flagged hours per assignment. (payroll@example.com)"
})
);
expect(insertedTickets[0].payout_context).toEqual(
expect.objectContaining({
payout_method: "commission",
payout_type: "cut",
cut_percent_applied: 40,
source_labor_rate: 120,
effective_rate: 48,
generated_from: "payall",
used_ticket_fallback: false
})
);
expect(res.json).toHaveBeenCalledWith(insertedTickets);
});
it("uses the current commission sale rate for remaining hours when older commission tickets were created from a lower sale rate", async () => {
const job = buildBaseJob({
rate_laa: 120,
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 100,
payout_method: "commission",
commission_rates: {
LAA: 40
},
employee: {
id: "emp-1",
first_name: "Current",
last_name: "Tech"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 5,
assigned_team: "team-1",
convertedtolbr: false
}
],
timetickets: [
{
employeeid: "emp-1",
ciecacode: "LAA",
productivehrs: 2,
rate: 40,
payout_context: {
payout_method: "commission",
payout_type: "cut",
cut_percent_applied: 40,
source_labor_rate: 100,
effective_rate: 40
}
}
]
});
const { client, req, res } = buildReqRes({ job });
client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } });
await payAllModule.payall(req, res);
const insertedTickets = client.request.mock.calls[1][1].timetickets;
expect(insertedTickets).toHaveLength(1);
expect(insertedTickets[0]).toEqual(
expect.objectContaining({
employeeid: "emp-1",
productivehrs: 3,
rate: 48,
ciecacode: "LAA"
})
);
expect(insertedTickets[0].payout_context).toEqual(
expect.objectContaining({
payout_method: "commission",
cut_percent_applied: 40,
source_labor_rate: 120,
effective_rate: 48,
generated_from: "payall",
used_ticket_fallback: false
})
);
expect(res.json).toHaveBeenCalledWith(insertedTickets);
});
it("rejects duplicate claim-task submissions for completed presets", async () => {
const job = buildBaseJob({
completed_tasks: [{ name: "Disassembly" }],
@@ -381,6 +819,132 @@ describe("payroll routes", () => {
});
});
it("returns commission-aware claim-task previews and reports unassigned hours", async () => {
const job = buildBaseJob({
rate_laa: 120,
rate_lab: 80,
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body",
LAB: "Refinish"
}
}
},
md_tasks_presets: {
presets: [
{
name: "Body Prep",
hourstype: ["LAA", "LAB"],
percent: 50,
nextstatus: "Prep",
memo: "Prep body work"
}
]
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 50,
payout_method: "hourly",
labor_rates: {
LAA: 30,
LAB: 25
},
employee: {
id: "emp-hourly",
first_name: "Hourly",
last_name: "Tech"
}
},
{
percentage: 50,
payout_method: "commission",
commission_rates: {
LAA: 40,
LAB: 50
},
employee: {
id: "emp-commission",
first_name: "Commission",
last_name: "Tech"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 4,
assigned_team: "team-1",
convertedtolbr: false
},
{
mod_lbr_ty: "LAB",
mod_lb_hrs: 1.5,
assigned_team: null,
convertedtolbr: false
}
]
});
const { client, req, res } = buildReqRes({
job,
body: {
task: "Body Prep",
calculateOnly: true,
employee: {
name: "Payroll Manager"
}
}
});
await claimTaskModule.claimtask(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.json).toHaveBeenCalledWith({
unassignedHours: 1.5,
ticketsToInsert: expect.arrayContaining([
expect.objectContaining({
task_name: "Body Prep",
employeeid: "emp-hourly",
productivehrs: 1,
rate: 30,
ciecacode: "LAA",
created_by: "Payroll Manager",
payout_context: expect.objectContaining({
payout_method: "hourly",
payout_type: "hourly",
generated_from: "claimtask",
task_name: "Body Prep"
})
}),
expect.objectContaining({
task_name: "Body Prep",
employeeid: "emp-commission",
productivehrs: 1,
rate: 48,
ciecacode: "LAA",
created_by: "Payroll Manager",
payout_context: expect.objectContaining({
payout_method: "commission",
payout_type: "cut",
cut_percent_applied: 40,
source_labor_rate: 120,
generated_from: "claimtask",
task_name: "Body Prep"
})
})
])
});
});
it("rejects claim-task when an assigned team member is missing the hourly rate for the selected labor type", async () => {
const job = buildBaseJob({
bodyshop: {
@@ -462,4 +1026,78 @@ describe("payroll routes", () => {
error: "Missing hourly payout rate for Missing Rate on labor type LAB."
});
});
it("locks in the current enhanced-payroll behavior of ignoring lbr_adjustments when calculating labor totals", async () => {
const job = buildBaseJob({
lbr_adjustments: {
LAA: 2.5
},
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 100,
payout_method: "commission",
commission_rates: {
LAA: 40
},
employee: {
id: "emp-1",
first_name: "Current",
last_name: "Tech"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 4,
assigned_team: "team-1",
convertedtolbr: false
}
],
timetickets: [
{
employeeid: "emp-1",
ciecacode: "LAA",
productivehrs: 1,
rate: 48,
payout_context: {
payout_method: "commission"
}
}
]
});
const { client, req, res } = buildReqRes({ job });
await calculateTotalsModule.calculatelabor(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.json).toHaveBeenCalledWith([
{
employeeid: "emp-1",
rate: 40,
mod_lbr_ty: "LAA",
expectedHours: 4,
claimedHours: 1
}
]);
});
});