817 lines
23 KiB
JavaScript
817 lines
23 KiB
JavaScript
const client = require("../graphql-client/graphql-client").client;
|
|
const { GET_JOB_BY_PK } = require("../graphql-client/queries");
|
|
|
|
/**
|
|
* Remove all non-digit characters from a string.
|
|
* @param s
|
|
* @returns {string}
|
|
*/
|
|
const digitsOnly = (s) => String(s || "").replace(/\D/g, "");
|
|
|
|
/**
|
|
* Pick job ID from various possible locations.
|
|
* @param ctx
|
|
* @param explicitId
|
|
* @returns {*|null}
|
|
*/
|
|
const pickJobId = (ctx, explicitId) =>
|
|
explicitId || ctx?.job?.id || ctx?.payload?.job?.id || ctx?.payload?.jobId || ctx?.jobId || null;
|
|
|
|
/**
|
|
* Safely get VIN from job object.
|
|
* @param job
|
|
* @returns {*|string|null}
|
|
*/
|
|
const safeVin = (job) => (job?.v_vin && String(job.v_vin).trim()) || null;
|
|
|
|
/**
|
|
* Extract blocks array from combined search result.
|
|
* @param res
|
|
* @returns {any[]|*[]}
|
|
*/
|
|
const blocksFromCombinedSearchResult = (res) => {
|
|
const data = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
|
|
return Array.isArray(data) ? data : [];
|
|
};
|
|
|
|
/**
|
|
* Convert a Dinero.js object or number into an "N2" string ("123.45").
|
|
* @param value
|
|
* @returns {string}
|
|
*/
|
|
const asN2 = (dineroLike) => {
|
|
if (!dineroLike) return "0.00";
|
|
|
|
// Handle Dinero v1/v2-ish or raw objects
|
|
if (typeof dineroLike.toUnit === "function") {
|
|
return dineroLike.toUnit().toFixed(2);
|
|
}
|
|
|
|
const precision = dineroLike.precision ?? 2;
|
|
const amount = (dineroLike.amount ?? 0) / Math.pow(10, precision);
|
|
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:
|
|
* - Dinero instances (getAmount / toUnit)
|
|
* - { cents }
|
|
* - { amount, precision }
|
|
* - plain numbers (treated as units, e.g. dollars)
|
|
* - numeric strings (treated as units, e.g. "123.45")
|
|
* @param value
|
|
* @returns {number}
|
|
*/
|
|
const toMoneyCents = (value) => {
|
|
if (value == null || value === "") return 0;
|
|
|
|
if (typeof value.getAmount === "function") {
|
|
return value.getAmount();
|
|
}
|
|
|
|
if (typeof value.toUnit === "function") {
|
|
const unit = value.toUnit();
|
|
return Number.isFinite(unit) ? Math.round(unit * 100) : 0;
|
|
}
|
|
|
|
if (typeof value.cents === "number") {
|
|
return value.cents;
|
|
}
|
|
|
|
if (typeof value.amount === "number") {
|
|
const precision = typeof value.precision === "number" ? value.precision : 2;
|
|
if (precision === 2) return value.amount;
|
|
const factor = Math.pow(10, 2 - precision);
|
|
return Math.round(value.amount * factor);
|
|
}
|
|
|
|
if (typeof value === "number") {
|
|
return Math.round(value * 100);
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
const parsed = Number.parseFloat(value);
|
|
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
|
}
|
|
|
|
return 0;
|
|
};
|
|
|
|
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
|
|
* @returns {{parts: string, labor: string, total: string}|null}
|
|
*/
|
|
const buildEstimateFromAllocations = (allocations) => {
|
|
if (!Array.isArray(allocations) || allocations.length === 0) return null;
|
|
|
|
const totals = allocations.reduce(
|
|
(acc, alloc) => {
|
|
acc.parts += toMoneyCents(alloc?.partsSale);
|
|
acc.labor += toMoneyCents(alloc?.laborTaxableSale);
|
|
acc.labor += toMoneyCents(alloc?.laborNonTaxableSale);
|
|
acc.total += toMoneyCents(alloc?.totalSale);
|
|
return acc;
|
|
},
|
|
{ parts: 0, labor: 0, total: 0 }
|
|
);
|
|
|
|
// If totalSale wasn't provided, keep total coherent with parts + labor.
|
|
if (!totals.total) {
|
|
totals.total = totals.parts + totals.labor;
|
|
}
|
|
|
|
return {
|
|
parts: asN2FromCents(totals.parts),
|
|
labor: asN2FromCents(totals.labor),
|
|
total: asN2FromCents(totals.total)
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Build RR estimate block from precomputed job totals.
|
|
* @param job
|
|
* @returns {{parts: string, labor: string, total: string}|null}
|
|
*/
|
|
const buildEstimateFromJobTotals = (job) => {
|
|
const totals = job?.job_totals;
|
|
if (!totals) return null;
|
|
|
|
const partsCents = toMoneyCents(totals?.parts?.parts?.total) + toMoneyCents(totals?.parts?.sublets?.total);
|
|
const laborCents = toMoneyCents(totals?.rates?.rates_subtotal ?? totals?.rates?.subtotal);
|
|
let totalCents = toMoneyCents(totals?.totals?.subtotal);
|
|
|
|
if (!totalCents) {
|
|
totalCents = partsCents + laborCents;
|
|
}
|
|
|
|
// If we truly have no numbers from totals, omit estimate entirely.
|
|
if (!partsCents && !laborCents && !totalCents) return null;
|
|
|
|
return {
|
|
parts: asN2FromCents(partsCents),
|
|
labor: asN2FromCents(laborCents),
|
|
total: asN2FromCents(totalCents)
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Build RR estimate block from the best available source.
|
|
* @param job
|
|
* @param allocations
|
|
* @returns {{parts: string, labor: string, total: string}|null}
|
|
*/
|
|
const buildRREstimate = ({ job, allocations } = {}) => {
|
|
return buildEstimateFromAllocations(allocations) || buildEstimateFromJobTotals(job);
|
|
};
|
|
|
|
/**
|
|
* Build RO.GOG structure for the reynolds-rome-client `createRepairOrder` payload
|
|
* from allocations.
|
|
*
|
|
* Supports the allocation shape:
|
|
* {
|
|
* center,
|
|
* partsSale,
|
|
* partsTaxableSale,
|
|
* partsNonTaxableSale,
|
|
* laborTaxableSale,
|
|
* laborNonTaxableSale,
|
|
* extrasSale,
|
|
* extrasTaxableSale,
|
|
* extrasNonTaxableSale,
|
|
* totalSale,
|
|
* cost,
|
|
* profitCenter,
|
|
* costCenter
|
|
* }
|
|
*
|
|
* For each center, we can emit up to 6 GOG *segments*:
|
|
* - taxable parts (CustTxblNTxblFlag="T")
|
|
* - non-taxable parts (CustTxblNTxblFlag="N")
|
|
* - taxable extras (CustTxblNTxblFlag="T")
|
|
* - non-taxable extras (CustTxblNTxblFlag="N")
|
|
* - taxable labor (CustTxblNTxblFlag="T")
|
|
* - non-taxable labor (CustTxblNTxblFlag="N")
|
|
*
|
|
* IMPORTANT:
|
|
* Each segment becomes its OWN JobNo / AllGogOpCodeInfo, with exactly one
|
|
* AllGogLineItmInfo inside. This keeps a clean 1:1 mapping between:
|
|
* - <AllGogOpCodeInfo> (ROGOG)
|
|
* - <OpCodeLaborInfo> (ROLABOR)
|
|
* and ensures taxable/non-taxable flags line up by JobNo.
|
|
*
|
|
* We attach segmentKind/segmentIndex/segmentCount metadata on each op
|
|
* for UI/debug purposes. The XML templates can safely ignore these.
|
|
*
|
|
* @param {Array} allocations
|
|
* @param {Object} opts
|
|
* @param {string} opts.opCode - RR OpCode for the job (global, overridable)
|
|
* @param {string} [opts.payType="Cust"] - PayType (always "Cust" per Marc)
|
|
* @param {string} [opts.roNo] - Optional RoNo to echo on <Rogog RoNo="">
|
|
* @returns {null|{roNo?: string, ops: Array}}
|
|
*/
|
|
const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo } = {}) => {
|
|
if (!Array.isArray(allocations) || !allocations.length || !opCode) return null;
|
|
|
|
const ops = [];
|
|
|
|
const asMoneyLike = (amountCents) => ({
|
|
amount: amountCents || 0,
|
|
precision: 2
|
|
});
|
|
|
|
for (const alloc of allocations) {
|
|
const pc = alloc?.profitCenter || {};
|
|
const breakOut = pc.rr_gogcode;
|
|
const itemType = pc.rr_item_type;
|
|
|
|
// Only centers configured for RR GOG are included
|
|
if (!breakOut || !itemType) continue;
|
|
|
|
const partsTaxableCents = toMoneyCents(alloc.partsTaxableSale);
|
|
const partsNonTaxableCents = toMoneyCents(alloc.partsNonTaxableSale);
|
|
const extrasTaxableCents = toMoneyCents(alloc.extrasTaxableSale);
|
|
const extrasNonTaxableCents = toMoneyCents(alloc.extrasNonTaxableSale);
|
|
const laborTaxableCents = toMoneyCents(alloc.laborTaxableSale);
|
|
const laborNonTaxableCents = toMoneyCents(alloc.laborNonTaxableSale);
|
|
const costCents = toMoneyCents(alloc.cost);
|
|
|
|
const segments = [];
|
|
|
|
// 1) Taxable parts segment -> "T"
|
|
if (partsTaxableCents !== 0) {
|
|
segments.push({
|
|
kind: "partsTaxable",
|
|
saleCents: partsTaxableCents,
|
|
txFlag: "T"
|
|
});
|
|
}
|
|
|
|
// 2) Non-taxable parts segment -> "N"
|
|
if (partsNonTaxableCents !== 0) {
|
|
segments.push({
|
|
kind: "partsNonTaxable",
|
|
saleCents: partsNonTaxableCents,
|
|
txFlag: "N"
|
|
});
|
|
}
|
|
|
|
// 3) Taxable extras -> "T"
|
|
if (extrasTaxableCents !== 0) {
|
|
segments.push({
|
|
kind: "extrasTaxable",
|
|
saleCents: extrasTaxableCents,
|
|
txFlag: "T"
|
|
});
|
|
}
|
|
|
|
// 4) Non-taxable extras -> "N"
|
|
if (extrasNonTaxableCents !== 0) {
|
|
segments.push({
|
|
kind: "extrasNonTaxable",
|
|
saleCents: extrasNonTaxableCents,
|
|
txFlag: "N"
|
|
});
|
|
}
|
|
|
|
// 5) Taxable labor segment -> "T"
|
|
if (laborTaxableCents !== 0) {
|
|
segments.push({
|
|
kind: "laborTaxable",
|
|
saleCents: laborTaxableCents,
|
|
txFlag: "T"
|
|
});
|
|
}
|
|
|
|
// 6) Non-tax labor segment -> "N"
|
|
if (laborNonTaxableCents !== 0) {
|
|
segments.push({
|
|
kind: "laborNonTaxable",
|
|
saleCents: laborNonTaxableCents,
|
|
txFlag: "N"
|
|
});
|
|
}
|
|
|
|
if (!segments.length) continue;
|
|
|
|
// Proportionally split cost across segments based on their sale amounts
|
|
const totalCostCents = costCents;
|
|
const totalSaleCents = segments.reduce((sum, seg) => sum + seg.saleCents, 0);
|
|
|
|
let remainingCostCents = totalCostCents;
|
|
|
|
segments.forEach((seg, idx) => {
|
|
let segCost = 0;
|
|
|
|
if (totalCostCents > 0 && totalSaleCents > 0) {
|
|
if (idx === segments.length - 1) {
|
|
// Last segment gets the remainder to avoid rounding drift
|
|
segCost = remainingCostCents;
|
|
} else {
|
|
segCost = Math.round((seg.saleCents / totalSaleCents) * totalCostCents);
|
|
remainingCostCents -= segCost;
|
|
}
|
|
}
|
|
|
|
seg.costCents = segCost;
|
|
});
|
|
|
|
const itemDescBase = pc.accountdesc || pc.accountname || alloc.center || "";
|
|
const segmentCount = segments.length;
|
|
|
|
// 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,
|
|
itemType,
|
|
itemDesc: itemDescBase,
|
|
custQty: "1.0",
|
|
custPayTypeFlag: "C",
|
|
custTxblNtxblFlag: seg.txFlag || "N",
|
|
amount: {
|
|
payType,
|
|
amtType: "Unit",
|
|
custPrice: asN2(asMoneyLike(seg.saleCents)),
|
|
dlrCost: asN2(asMoneyLike(seg.costCents))
|
|
}
|
|
};
|
|
|
|
ops.push({
|
|
opCode,
|
|
jobNo,
|
|
lines: [line], // exactly one AllGogLineItmInfo per AllGogOpCodeInfo
|
|
// Extra metadata for UI / debugging
|
|
segmentKind: seg.kind,
|
|
segmentIndex: idx,
|
|
segmentCount,
|
|
segmentHours,
|
|
segmentBillRate
|
|
});
|
|
});
|
|
}
|
|
|
|
if (!ops.length) return null;
|
|
|
|
return {
|
|
roNo,
|
|
ops
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Build RO.ROLABOR structure for the reynolds-rome-client `createRepairOrder` payload
|
|
* from an already-built RO.GOG structure.
|
|
*
|
|
* 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 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
|
|
* @param {string} [opts.payType="Cust"]
|
|
* @returns {null|{ops: Array}}
|
|
*/
|
|
const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
|
|
if (!rogg || !Array.isArray(rogg.ops)) return null;
|
|
|
|
const ops = rogg.ops.map((op) => {
|
|
const firstLine = op.lines?.[0] || {};
|
|
|
|
// Pull tax flag from the GOG line.
|
|
// Prefer the property we set in buildRogogFromAllocations (custTxblNTxblFlag),
|
|
// but also accept custTxblNtxblFlag in case we ever change naming.
|
|
const txFlag = firstLine.custTxblNtxblFlag ?? "N";
|
|
|
|
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,
|
|
jobNo: op.jobNo,
|
|
custPayTypeFlag: linePayType,
|
|
custTxblNtxblFlag: txFlag,
|
|
bill: {
|
|
payType,
|
|
...laborBill
|
|
},
|
|
amount: {
|
|
payType,
|
|
amtType: "Job",
|
|
custPrice: laborAmount,
|
|
totalAmt: laborAmount
|
|
}
|
|
};
|
|
});
|
|
|
|
if (!ops.length) return null;
|
|
|
|
return { ops };
|
|
};
|
|
|
|
/**
|
|
* Query job data by ID from GraphQL API.
|
|
* @param ctx
|
|
* @param jobId
|
|
* @returns {Promise<*>}
|
|
* @constructor
|
|
*/
|
|
const QueryJobData = async (ctx = {}, jobId) => {
|
|
if (ctx?.job) return ctx.job;
|
|
if (ctx?.payload?.job) return ctx.payload.job;
|
|
|
|
const id = pickJobId(ctx, jobId);
|
|
if (!id) throw new Error("QueryJobData: jobId required (none found in ctx or args)");
|
|
|
|
try {
|
|
const res = await client.request(GET_JOB_BY_PK, { id });
|
|
const job = res?.jobs_by_pk;
|
|
if (!job) throw new Error(`Job ${id} not found`);
|
|
return job;
|
|
} catch (e) {
|
|
const msg = e?.response?.errors?.[0]?.message || e.message || "unknown";
|
|
throw new Error(`QueryJobData failed: ${msg}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Build Repair Order payload for RR from job and customer data.
|
|
* @param {Object} args
|
|
* @param job
|
|
* @param selectedCustomer
|
|
* @param advisorNo
|
|
* @param story
|
|
* @param makeOverride
|
|
* @param allocations
|
|
* @param {string} [opCode] - RR OpCode for this RO (global default / override)
|
|
* @returns {Object}
|
|
*/
|
|
const buildRRRepairOrderPayload = ({
|
|
job,
|
|
selectedCustomer,
|
|
advisorNo,
|
|
story,
|
|
makeOverride,
|
|
allocations,
|
|
opCode
|
|
} = {}) => {
|
|
const customerNo = selectedCustomer?.customerNo
|
|
? String(selectedCustomer.customerNo).trim()
|
|
: selectedCustomer?.custNo
|
|
? String(selectedCustomer.custNo).trim()
|
|
: null;
|
|
|
|
if (!customerNo) throw new Error("No RR customer selected (customerNo/custNo missing)");
|
|
|
|
const adv = advisorNo != null && String(advisorNo).trim() !== "" ? String(advisorNo).trim() : null;
|
|
if (!adv) throw new Error("advisorNo is required for RR export");
|
|
|
|
const vinRaw = job?.v_vin;
|
|
const vin =
|
|
typeof vinRaw === "string"
|
|
? vinRaw
|
|
.replace(/[^A-Za-z0-9]/g, "")
|
|
.toUpperCase()
|
|
.slice(0, 17) || undefined
|
|
: undefined;
|
|
|
|
// Use ro_number when present; fallback to job.id
|
|
const ro = job?.ro_number != null ? job.ro_number : job?.id != null ? job.id : null;
|
|
if (ro == null) throw new Error("Missing repair order identifier (ro_number/id)");
|
|
|
|
const roStr = String(ro);
|
|
|
|
// Base payload shape expected by reynolds-rome-client (buildCreateRepairOrder)
|
|
const payload = {
|
|
outsdRoNo: roStr,
|
|
departmentType: "B",
|
|
vin,
|
|
customerNo: String(customerNo),
|
|
advisorNo: adv,
|
|
mileageIn: job.kmin
|
|
};
|
|
|
|
const estimate = buildRREstimate({ job, allocations });
|
|
if (estimate) {
|
|
payload.estimate = estimate;
|
|
}
|
|
|
|
if (story) {
|
|
payload.roComment = String(story).trim();
|
|
}
|
|
|
|
if (makeOverride) {
|
|
// Passed through so the template can override DMS Make if needed
|
|
payload.makeOverride = String(makeOverride).trim();
|
|
}
|
|
|
|
const haveAllocations = Array.isArray(allocations) && allocations.length > 0;
|
|
|
|
if (haveAllocations) {
|
|
const effectiveOpCode = (opCode && String(opCode).trim()) || null;
|
|
|
|
if (effectiveOpCode) {
|
|
// Build RO.GOG and RO.LABOR in the new normalized shape
|
|
const rogg = buildRogogFromAllocations(allocations, {
|
|
opCode: effectiveOpCode,
|
|
payType: "Cust"
|
|
});
|
|
|
|
if (rogg) {
|
|
payload.rogg = rogg;
|
|
|
|
const rolabor = buildRolaborFromRogog(rogg, { payType: "Cust" });
|
|
if (rolabor) {
|
|
payload.rolabor = rolabor;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return payload;
|
|
};
|
|
|
|
/**
|
|
* Make vehicle search payload from job data
|
|
* @param job
|
|
* @returns {{kind: string, license: string}|null|{kind: string, vin: *|string}}
|
|
*/
|
|
const makeVehicleSearchPayloadFromJob = (job) => {
|
|
const vin = safeVin(job);
|
|
if (vin) return { kind: "vin", vin };
|
|
|
|
const plate = job?.plate_no;
|
|
if (plate) return { kind: "license", license: String(plate).trim() };
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Make customer search payload from job data
|
|
* @param job
|
|
* @returns {{kind: string, vin: *|string}|{kind: string, name: {name: string}}|{kind: string, phone: string}|null}
|
|
*/
|
|
const makeCustomerSearchPayloadFromJob = (job) => {
|
|
const phone = job?.ownr_ph1;
|
|
const d = digitsOnly(phone);
|
|
if (d.length >= 7) return { kind: "phone", phone: d };
|
|
|
|
const lastName = job?.ownr_ln;
|
|
const company = job?.ownr_co_nm;
|
|
const lnOrCompany = lastName || company;
|
|
if (lnOrCompany) return { kind: "name", name: { name: String(lnOrCompany).trim() } };
|
|
|
|
const vin = safeVin(job);
|
|
if (vin) return { kind: "vin", vin };
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Normalize customer candidates from combined search result.
|
|
* @param res
|
|
* @returns {*[]}
|
|
*/
|
|
const normalizeCustomerCandidates = (res) => {
|
|
const blocks = blocksFromCombinedSearchResult(res);
|
|
const out = [];
|
|
for (const blk of blocks) {
|
|
const serv = Array.isArray(blk?.ServVehicle) ? blk.ServVehicle : [];
|
|
const custNos = serv.map((sv) => sv?.VehicleServInfo?.CustomerNo).filter(Boolean);
|
|
|
|
const nci = blk?.NameContactId;
|
|
const ind = nci?.NameId?.IndName;
|
|
const bus = nci?.NameId?.BusName;
|
|
const personal = [ind?.FName, ind?.LName].filter(Boolean).join(" ").trim();
|
|
const company = bus?.CompanyName;
|
|
const name = (personal || company || "").trim();
|
|
|
|
for (const custNo of custNos) {
|
|
out.push({ custNo, name: name || `Customer ${custNo}`, _blk: blk });
|
|
}
|
|
}
|
|
const seen = new Set();
|
|
return out.filter((c) => {
|
|
if (!c.custNo || seen.has(c.custNo)) return false;
|
|
seen.add(c.custNo);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Normalize vehicle candidates from combined search result.
|
|
* @param res
|
|
* @returns {*[]}
|
|
*/
|
|
const normalizeVehicleCandidates = (res) => {
|
|
const blocks = blocksFromCombinedSearchResult(res);
|
|
const out = [];
|
|
for (const blk of blocks) {
|
|
const serv = Array.isArray(blk?.ServVehicle) ? blk.ServVehicle : [];
|
|
for (const sv of serv) {
|
|
const v = sv?.Vehicle || {};
|
|
const vin = v?.Vin || v?.VIN || v?.vin;
|
|
|
|
if (!vin) continue;
|
|
|
|
const year = v?.VehicleYr || v?.ModelYear || v?.Year;
|
|
const make = v?.VehicleMake || v?.MakeName || v?.Make;
|
|
const model = v?.MdlNo || v?.ModelDesc || v?.Model;
|
|
|
|
const label = [year, make, model, vin].filter(Boolean).join(" ");
|
|
out.push({ vin, year, make, model, label, _blk: blk });
|
|
}
|
|
}
|
|
const seen = new Set();
|
|
return out.filter((v) => {
|
|
if (!v.vin || seen.has(v.vin)) return false;
|
|
seen.add(v.vin);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Build split labor lines from job allocations.
|
|
* @param jobAllocations
|
|
* @returns {*[]}
|
|
*/
|
|
const buildSplitLaborLinesFromAllocations = (jobAllocations) => {
|
|
const lines = [];
|
|
|
|
for (const alloc of jobAllocations || []) {
|
|
const { center, laborTaxableSale, laborNonTaxableSale, profitCenter, costCenter } = alloc;
|
|
|
|
// Taxable labor
|
|
if (laborTaxableSale && !laborTaxableSale.isZero()) {
|
|
lines.push({
|
|
centerName: center,
|
|
profitCenter,
|
|
costCenter,
|
|
amount: laborTaxableSale,
|
|
isTaxable: true,
|
|
source: "labor"
|
|
});
|
|
}
|
|
|
|
// Non-taxable labor
|
|
if (laborNonTaxableSale && !laborNonTaxableSale.isZero()) {
|
|
lines.push({
|
|
centerName: center,
|
|
profitCenter,
|
|
costCenter,
|
|
amount: laborNonTaxableSale,
|
|
isTaxable: false,
|
|
source: "labor"
|
|
});
|
|
}
|
|
}
|
|
|
|
return lines;
|
|
};
|
|
|
|
module.exports = {
|
|
QueryJobData,
|
|
buildRRRepairOrderPayload,
|
|
makeCustomerSearchPayloadFromJob,
|
|
buildSplitLaborLinesFromAllocations,
|
|
makeVehicleSearchPayloadFromJob,
|
|
normalizeCustomerCandidates,
|
|
normalizeVehicleCandidates,
|
|
buildRogogFromAllocations,
|
|
buildRolaborFromRogog,
|
|
buildMinimalRolaborFromJob
|
|
};
|