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); }; /** * Build RO.GOG structure for the reynolds-rome-client `createRepairOrder` payload * from CDK allocations. * @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 = []; for (const alloc of allocations) { const pc = alloc?.profitCenter || {}; const breakOut = pc.rr_gogcode; const itemType = pc.rr_item_type; // Only centers that have been configured for RR GOG are included if (!breakOut || !itemType) continue; const saleN2 = asN2(alloc.sale); const costN2 = asN2(alloc.cost); const itemDesc = pc.accountdesc || pc.accountname || alloc.center || ""; const jobNo = String(ops.length + 1); // 1-based JobNo ops.push({ opCode, jobNo, lines: [ { breakOut, itemType, itemDesc, custQty: "1.0", // warrQty: "0.0", // intrQty: "0.0", custPayTypeFlag: "C", // warrPayTypeFlag: "W", // intrPayTypeFlag: "I", custTxblNtxblFlag: pc.rr_cust_txbl_flag || "T", // warrTxblNtxblFlag: "N", // intrTxblNtxblFlag: "N", amount: { payType, amtType: "Unit", custPrice: saleN2, dlrCost: costN2 } } ] }); } 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. * @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] || {}; return { opCode: op.opCode, jobNo: op.jobNo, custPayTypeFlag: firstLine.custPayTypeFlag || "C", // warrPayTypeFlag: firstLine.warrPayTypeFlag || "W", // intrPayTypeFlag: firstLine.intrPayTypeFlag || "I", custTxblNtxblFlag: firstLine.custTxblNtxblFlag || "N", // warrTxblNtxblFlag: firstLine.warrTxblNtxblFlag || "N", // intrTxblNtxblFlag: firstLine.intrTxblNtxblFlag || "N", // vlrCode: undefined, bill: { payType, jobTotalHrs: "0", billTime: "0", billRate: "0" }, amount: { payType, amtType: "Job", custPrice: "0", totalAmt: "0" } }; }); if (!ops.length) return null; return { ops }; }; /** * Build a header-level TaxCodeInfo payload from allocations (e.g. PROVINCIAL SALES TAX line). * * Shape returned matches what `buildCreateRepairOrder` expects for: * * payload.tax = { * payType, * taxCode, * txblGrossAmt, * grossTaxAmt * } * * NOTE: We are currently NOT wiring this into the payload (see buildRRRepairOrderPayload) * so that TaxCodeInfo is suppressed in the XML, but we keep this helper around for * future use. * * @param {Array} allocations * @param {Object} opts * @param {string} opts.taxCode - RR tax code (configured per dealer) * @param {string} [opts.payType="Cust"] * @returns {null|{payType, taxCode, txblGrossAmt, grossTaxAmt}} */ const buildTaxFromAllocations = (allocations, { taxCode, payType = "Cust" } = {}) => { if (!taxCode || !Array.isArray(allocations) || !allocations.length) return null; const taxAlloc = allocations.find((a) => a && a.tax); if (!taxAlloc || !taxAlloc.sale) return null; const grossTaxNum = parseFloat(asN2(taxAlloc.sale)); if (!Number.isFinite(grossTaxNum)) return null; const rate = typeof taxAlloc.profitCenter?.rate === "number" ? taxAlloc.profitCenter.rate : null; let taxableGrossNum = grossTaxNum; if (rate && rate > 0) { const r = rate / 100; taxableGrossNum = grossTaxNum / r; } return { payType, taxCode, txblGrossAmt: taxableGrossNum.toFixed(2), grossTaxAmt: grossTaxNum.toFixed(2) }; }; /** * Build a minimal Rolabor structure in the new normalized shape. * * Useful for tests or for scenarios where you want a single zero-dollar * Rolabor op but don't have GOG data. Shape matches payload.rolabor for the * reynolds-rome-client builders. * * @param {Object} opts * @param {string} opts.opCode * @param {number|string} [opts.jobNo=1] * @param {string} [opts.payType="Cust"] * @returns {null|{ops: Array}} */ const buildRolaborSkeleton = ({ opCode, jobNo = 1, payType = "Cust" } = {}) => { if (!opCode) return null; return { ops: [ { opCode, jobNo: String(jobNo), custPayTypeFlag: "C", warrPayTypeFlag: "W", intrPayTypeFlag: "I", custTxblNtxblFlag: "N", warrTxblNtxblFlag: "N", intrTxblNtxblFlag: "N", vlrCode: undefined, bill: { payType, jobTotalHrs: "0", billTime: "0", billRate: "0" }, amount: { payType, amtType: "Job", custPrice: "0", totalAmt: "0" } } ] }; }; // ---------- Public API ---------- /** * 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 bodyshop * @param allocations * @param {string} [opCode] - RR OpCode for this RO (global default / override) * @param {string} [taxCode] - RR tax code for header tax (e.g. state/prov code) * @returns {Object} */ const buildRRRepairOrderPayload = ({ job, selectedCustomer, advisorNo, story, makeOverride, allocations, opCode, taxCode } = {}) => { 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 }; 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; const effectiveTaxCode = (taxCode && String(taxCode).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; } } } // --- TAX HEADER TEMPORARILY DISABLED --- // We intentionally do NOT attach payload.tax right now so that the Mustache // section that renders stays false and no TaxCodeInfo is sent. // // Keeping this commented-out for future enablement once RR confirms header // tax handling behaviour. // // if (effectiveTaxCode) { // const taxInfo = buildTaxFromAllocations(allocations, { // taxCode: effectiveTaxCode, // payType: "Cust" // }); // // if (taxInfo) { // payload.tax = taxInfo; // } // } } 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; }); }; module.exports = { QueryJobData, buildRRRepairOrderPayload, makeCustomerSearchPayloadFromJob, makeVehicleSearchPayloadFromJob, normalizeCustomerCandidates, normalizeVehicleCandidates, // exporting these so you can unit-test them directly if you want buildRogogFromAllocations, buildTaxFromAllocations, buildRolaborSkeleton, buildRolaborFromRogog };