Files
bodyshop/server/rr/rr-job-helpers.js

426 lines
12 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);
};
/**
* 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 <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 = [];
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 };
};
/**
* 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 <TaxCodeInfo> 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;
});
};
/**
* 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}}
*/
module.exports = {
QueryJobData,
buildRRRepairOrderPayload,
makeCustomerSearchPayloadFromJob,
makeVehicleSearchPayloadFromJob,
normalizeCustomerCandidates,
normalizeVehicleCandidates,
buildRogogFromAllocations,
buildRolaborFromRogog
};