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); }; /** * 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 }); /** * 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: * - (ROGOG) * - (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 * @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 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 }); }); } 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-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. * * @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"; return { opCode: op.opCode, jobNo: op.jobNo, custPayTypeFlag: linePayType, custTxblNtxblFlag: txFlag, bill: { payType, jobTotalHrs: "0", billTime: "0", billRate: "0" }, 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 };