feature/IO-3255-simplified-parts-management - Beef Up Change Request Parser, add Change Request documentation data
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
const KNOWN_PART_RATE_TYPES = [
|
||||
"PAA",
|
||||
"PAC",
|
||||
"PAG",
|
||||
"PAL",
|
||||
"PAM",
|
||||
"PAN",
|
||||
"PAO",
|
||||
"PAP",
|
||||
"PAR",
|
||||
"PAS",
|
||||
"PASL",
|
||||
"CCC",
|
||||
"CCD",
|
||||
"CCF",
|
||||
"CCM",
|
||||
"CCDR"
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 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;
|
||||
|
||||
const taxInfo = r.TaxInfo;
|
||||
const taxTier = taxInfo?.TaxTierInfo;
|
||||
let percentage = parseFloat(taxTier?.Percentage ?? "NaN");
|
||||
|
||||
if (isNaN(percentage)) {
|
||||
const tierRate = Array.isArray(r.RateTierInfo) ? r.RateTierInfo[0]?.Rate : r.RateTierInfo?.Rate;
|
||||
percentage = parseFloat(tierRate ?? "NaN");
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
extractPartsTaxRates,
|
||||
KNOWN_PART_RATE_TYPES
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
// CamelCase is used for GraphQL and database fields.
|
||||
|
||||
const client = require("../../../graphql-client/graphql-client").client;
|
||||
const { extractPartsTaxRates } = require("./lib/extractPartsTaxRates");
|
||||
const { parseXml, normalizeXmlObject } = require("../partsManagementUtils");
|
||||
|
||||
// GraphQL Queries and Mutations
|
||||
@@ -15,27 +16,7 @@ const {
|
||||
// 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"
|
||||
];
|
||||
|
||||
// Config: include labor lines and labor in totals (default false for development ease)
|
||||
// Config: include labor lines and labor in totals (default true)
|
||||
const INCLUDE_LABOR = true;
|
||||
/**
|
||||
* Fetches the default order status for a bodyshop.
|
||||
@@ -52,49 +33,6 @@ const getDefaultOrderStatus = async (shopId, logger) => {
|
||||
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 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;
|
||||
|
||||
const taxInfo = r.TaxInfo;
|
||||
const taxTier = taxInfo?.TaxTierInfo;
|
||||
let percentage = parseFloat(taxTier?.Percentage ?? "NaN");
|
||||
|
||||
if (isNaN(percentage)) {
|
||||
const tierRate = Array.isArray(r.RateTierInfo) ? r.RateTierInfo[0]?.Rate : r.RateTierInfo?.Rate;
|
||||
percentage = parseFloat(tierRate ?? "NaN");
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
|
||||
const client = require("../../../graphql-client/graphql-client").client;
|
||||
const { parseXml, normalizeXmlObject } = require("../partsManagementUtils");
|
||||
const { extractPartsTaxRates } = require("./lib/extractPartsTaxRates");
|
||||
|
||||
const {
|
||||
GET_JOB_BY_CLAIM,
|
||||
UPDATE_JOB_BY_ID,
|
||||
UPSERT_JOBLINES,
|
||||
DELETE_JOBLINES_BY_IDS
|
||||
DELETE_JOBLINES_BY_IDS,
|
||||
INSERT_JOBLINES
|
||||
} = require("../partsManagement.queries");
|
||||
|
||||
/**
|
||||
@@ -37,52 +38,143 @@ const extractUpdatedJobData = (rq) => {
|
||||
const doc = rq.DocumentInfo || {};
|
||||
const claim = rq.ClaimInfo || {};
|
||||
|
||||
return {
|
||||
const policyNo = claim.PolicyInfo?.PolicyInfo?.PolicyNum || claim.PolicyInfo?.PolicyNum || null;
|
||||
|
||||
const out = {
|
||||
comment: doc.Comment || null,
|
||||
clm_no: claim.ClaimNum || null,
|
||||
status: claim.ClaimStatus || null,
|
||||
policy_no: claim.PolicyInfo?.PolicyNum || null
|
||||
policy_no: policyNo
|
||||
};
|
||||
|
||||
// If ProfileInfo provided in ChangeRq, update parts_tax_rates to stay in sync with AddRq behavior
|
||||
if (rq.ProfileInfo) {
|
||||
out.parts_tax_rates = extractPartsTaxRates(rq.ProfileInfo);
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts updated job lines from the request payload.
|
||||
* @param addsChgs
|
||||
* @param jobId
|
||||
* @returns {{jobid: *, line_no: number, unq_seq: number, status, line_desc, part_type, part_qty: number, oem_partno, db_price: number, act_price: number, mod_lbr_ty, mod_lb_hrs: number, lbr_op, lbr_amt: number, notes, manual_line: boolean}[]}
|
||||
* Extracts updated job lines from the request payload, mirroring the AddRq splitting rules:
|
||||
* - PART lines carry only part pricing (act_price) and related fields
|
||||
* - If LaborInfo exists on a part line, add a separate LABOR line at unq_seq + 400000
|
||||
* - If RefinishLaborInfo exists, add a separate LABOR line at unq_seq + 500000 with mod_lbr_ty=LAR
|
||||
* - SUBLET lines become PAS part_type with act_price=SubletAmount
|
||||
*/
|
||||
const extractUpdatedJobLines = (addsChgs = {}, jobId) => {
|
||||
const lines = Array.isArray(addsChgs.DamageLineInfo) ? addsChgs.DamageLineInfo : [addsChgs.DamageLineInfo || []];
|
||||
const linesIn = Array.isArray(addsChgs.DamageLineInfo) ? addsChgs.DamageLineInfo : [addsChgs.DamageLineInfo || {}];
|
||||
|
||||
return lines.map((line) => ({
|
||||
jobid: jobId,
|
||||
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 === "1"
|
||||
}));
|
||||
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,
|
||||
notes: line.LineMemo || null,
|
||||
manual_line: line.ManualLineInd !== undefined ? coerceManual(line.ManualLineInd) : null
|
||||
};
|
||||
|
||||
const hasPart = Object.keys(partInfo).length > 0;
|
||||
const hasLaborOnly = Object.keys(laborInfo).length > 0 && !hasPart && Object.keys(subletInfo).length === 0;
|
||||
const hasSublet = Object.keys(subletInfo).length > 0;
|
||||
|
||||
if (hasPart) {
|
||||
const price = parseFloat(partInfo.PartPrice || partInfo.ListPrice || 0);
|
||||
out.push({
|
||||
...base,
|
||||
part_type: partInfo.PartType ? String(partInfo.PartType).toUpperCase() : null,
|
||||
part_qty: parseFloat(partInfo.Quantity || 0) || 1,
|
||||
oem_partno: partInfo.OEMPartNum || partInfo.PartNum || null,
|
||||
db_price: isNaN(price) ? 0 : price,
|
||||
act_price: isNaN(price) ? 0 : price
|
||||
});
|
||||
|
||||
// Split any attached labor on the part line into a derived labor jobline
|
||||
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) {
|
||||
out.push({
|
||||
...base,
|
||||
unq_seq: (parseInt(line.UniqueSequenceNum || 0, 10) || 0) + 400000,
|
||||
mod_lbr_ty: laborInfo.LaborType || null,
|
||||
mod_lb_hrs: isNaN(hrs) ? 0 : hrs,
|
||||
lbr_op: laborInfo.LaborOperation || null,
|
||||
lbr_amt: isNaN(amt) ? 0 : amt
|
||||
});
|
||||
}
|
||||
} else if (hasSublet) {
|
||||
out.push({
|
||||
...base,
|
||||
part_type: "PAS",
|
||||
part_qty: 1,
|
||||
act_price: parseFloat(subletInfo.SubletAmount || 0) || 0
|
||||
});
|
||||
}
|
||||
|
||||
// Labor-only line (no PartInfo): still upsert as a labor entry
|
||||
if (hasLaborOnly) {
|
||||
out.push({
|
||||
...base,
|
||||
mod_lbr_ty: laborInfo.LaborType || null,
|
||||
mod_lb_hrs: parseFloat(laborInfo.LaborHours || 0) || 0,
|
||||
lbr_op: laborInfo.LaborOperation || null,
|
||||
lbr_amt: parseFloat(laborInfo.LaborAmt || 0) || 0
|
||||
});
|
||||
}
|
||||
|
||||
// Separate refinish labor line
|
||||
if (Object.keys(refinishInfo).length > 0) {
|
||||
const rHrs = parseFloat(refinishInfo.LaborHours || 0);
|
||||
const rAmt = parseFloat(refinishInfo.LaborAmt || 0);
|
||||
if (!isNaN(rHrs) || !isNaN(rAmt)) {
|
||||
out.push({
|
||||
...base,
|
||||
unq_seq: (parseInt(line.UniqueSequenceNum || 0, 10) || 0) + 500000,
|
||||
line_desc: base.line_desc || "Refinish",
|
||||
mod_lbr_ty: "LAR",
|
||||
mod_lb_hrs: isNaN(rHrs) ? 0 : rHrs,
|
||||
lbr_op: refinishInfo.LaborOperation || null,
|
||||
lbr_amt: isNaN(rAmt) ? 0 : rAmt
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts deletion IDs from the deletions object.
|
||||
* @param deletions
|
||||
* @returns {number[]}
|
||||
* Extracts deletion IDs from the deletions object, also removing any derived labor/refinish lines
|
||||
* by including offsets (base + 400000, base + 500000).
|
||||
*/
|
||||
const extractDeletions = (deletions = {}) => {
|
||||
const lines = Array.isArray(deletions.DamageLineInfo) ? deletions.DamageLineInfo : [deletions.DamageLineInfo || []];
|
||||
const items = Array.isArray(deletions.DamageLineInfo) ? deletions.DamageLineInfo : [deletions.DamageLineInfo || {}];
|
||||
const baseSeqs = items.map((line) => parseInt(line.UniqueSequenceNum, 10)).filter((id) => Number.isInteger(id));
|
||||
|
||||
return lines.map((line) => parseInt(line.UniqueSequenceNum, 10)).filter((id) => !isNaN(id));
|
||||
const allSeqs = [];
|
||||
for (const u of baseSeqs) {
|
||||
allSeqs.push(u, u + 400000, u + 500000);
|
||||
}
|
||||
// De-dup
|
||||
return Array.from(new Set(allSeqs));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -113,14 +205,23 @@ const partsManagementVehicleDamageEstimateChgRq = async (req, res) => {
|
||||
|
||||
await client.request(UPDATE_JOB_BY_ID, { id: job.id, job: updatedJobData });
|
||||
|
||||
if (updatedLines.length > 0) {
|
||||
await client.request(UPSERT_JOBLINES, {
|
||||
joblines: updatedLines
|
||||
});
|
||||
// Build a set of unq_seq that will be updated (replaced). We delete them first to avoid duplicates.
|
||||
const updatedSeqs = Array.from(
|
||||
new Set((updatedLines || []).map((l) => l && l.unq_seq).filter((v) => Number.isInteger(v)))
|
||||
);
|
||||
|
||||
if ((deletedLineIds && deletedLineIds.length) || (updatedSeqs && updatedSeqs.length)) {
|
||||
const allToDelete = Array.from(new Set([...(deletedLineIds || []), ...(updatedSeqs || [])]));
|
||||
if (allToDelete.length) {
|
||||
await client.request(DELETE_JOBLINES_BY_IDS, { jobid: job.id, unqSeqs: allToDelete });
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedLineIds.length > 0) {
|
||||
await client.request(DELETE_JOBLINES_BY_IDS, { jobid: job.id, unqSeqs: deletedLineIds });
|
||||
if (updatedLines.length > 0) {
|
||||
// Insert fresh versions after deletion so we don’t depend on a unique constraint
|
||||
await client.request(INSERT_JOBLINES, {
|
||||
joblines: updatedLines
|
||||
});
|
||||
}
|
||||
|
||||
logger.log("parts-job-changed", "info", job.id, null);
|
||||
|
||||
Reference in New Issue
Block a user