1104 lines
27 KiB
JavaScript
1104 lines
27 KiB
JavaScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import mockRequire from "mock-require";
|
|
|
|
const logMock = vi.fn();
|
|
|
|
let payAllModule;
|
|
let claimTaskModule;
|
|
let calculateTotalsModule;
|
|
|
|
const buildBaseJob = (overrides = {}) => ({
|
|
id: "job-1",
|
|
completed_tasks: [],
|
|
rate_laa: 100,
|
|
bodyshop: {
|
|
id: "shop-1",
|
|
md_responsibility_centers: {
|
|
defaults: {
|
|
costs: {
|
|
LAA: "Body"
|
|
}
|
|
}
|
|
},
|
|
md_tasks_presets: {
|
|
presets: []
|
|
},
|
|
employee_teams: []
|
|
},
|
|
joblines: [],
|
|
timetickets: [],
|
|
...overrides
|
|
});
|
|
|
|
const buildReqRes = ({ job, body = {}, userEmail = "payroll@example.com" }) => {
|
|
const client = {
|
|
setHeaders: vi.fn().mockReturnThis(),
|
|
request: vi.fn().mockResolvedValueOnce({ jobs_by_pk: job })
|
|
};
|
|
|
|
const req = {
|
|
body: {
|
|
jobid: job.id,
|
|
...body
|
|
},
|
|
user: {
|
|
email: userEmail
|
|
},
|
|
BearerToken: "Bearer test",
|
|
userGraphQLClient: client
|
|
};
|
|
|
|
const res = {
|
|
json: vi.fn(),
|
|
status: vi.fn().mockReturnThis()
|
|
};
|
|
|
|
return { client, req, res };
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
mockRequire.stopAll();
|
|
mockRequire("../utils/logger", { log: logMock });
|
|
payAllModule = require("./pay-all");
|
|
claimTaskModule = require("./claim-task");
|
|
calculateTotalsModule = require("./calculate-totals");
|
|
});
|
|
|
|
describe("payroll payout helpers", () => {
|
|
it("defaults team members to hourly payout when no payout method is stored", () => {
|
|
const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
|
|
{},
|
|
{
|
|
labor_rates: {
|
|
LAA: 27.5
|
|
},
|
|
employee: {
|
|
id: "emp-1"
|
|
}
|
|
},
|
|
"LAA"
|
|
);
|
|
|
|
expect(effectiveRate).toBe(27.5);
|
|
expect(payoutContext).toEqual(
|
|
expect.objectContaining({
|
|
payout_type: "hourly",
|
|
payout_method: "hourly",
|
|
cut_percent_applied: null,
|
|
source_labor_rate: null,
|
|
source_labor_type: "LAA",
|
|
effective_rate: 27.5
|
|
})
|
|
);
|
|
});
|
|
|
|
it("calculates commission payout rates from the raw job labor sale rate", () => {
|
|
const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
|
|
{
|
|
rate_laa: 120
|
|
},
|
|
{
|
|
payout_method: "commission",
|
|
commission_rates: {
|
|
LAA: 35
|
|
},
|
|
employee: {
|
|
id: "emp-1"
|
|
}
|
|
},
|
|
"LAA"
|
|
);
|
|
|
|
expect(effectiveRate).toBe(42);
|
|
expect(payoutContext).toEqual(
|
|
expect.objectContaining({
|
|
payout_type: "cut",
|
|
payout_method: "commission",
|
|
cut_percent_applied: 35,
|
|
source_labor_rate: 120,
|
|
source_labor_type: "LAA",
|
|
effective_rate: 42
|
|
})
|
|
);
|
|
});
|
|
|
|
it("uses Dinero half-even rounding for stored hourly rates", () => {
|
|
const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
|
|
{},
|
|
{
|
|
labor_rates: {
|
|
LAA: 10.005
|
|
},
|
|
employee: {
|
|
id: "emp-1"
|
|
}
|
|
},
|
|
"LAA"
|
|
);
|
|
|
|
expect(effectiveRate).toBe(10);
|
|
expect(payoutContext.effective_rate).toBe(10);
|
|
});
|
|
|
|
it("throws a useful error when commission configuration is incomplete", () => {
|
|
expect(() =>
|
|
payAllModule.BuildPayoutDetails(
|
|
{
|
|
rate_laa: 100
|
|
},
|
|
{
|
|
payout_method: "commission",
|
|
commission_rates: {},
|
|
employee: {
|
|
first_name: "Jane",
|
|
last_name: "Doe"
|
|
}
|
|
},
|
|
"LAA"
|
|
)
|
|
).toThrow("Missing commission percent for Jane Doe on labor type LAA.");
|
|
});
|
|
|
|
it("throws a useful error when an hourly payout rate is missing", () => {
|
|
expect(() =>
|
|
payAllModule.BuildPayoutDetails(
|
|
{},
|
|
{
|
|
labor_rates: {},
|
|
employee: {
|
|
first_name: "John",
|
|
last_name: "Smith"
|
|
}
|
|
},
|
|
"LAB"
|
|
)
|
|
).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", () => {
|
|
it("aggregates claimed hours across prior ticket rates and inserts the remaining delta at the current rate", async () => {
|
|
const job = buildBaseJob({
|
|
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
|
|
},
|
|
labor_rates: {
|
|
LAA: 30
|
|
},
|
|
employee: {
|
|
id: "emp-1",
|
|
first_name: "Jane",
|
|
last_name: "Doe"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
joblines: [
|
|
{
|
|
mod_lbr_ty: "LAA",
|
|
mod_lb_hrs: 10,
|
|
assigned_team: "team-1",
|
|
convertedtolbr: false
|
|
}
|
|
],
|
|
timetickets: [
|
|
{
|
|
employeeid: "emp-1",
|
|
ciecacode: "LAA",
|
|
productivehrs: 2,
|
|
rate: 30,
|
|
payout_context: {
|
|
payout_method: "hourly"
|
|
}
|
|
},
|
|
{
|
|
employeeid: "emp-1",
|
|
ciecacode: "LAA",
|
|
productivehrs: 3,
|
|
rate: 35,
|
|
payout_context: {
|
|
payout_method: "commission"
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
const { client, req, res } = buildReqRes({ job });
|
|
client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } });
|
|
|
|
await payAllModule.payall(req, res);
|
|
|
|
expect(client.request).toHaveBeenCalledTimes(2);
|
|
|
|
const insertedTickets = client.request.mock.calls[1][1].timetickets;
|
|
expect(insertedTickets).toHaveLength(1);
|
|
expect(insertedTickets[0]).toEqual(
|
|
expect.objectContaining({
|
|
task_name: "Pay All",
|
|
employeeid: "emp-1",
|
|
productivehrs: 5,
|
|
rate: 40,
|
|
ciecacode: "LAA",
|
|
cost_center: "Body",
|
|
created_by: "payroll@example.com"
|
|
})
|
|
);
|
|
expect(insertedTickets[0].payout_context).toEqual(
|
|
expect.objectContaining({
|
|
payout_method: "commission",
|
|
cut_percent_applied: 40,
|
|
source_labor_rate: 100,
|
|
generated_from: "payall",
|
|
task_name: "Pay All",
|
|
used_ticket_fallback: false
|
|
})
|
|
);
|
|
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" }],
|
|
bodyshop: {
|
|
id: "shop-1",
|
|
md_responsibility_centers: {
|
|
defaults: {
|
|
costs: {
|
|
LAA: "Body"
|
|
}
|
|
}
|
|
},
|
|
md_tasks_presets: {
|
|
presets: [
|
|
{
|
|
name: "Disassembly",
|
|
hourstype: ["LAA"],
|
|
percent: 50,
|
|
nextstatus: "In Progress",
|
|
memo: "Flag disassembly"
|
|
}
|
|
]
|
|
},
|
|
employee_teams: []
|
|
}
|
|
});
|
|
|
|
const { client, req, res } = buildReqRes({
|
|
job,
|
|
body: {
|
|
task: "Disassembly",
|
|
calculateOnly: false,
|
|
employee: {
|
|
name: "Jane Doe",
|
|
employeeid: "emp-1"
|
|
}
|
|
}
|
|
});
|
|
|
|
await claimTaskModule.claimtask(req, res);
|
|
|
|
expect(client.request).toHaveBeenCalledTimes(1);
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
success: false,
|
|
error: "Provided task preset has already been completed for this job."
|
|
});
|
|
});
|
|
|
|
it("rejects claim-task when task presets over-allocate the same labor type", async () => {
|
|
const job = buildBaseJob({
|
|
bodyshop: {
|
|
id: "shop-1",
|
|
md_responsibility_centers: {
|
|
defaults: {
|
|
costs: {
|
|
LAA: "Body"
|
|
}
|
|
}
|
|
},
|
|
md_tasks_presets: {
|
|
presets: [
|
|
{
|
|
name: "Body Prep",
|
|
hourstype: ["LAA"],
|
|
percent: 60,
|
|
nextstatus: "Prep",
|
|
memo: "Prep body work"
|
|
},
|
|
{
|
|
name: "Body Prime",
|
|
hourstype: ["LAA"],
|
|
percent: 50,
|
|
nextstatus: "Prime",
|
|
memo: "Prime body work"
|
|
}
|
|
]
|
|
},
|
|
employee_teams: []
|
|
}
|
|
});
|
|
|
|
const { client, req, res } = buildReqRes({
|
|
job,
|
|
body: {
|
|
task: "Body Prep",
|
|
calculateOnly: true,
|
|
employee: {
|
|
name: "Jane Doe",
|
|
employeeid: "emp-1"
|
|
}
|
|
}
|
|
});
|
|
|
|
await claimTaskModule.claimtask(req, res);
|
|
|
|
expect(client.request).toHaveBeenCalledTimes(1);
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
success: false,
|
|
error: "Task preset percentages for labor type LAA total 110% and cannot exceed 100%."
|
|
});
|
|
});
|
|
|
|
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: {
|
|
id: "shop-1",
|
|
md_responsibility_centers: {
|
|
defaults: {
|
|
costs: {
|
|
LAB: "Body"
|
|
}
|
|
}
|
|
},
|
|
md_tasks_presets: {
|
|
presets: [
|
|
{
|
|
name: "Teardown",
|
|
hourstype: ["LAB"],
|
|
percent: 100,
|
|
nextstatus: "In Progress",
|
|
memo: "Teardown"
|
|
}
|
|
]
|
|
},
|
|
employee_teams: [
|
|
{
|
|
id: "team-1",
|
|
employee_team_members: [
|
|
{
|
|
percentage: 50,
|
|
labor_rates: {
|
|
LAB: 45
|
|
},
|
|
employee: {
|
|
id: "emp-1",
|
|
first_name: "Configured",
|
|
last_name: "Tech"
|
|
}
|
|
},
|
|
{
|
|
percentage: 50,
|
|
labor_rates: {},
|
|
employee: {
|
|
id: "emp-2",
|
|
first_name: "Missing",
|
|
last_name: "Rate"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
joblines: [
|
|
{
|
|
mod_lbr_ty: "LAB",
|
|
mod_lb_hrs: 4.4,
|
|
assigned_team: "team-1",
|
|
convertedtolbr: false
|
|
}
|
|
]
|
|
});
|
|
|
|
const { client, req, res } = buildReqRes({
|
|
job,
|
|
body: {
|
|
task: "Teardown",
|
|
calculateOnly: true,
|
|
employee: {
|
|
name: "Dave",
|
|
email: "dave@rome.test"
|
|
}
|
|
}
|
|
});
|
|
|
|
await claimTaskModule.claimtask(req, res);
|
|
|
|
expect(client.request).toHaveBeenCalledTimes(1);
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
success: false,
|
|
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
|
|
}
|
|
]);
|
|
});
|
|
});
|