const client = require("../../../graphql-client/graphql-client").client; const { extractPartsTaxRates } = require("./lib/extractPartsTaxRates"); const { parseXml, normalizeXmlObject } = require("../partsManagementUtils"); const opCodes = require("./lib/opCodes.json"); // New imports for S3 XML archival const { uploadFileToS3 } = require("../../../utils/s3"); const InstanceMgr = require("../../../utils/instanceMgr").default; // GraphQL Queries and Mutations const { GET_BODYSHOP_STATUS, GET_VEHICLE_BY_SHOP_VIN, INSERT_OWNER, INSERT_JOB_WITH_LINES } = require("../partsManagement.queries"); const { v4: uuidv4 } = require("uuid"); // Defaults const FALLBACK_DEFAULT_JOB_STATUS = "Open"; const ESTIMATE_XML_BUCKET = process.env?.NODE_ENV === "development" ? "parts-estimates" // local/dev shared bucket name : InstanceMgr({ imex: `imex-webest-xml`, rome: `rome-webest-xml` }); const buildEstimateXmlKey = (rq) => { const refClaimNum = rq.RefClaimNum; const shopId = rq.ShopID; const ts = new Date().toISOString().replace(/:/g, "-"); const safeClaim = (refClaimNum || "no-claim").toString().replace(/[^A-Za-z0-9_-]/g, "_"); return `addRequest/${shopId}/${safeClaim}/${ts}-${uuidv4()}.xml`; }; /** * Fetches the default order status for a bodyshop. * @param {string} shopId - The bodyshop UUID. * @param {object} logger - The logger instance. * @returns {Promise} The default status or fallback. */ const getDefaultJobStatus = async (shopId, logger) => { try { const { bodyshop_by_pk } = await client.request(GET_BODYSHOP_STATUS, { id: shopId }); return bodyshop_by_pk?.md_ro_statuses?.default_imported || FALLBACK_DEFAULT_JOB_STATUS; } catch (err) { logger.log("parts-bodyshop-fetch-failed", "warn", shopId, null, { error: err }); return FALLBACK_DEFAULT_JOB_STATUS; } }; /** * Finds an existing vehicle by shopId and VIN. * @param {string} shopId - The bodyshop UUID. * @param {string} v_vin - The vehicle VIN. * @param {object} logger - The logger instance. * @returns {Promise} The vehicle ID or null if not found. */ const findExistingVehicle = async (shopId, v_vin, logger) => { if (!v_vin) return null; try { const { vehicles } = await client.request(GET_VEHICLE_BY_SHOP_VIN, { shopid: shopId, v_vin }); if (vehicles?.length > 0) { logger.log("parts-vehicle-found", "info", vehicles[0].id, null, { shopid: shopId, v_vin }); return vehicles[0].id; } } catch (err) { logger.log("parts-vehicle-fetch-failed", "warn", null, null, { error: err }); } return null; }; /** * Extracts job-related data from the XML request. * @param {object} rq - The VehicleDamageEstimateAddRq object. * @returns {object} Extracted job data. */ const extractJobData = (rq) => { const doc = rq.DocumentInfo || {}; const ev = rq.EventInfo || {}; const asgn = ev.AssignmentEvent || {}; const ci = rq.ClaimInfo || {}; return { driveable: !!rq.VehicleInfo?.Condition?.DrivableInd, shopId: rq.ShopID || rq.shopId, // status: ci.ClaimStatus || null, Proper, setting it default for now refClaimNum: rq.RefClaimNum, ciecaid: rq.RqUID || null, // Pull Cieca_ttl from ClaimInfo per schema/sample cieca_ttl: parseFloat(ci.Cieca_ttl || 0), cat_no: doc.VendorCode || null, category: doc.DocumentType || null, classType: doc.DocumentStatus || null, comment: doc.Comment || null, asgn_no: asgn.AssignmentNumber || null, asgn_type: asgn.AssignmentType || null, asgn_date: asgn.AssignmentDate || null, // asgn_created: asgn.CreateDateTime || null, scheduled_in: ev.RepairEvent?.RequestedPickUpDateTime || null, scheduled_completion: ev.RepairEvent?.TargetCompletionDateTime || null, clm_no: ci.ClaimNum || null, policy_no: ci.PolicyInfo?.PolicyInfo?.PolicyNum || ci.PolicyInfo?.PolicyNum || null, ded_amt: parseFloat(ci.PolicyInfo?.CoverageInfo?.Coverage?.DeductibleInfo?.DeductibleAmt || 0) }; }; /** * Extracts owner data from the XML request. * Falls back to Claimant if Owner is missing. * @param {object} rq - The VehicleDamageEstimateAddRq object. * @param {string} shopId - The bodyshop UUID. * @returns {object} Owner data for insertion and inline use. */ const extractOwnerData = (rq, shopId) => { const ownerOrClaimant = rq.AdminInfo?.Owner?.Party || rq.AdminInfo?.Claimant?.Party || {}; const personInfo = ownerOrClaimant.PersonInfo || {}; const personName = personInfo.PersonName || {}; const address = personInfo.Communications?.Address || {}; let ownr_ph1, ownr_ph2, ownr_ea; const comms = Array.isArray(ownerOrClaimant.ContactInfo?.Communications) ? ownerOrClaimant.ContactInfo.Communications : [ownerOrClaimant.ContactInfo?.Communications || {}]; for (const c of comms) { // -- Document if (c.CommQualifier === "CP") ownr_ph1 = c.CommPhone; if (c.CommQualifier === "WP") ownr_ph2 = c.CommPhone; if (c.CommQualifier === "EM") ownr_ea = c.CommEmail; // if (c.CommQualifier === "AL") ownr_alt_ph = c.CommPhone; } return { shopid: shopId, ownr_fn: personName.FirstName || null, ownr_ln: personName.LastName || null, ownr_co_nm: ownerOrClaimant.OrgInfo?.CompanyName || null, ownr_addr1: address.Address1 || null, ownr_addr2: address.Address2 || null, ownr_city: address.City || null, ownr_st: address.StateProvince || null, ownr_zip: address.PostalCode || null, ownr_ctry: address.Country || null, ownr_ph1, ownr_ph2, ownr_ea // ownr_alt_ph // ownr_id_qualifier: ownerOrClaimant.IDInfo?.IDQualifierCode || null // New // ownr_id_num: ownerOrClaimant.IDInfo?.IDNum || null, // New // ownr_preferred_contact: ownerOrClaimant.PreferredContactMethod || null // New }; }; /** * Extracts estimator data from the XML request. * @param {object} rq - The VehicleDamageEstimateAddRq object. * @returns {object} Estimator data. */ const extractEstimatorData = (rq) => { const estParty = rq.AdminInfo?.Estimator?.Party || {}; const estComms = Array.isArray(estParty.ContactInfo?.Communications) ? estParty.ContactInfo.Communications : [estParty.ContactInfo?.Communications || {}]; return { est_co_nm: rq.AdminInfo?.Estimator?.Affiliation || null, est_ct_fn: estParty.PersonInfo?.PersonName?.FirstName || null, est_ct_ln: estParty.PersonInfo?.PersonName?.LastName || null, est_ea: estComms.find((c) => c.CommQualifier === "EM")?.CommEmail || null }; }; /** * Extracts adjuster data from the XML request. * @param {object} rq - The VehicleDamageEstimateAddRq object. * @returns {object} Adjuster data. */ // const extractAdjusterData = (rq) => { // const adjParty = rq.AdminInfo?.Adjuster?.Party || {}; // const adjComms = Array.isArray(adjParty.ContactInfo?.Communications) // ? adjParty.ContactInfo.Communications // : [adjParty.ContactInfo?.Communications || {}]; // // return { // //TODO (FUTURE): I dont think we display agt_ct_* fields in app. Have they typically been sending data here? // agt_ct_fn: adjParty.PersonInfo?.PersonName?.FirstName || null, // agt_ct_ln: adjParty.PersonInfo?.PersonName?.LastName || null, // agt_ct_ph: adjComms.find((c) => c.CommQualifier === "CP")?.CommPhone || null, // agt_ea: adjComms.find((c) => c.CommQualifier === "EM")?.CommEmail || null // }; // }; /** * Extracts repair facility data from the XML request. * @param {object} rq - The VehicleDamageEstimateAddRq object. * @returns {object} Repair facility data. */ // const extractRepairFacilityData = (rq) => { // const rfParty = rq.AdminInfo?.RepairFacility?.Party || {}; // const rfComms = Array.isArray(rfParty.ContactInfo?.Communications) // ? rfParty.ContactInfo.Communications // : [rfParty.ContactInfo?.Communications || {}]; // // return { // servicing_dealer: rfParty.OrgInfo?.CompanyName || null, // // TODO (Future): The servicing dealer fields are a relic from synergy for a few folks // // TODO (Future): I suspect RF data could be ignored since they are the RF. // servicing_dealer_contact: // rfComms.find((c) => c.CommQualifier === "WP" || c.CommQualifier === "FX")?.CommPhone || null // }; // }; /** * Extracts loss information from the XML request. * @param rq * @returns {{loss_dt: (*|null), reported_dt: (*|null), loss_type_code: (*|null), loss_type_desc: (*|null)}} */ const extractLossInfo = (rq) => { const loss = rq.ClaimInfo?.LossInfo?.Facts || {}; const custom = rq.ClaimInfo?.CustomElement || {}; return { loss_date: loss.LossDateTime || null, loss_type: custom.LossTypeCode || null, loss_desc: custom.LossTypeDesc || null // area_of_impact: { // impact_1: loss.PrimaryPOI?.POICode || null, // imact_2 :loss.SecondaryPOI?.POICode || null, // }, // tlosind: rq.ClaimInfo?.LossInfo?.TotalLossInd || null, // damage_memo: loss.DamageMemo || null, //(maybe ins_memo) }; }; /** * Extracts insurance data from the XML request. * @param rq * @returns {{insd_ln: (*|null), insd_fn: (string|null), insd_title: (*|null), insd_co_nm: (*|string|null), insd_addr1: * (*|null), insd_addr2: (*|null), insd_city: (*|null), insd_st: (*|null), insd_zip: (*|null), insd_ctry: (*|null), * insd_ph1, insd_ph1x, insd_ph2, insd_ph2x, insd_fax, insd_faxx, insd_ea}} */ const extractInsuranceData = (rq) => { const insuredParty = rq.AdminInfo?.Insured?.Party || {}; const insuredPerson = insuredParty.PersonInfo || {}; const insuredComms = Array.isArray(insuredParty.ContactInfo?.Communications) ? insuredParty.ContactInfo.Communications : [insuredParty.ContactInfo?.Communications || {}]; const insuredAddress = insuredPerson.Communications?.Address || {}; const insurerParty = rq.AdminInfo?.InsuranceCompany?.Party || {}; let insd_ph1, insd_ph1x, insd_ph2, insd_ph2x, insd_fax, insd_faxx, insd_ea; for (const c of insuredComms) { if (c.CommQualifier === "CP") { insd_ph1 = c.CommPhone; insd_ph1x = c.CommPhoneExt; } if (c.CommQualifier === "WP") { insd_ph2 = c.CommPhone; insd_ph2x = c.CommPhoneExt; } if (c.CommQualifier === "FX") { insd_fax = c.CommPhone; insd_faxx = c.CommPhoneExt; } if (c.CommQualifier === "EM") insd_ea = c.CommEmail; } return { insd_ln: insuredPerson.PersonName?.LastName || null, insd_fn: insuredPerson.PersonName?.FirstName || null, insd_title: insuredPerson.PersonName?.Title || null, insd_co_nm: insurerParty.OrgInfo?.CompanyName || insuredParty.OrgInfo?.CompanyName || null, insd_addr1: insuredAddress.Address1 || null, insd_addr2: insuredAddress.Address2 || null, insd_city: insuredAddress.City || null, insd_st: insuredAddress.StateProvince || null, insd_zip: insuredAddress.PostalCode || null, insd_ctry: insuredAddress.Country || null, insd_ph1, insd_ph1x, insd_ph2, insd_ph2x, insd_fax, insd_faxx, insd_ea }; }; /** * Extracts vehicle data from the XML request. * @param {object} rq - The VehicleDamageEstimateAddRq object. * @param {string} shopId - The bodyshop UUID. * @returns {object} Vehicle data for insertion and inline use. */ const extractVehicleData = (rq, shopId) => { const desc = rq.VehicleInfo?.VehicleDesc || {}; const exterior = rq.VehicleInfo?.Paint?.Exterior || {}; const interior = rq.VehicleInfo?.Paint?.Interior || {}; return { shopid: shopId, // VIN may be either VINInfo.VINNum or VINInfo.VIN.VINNum depending on producer v_vin: rq.VehicleInfo?.VINInfo?.VINNum || rq.VehicleInfo?.VINInfo?.VIN?.VINNum || null, plate_no: rq.VehicleInfo?.License?.LicensePlateNum || null, plate_st: rq.VehicleInfo?.License?.LicensePlateStateProvince || null, v_model_yr: desc.ModelYear || null, v_make_desc: desc.MakeDesc || null, v_model_desc: desc.ModelName || null, v_color: exterior.Color?.ColorName || null, v_bstyle: desc.BodyStyle || null, v_engine: desc.EngineDesc || null, // TODO (for future) Need to confirm with exact data, but this is typically a list of options. Not used AFAIK. // v_options: desc.SubModelDesc || null, v_type: desc.FuelType || null, v_cond: rq.VehicleInfo?.Condition?.DrivableInd, v_trimcode: desc.TrimCode || null, v_tone: exterior.Tone || null, v_stage: exterior.RefinishStage || rq.VehicleInfo?.Paint?.RefinishStage || null, v_prod_dt: desc.ProductionDate || null, v_paint_codes: Array.isArray(exterior.PaintCodeInfo) ? exterior.PaintCodeInfo.map((p) => p.PaintCode).join(",") : exterior.PaintCode || null, v_mldgcode: desc.MldgCode || null, v_makecode: desc.MakeCode || null, trim_color: interior.ColorName || desc.TrimColor || null, db_v_code: desc.DatabaseCode || null }; }; /** * Extracts job lines from the XML request. * @param {object} rq - The VehicleDamageEstimateAddRq object. * @returns {object[]} Array of job line objects. */ const extractJobLines = (rq) => { // Normalize to array without lodash toArray (which flattens object values incorrectly) const dl = rq.DamageLineInfo; const damageLines = Array.isArray(dl) ? dl : dl ? [dl] : []; if (damageLines.length === 0) { return []; } const out = []; for (const line of damageLines) { const partInfo = line.PartInfo || {}; const laborInfo = line.LaborInfo || {}; const refinishInfo = line.RefinishLaborInfo || {}; const subletInfo = line.SubletInfo || {}; const base = { line_no: parseInt(line.LineNum || 0, 10), unq_seq: parseInt(line.UniqueSequenceNum || 0, 10), status: line.LineStatusCode || null, line_desc: line.LineDesc || null, notes: line.LineMemo || null }; const lineOut = { ...base }; // Manual line flag coercion // if (line.ManualLineInd !== undefined) { // lineOut.manual_line = // line.ManualLineInd === true || // line.ManualLineInd === 1 || // line.ManualLineInd === "1" || // // TODO (FUTURE): manual line tracks manual in IO or not, this woudl presumably always be false // (typeof line.ManualLineInd === "string" && line.ManualLineInd.toUpperCase() === "Y"); // } else { // lineOut.manual_line = null; // } // Is set to false because anything coming from the DMS is considered not a manual line, it becomes // a manual line once it is edited in OUR system. lineOut.manual_line = false; // Parts (preferred) or Sublet (fallback when no PartInfo) const hasPart = Object.keys(partInfo).length > 0; const hasSublet = Object.keys(subletInfo).length > 0; if (hasPart) { lineOut.part_type = partInfo.PartType || null ? String(partInfo.PartType).toUpperCase() : null; lineOut.part_qty = parseFloat(partInfo.Quantity || 0) || 1; lineOut.oem_partno = partInfo.OEMPartNum; lineOut.alt_partno = partInfo?.NonOEM?.NonOEMPartNum; // THIS NEEDS TO BE CHANGED IN CHANGE REQUEST lineOut.act_price = parseFloat(partInfo?.PartPrice || 0); lineOut.db_price = parseFloat(partInfo?.OEMPartPrice || 0); // Tax flag from PartInfo.TaxableInd when provided if ( partInfo.TaxableInd !== undefined && (typeof partInfo.TaxableInd === "string" || typeof partInfo.TaxableInd === "number" || typeof partInfo.TaxableInd === "boolean") ) { lineOut.tax_part = partInfo.TaxableInd === true || partInfo.TaxableInd === 1 || partInfo.TaxableInd === "1" || (typeof partInfo.TaxableInd === "string" && partInfo.TaxableInd.toUpperCase() === "Y"); } } //TODO (FUTURE): Some nuance here. Usually a part and sublet amount shouldnt be on the same line, but they theoretically // could. May require additional discussion. // EMS - > Misc Amount, calibration for example, painting, etc else if (hasSublet) { const amt = parseFloat(subletInfo.SubletAmount || 0); lineOut.part_type = "PAS"; // Sublet as parts-as-service lineOut.part_qty = 1; lineOut.act_price = isNaN(amt) ? 0 : amt; } // Primary labor (if present) recorded on the same line 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 = opCodes?.[opCodeKey]?.desc || null; lineOut.lbr_amt = isNaN(amt) ? 0 : amt; } //TODO (FUTURE): what's the BMS logic for this? Body and refinish operations can often happen to the same part, // but most systems output a second line for the refinish labor. //TODO (FUTURE): 2nd line may include a duplicate of the part price, but that can be removed. This is the case for CCC. // Refinish labor (if present) recorded on the same line using secondary labor fields 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?.LaborAmtJudgmentInd; lineOut.lbr_hrs_j = !!refinishInfo?.LaborHoursJudgmentInd; lineOut.lbr_op_j = !!refinishInfo.LaborOperationJudgmentInd; // Aggregate refinish labor amount into the total labor amount for the line 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; }; // Helper to extract a GRAND TOTAL amount from RepairTotalsInfo // const extractGrandTotal = (rq) => { // const rti = rq.RepairTotalsInfo; // const groups = Array.isArray(rti) ? rti : rti ? [rti] : []; // for (const grp of groups) { // const sums = Array.isArray(grp.SummaryTotalsInfo) // ? grp.SummaryTotalsInfo // : grp.SummaryTotalsInfo // ? [grp.SummaryTotalsInfo] // : []; // for (const s of sums) { // const type = (s.TotalType || "").toString().toUpperCase(); // const desc = (s.TotalTypeDesc || "").toString().toUpperCase(); // if (type.includes("GRAND") || type === "TOTAL" || desc.includes("GRAND")) { // const amt = parseFloat(s.TotalAmt ?? "NaN"); // if (!isNaN(amt)) return amt; // } // } // } // return null; // }; /** * Inserts an owner and returns the owner ID. * @param {object} ownerInput - The owner data to insert. * @param {object} logger - The logger instance. * @returns {Promise} The owner ID or null if insertion fails. */ const insertOwner = async (ownerInput, logger) => { try { const { insert_owners_one } = await client.request(INSERT_OWNER, { owner: ownerInput }); return insert_owners_one?.id; } catch (err) { logger.log("parts-owner-insert-failed", "warn", null, null, { error: err }); return null; } }; // Fallback: compute a naive total from joblines (parts + sublet + labor amounts) // const computeLinesTotal = (joblines = []) => { // let parts = 0; // let labor = 0; // for (const jl of joblines) { // if (jl?.part_type) { // const qty = Number.isFinite(jl.part_qty) ? jl.part_qty : 1; // const price = Number.isFinite(jl.act_price) ? jl.act_price : 0; // parts += price * (qty || 1); // } else if (!jl.part_type && Number.isFinite(jl.act_price)) { // parts += jl.act_price; // } // if (Number.isFinite(jl.lbr_amt)) { // labor += jl.lbr_amt; // } // } // const total = parts + labor; // // //TODO (FUTURE): clm_total is the 100% full amount of the repair including deductible, betterment and taxes. Typically provided by the source system. // return Number.isFinite(total) && total > 0 ? total : 0; // //TODO (FUTURE): clm_total is the 100% full amount of the repair including deductible, // // betterment and taxes. Typically provided by the source system. /** * Handles the VehicleDamageEstimateAddRq XML request from parts management. * @param {object} req - The HTTP request object. * @param {object} res - The HTTP response object. * @returns {Promise} */ const vehicleDamageEstimateAddRq = 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.VehicleDamageEstimateAddRq); const { shopId, refClaimNum, ciecaid, cieca_ttl, cat_no, category, classType, comment, date_exported, asgn_no, asgn_type, asgn_date, scheduled_in, scheduled_completion, clm_no, policy_no, ded_amt, driveable } = extractJobData(rq); const defaultStatus = await getDefaultJobStatus(shopId, logger); const parts_tax_rates = extractPartsTaxRates(rq.ProfileInfo); const ownerData = extractOwnerData(rq, shopId); const estimatorData = extractEstimatorData(rq); const vehicleData = extractVehicleData(rq, shopId); const lossInfo = extractLossInfo(rq); const joblinesData = extractJobLines(rq); const insuranceData = extractInsuranceData(rq); const ownerid = await insertOwner(ownerData, logger); const vehicleid = await findExistingVehicle(shopId, vehicleData.v_vin, logger); const jobInput = { shopid: shopId, driveable, converted: true, ownerid, ro_number: refClaimNum, ciecaid, cieca_ttl, cat_no, category, class: classType, parts_tax_rates, clm_no, status: defaultStatus, clm_total: 0, policy_no, ded_amt, comment, date_exported, asgn_no, asgn_type, asgn_date, scheduled_in, scheduled_completion, ...insuranceData, ...lossInfo, ...ownerData, ...estimatorData, v_vin: vehicleData.v_vin, v_model_yr: vehicleData.v_model_yr, v_model_desc: vehicleData.v_model_desc, v_make_desc: vehicleData.v_make_desc, v_color: vehicleData.v_color, plate_no: vehicleData.plate_no, plate_st: vehicleData.plate_st, ...(vehicleid ? { vehicleid } : { vehicle: { data: vehicleData } }), joblines: { data: joblinesData } }; const { insert_jobs_one: newJob } = await client.request(INSERT_JOB_WITH_LINES, { job: jobInput }); // Upload AFTER job creation to include job id in filename (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", shopId, newJob.id, { key, bytes: rawXml?.length || 0 }); } catch (e) { logger.log("parts-estimate-xml-upload-failed", "warn", shopId, null, { error: e?.message }); } })(); return res.status(200).json({ success: true, jobId: newJob.id }); } catch (err) { logger.log("parts-route-error", "error", null, null, { error: err }); return res.status(err.status || 500).json({ error: err.message || "Internal error" }); } }; module.exports = vehicleDamageEstimateAddRq;