// no-dd-sa:javascript-code-style/assignment-name // CamelCase is used for GraphQL and database fields. const xml2js = require("xml2js"); const client = require("../../graphql-client/graphql-client").client; // GraphQL Queries and Mutations const { GET_BODYSHOP_STATUS, GET_VEHICLE_BY_SHOP_VIN, INSERT_OWNER, INSERT_JOB_WITH_LINES } = require("./partsManagement.queries"); // Defaults const FALLBACK_DEFAULT_ORDER_STATUS = "OPEN"; // Known part rate types for tax rates const KNOWN_PART_RATE_TYPES = [ "PAA", "PAC", "PAG", "PAL", "PAM", "PAN", "PAO", "PAP", "PAR", "PAS", "PASL", "CCC", "CCD", "CCF", "CCM", "CCDR" ]; /** * Parses XML string into a JavaScript object. * @param {string} xml - The XML string to parse. * @param {object} logger - The logger instance. * @returns {Promise} The parsed XML object. * @throws {Error} If XML parsing fails. */ const parseXml = async (xml, logger) => { try { return await xml2js.parseStringPromise(xml, { explicitArray: false, tagNameProcessors: [xml2js.processors.stripPrefix], attrNameProcessors: [xml2js.processors.stripPrefix] }); } catch (err) { logger.log("parts-xml-parse-error", "error", null, null, { error: err }); throw new Error("Invalid XML"); } }; /** * Recursively strip `xml2js`-style { _: 'value', $: { ... } } nodes into plain strings. * @param {*} obj - Parsed XML object * @returns {*} Normalized object */ const normalizeXmlObject = (obj) => { if (Array.isArray(obj)) { return obj.map(normalizeXmlObject); } if (typeof obj === "object" && obj !== null) { if (Object.keys(obj).length === 2 && "_" in obj && "$" in obj) { return normalizeXmlObject(obj._); // unwrap {_:"value",$:{...}} to just "value" } if (Object.keys(obj).length === 1 && "_" in obj) { return normalizeXmlObject(obj._); // unwrap {_:"value"} } const normalized = {}; for (const key in obj) { normalized[key] = normalizeXmlObject(obj[key]); } return normalized; } return obj; }; /** * 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 getDefaultOrderStatus = async (shopId, logger) => { try { const { bodyshop_by_pk } = await client.request(GET_BODYSHOP_STATUS, { id: shopId }); return bodyshop_by_pk?.md_order_statuses?.default_open || FALLBACK_DEFAULT_ORDER_STATUS; } catch (err) { logger.log("parts-bodyshop-fetch-failed", "warn", shopId, null, { error: err }); return FALLBACK_DEFAULT_ORDER_STATUS; } }; /** * Extracts and processes parts tax rates from profile info. * @param {object} profile - The ProfileInfo object from XML. * @returns {object} The parts tax rates object. */ const extractPartsTaxRates = (profile = {}) => { const rateInfos = Array.isArray(profile.RateInfo) ? profile.RateInfo : [profile.RateInfo || {}]; const partsTaxRates = {}; for (const code of KNOWN_PART_RATE_TYPES) { const rateInfo = rateInfos.find((r) => { const rateType = typeof r?.RateType === "string" ? r.RateType : typeof r?.RateType === "object" && r?.RateType._ // xml2js sometimes uses _ for text content ? r.RateType._ : ""; return rateType.toUpperCase() === code; }); if (!rateInfo) { partsTaxRates[code] = {}; continue; } const taxInfo = rateInfo.TaxInfo; const taxTier = taxInfo?.TaxTierInfo; let percentage = parseFloat(taxTier?.Percentage ?? "NaN"); if (isNaN(percentage)) { const tierRate = Array.isArray(rateInfo.RateTierInfo) ? rateInfo.RateTierInfo[0]?.Rate : rateInfo.RateTierInfo?.Rate; percentage = parseFloat(tierRate ?? "NaN"); } partsTaxRates[code] = isNaN(percentage) ? {} : { prt_discp: 0, prt_mktyp: false, prt_mkupp: 0, prt_tax_in: true, prt_tax_rt: percentage / 100 }; } return partsTaxRates; }; /** * 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 { shopId: rq.ShopID || rq.shopId, refClaimNum: rq.RefClaimNum, ciecaid: rq.RqUID || null, cieca_ttl: parseFloat(rq.Cieca_ttl || 0), cat_no: doc.VendorCode || null, category: doc.DocumentType || null, classType: doc.DocumentStatus || null, comment: doc.Comment || null, date_exported: doc.TransmitDateTime || 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, status: ci.ClaimStatus || null, policy_no: ci.PolicyInfo?.PolicyNum || null, ded_amt: parseFloat(ci.PolicyInfo?.CoverageInfo?.Coverage?.DeductibleInfo?.DeductibleAmt || 0) // document_id: doc.DocumentID || null, // bms_version: doc.BMSVer || null, // reference_info: // doc.ReferenceInfo?.OtherReferenceInfo?.map((ref) => ({ // name: ref.OtherReferenceName, // number: ref.OtherRefNum // })) || [], // currency_info: doc.CurrencyInfo // ? { // code: doc.CurrencyInfo.CurCode, // base_code: doc.CurrencyInfo.BaseCurCode, // rate: parseFloat(doc.CurrencyInfo.CurRate || 0), // rule: doc.CurrencyInfo.CurConvertRule // } // : null }; }; /** * Extracts owner data from the XML request. * @param {object} rq - The VehicleDamageEstimateAddRq object. * @param {string} shopId - The bodyshop UUID. * @returns {object} Owner data for insertion and inline use. */ /** * 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, ownr_alt_ph; const comms = Array.isArray(ownerOrClaimant.ContactInfo?.Communications) ? ownerOrClaimant.ContactInfo.Communications : [ownerOrClaimant.ContactInfo?.Communications || {}]; for (const c of comms) { 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 { 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, 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 // primary_poi: loss.PrimaryPOI?.POICode || null, // secondary_poi: loss.SecondaryPOI?.POICode || null, // damage_memo: loss.DamageMemo || null, //(maybe ins_memo) // total_loss_ind: rq.ClaimInfo?.LossInfo?.TotalLossInd || null // New }; }; /** * 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, v_vin: 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, 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 // v_model_num: desc.ModelNum || null // v_odo: desc.OdometerInfo?.OdometerReading || null }; }; /** * Extracts job lines from the XML request. * @param {object} rq - The VehicleDamageEstimateAddRq object. * @returns {object[]} Array of job line objects. */ const extractJobLines = (rq) => { const damageLines = Array.isArray(rq.DamageLineInfo) ? rq.DamageLineInfo : [rq.DamageLineInfo]; return damageLines.map((line) => { const jobLine = { line_no: parseInt(line.LineNum, 10), unq_seq: parseInt(line.UniqueSequenceNum, 10), status: line.LineStatusCode || null, line_desc: line.LineDesc || null, part_type: line.PartInfo?.PartType || null, part_qty: parseFloat(line.PartInfo?.Quantity || 0), oem_partno: line.PartInfo?.OEMPartNum || null, db_price: parseFloat(line.PartInfo?.PartPrice || 0), act_price: parseFloat(line.PartInfo?.PartPrice || 0), mod_lbr_ty: line.LaborInfo?.LaborType || null, mod_lb_hrs: parseFloat(line.LaborInfo?.LaborHours || 0), lbr_op: line.LaborInfo?.LaborOperation || null, lbr_amt: parseFloat(line.LaborInfo?.LaborAmt || 0), notes: line.LineMemo || null, manual_line: line.ManualLineInd || null // taxable_ind: line.PartInfo?.TaxableInd || null, // automated_entry: line.AutomatedEntry || null }; // TODO: Commented out as not clear if needed for version 1, this only applies to Imex and not rome on the front // end // if ((jobLine.part_type === "PASL" || jobLine.part_type === "PAS") && jobLine.lbr_op !== "OP11") { // jobLine.tax_part = true; // } // if (line.db_ref === "900510") { // jobLine.tax_part = true; // } return jobLine; }); }; /** * 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; }; /** * 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; } }; /** * 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 partsManagementVehicleDamageEstimateAddRq = async (req, res) => { const { logger } = req; try { // Parse XML const payload = await parseXml(req.body, logger); const rq = normalizeXmlObject(payload.VehicleDamageEstimateAddRq); if (!rq) { logger.log("parts-missing-root", "error"); return res.status(400).send("Missing "); } // Extract job data 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, status, policy_no, ded_amt } = extractJobData(rq); if (!shopId) { throw { status: 400, message: "Missing in XML" }; } // Get default status const defaultStatus = await getDefaultOrderStatus(shopId, logger); // Extract additional data const parts_tax_rates = extractPartsTaxRates(rq.ProfileInfo); const ownerData = extractOwnerData(rq, shopId); const estimatorData = extractEstimatorData(rq); const adjusterData = extractAdjusterData(rq); const repairFacilityData = extractRepairFacilityData(rq); const vehicleData = extractVehicleData(rq, shopId); const lossInfo = extractLossInfo(rq); const joblinesData = extractJobLines(rq); const insuranceData = extractInsuranceData(rq); // Find or create relationships const ownerid = await insertOwner(ownerData, logger); const vehicleid = await findExistingVehicle(shopId, vehicleData.v_vin, logger); // Build job input const jobInput = { shopid: shopId, ownerid, ro_number: refClaimNum, ciecaid, cieca_ttl, cat_no, category, class: classType, parts_tax_rates, clm_no, status: status || defaultStatus, clm_total: cieca_ttl, policy_no, ded_amt, comment, date_exported, asgn_no, asgn_type, asgn_date, scheduled_in, scheduled_completion, ...insuranceData, // Inline insurance data ...lossInfo, // Inline loss information ...ownerData, // Inline owner data ...estimatorData, // Inline estimator data ...adjusterData, // Inline adjuster data ...repairFacilityData, // Inline repair facility data // Inline vehicle data 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 } }; // Insert job const { insert_jobs_one: newJob } = await client.request(INSERT_JOB_WITH_LINES, { job: jobInput }); logger.log("parts-job-created", "info", newJob.id, null); return res.status(200).json({ success: true, jobId: newJob.id }); } catch (err) { logger.log("parts-route-error", "error", null, null, { error: err }); console.dir({ err }); return res.status(err.status || 500).json({ error: err.message || "Internal error" }); } }; module.exports = partsManagementVehicleDamageEstimateAddRq;