386 lines
13 KiB
JavaScript
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;
|