feature/IO-3255-simplified-parts-management - vehicleDamageEstimateAddRq.js enhancements
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
|
||||
const client = require("../../../graphql-client/graphql-client").client;
|
||||
const { parseXml, normalizeXmlObject } = require("../partsManagementUtils");
|
||||
const { toArray } = require("lodash");
|
||||
|
||||
// GraphQL Queries and Mutations
|
||||
const {
|
||||
@@ -35,6 +34,8 @@ const KNOWN_PART_RATE_TYPES = [
|
||||
"CCM",
|
||||
"CCDR"
|
||||
];
|
||||
// Config: include labor lines and labor in totals (default false for development ease)
|
||||
const INCLUDE_LABOR = process.env.PARTS_MGMT_INCLUDE_LABOR === "true";
|
||||
/**
|
||||
* Fetches the default order status for a bodyshop.
|
||||
* @param {string} shopId - The bodyshop UUID.
|
||||
@@ -60,47 +61,61 @@ 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._
|
||||
: "";
|
||||
for (const r of rateInfos) {
|
||||
const rateTypeRaw =
|
||||
typeof r?.RateType === "string"
|
||||
? r.RateType
|
||||
: typeof r?.RateType === "object" && r?.RateType._
|
||||
? r.RateType._
|
||||
: "";
|
||||
const rateType = (rateTypeRaw || "").toUpperCase();
|
||||
if (!KNOWN_PART_RATE_TYPES.includes(rateType)) continue;
|
||||
|
||||
return rateType.toUpperCase() === code;
|
||||
});
|
||||
|
||||
if (!rateInfo) {
|
||||
partsTaxRates[code] = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
const taxInfo = rateInfo.TaxInfo;
|
||||
const taxInfo = r.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;
|
||||
const tierRate = Array.isArray(r.RateTierInfo) ? r.RateTierInfo[0]?.Rate : r.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
|
||||
};
|
||||
if (!isNaN(percentage)) {
|
||||
partsTaxRates[rateType] = {
|
||||
prt_discp: 0,
|
||||
prt_mktyp: false,
|
||||
prt_mkupp: 0,
|
||||
prt_tax_in: true,
|
||||
prt_tax_rt: percentage / 100
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return partsTaxRates;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<string|null>} 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.
|
||||
@@ -116,12 +131,14 @@ const extractJobData = (rq) => {
|
||||
shopId: rq.ShopID || rq.shopId,
|
||||
refClaimNum: rq.RefClaimNum,
|
||||
ciecaid: rq.RqUID || null,
|
||||
cieca_ttl: parseFloat(rq.Cieca_ttl || 0),
|
||||
// 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,
|
||||
date_exported: doc.TransmitDateTime || null,
|
||||
// TODO: This causes the job to be read only in the UI
|
||||
// date_exported: doc.TransmitDateTime || null,
|
||||
asgn_no: asgn.AssignmentNumber || null,
|
||||
asgn_type: asgn.AssignmentType || null,
|
||||
asgn_date: asgn.AssignmentDate || null,
|
||||
@@ -130,23 +147,8 @@ const extractJobData = (rq) => {
|
||||
scheduled_completion: ev.RepairEvent?.TargetCompletionDateTime || null,
|
||||
clm_no: ci.ClaimNum || null,
|
||||
status: ci.ClaimStatus || null,
|
||||
policy_no: ci.PolicyInfo?.PolicyNum || null,
|
||||
policy_no: ci.PolicyInfo?.PolicyInfo?.PolicyNum || 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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -340,7 +342,8 @@ const extractVehicleData = (rq, shopId) => {
|
||||
const interior = rq.VehicleInfo?.Paint?.Interior || {};
|
||||
return {
|
||||
shopid: shopId,
|
||||
v_vin: rq.VehicleInfo?.VINInfo?.VIN?.VINNum || null,
|
||||
// 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,
|
||||
@@ -363,8 +366,6 @@ const extractVehicleData = (rq, shopId) => {
|
||||
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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -374,71 +375,154 @@ const extractVehicleData = (rq, shopId) => {
|
||||
* @returns {object[]} Array of job line objects.
|
||||
*/
|
||||
const extractJobLines = (rq) => {
|
||||
const damageLines = toArray(rq.DamageLineInfo);
|
||||
// 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 []; // Or throw if required
|
||||
return [];
|
||||
}
|
||||
|
||||
return damageLines.map((line) => {
|
||||
const out = [];
|
||||
|
||||
for (const line of damageLines) {
|
||||
const partInfo = line.PartInfo || {};
|
||||
const laborInfo = line.LaborInfo || {};
|
||||
const refinishInfo = line.RefinishLaborInfo || {};
|
||||
const subletInfo = line.SubletInfo || {};
|
||||
|
||||
let jobLineType = "PART";
|
||||
if (Object.keys(subletInfo).length > 0) jobLineType = "SUBLET";
|
||||
else if (Object.keys(laborInfo).length > 0 && Object.keys(partInfo).length === 0) jobLineType = "LABOR";
|
||||
|
||||
const jobLine = {
|
||||
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
|
||||
// line_type: jobLineType // New field for clarity
|
||||
line_desc: line.LineDesc || null,
|
||||
notes: line.LineMemo || null
|
||||
};
|
||||
|
||||
if (jobLineType === "PART") {
|
||||
jobLine.part_type = partInfo.PartType || null;
|
||||
jobLine.part_qty = parseFloat(partInfo.Quantity || 0);
|
||||
jobLine.oem_partno = partInfo.OEMPartNum || null;
|
||||
jobLine.db_price = parseFloat(partInfo.PartPrice || 0);
|
||||
jobLine.act_price = parseFloat(partInfo.PartPrice || 0); // Or NonOEM if selected
|
||||
const price = parseFloat(partInfo.PartPrice || partInfo.ListPrice || 0);
|
||||
out.push({
|
||||
...base,
|
||||
part_type: partInfo.PartType || null,
|
||||
part_qty: parseFloat(partInfo.Quantity || 0) || 1,
|
||||
oem_partno: partInfo.OEMPartNum || null,
|
||||
db_price: price,
|
||||
act_price: price,
|
||||
// Labor fields if present on same line
|
||||
mod_lbr_ty: laborInfo.LaborType || null,
|
||||
mod_lb_hrs: parseFloat(laborInfo.LaborHours || 0),
|
||||
lbr_op: laborInfo.LaborOperation || null,
|
||||
lbr_amt: INCLUDE_LABOR ? parseFloat(laborInfo.LaborAmt || 0) : 0,
|
||||
// Tax flag from PartInfo.TaxableInd when provided
|
||||
...(partInfo.TaxableInd !== undefined
|
||||
? {
|
||||
tax_part:
|
||||
partInfo.TaxableInd === true ||
|
||||
partInfo.TaxableInd === 1 ||
|
||||
partInfo.TaxableInd === "1" ||
|
||||
(typeof partInfo.TaxableInd === "string" && partInfo.TaxableInd.toUpperCase() === "Y")
|
||||
}
|
||||
: {}),
|
||||
// Manual line flag coercion
|
||||
...(line.ManualLineInd !== undefined
|
||||
? {
|
||||
manual_line:
|
||||
line.ManualLineInd === true ||
|
||||
line.ManualLineInd === 1 ||
|
||||
line.ManualLineInd === "1" ||
|
||||
(typeof line.ManualLineInd === "string" && line.ManualLineInd.toUpperCase() === "Y")
|
||||
}
|
||||
: { manual_line: null })
|
||||
});
|
||||
} else if (jobLineType === "SUBLET") {
|
||||
jobLine.part_type = "SUB"; // Custom code for sublet
|
||||
jobLine.part_qty = 1; // Default
|
||||
jobLine.act_price = parseFloat(subletInfo.SubletAmount || 0);
|
||||
// jobLine.sublet_vendor = subletInfo.SubletVendorName || null; // TODO: Clarify
|
||||
// jobLine.lbr_hrs = parseFloat(subletInfo.SubletLaborHours || 0); // TODO: Clarify
|
||||
} // Labor-only already handled
|
||||
out.push({
|
||||
...base,
|
||||
part_type: "PAS",
|
||||
part_qty: 1,
|
||||
act_price: parseFloat(subletInfo.SubletAmount || 0),
|
||||
// Manual line flag
|
||||
...(line.ManualLineInd !== undefined
|
||||
? {
|
||||
manual_line:
|
||||
line.ManualLineInd === true ||
|
||||
line.ManualLineInd === 1 ||
|
||||
line.ManualLineInd === "1" ||
|
||||
(typeof line.ManualLineInd === "string" && line.ManualLineInd.toUpperCase() === "Y")
|
||||
}
|
||||
: { manual_line: null })
|
||||
});
|
||||
} else if (INCLUDE_LABOR) {
|
||||
// Labor-only line (only when enabled)
|
||||
out.push({
|
||||
...base,
|
||||
mod_lbr_ty: laborInfo.LaborType || null,
|
||||
mod_lb_hrs: parseFloat(laborInfo.LaborHours || 0),
|
||||
lbr_op: laborInfo.LaborOperation || null,
|
||||
lbr_amt: parseFloat(laborInfo.LaborAmt || 0),
|
||||
...(line.ManualLineInd !== undefined
|
||||
? {
|
||||
manual_line:
|
||||
line.ManualLineInd === true ||
|
||||
line.ManualLineInd === 1 ||
|
||||
line.ManualLineInd === "1" ||
|
||||
(typeof line.ManualLineInd === "string" && line.ManualLineInd.toUpperCase() === "Y")
|
||||
}
|
||||
: { manual_line: null })
|
||||
});
|
||||
}
|
||||
|
||||
jobLine.mod_lbr_ty = laborInfo.LaborType || null;
|
||||
jobLine.mod_lb_hrs = parseFloat(laborInfo.LaborHours || 0);
|
||||
jobLine.lbr_op = laborInfo.LaborOperation || null;
|
||||
jobLine.lbr_amt = parseFloat(laborInfo.LaborAmt || 0);
|
||||
jobLine.notes = line.LineMemo || null;
|
||||
jobLine.manual_line = line.ManualLineInd || null;
|
||||
// Add a separate refinish labor line if present and enabled
|
||||
if (INCLUDE_LABOR && Object.keys(refinishInfo).length > 0) {
|
||||
const hrs = parseFloat(refinishInfo.LaborHours || 0);
|
||||
const amt = parseFloat(refinishInfo.LaborAmt || 0);
|
||||
if (!isNaN(hrs) || !isNaN(amt)) {
|
||||
out.push({
|
||||
...base,
|
||||
// tweak unq_seq to avoid collisions in later upserts
|
||||
unq_seq: (parseInt(line.UniqueSequenceNum || 0, 10) || 0) + 500000,
|
||||
line_desc: base.line_desc || "Refinish",
|
||||
mod_lbr_ty: "LAR",
|
||||
mod_lb_hrs: isNaN(hrs) ? 0 : hrs,
|
||||
lbr_op: refinishInfo.LaborOperation || null,
|
||||
lbr_amt: isNaN(amt) ? 0 : amt,
|
||||
...(line.ManualLineInd !== undefined
|
||||
? {
|
||||
manual_line:
|
||||
line.ManualLineInd === true ||
|
||||
line.ManualLineInd === 1 ||
|
||||
line.ManualLineInd === "1" ||
|
||||
(typeof line.ManualLineInd === "string" && line.ManualLineInd.toUpperCase() === "Y")
|
||||
}
|
||||
: { manual_line: null })
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return jobLine;
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<string|null>} 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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log("parts-vehicle-fetch-failed", "warn", null, null, { error: err });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -459,6 +543,26 @@ const insertOwner = async (ownerInput, logger) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 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 && 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 (INCLUDE_LABOR && Number.isFinite(jl.lbr_amt)) {
|
||||
labor += jl.lbr_amt;
|
||||
}
|
||||
}
|
||||
const total = parts + labor;
|
||||
return Number.isFinite(total) && total > 0 ? total : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the VehicleDamageEstimateAddRq XML request from parts management.
|
||||
* @param {object} req - The HTTP request object.
|
||||
@@ -517,6 +621,10 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
|
||||
const joblinesData = extractJobLines(rq);
|
||||
const insuranceData = extractInsuranceData(rq);
|
||||
|
||||
// Derive clm_total: prefer RepairTotalsInfo SummaryTotals GRAND TOTAL; else sum from lines
|
||||
const grandTotal = extractGrandTotal(rq);
|
||||
const computedTotal = grandTotal ?? computeLinesTotal(joblinesData);
|
||||
|
||||
// Find or create relationships
|
||||
const ownerid = await insertOwner(ownerData, logger);
|
||||
const vehicleid = await findExistingVehicle(shopId, vehicleData.v_vin, logger);
|
||||
@@ -524,6 +632,7 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
|
||||
// Build job input
|
||||
const jobInput = {
|
||||
shopid: shopId,
|
||||
converted: true,
|
||||
ownerid,
|
||||
ro_number: refClaimNum,
|
||||
ciecaid,
|
||||
@@ -534,7 +643,7 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
|
||||
parts_tax_rates,
|
||||
clm_no,
|
||||
status: status || defaultStatus,
|
||||
clm_total: cieca_ttl,
|
||||
clm_total: computedTotal || null,
|
||||
policy_no,
|
||||
ded_amt,
|
||||
comment,
|
||||
@@ -544,12 +653,13 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
|
||||
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 insurance/loss/contacts
|
||||
...insuranceData,
|
||||
...lossInfo,
|
||||
...ownerData,
|
||||
...estimatorData,
|
||||
...adjusterData,
|
||||
...repairFacilityData,
|
||||
// Inline vehicle data
|
||||
v_vin: vehicleData.v_vin,
|
||||
v_model_yr: vehicleData.v_model_yr,
|
||||
|
||||
Reference in New Issue
Block a user