feature/IO-3647-Reynolds-Integration-Phase-2 - Enhance early RO with meaningful amounts.
This commit is contained in:
@@ -52,6 +52,19 @@ const asN2 = (dineroLike) => {
|
||||
return amount.toFixed(2);
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value) => {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize various "money-like" shapes to integer cents.
|
||||
* Supports:
|
||||
@@ -100,6 +113,100 @@ const toMoneyCents = (value) => {
|
||||
|
||||
const asN2FromCents = (cents) => asN2({ amount: Number.isFinite(cents) ? cents : 0, precision: 2 });
|
||||
|
||||
const formatDecimal = (value, maxDecimals = 2) => {
|
||||
const factor = Math.pow(10, maxDecimals);
|
||||
const rounded = Math.round(Math.max(0, toFiniteNumber(value)) * factor) / factor;
|
||||
if (!Number.isFinite(rounded)) return "0";
|
||||
return rounded.toFixed(maxDecimals).replace(/\.?0+$/, "") || "0";
|
||||
};
|
||||
|
||||
const buildRolaborBillFields = ({ amountUnits = 0, hours = 0, rate = 0 } = {}) => {
|
||||
const normalizedAmount = toFiniteNumber(amountUnits);
|
||||
|
||||
if (normalizedAmount <= 0) {
|
||||
return {
|
||||
jobTotalHrs: "0",
|
||||
billTime: "0",
|
||||
billRate: "0"
|
||||
};
|
||||
}
|
||||
|
||||
let resolvedHours = toFiniteNumber(hours);
|
||||
let resolvedRate = toFiniteNumber(rate);
|
||||
|
||||
if (resolvedHours > 0 && resolvedRate <= 0) {
|
||||
resolvedRate = normalizedAmount / resolvedHours;
|
||||
} else if (resolvedRate > 0 && resolvedHours <= 0) {
|
||||
resolvedHours = normalizedAmount / resolvedRate;
|
||||
} else if (resolvedHours <= 0 && resolvedRate <= 0) {
|
||||
// Keep the math internally consistent even if the source job has dollars but no usable hours.
|
||||
resolvedHours = 1;
|
||||
resolvedRate = normalizedAmount;
|
||||
}
|
||||
|
||||
return {
|
||||
jobTotalHrs: formatDecimal(resolvedHours),
|
||||
billTime: formatDecimal(resolvedHours),
|
||||
billRate: resolvedRate.toFixed(2)
|
||||
};
|
||||
};
|
||||
|
||||
const buildMinimalRolaborFromJob = (job, { opCode, payType = "Cust" } = {}) => {
|
||||
const trimmedOpCode = opCode != null ? String(opCode).trim() : "";
|
||||
if (!job || !trimmedOpCode) return null;
|
||||
|
||||
let totalHours = 0;
|
||||
let totalAmountUnits = 0;
|
||||
|
||||
for (const line of job?.joblines || []) {
|
||||
const laborType = typeof line?.mod_lbr_ty === "string" ? line.mod_lbr_ty.trim() : "";
|
||||
if (!laborType) continue;
|
||||
|
||||
const lineHours = toFiniteNumber(line?.mod_lb_hrs ?? line?.db_hrs);
|
||||
const configuredRate = toFiniteNumber(job?.[`rate_${laborType.toLowerCase()}`]);
|
||||
let lineAmountUnits = toFiniteNumber(line?.lbr_amt);
|
||||
|
||||
if (lineAmountUnits <= 0 && lineHours > 0 && configuredRate > 0) {
|
||||
lineAmountUnits = lineHours * configuredRate;
|
||||
}
|
||||
|
||||
if (lineAmountUnits <= 0 && lineHours <= 0) continue;
|
||||
|
||||
totalHours += lineHours;
|
||||
totalAmountUnits += lineAmountUnits;
|
||||
}
|
||||
|
||||
if (totalAmountUnits <= 0 && totalHours <= 0) return null;
|
||||
|
||||
const bill = buildRolaborBillFields({
|
||||
amountUnits: totalAmountUnits,
|
||||
hours: totalHours,
|
||||
rate: totalHours > 0 ? totalAmountUnits / totalHours : 0
|
||||
});
|
||||
const formattedAmount = totalAmountUnits.toFixed(2);
|
||||
|
||||
return {
|
||||
ops: [
|
||||
{
|
||||
opCode: trimmedOpCode,
|
||||
jobNo: "1",
|
||||
custPayTypeFlag: "C",
|
||||
custTxblNtxblFlag: toFiniteNumber(job?.tax_lbr_rt) > 0 ? "T" : "N",
|
||||
bill: {
|
||||
payType,
|
||||
...bill
|
||||
},
|
||||
amount: {
|
||||
payType,
|
||||
amtType: "Job",
|
||||
custPrice: formattedAmount,
|
||||
totalAmt: formattedAmount
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build RR estimate block from allocation totals.
|
||||
* @param {Array} allocations
|
||||
@@ -326,6 +433,13 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
|
||||
// Each segment becomes its own op / JobNo with a single line
|
||||
segments.forEach((seg, idx) => {
|
||||
const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments
|
||||
const isLaborSegment = seg.kind === "laborTaxable" || seg.kind === "laborNonTaxable";
|
||||
const segmentHours = isLaborSegment
|
||||
? seg.kind === "laborTaxable"
|
||||
? toFiniteNumber(alloc.laborTaxableHours)
|
||||
: toFiniteNumber(alloc.laborNonTaxableHours)
|
||||
: 0;
|
||||
const segmentBillRate = isLaborSegment && segmentHours > 0 ? seg.saleCents / 100 / segmentHours : 0;
|
||||
|
||||
const line = {
|
||||
breakOut,
|
||||
@@ -349,7 +463,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
|
||||
// Extra metadata for UI / debugging
|
||||
segmentKind: seg.kind,
|
||||
segmentIndex: idx,
|
||||
segmentCount
|
||||
segmentCount,
|
||||
segmentHours,
|
||||
segmentBillRate
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -368,9 +484,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
|
||||
*
|
||||
* We still keep a 1:1 mapping with GOG ops: each op gets a corresponding
|
||||
* OpCodeLaborInfo entry using the same JobNo and the same tax flag as its
|
||||
* GOG line. Labor-specific hours/rate remain zeroed out, but actual labor
|
||||
* sale amounts are mirrored into ROLABOR for labor segments so RR receives
|
||||
* the expected labor pricing on updates. Non-labor ops remain zeroed.
|
||||
* GOG line. Labor sale amounts are mirrored into ROLABOR and, when hours
|
||||
* are available from allocations, weighted bill hours/rates are also
|
||||
* populated so the labor subsection is editable in Ignite.
|
||||
*
|
||||
* @param {Object} rogg - result of buildRogogFromAllocations
|
||||
* @param {Object} opts
|
||||
@@ -391,6 +507,17 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
|
||||
const linePayType = firstLine.custPayTypeFlag || "C";
|
||||
const isLaborSegment = op.segmentKind === "laborTaxable" || op.segmentKind === "laborNonTaxable";
|
||||
const laborAmount = isLaborSegment ? String(firstLine?.amount?.custPrice ?? "0") : "0";
|
||||
const laborBill = isLaborSegment
|
||||
? buildRolaborBillFields({
|
||||
amountUnits: laborAmount,
|
||||
hours: op.segmentHours,
|
||||
rate: op.segmentBillRate
|
||||
})
|
||||
: {
|
||||
jobTotalHrs: "0",
|
||||
billTime: "0",
|
||||
billRate: "0"
|
||||
};
|
||||
|
||||
return {
|
||||
opCode: op.opCode,
|
||||
@@ -399,9 +526,7 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
|
||||
custTxblNtxblFlag: txFlag,
|
||||
bill: {
|
||||
payType,
|
||||
jobTotalHrs: "0",
|
||||
billTime: "0",
|
||||
billRate: "0"
|
||||
...laborBill
|
||||
},
|
||||
amount: {
|
||||
payType,
|
||||
@@ -686,5 +811,6 @@ module.exports = {
|
||||
normalizeCustomerCandidates,
|
||||
normalizeVehicleCandidates,
|
||||
buildRogogFromAllocations,
|
||||
buildRolaborFromRogog
|
||||
buildRolaborFromRogog,
|
||||
buildMinimalRolaborFromJob
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user