Files
bodyshop/server/payroll/pay-all.js

386 lines
13 KiB
JavaScript

const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const logger = require("../utils/logger");
Dinero.globalRoundingMode = "HALF_EVEN";
Dinero.globalFormatRoundingMode = "HALF_EVEN";
const PAYOUT_METHODS = {
hourly: "hourly",
commission: "commission"
};
const CURRENCY_PRECISION = 2;
const HOURS_PRECISION = 5;
const toNumber = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
const normalizeNumericString = (value) => {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
const asString = value.toString();
if (!asString.toLowerCase().includes("e")) {
return asString;
}
return value.toFixed(12).replace(/0+$/, "").replace(/\.$/, "");
}
return `${value ?? ""}`.trim();
};
const decimalToDinero = (value, errorMessage = "Invalid numeric value.") => {
const normalizedValue = normalizeNumericString(value);
const parsedValue = Number(normalizedValue);
if (!Number.isFinite(parsedValue)) {
throw new Error(errorMessage);
}
const isNegative = normalizedValue.startsWith("-");
const unsignedValue = normalizedValue.replace(/^[+-]/, "");
const [wholePart = "0", fractionPartRaw = ""] = unsignedValue.split(".");
const wholeDigits = wholePart.replace(/\D/g, "") || "0";
const fractionDigits = fractionPartRaw.replace(/\D/g, "");
const amount = Number(`${wholeDigits}${fractionDigits}` || "0") * (isNegative ? -1 : 1);
return Dinero({
amount,
precision: fractionDigits.length
});
};
const roundValueWithDinero = (value, precision, errorMessage) =>
decimalToDinero(value, errorMessage).convertPrecision(precision, Dinero.globalRoundingMode).toUnit();
const roundCurrency = (value, errorMessage = "Invalid currency value.") =>
roundValueWithDinero(value, CURRENCY_PRECISION, errorMessage);
const roundHours = (value, errorMessage = "Invalid hours value.") => roundValueWithDinero(value, HOURS_PRECISION, errorMessage);
const normalizePayoutMethod = (value) =>
value === PAYOUT_METHODS.commission ? PAYOUT_METHODS.commission : PAYOUT_METHODS.hourly;
const hasOwnValue = (obj, key) => Object.prototype.hasOwnProperty.call(obj || {}, key);
const getJobSaleRateField = (laborType) => `rate_${String(laborType || "").toLowerCase()}`;
const getTeamMemberLabel = (teamMember) => {
const fullName = `${teamMember?.employee?.first_name || ""} ${teamMember?.employee?.last_name || ""}`.trim();
return fullName || teamMember?.employee?.id || teamMember?.employeeid || "unknown employee";
};
const parseRequiredNumber = (value, errorMessage) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(errorMessage);
}
return parsed;
};
const buildFallbackPayoutContext = ({ laborType, rate }) => ({
payout_type: "legacy",
payout_method: "legacy",
cut_percent_applied: null,
source_labor_rate: null,
source_labor_type: laborType,
effective_rate: roundCurrency(rate)
});
function BuildPayoutDetails(job, teamMember, laborType) {
const payoutMethod = normalizePayoutMethod(teamMember?.payout_method);
const teamMemberLabel = getTeamMemberLabel(teamMember);
const sourceLaborRateField = getJobSaleRateField(laborType);
if (payoutMethod === PAYOUT_METHODS.hourly && !hasOwnValue(teamMember?.labor_rates, laborType)) {
throw new Error(`Missing hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.`);
}
if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(teamMember?.commission_rates, laborType)) {
throw new Error(`Missing commission percent for ${teamMemberLabel} on labor type ${laborType}.`);
}
if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(job, sourceLaborRateField)) {
throw new Error(`Missing sale rate ${sourceLaborRateField} for labor type ${laborType}.`);
}
const hourlyRate =
payoutMethod === PAYOUT_METHODS.hourly
? roundCurrency(
parseRequiredNumber(
teamMember?.labor_rates?.[laborType],
`Invalid hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.`
)
)
: null;
const commissionPercent =
payoutMethod === PAYOUT_METHODS.commission
? roundCurrency(
parseRequiredNumber(
teamMember?.commission_rates?.[laborType],
`Invalid commission percent for ${teamMemberLabel} on labor type ${laborType}.`
)
)
: null;
if (commissionPercent !== null && (commissionPercent < 0 || commissionPercent > 100)) {
throw new Error(`Commission percent for ${teamMemberLabel} on labor type ${laborType} must be between 0 and 100.`);
}
const sourceLaborRate =
payoutMethod === PAYOUT_METHODS.commission
? roundCurrency(
parseRequiredNumber(job?.[sourceLaborRateField], `Invalid sale rate ${sourceLaborRateField} for labor type ${laborType}.`)
)
: null;
const effectiveRate =
payoutMethod === PAYOUT_METHODS.commission
? roundCurrency((sourceLaborRate * toNumber(commissionPercent)) / 100)
: hourlyRate;
return {
effectiveRate,
payoutContext: {
payout_type: payoutMethod === PAYOUT_METHODS.commission ? "cut" : "hourly",
payout_method: payoutMethod,
cut_percent_applied: commissionPercent,
source_labor_rate: sourceLaborRate,
source_labor_type: laborType,
effective_rate: effectiveRate
}
};
}
function BuildGeneratedPayoutContext({ baseContext, generatedBy, generatedFrom, taskName, usedTicketFallback }) {
return {
...(baseContext || {}),
generated_by: generatedBy,
generated_at: new Date().toISOString(),
generated_from: generatedFrom,
task_name: taskName,
used_ticket_fallback: Boolean(usedTicketFallback)
};
}
function getAllKeys(...objects) {
return [...new Set(objects.flatMap((obj) => (obj ? Object.keys(obj) : [])))];
}
function buildPayAllMemo({ deltaHours, hasExpected, hasClaimed, userEmail }) {
if (!hasClaimed && deltaHours > 0) {
return `Add unflagged hours. (${userEmail})`;
}
if (!hasExpected && deltaHours < 0) {
return `Remove flagged hours per assignment. (${userEmail})`;
}
return `Adjust flagged hours per assignment. (${userEmail})`;
}
exports.payall = async function (req, res) {
const { jobid } = req.body;
logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null);
const BearerToken = req.BearerToken;
const client = req.userGraphQLClient;
try {
const { jobs_by_pk: job } = await client
.setHeaders({ Authorization: BearerToken })
.request(queries.QUERY_JOB_PAYROLL_DATA, {
id: jobid
});
const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job);
const ticketHash = CalculateTicketsHoursForJob(job);
if (assignmentHash.unassigned > 0) {
res.json({ success: false, error: "Not all hours have been assigned." });
return;
}
const ticketsToInsert = [];
const employeeIds = getAllKeys(employeeHash, ticketHash);
employeeIds.forEach((employeeId) => {
const expectedByLabor = employeeHash[employeeId] || {};
const claimedByLabor = ticketHash[employeeId] || {};
getAllKeys(expectedByLabor, claimedByLabor).forEach((laborType) => {
const expected = expectedByLabor[laborType];
const claimed = claimedByLabor[laborType];
const deltaHours = roundHours((expected?.hours || 0) - (claimed?.hours || 0));
if (deltaHours === 0) {
return;
}
const effectiveRate = roundCurrency(expected?.rate ?? claimed?.rate);
const payoutContext = BuildGeneratedPayoutContext({
baseContext:
expected?.payoutContext ||
claimed?.payoutContext ||
buildFallbackPayoutContext({ laborType, rate: effectiveRate }),
generatedBy: req.user.email,
generatedFrom: "payall",
taskName: "Pay All",
usedTicketFallback: !expected && Boolean(claimed)
});
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: employeeId,
productivehrs: deltaHours,
rate: effectiveRate,
ciecacode: laborType,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborType],
flat_rate: true,
created_by: req.user.email,
payout_context: payoutContext,
memo: buildPayAllMemo({
deltaHours,
hasExpected: Boolean(expected),
hasClaimed: Boolean(claimed),
userEmail: req.user.email
})
});
});
});
const filteredTickets = ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0);
if (filteredTickets.length > 0) {
await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: filteredTickets
});
}
res.json(filteredTickets);
} catch (error) {
logger.log("job-payroll-labor-totals-error", "ERROR", req.user.email, jobid, {
jobid,
error: JSON.stringify(error)
});
res.status(400).json({ error: error.message });
}
};
function CalculateExpectedHoursForJob(job, filterToLbrTypes) {
const assignmentHash = { unassigned: 0 };
const employeeHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
const laborTypeFilter = Array.isArray(filterToLbrTypes) ? filterToLbrTypes : null;
job.joblines
.filter((jobline) => {
if (!laborTypeFilter) {
return true;
}
const convertedLaborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty : null;
return laborTypeFilter.includes(jobline.mod_lbr_ty) || (convertedLaborType && laborTypeFilter.includes(convertedLaborType));
})
.forEach((jobline) => {
const laborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty || jobline.mod_lbr_ty : jobline.mod_lbr_ty;
const laborHours = roundHours(
toNumber(jobline.mod_lb_hrs) + (jobline.convertedtolbr ? toNumber(jobline.convertedtolbr_data?.mod_lb_hrs) : 0)
);
if (laborHours === 0) {
return;
}
if (jobline.assigned_team === null) {
assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
return;
}
const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team);
if (!theTeam) {
assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
return;
}
assignmentHash[jobline.assigned_team] = roundHours((assignmentHash[jobline.assigned_team] || 0) + laborHours);
theTeam.employee_team_members.forEach((teamMember) => {
const employeeId = teamMember.employee.id;
const { effectiveRate, payoutContext } = BuildPayoutDetails(job, teamMember, laborType);
if (!employeeHash[employeeId]) {
employeeHash[employeeId] = {};
}
if (!employeeHash[employeeId][laborType]) {
employeeHash[employeeId][laborType] = {
hours: 0,
rate: effectiveRate,
payoutContext
};
}
const hoursOwed = roundHours((toNumber(teamMember.percentage) * laborHours) / 100);
employeeHash[employeeId][laborType].hours = roundHours(employeeHash[employeeId][laborType].hours + hoursOwed);
employeeHash[employeeId][laborType].rate = effectiveRate;
employeeHash[employeeId][laborType].payoutContext = payoutContext;
});
});
return { assignmentHash, employeeHash };
}
function CalculateTicketsHoursForJob(job) {
const ticketHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
job.timetickets.forEach((ticket) => {
if (!ticket?.employeeid || !ticket?.ciecacode) {
return;
}
if (!ticketHash[ticket.employeeid]) {
ticketHash[ticket.employeeid] = {};
}
if (!ticketHash[ticket.employeeid][ticket.ciecacode]) {
ticketHash[ticket.employeeid][ticket.ciecacode] = {
hours: 0,
rate: roundCurrency(ticket.rate),
payoutContext: ticket.payout_context || null
};
}
ticketHash[ticket.employeeid][ticket.ciecacode].hours = roundHours(
ticketHash[ticket.employeeid][ticket.ciecacode].hours + toNumber(ticket.productivehrs)
);
if (ticket.rate !== null && ticket.rate !== undefined) {
ticketHash[ticket.employeeid][ticket.ciecacode].rate = roundCurrency(ticket.rate);
}
if (ticket.payout_context) {
ticketHash[ticket.employeeid][ticket.ciecacode].payoutContext = ticket.payout_context;
}
});
return ticketHash;
}
exports.BuildPayoutDetails = BuildPayoutDetails;
exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob;
exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob;
exports.RoundPayrollHours = roundHours;