// no-dd-sa:javascript-code-style/assignment-name // Handler for VehicleDamageEstimateChgRq const client = require("../../../graphql-client/graphql-client").client; const { parseXml, normalizeXmlObject } = require("../partsManagementUtils"); const { extractPartsTaxRates } = require("./lib/extractPartsTaxRates"); const opCodes = require("./lib/opCodes.json"); const { uploadFileToS3 } = require("../../../utils/s3"); const InstanceMgr = require("../../../utils/instanceMgr").default; const { GET_JOB_BY_ID, UPDATE_JOB_BY_ID, SOFT_DELETE_JOBLINES_BY_IDS, GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ, GET_JOBLINE_IDS_BY_JOBID_UNQSEQ, UPDATE_JOBLINE_BY_PK, INSERT_JOBLINES } = require("../partsManagement.queries"); /** * Finds a job by shop ID and job ID. * @param shopId * @param jobId * @param logger * @returns {Promise<*|null>} */ const findJob = async (shopId, jobId, logger) => { try { const { jobs } = await client.request(GET_JOB_BY_ID, { shopid: shopId, jobid: jobId }); return jobs?.[0] || null; } catch (err) { logger.log("parts-job-lookup-failed", "error", null, null, { error: err }); return null; } }; /** * Extracts updated job data from the request payload. * Mirrors AddRq for parts_tax_rates + driveable when present. * @param rq */ const extractUpdatedJobData = (rq) => { const doc = rq.DocumentInfo || {}; const claim = rq.ClaimInfo || {}; const policyNo = claim.PolicyInfo?.PolicyInfo?.PolicyNum || claim.PolicyInfo?.PolicyNum || null; const out = { comment: doc.Comment || null, clm_no: claim.ClaimNum || null, // TODO (future): status omitted intentionally to avoid overwriting with 'Auth Cust' policy_no: policyNo }; if (rq.ProfileInfo) { out.parts_tax_rates = extractPartsTaxRates(rq.ProfileInfo); } if (rq.VehicleInfo?.Condition?.DrivableInd !== undefined) { out.driveable = !!rq.VehicleInfo.Condition.DrivableInd; } return out; }; /** * Build jobline payloads for updates/inserts (no split between parts & labor). * - Refinish labor aggregated into lbr_* secondary fields and lbr_amt. * - SUBLET-only -> PAS line with act_price = SubletAmount. * - Notes merged with current DB value by unq_seq. */ const extractUpdatedJobLines = (addsChgs = {}, jobId, currentJobLineNotes = {}) => { const linesIn = Array.isArray(addsChgs.DamageLineInfo) ? addsChgs.DamageLineInfo : [addsChgs.DamageLineInfo || {}]; const coerceManual = (val) => val === true || val === 1 || val === "1" || (typeof val === "string" && val.toUpperCase() === "Y"); const out = []; for (const line of linesIn) { if (!line || Object.keys(line).length === 0) continue; const partInfo = line.PartInfo || {}; const laborInfo = line.LaborInfo || {}; const refinishInfo = line.RefinishLaborInfo || {}; const subletInfo = line.SubletInfo || {}; const base = { jobid: jobId, line_no: parseInt(line.LineNum || 0, 10), unq_seq: parseInt(line.UniqueSequenceNum || 0, 10), status: line.LineStatusCode || null, line_desc: line.LineDesc || null, manual_line: false // manual_line: line.ManualLineInd !== undefined ? coerceManual(line.ManualLineInd) : null }; const lineOut = { ...base }; // --- Notes merge --- const unqSeq = lineOut.unq_seq; const currentNotes = currentJobLineNotes?.[unqSeq] || null; const newNotes = line.LineMemo || null; if (newNotes && currentNotes) { if (currentNotes === newNotes || currentNotes.includes(newNotes)) lineOut.notes = currentNotes; else lineOut.notes = `${currentNotes} | ${newNotes}`; } else if (newNotes) lineOut.notes = newNotes; else if (currentNotes) lineOut.notes = currentNotes; else lineOut.notes = null; // --- end notes merge --- const hasPart = Object.keys(partInfo).length > 0; const hasSublet = Object.keys(subletInfo).length > 0; if (hasPart) { lineOut.part_qty = parseFloat(partInfo.Quantity || 0) || 1; lineOut.oem_partno = partInfo.OEMPartNum; lineOut.alt_partno = partInfo?.NonOEM?.NonOEMPartNum; lineOut.part_type = partInfo.PartType || null ? String(partInfo.PartType).toUpperCase() : null; lineOut.act_price = parseFloat(partInfo?.PartPrice || 0); lineOut.db_price = parseFloat(partInfo?.OEMPartPrice || 0); if (partInfo.TaxableInd !== undefined) { const t = partInfo.TaxableInd; lineOut.tax_part = t === true || t === 1 || t === "1" || (typeof t === "string" && t.toUpperCase() === "Y"); } } else if (hasSublet) { const amt = parseFloat(subletInfo.SubletAmount || 0); lineOut.part_type = "PAS"; lineOut.part_qty = 1; lineOut.act_price = isNaN(amt) ? 0 : amt; } // Primary labor const hrs = parseFloat(laborInfo.LaborHours || 0); const amt = parseFloat(laborInfo.LaborAmt || 0); const hasLabor = (!!laborInfo.LaborType && String(laborInfo.LaborType).length > 0) || (!isNaN(hrs) && hrs !== 0) || (!isNaN(amt) && amt !== 0); if (hasLabor) { lineOut.mod_lbr_ty = laborInfo.LaborType || null; lineOut.mod_lb_hrs = isNaN(hrs) ? 0 : hrs; const opCodeKey = typeof laborInfo.LaborOperation === "string" ? laborInfo.LaborOperation.trim().toUpperCase() : null; lineOut.op_code_desc = opCodeKey && opCodes?.[opCodeKey]?.desc ? opCodes[opCodeKey].desc : null; lineOut.lbr_amt = isNaN(amt) ? 0 : amt; } // Refinish (secondary fields, add amount) const rHrs = parseFloat(refinishInfo.LaborHours || 0); const rAmt = parseFloat(refinishInfo.LaborAmt || 0); const hasRefinish = Object.keys(refinishInfo).length > 0 && ((refinishInfo.LaborType && String(refinishInfo.LaborType).length > 0) || !isNaN(rHrs) || !isNaN(rAmt) || !!refinishInfo.LaborOperation); if (hasRefinish) { lineOut.lbr_typ_j = refinishInfo.LaborType || "LAR"; lineOut.lbr_hrs_j = isNaN(rHrs) ? 0 : rHrs; lineOut.lbr_op_j = refinishInfo.LaborOperation || null; if (!isNaN(rAmt)) lineOut.lbr_amt = (Number.isFinite(lineOut.lbr_amt) ? lineOut.lbr_amt : 0) + rAmt; if (refinishInfo.PaintStagesNum !== undefined) lineOut.paint_stg = refinishInfo.PaintStagesNum; if (refinishInfo.PaintTonesNum !== undefined) lineOut.paint_tone = refinishInfo.PaintTonesNum; } out.push(lineOut); } return out; }; /** * Expand deletion IDs to include derived labor/refinish offsets. */ const extractDeletions = (deletions = {}) => { const items = Array.isArray(deletions.DamageLineInfo) ? deletions.DamageLineInfo : [deletions.DamageLineInfo || {}]; const baseSeqs = items.map((line) => parseInt(line.UniqueSequenceNum, 10)).filter((id) => Number.isInteger(id)); const allSeqs = []; for (const u of baseSeqs) allSeqs.push(u, u + 400000, u + 500000); return Array.from(new Set(allSeqs)); }; // S3 bucket + key builder (mirrors AddRq but with changeRequest prefix) const ESTIMATE_XML_BUCKET = process.env?.NODE_ENV === "development" ? "parts-estimates" : InstanceMgr({ imex: `imex-webest-xml`, rome: `rome-webest-xml` }); const buildEstimateXmlKey = (rq) => { const shopId = rq.ShopID; const jobId = rq.JobID; const ts = new Date().toISOString().replace(/:/g, "-"); return `changeRequest/${shopId}/${jobId}/${ts}.xml`; }; /** * Convert a full jobline object into a jobs_set_input for update_by_pk (omit immutable fields). */ const toJoblineSetInput = (jl) => { const { // immutable identity fields: // jobid, // unq_seq, // everything else: line_no, status, line_desc, manual_line, notes, part_qty, oem_partno, alt_partno, part_type, act_price, db_price, tax_part, mod_lbr_ty, mod_lb_hrs, op_code_desc, lbr_amt, lbr_typ_j, lbr_hrs_j, lbr_op_j, paint_stg, paint_tone } = jl; return { line_no, status, line_desc, manual_line, notes, part_qty, oem_partno, alt_partno, part_type, act_price, db_price, tax_part, mod_lbr_ty, mod_lb_hrs, op_code_desc, lbr_amt, lbr_typ_j, lbr_hrs_j, lbr_op_j, paint_stg, paint_tone }; }; /** * Handles VehicleDamageEstimateChgRq requests: * - Update core job fields * - For lines: update by PK if existing; otherwise bulk insert * - Soft-delete only explicit deletions (exclude any updated seqs) */ const partsManagementVehicleDamageEstimateChgRq = async (req, res) => { const { logger } = req; const rawXml = typeof req.body === "string" ? req.body : Buffer.isBuffer(req.body) ? req.body.toString("utf8") : ""; try { const payload = await parseXml(req.body, logger); const rq = normalizeXmlObject(payload.VehicleDamageEstimateChgRq); const jobId = rq.JobID; const shopId = rq.ShopID; // Fire-and-forget archival on valid request (async () => { try { const key = buildEstimateXmlKey(rq); await uploadFileToS3({ bucketName: ESTIMATE_XML_BUCKET, key, content: rawXml || "", contentType: "application/xml" }); logger.log("parts-estimate-xml-uploaded", "info", jobId, null, { key, bytes: rawXml?.length || 0 }); } catch (e) { logger.log("parts-estimate-xml-upload-failed", "warn", jobId, null, { error: e?.message }); } })(); const job = await findJob(shopId, jobId, logger); if (!job) return res.status(404).send("Job not found"); // --- Updated seqs from incoming changes --- const linesIn = Array.isArray(rq.AddsChgs?.DamageLineInfo) ? rq.AddsChgs.DamageLineInfo : [rq.AddsChgs?.DamageLineInfo || {}]; const updatedSeqs = Array.from( new Set((linesIn || []).map((l) => parseInt(l?.UniqueSequenceNum || 0, 10)).filter((v) => Number.isInteger(v))) ); // --- Fetch current notes for merge --- let currentJobLineNotes = {}; if (updatedSeqs.length > 0) { const resp = await client.request(GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ, { jobid: job.id, unqSeqs: updatedSeqs }); if (resp?.joblines) { for (const jl of resp.joblines) currentJobLineNotes[jl.unq_seq] = jl.notes; } } const updatedJobData = extractUpdatedJobData(rq); const updatedLines = extractUpdatedJobLines(rq.AddsChgs, job.id, currentJobLineNotes); // --- Look up existing rows (by natural key) to decide update vs insert --- let existingIdByUnqSeq = {}; if (updatedSeqs.length > 0) { const existing = await client.request(GET_JOBLINE_IDS_BY_JOBID_UNQSEQ, { jobid: job.id, unqSeqs: updatedSeqs }); if (existing?.joblines) { for (const row of existing.joblines) existingIdByUnqSeq[row.unq_seq] = row.id; } } const toUpdate = []; const toInsert = []; for (const jl of updatedLines) { const id = existingIdByUnqSeq[jl.unq_seq]; if (id) toUpdate.push({ id, _set: toJoblineSetInput(jl) }); else toInsert.push(jl); } // Build deletions list and exclude any seqs we are updating (avoid accidental removal) const deletedLineIdsAll = extractDeletions(rq.Deletions); const deletionSeqs = deletedLineIdsAll.filter((u) => !updatedSeqs.includes(u)); // Mutations: const updateJobPromise = client.request(UPDATE_JOB_BY_ID, { id: job.id, job: updatedJobData }); const softDeletePromise = deletionSeqs.length ? client.request(SOFT_DELETE_JOBLINES_BY_IDS, { jobid: job.id, unqSeqs: deletionSeqs }) : Promise.resolve({}); // Update each existing row by primary key (parallelized) const perRowUpdatesPromise = toUpdate.length > 0 ? Promise.all(toUpdate.map(({ id, _set }) => client.request(UPDATE_JOBLINE_BY_PK, { id, jl: _set }))) : Promise.resolve([]); // Insert brand-new rows in bulk const insertPromise = toInsert.length > 0 ? client.request(INSERT_JOBLINES, { joblines: toInsert }) : Promise.resolve({}); await Promise.all([updateJobPromise, softDeletePromise, perRowUpdatesPromise, insertPromise]); logger.log("parts-job-changed", "info", job.id, null); return res.status(200).json({ success: true, jobId: job.id }); } catch (err) { logger.log("parts-chgrq-error", "error", null, null, { error: err }); return res.status(err.status || 500).json({ error: err.message || "Internal error" }); } }; module.exports = partsManagementVehicleDamageEstimateChgRq;