Revert "Release/2026 02 27 (pull request #3070)"
This commit is contained in:
@@ -1,792 +0,0 @@
|
||||
|
||||
|
||||
const Fuse = require('fuse.js');
|
||||
|
||||
const { standardizedFieldsnames } = require('./bill-ocr-normalize');
|
||||
const InstanceManager = require("../../utils/instanceMgr").default;
|
||||
|
||||
const PRICE_PERCENT_MARGIN_TOLERANCE = 0.5; //Used to make sure prices and costs are likely.
|
||||
const PRICE_QUANTITY_MARGIN_TOLERANCE = 0.03; //Used to make sure that if there is a quantity, the price is likely a unit price.
|
||||
// Helper function to normalize fields
|
||||
const normalizePartNumber = (str) => {
|
||||
return str.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||
};
|
||||
|
||||
const normalizeText = (str) => {
|
||||
return str.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, ' ').trim().toUpperCase();
|
||||
};
|
||||
const normalizePrice = (str) => {
|
||||
if (typeof str !== 'string') return str;
|
||||
|
||||
let value = str.trim();
|
||||
|
||||
// Handle European-style decimal comma like "292,37".
|
||||
// Only treat the *last* comma as a decimal separator when:
|
||||
// - there's no '.' anywhere (so we don't fight normal US formatting like "1,234.56")
|
||||
// - and the suffix after the last comma is 1-2 digits (so "1,234" stays 1234)
|
||||
if (!value.includes('.') && value.includes(',')) {
|
||||
const lastCommaIndex = value.lastIndexOf(',');
|
||||
const decimalSuffix = value.slice(lastCommaIndex + 1).trim();
|
||||
|
||||
if (/^\d{1,2}$/.test(decimalSuffix)) {
|
||||
const before = value.slice(0, lastCommaIndex).replace(/,/g, '');
|
||||
value = `${before}.${decimalSuffix}`;
|
||||
} else {
|
||||
// Treat commas as thousands separators (or noise) and drop them.
|
||||
value = value.replace(/,/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
return value.replace(/[^0-9.-]+/g, "");
|
||||
};
|
||||
|
||||
const roundToIncrement = (value, increment) => {
|
||||
if (typeof value !== 'number' || !isFinite(value) || typeof increment !== 'number' || !isFinite(increment) || increment <= 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const rounded = Math.round((value + Number.EPSILON) / increment) * increment;
|
||||
// Prevent float artifacts (e.g. 0.20500000000000002)
|
||||
const decimals = Math.max(0, Math.ceil(-Math.log10(increment)));
|
||||
return parseFloat(rounded.toFixed(decimals));
|
||||
};
|
||||
|
||||
//More complex function. Not necessary at the moment, keeping for reference.
|
||||
// const normalizePriceFinal = (str) => {
|
||||
// if (typeof str !== 'string') {
|
||||
// // If it's already a number, format to 2 decimals
|
||||
// const num = parseFloat(str);
|
||||
// return isNaN(num) ? 0 : num;
|
||||
// }
|
||||
|
||||
// // First, try to extract valid decimal number patterns (e.g., "123.45")
|
||||
// const decimalPattern = /\d+\.\d{1,2}/g;
|
||||
// const decimalMatches = str.match(decimalPattern);
|
||||
|
||||
// if (decimalMatches && decimalMatches.length > 0) {
|
||||
// // Found valid decimal number(s)
|
||||
// const numbers = decimalMatches.map(m => parseFloat(m)).filter(n => !isNaN(n) && n > 0);
|
||||
|
||||
// if (numbers.length === 1) {
|
||||
// return numbers[0];
|
||||
// }
|
||||
|
||||
// if (numbers.length > 1) {
|
||||
// // Check if all numbers are the same (e.g., "47.57.47.57" -> [47.57, 47.57])
|
||||
// const uniqueNumbers = [...new Set(numbers)];
|
||||
// if (uniqueNumbers.length === 1) {
|
||||
// return uniqueNumbers[0];
|
||||
// }
|
||||
|
||||
// // Check if numbers are very close (within 1% tolerance)
|
||||
// const avg = numbers.reduce((a, b) => a + b, 0) / numbers.length;
|
||||
// const allClose = numbers.every(num => Math.abs(num - avg) / avg < 0.01);
|
||||
|
||||
// if (allClose) {
|
||||
// return avg;
|
||||
// }
|
||||
|
||||
// // Return the first number (most likely correct)
|
||||
// return numbers[0];
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Fallback: Split on common delimiters and extract all potential numbers
|
||||
// const parts = str.split(/[\/|\\,;]/).map(part => part.trim()).filter(part => part.length > 0);
|
||||
|
||||
// if (parts.length > 1) {
|
||||
// // Multiple values detected - extract and parse all valid numbers
|
||||
// const numbers = parts
|
||||
// .map(part => {
|
||||
// const cleaned = part.replace(/[^0-9.-]+/g, "");
|
||||
// const parsed = parseFloat(cleaned);
|
||||
// return isNaN(parsed) ? null : parsed;
|
||||
// })
|
||||
// .filter(num => num !== null && num > 0);
|
||||
|
||||
// if (numbers.length === 0) {
|
||||
// // No valid numbers found, try fallback to basic cleaning
|
||||
// const cleaned = str.replace(/[^0-9.-]+/g, "");
|
||||
// const parsed = parseFloat(cleaned);
|
||||
// return isNaN(parsed) ? 0 : parsed;
|
||||
// }
|
||||
|
||||
// if (numbers.length === 1) {
|
||||
// return numbers[0];
|
||||
// }
|
||||
|
||||
// // Multiple valid numbers
|
||||
// const uniqueNumbers = [...new Set(numbers)];
|
||||
|
||||
// if (uniqueNumbers.length === 1) {
|
||||
// return uniqueNumbers[0];
|
||||
// }
|
||||
|
||||
// // Check if numbers are very close (within 1% tolerance)
|
||||
// const avg = numbers.reduce((a, b) => a + b, 0) / numbers.length;
|
||||
// const allClose = numbers.every(num => Math.abs(num - avg) / avg < 0.01);
|
||||
|
||||
// if (allClose) {
|
||||
// return avg;
|
||||
// }
|
||||
|
||||
// // Return the first valid number
|
||||
// return numbers[0];
|
||||
// }
|
||||
|
||||
// // Single value or no delimiters, clean normally
|
||||
// const cleaned = str.replace(/[^0-9.-]+/g, "");
|
||||
// const parsed = parseFloat(cleaned);
|
||||
// return isNaN(parsed) ? 0 : parsed;
|
||||
// };
|
||||
|
||||
|
||||
|
||||
// Helper function to calculate Textract OCR confidence (0-100%)
|
||||
const calculateTextractConfidence = (textractLineItem) => {
|
||||
if (!textractLineItem || Object.keys(textractLineItem).length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const confidenceValues = [];
|
||||
|
||||
// Collect confidence from all fields in the line item
|
||||
Object.values(textractLineItem).forEach(field => {
|
||||
if (field.confidence && typeof field.confidence === 'number') {
|
||||
confidenceValues.push(field.confidence);
|
||||
}
|
||||
});
|
||||
|
||||
if (confidenceValues.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Check if critical normalized labels are present
|
||||
const hasActualCost = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_cost);
|
||||
const hasActualPrice = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_price);
|
||||
const hasLineDesc = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.line_desc);
|
||||
const hasQuantity = textractLineItem?.QUANTITY?.value; //We don't normalize quantity, we just use what textract gives us.
|
||||
|
||||
// Calculate weighted average, giving more weight to important fields
|
||||
// If we can identify key fields (ITEM, PRODUCT_CODE, PRICE), weight them higher
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
|
||||
Object.entries(textractLineItem).forEach(([key, field]) => {
|
||||
if (field.confidence && typeof field.confidence === 'number') {
|
||||
// Weight important fields higher
|
||||
let weight = 1;
|
||||
if (field.normalizedLabel === standardizedFieldsnames.actual_cost || field.normalizedLabel === standardizedFieldsnames.actual_price) {
|
||||
weight = 4;
|
||||
}
|
||||
else if (field.normalizedLabel === standardizedFieldsnames.part_no || field.normalizedLabel === standardizedFieldsnames.line_desc) {
|
||||
weight = 3.5;
|
||||
}
|
||||
else if (field.normalizedLabel === standardizedFieldsnames.quantity) {
|
||||
weight = 3.5;
|
||||
}
|
||||
// We generally ignore the key from textract. Keeping for future reference.
|
||||
// else if (key === 'ITEM' || key === 'PRODUCT_CODE') {
|
||||
// weight = 3; // Description and part number are most important
|
||||
// } else if (key === 'PRICE' || key === 'UNIT_PRICE' || key === 'QUANTITY') {
|
||||
// weight = 2; // Price and quantity moderately important
|
||||
// }
|
||||
|
||||
weightedSum += field.confidence * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
});
|
||||
|
||||
let avgConfidence = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
||||
|
||||
// Apply penalty if critical normalized labels are missing
|
||||
let missingFieldsPenalty = 1.0;
|
||||
let missingCount = 0;
|
||||
if (!hasActualCost) missingCount++;
|
||||
if (!hasActualPrice) missingCount++;
|
||||
if (!hasLineDesc) missingCount++;
|
||||
if (!hasQuantity) missingCount++;
|
||||
|
||||
// Each missing field reduces confidence by 20%
|
||||
if (missingCount > 0) {
|
||||
missingFieldsPenalty = 1.0 - (missingCount * 0.20);
|
||||
}
|
||||
|
||||
avgConfidence = avgConfidence * missingFieldsPenalty;
|
||||
|
||||
return Math.round(avgConfidence * 100) / 100; // Round to 2 decimal places
|
||||
};
|
||||
|
||||
const calculateMatchConfidence = (matches, bestMatch) => {
|
||||
if (!matches || matches.length === 0 || !bestMatch) {
|
||||
return 0; // No match = 0% confidence
|
||||
}
|
||||
|
||||
// Base confidence from the match score
|
||||
// finalScore is already weighted and higher is better
|
||||
// Normalize it to a 0-100 scale
|
||||
const baseScore = Math.min(bestMatch.finalScore * 10, 100); // Scale factor of 10, cap at 100
|
||||
|
||||
// Bonus for multiple field matches (up to +15%)
|
||||
const fieldMatchBonus = Math.min(bestMatch.fieldMatches.length * 5, 15);
|
||||
|
||||
// Bonus for having price data (+10%)
|
||||
const priceDataBonus = bestMatch.hasPriceData ? 10 : 0;
|
||||
|
||||
// Bonus for clear winner (gap between 1st and 2nd match)
|
||||
let confidenceMarginBonus = 0;
|
||||
if (matches.length > 1) {
|
||||
const scoreDiff = bestMatch.finalScore - matches[1].finalScore;
|
||||
// If the best match is significantly better than the second best, add bonus
|
||||
confidenceMarginBonus = Math.min(scoreDiff * 5, 10); // Up to +10%
|
||||
} else {
|
||||
// Only one match found, add small bonus
|
||||
confidenceMarginBonus = 5;
|
||||
}
|
||||
|
||||
// Calculate total match confidence
|
||||
let matchConfidence = baseScore + fieldMatchBonus + priceDataBonus + confidenceMarginBonus;
|
||||
|
||||
// Cap at 100% and round to 2 decimal places
|
||||
matchConfidence = Math.min(Math.round(matchConfidence * 100) / 100, 100);
|
||||
|
||||
// Ensure minimum of 1% if there's any match at all
|
||||
return Math.max(matchConfidence, 1);
|
||||
};
|
||||
|
||||
const calculateOverallConfidence = (ocrConfidence, matchConfidence) => {
|
||||
// If there's no match, OCR confidence doesn't matter much
|
||||
if (matchConfidence === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Overall confidence is affected by both how well Textract read the data
|
||||
// and how well we matched it to existing joblines
|
||||
// Use a weighted average: 60% OCR confidence, 40% match confidence
|
||||
// OCR confidence is more important because even perfect match is useless without good OCR
|
||||
const overall = (ocrConfidence * 0.6) + (matchConfidence * 0.4);
|
||||
|
||||
return Math.round(overall * 100) / 100;
|
||||
};
|
||||
|
||||
// Helper function to merge and deduplicate results with weighted scoring
|
||||
const mergeResults = (resultsArray, weights = []) => {
|
||||
const scoreMap = new Map();
|
||||
|
||||
resultsArray.forEach((results, index) => {
|
||||
const weight = weights[index] || 1;
|
||||
results.forEach(result => {
|
||||
const id = result.item.id;
|
||||
const weightedScore = result.score * weight;
|
||||
|
||||
if (!scoreMap.has(id)) {
|
||||
scoreMap.set(id, { item: result.item, score: weightedScore, count: 1 });
|
||||
} else {
|
||||
const existing = scoreMap.get(id);
|
||||
// Lower score is better in Fuse.js, so take the minimum
|
||||
existing.score = Math.min(existing.score, weightedScore);
|
||||
existing.count++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Convert back to array and sort by score (lower is better)
|
||||
return Array.from(scoreMap.values())
|
||||
.sort((a, b) => {
|
||||
// Prioritize items found in multiple searches
|
||||
if (a.count !== b.count) return b.count - a.count;
|
||||
return a.score - b.score;
|
||||
})
|
||||
.slice(0, 5); // Return top 5 results
|
||||
};
|
||||
|
||||
async function generateBillFormData({ processedData, jobid: jobidFromProps, bodyshopid, partsorderid, req }) {
|
||||
const client = req.userGraphQLClient;
|
||||
|
||||
let jobid = jobidFromProps;
|
||||
//If no jobid, fetch it, and funnel it back.
|
||||
if (!jobid || jobid === null || jobid === undefined || jobid === "" || jobid === "null" || jobid === "undefined") {
|
||||
const ro_number = processedData.summary?.PO_NUMBER?.value || Object.values(processedData.summary).find(value => value.normalizedLabel === 'ro_number')?.value;
|
||||
if (!ro_number) {
|
||||
throw new Error("Could not find RO number in the extracted data to associate with the bill. Select an RO and try again.");
|
||||
}
|
||||
|
||||
const { jobs } = await client.request(`
|
||||
query QUERY_BILL_OCR_JOB_BY_RO($ro_number: String!) {
|
||||
jobs(where: {ro_number: {_eq: $ro_number}}) {
|
||||
id
|
||||
}
|
||||
}`, { ro_number });
|
||||
|
||||
if (jobs.length === 0) {
|
||||
throw new Error("No job found for the detected RO/PO number.");
|
||||
}
|
||||
jobid = jobs[0].id;
|
||||
}
|
||||
|
||||
const jobData = await client.request(`
|
||||
query QUERY_BILL_OCR_DATA($jobid: uuid!) {
|
||||
vendors {
|
||||
id
|
||||
name
|
||||
}
|
||||
jobs_by_pk(id: $jobid) {
|
||||
id
|
||||
bodyshop {
|
||||
id
|
||||
md_responsibility_centers
|
||||
cdk_dealerid
|
||||
pbs_serialnumber
|
||||
rr_dealerid
|
||||
}
|
||||
joblines {
|
||||
id
|
||||
line_desc
|
||||
removed
|
||||
act_price
|
||||
db_price
|
||||
oem_partno
|
||||
alt_partno
|
||||
part_type
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
`, {
|
||||
jobid, // TODO: Parts order IDs are currently ignore. If receving a parts order, it could be used to more precisely match to joblines.
|
||||
});
|
||||
|
||||
//Create fuses of line descriptions for matching.
|
||||
const jobLineDescFuse = new Fuse(
|
||||
jobData.jobs_by_pk.joblines.map(jl => ({ ...jl, line_desc_normalized: normalizeText(jl.line_desc || ""), oem_partno_normalized: normalizePartNumber(jl.oem_partno || ""), alt_partno_normalized: normalizePartNumber(jl.alt_partno || "") })),
|
||||
{
|
||||
keys: [{
|
||||
name: 'line_desc',
|
||||
weight: 6
|
||||
}, {
|
||||
name: 'oem_partno',
|
||||
weight: 8
|
||||
}, {
|
||||
name: 'alt_partno',
|
||||
weight: 5
|
||||
},
|
||||
{
|
||||
name: 'act_price',
|
||||
weight: 1
|
||||
},
|
||||
{
|
||||
name: 'line_desc_normalized',
|
||||
weight: 4
|
||||
},
|
||||
{
|
||||
name: 'oem_partno_normalized',
|
||||
weight: 6
|
||||
},
|
||||
{
|
||||
name: 'alt_partno_normalized',
|
||||
weight: 3
|
||||
}],
|
||||
threshold: 0.4, //Adjust as needed for matching sensitivity,
|
||||
includeScore: true,
|
||||
|
||||
}
|
||||
);
|
||||
const joblineMatches = joblineFuzzySearch({ fuseToSearch: jobLineDescFuse, processedData });
|
||||
|
||||
const vendorFuse = new Fuse(
|
||||
jobData.vendors.map(v => ({ ...v, name_normalized: normalizeText(v.name) })),
|
||||
{
|
||||
keys: [{ name: "name", weight: 3 }, { name: 'name_normalized', weight: 2 }],
|
||||
threshold: 0.4,
|
||||
includeScore: true,
|
||||
},
|
||||
|
||||
);
|
||||
|
||||
const vendorMatches = vendorFuse.search(normalizeText(processedData.summary?.VENDOR_NAME?.value || processedData.summary?.NAME?.value));
|
||||
|
||||
let vendorid;
|
||||
if (vendorMatches.length > 0) {
|
||||
vendorid = vendorMatches[0].item.id;
|
||||
}
|
||||
const { jobs_by_pk: job } = jobData;
|
||||
if (!job) {
|
||||
throw new Error('Job not found for bill form data generation.');
|
||||
}
|
||||
|
||||
|
||||
//Is there a subtotal level discount? If there is, we need to figure out what the percentage is, and apply that to the actual cost as a reduction
|
||||
const subtotalDiscountValueRaw = processedData.summary?.DISCOUNT?.value || processedData.summary?.SUBTOTAL_DISCOUNT?.value || 0;
|
||||
let discountPercentageDecimal = 0;
|
||||
if (subtotalDiscountValueRaw) {
|
||||
const subtotal = parseFloat(normalizePrice(processedData.summary?.SUBTOTAL?.value || 0)) || 0;
|
||||
const subtotalDiscountValue = parseFloat(normalizePrice(subtotalDiscountValueRaw)) || 0;
|
||||
if (subtotal > 0 && subtotalDiscountValue) {
|
||||
// Store discount percentage as a decimal (e.g. 20.5% => 0.205),
|
||||
// but only allow half-percent increments (0.005 steps).
|
||||
discountPercentageDecimal = Math.abs(subtotalDiscountValue / subtotal);
|
||||
discountPercentageDecimal = roundToIncrement(discountPercentageDecimal, 0.005);
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: How do we handle freight lines and core charges?
|
||||
//Create the form data structure for the bill posting screen.
|
||||
const billFormData = {
|
||||
"jobid": jobid,
|
||||
"vendorid": vendorid,
|
||||
"invoice_number": processedData.summary?.INVOICE_RECEIPT_ID?.value,
|
||||
"date": processedData.summary?.INVOICE_RECEIPT_DATE?.value,
|
||||
"is_credit_memo": false,
|
||||
"total": normalizePrice(processedData.summary?.INVOICE_TOTAL?.value || processedData.summary?.TOTAL?.value),
|
||||
"billlines": joblineMatches.map(jlMatchLine => {
|
||||
const { matches, textractLineItem, } = jlMatchLine
|
||||
//Matches should be pre-sorted, take the first one.
|
||||
const matchToUse = matches.length > 0 ? matches[0] : null;
|
||||
|
||||
// Calculate confidence scores
|
||||
const ocrConfidence = calculateTextractConfidence(textractLineItem);
|
||||
const matchConfidence = calculateMatchConfidence(matches, matchToUse);
|
||||
const overallConfidence = calculateOverallConfidence(ocrConfidence, matchConfidence);
|
||||
//TODO: Should be using the textract if there is an exact match on the normalized label.
|
||||
//if there isn't then we can do the below.
|
||||
|
||||
let actualPrice, actualCost;
|
||||
//TODO: What is several match on the normalized name? We need to pick the most likely one.
|
||||
const hasNormalizedActualPrice = Object.keys(textractLineItem).find(key => textractLineItem[key].normalizedLabel === 'actual_price');
|
||||
const hasNormalizedActualCost = Object.keys(textractLineItem).find(key => textractLineItem[key].normalizedLabel === 'actual_cost');
|
||||
|
||||
if (hasNormalizedActualPrice) {
|
||||
actualPrice = textractLineItem[hasNormalizedActualPrice].value;
|
||||
}
|
||||
if (hasNormalizedActualCost) {
|
||||
actualCost = textractLineItem[hasNormalizedActualCost].value;
|
||||
}
|
||||
|
||||
if (!hasNormalizedActualPrice || !hasNormalizedActualCost) {
|
||||
//This is if there was no match found for normalized labels.
|
||||
//Check all prices, and generally the higher one will be the actual price and the lower one will be the cost.
|
||||
//Need to make sure that other random items are excluded. This should be within a reasonable range of the matched jobline at matchToUse.item.act_price
|
||||
//Iterate over all of the text values, and check out which of them are currencies.
|
||||
//They'll be in the format starting with a $ sign usually.
|
||||
const currencyTextractLineItems = [] // {key, value}
|
||||
Object.keys(textractLineItem).forEach(key => {
|
||||
const currencyValue = textractLineItem[key].value?.startsWith('$') ? textractLineItem[key].value : null;
|
||||
if (currencyValue) {
|
||||
//Clean it and parse it
|
||||
const cleanValue = parseFloat(currencyValue.replace(/[^0-9.-]/g, '')) || 0;
|
||||
currencyTextractLineItems.push({ key, value: cleanValue })
|
||||
}
|
||||
})
|
||||
|
||||
//Sort them descending
|
||||
currencyTextractLineItems.sort((a, b) => b.value - a.value);
|
||||
//Most expensive should be the actual price, second most expensive should be the cost.
|
||||
if (!actualPrice) actualPrice = currencyTextractLineItems.length > 0 ? currencyTextractLineItems[0].value : 0;
|
||||
if (!actualCost) actualCost = currencyTextractLineItems.length > 1 ? currencyTextractLineItems[1].value : 0;
|
||||
|
||||
if (matchToUse) {
|
||||
//Double check that they're within 50% of the matched jobline price if there is one.
|
||||
const joblinePrice = parseFloat(matchToUse.item.act_price) || 0;
|
||||
if (!hasNormalizedActualPrice && actualPrice > 0 && (actualPrice < joblinePrice * (1 - PRICE_PERCENT_MARGIN_TOLERANCE) || actualPrice > joblinePrice * (1 + PRICE_PERCENT_MARGIN_TOLERANCE))) {
|
||||
actualPrice = joblinePrice; //Set to the jobline as a fallback.
|
||||
}
|
||||
if (!hasNormalizedActualCost && actualCost > 0 && (actualCost < joblinePrice * (1 - PRICE_PERCENT_MARGIN_TOLERANCE) || actualCost > joblinePrice * (1 + PRICE_PERCENT_MARGIN_TOLERANCE))) {
|
||||
actualCost = null //Blank it out if it's not likely.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//If there's nothing, just fall back to seeing if there's a price object from textract.
|
||||
|
||||
if (!actualPrice && textractLineItem.PRICE) {
|
||||
actualPrice = textractLineItem.PRICE.value;
|
||||
}
|
||||
if (!actualCost && textractLineItem.PRICE) {
|
||||
actualCost = textractLineItem.PRICE.value;
|
||||
}
|
||||
|
||||
//If quantity greater than 1, check if the actual cost is a multiple of the actual price, if so, divide it out to get the unit price.
|
||||
const quantity = parseInt(textractLineItem?.QUANTITY?.value);
|
||||
if (quantity && quantity > 1) {
|
||||
if (actualPrice && quantity && Math.abs((actualPrice / quantity) - (parseFloat(matchToUse?.item?.act_price) || 0)) / ((parseFloat(matchToUse?.item?.act_price) || 1)) < PRICE_QUANTITY_MARGIN_TOLERANCE) {
|
||||
actualPrice = actualPrice / quantity;
|
||||
}
|
||||
if (actualCost && quantity && Math.abs((actualCost / quantity) - (parseFloat(matchToUse?.item?.act_price) || 0)) / ((parseFloat(matchToUse?.item?.act_price) || 1)) < PRICE_QUANTITY_MARGIN_TOLERANCE) {
|
||||
actualCost = actualCost / quantity;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (discountPercentageDecimal > 0) {
|
||||
actualCost = actualCost * (1 - discountPercentageDecimal);
|
||||
}
|
||||
|
||||
const responsibilityCenters = job.bodyshop.md_responsibility_centers
|
||||
//TODO: Do we need to verify the lines to see if it is a unit price or total price (i.e. quantity * price)
|
||||
const lineObject = {
|
||||
"line_desc": matchToUse?.item?.line_desc || textractLineItem.ITEM?.value || "NO DESCRIPTION",
|
||||
"quantity": textractLineItem.QUANTITY?.value,
|
||||
"actual_price": normalizePrice(actualPrice),
|
||||
"actual_cost": normalizePrice(actualCost),
|
||||
"cost_center": matchToUse?.item?.part_type
|
||||
? bodyshopHasDmsKey(job.bodyshop)
|
||||
? matchToUse?.item?.part_type !== "PAE"
|
||||
? matchToUse?.item?.part_type
|
||||
: null
|
||||
: responsibilityCenters.defaults &&
|
||||
(responsibilityCenters.defaults.costs[matchToUse?.item?.part_type] || null)
|
||||
: null,
|
||||
"applicable_taxes": {
|
||||
"federal": InstanceManager({ imex: true, rome: false }),
|
||||
"state": false,
|
||||
"local": false
|
||||
},
|
||||
"joblineid": matchToUse?.item?.id || "noline",
|
||||
"confidence": `T${overallConfidence} - O${ocrConfidence} - J${matchConfidence}`
|
||||
}
|
||||
return lineObject
|
||||
})
|
||||
}
|
||||
|
||||
return billFormData
|
||||
|
||||
}
|
||||
|
||||
function joblineFuzzySearch({ fuseToSearch, processedData }) {
|
||||
const matches = []
|
||||
const searchStats = []; // Track search statistics
|
||||
|
||||
processedData.lineItems.forEach((lineItem, lineIndex) => {
|
||||
const lineStats = {
|
||||
lineNumber: lineIndex + 1,
|
||||
searches: []
|
||||
};
|
||||
|
||||
// Refined ITEM search (multi-word description)
|
||||
const refinedItemResults = (() => {
|
||||
if (!lineItem.ITEM?.value) return [];
|
||||
|
||||
const itemValue = lineItem.ITEM.value;
|
||||
const normalized = normalizeText(itemValue);
|
||||
|
||||
// 1: Full string search
|
||||
const fullSearch = fuseToSearch.search(normalized);
|
||||
lineStats.searches.push({ type: 'ITEM - Full String', term: normalized, results: fullSearch.length });
|
||||
|
||||
// 2: Search individual significant words (3+ chars)
|
||||
const words = normalized.split(' ').filter(w => w.length >= 3);
|
||||
const wordSearches = words.map(word => {
|
||||
const results = fuseToSearch.search(word);
|
||||
lineStats.searches.push({ type: 'ITEM - Individual Word', term: word, results: results.length });
|
||||
return results;
|
||||
});
|
||||
|
||||
// 3: Search without spaces entirely
|
||||
const noSpaceSearch = fuseToSearch.search(normalized.replace(/\s+/g, ''));
|
||||
lineStats.searches.push({ type: 'ITEM - No Spaces', term: normalized.replace(/\s+/g, ''), results: noSpaceSearch.length });
|
||||
|
||||
// Merge results with weights (full search weighted higher)
|
||||
return mergeResults(
|
||||
[fullSearch, ...wordSearches, noSpaceSearch],
|
||||
[1.0, ...words.map(() => 1.5), 1.2] // Full search best, individual words penalized slightly
|
||||
);
|
||||
})();
|
||||
|
||||
// Refined PRODUCT_CODE search (part numbers)
|
||||
const refinedProductCodeResults = (() => {
|
||||
if (!lineItem.PRODUCT_CODE?.value) return [];
|
||||
|
||||
const productCode = lineItem.PRODUCT_CODE.value;
|
||||
const normalized = normalizePartNumber(productCode);
|
||||
|
||||
// 1: Normalized search (no spaces/special chars)
|
||||
const normalizedSearch = fuseToSearch.search(normalized);
|
||||
lineStats.searches.push({ type: 'PRODUCT_CODE - Normalized', term: normalized, results: normalizedSearch.length });
|
||||
|
||||
// 2: Original with minimal cleaning
|
||||
const minimalClean = productCode.replace(/\s+/g, '').toUpperCase();
|
||||
const minimalSearch = fuseToSearch.search(minimalClean);
|
||||
lineStats.searches.push({ type: 'PRODUCT_CODE - Minimal Clean', term: minimalClean, results: minimalSearch.length });
|
||||
|
||||
// 3: Search with dashes (common in part numbers)
|
||||
const withDashes = productCode.replace(/[^a-zA-Z0-9-]/g, '').toUpperCase();
|
||||
const dashSearch = fuseToSearch.search(withDashes);
|
||||
lineStats.searches.push({ type: 'PRODUCT_CODE - With Dashes', term: withDashes, results: dashSearch.length });
|
||||
|
||||
// 4: Special chars to spaces (preserve word boundaries)
|
||||
const specialCharsToSpaces = productCode.replace(/[^a-zA-Z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim().toUpperCase();
|
||||
const specialCharsSearch = fuseToSearch.search(specialCharsToSpaces);
|
||||
lineStats.searches.push({ type: 'PRODUCT_CODE - Special Chars to Spaces', term: specialCharsToSpaces, results: specialCharsSearch.length });
|
||||
|
||||
return mergeResults(
|
||||
[normalizedSearch, minimalSearch, dashSearch, specialCharsSearch],
|
||||
[1.0, 1.1, 1.2, 1.15] // Prefer fully normalized, special chars to spaces slightly weighted
|
||||
);
|
||||
})();
|
||||
|
||||
// Refined PRICE search
|
||||
const refinedPriceResults = (() => {
|
||||
if (!lineItem.PRICE?.value) return [];
|
||||
|
||||
const price = normalizePrice(lineItem.PRICE.value);
|
||||
|
||||
// 1: Exact price match
|
||||
const exactSearch = fuseToSearch.search(price);
|
||||
lineStats.searches.push({ type: 'PRICE - Exact', term: price, results: exactSearch.length });
|
||||
|
||||
// 2: Price with 2 decimal places
|
||||
const priceFloat = parseFloat(price);
|
||||
if (!isNaN(priceFloat)) {
|
||||
const formattedPrice = priceFloat.toFixed(2);
|
||||
const formattedSearch = fuseToSearch.search(formattedPrice);
|
||||
lineStats.searches.push({ type: 'PRICE - Formatted (2 decimals)', term: formattedPrice, results: formattedSearch.length });
|
||||
|
||||
return mergeResults([exactSearch, formattedSearch], [1.0, 1.1]);
|
||||
}
|
||||
|
||||
return exactSearch;
|
||||
})();
|
||||
|
||||
// Refined UNIT_PRICE search
|
||||
const refinedUnitPriceResults = (() => {
|
||||
if (!lineItem.UNIT_PRICE?.value) return [];
|
||||
|
||||
const unitPrice = normalizePrice(lineItem.UNIT_PRICE.value);
|
||||
|
||||
// 1: Exact price match
|
||||
const exactSearch = fuseToSearch.search(unitPrice);
|
||||
lineStats.searches.push({ type: 'UNIT_PRICE - Exact', term: unitPrice, results: exactSearch.length });
|
||||
|
||||
// 2: Price with 2 decimal places
|
||||
const priceFloat = parseFloat(unitPrice);
|
||||
if (!isNaN(priceFloat)) {
|
||||
const formattedPrice = priceFloat.toFixed(2);
|
||||
const formattedSearch = fuseToSearch.search(formattedPrice);
|
||||
lineStats.searches.push({ type: 'UNIT_PRICE - Formatted (2 decimals)', term: formattedPrice, results: formattedSearch.length });
|
||||
|
||||
return mergeResults([exactSearch, formattedSearch], [1.0, 1.1]);
|
||||
}
|
||||
|
||||
return exactSearch;
|
||||
})();
|
||||
|
||||
//Merge them all together and sort by the highest scores.
|
||||
const combinedScoreMap = new Map();
|
||||
|
||||
// Weight different field types differently
|
||||
const fieldWeights = {
|
||||
productCode: 5.0, // Most important - part numbers should match
|
||||
item: 3.0, // Second most important - description
|
||||
price: 1.0, // Less important - prices can vary
|
||||
unitPrice: 0.8 // Least important - similar to price
|
||||
};
|
||||
|
||||
[
|
||||
{ results: refinedProductCodeResults, weight: fieldWeights.productCode, field: 'productCode' },
|
||||
{ results: refinedItemResults, weight: fieldWeights.item, field: 'item' },
|
||||
{ results: refinedPriceResults, weight: fieldWeights.price, field: 'price' },
|
||||
{ results: refinedUnitPriceResults, weight: fieldWeights.unitPrice, field: 'unitPrice' }
|
||||
].forEach(({ results, weight, field }) => {
|
||||
results.forEach((result, index) => {
|
||||
const id = result.item.id;
|
||||
|
||||
// Position bonus (first result is better than fifth)
|
||||
const positionBonus = (5 - index) / 5;
|
||||
|
||||
// Lower score is better in Fuse.js, so invert it and apply weights
|
||||
const normalizedScore = (1 - result.score) * weight * positionBonus;
|
||||
|
||||
if (!combinedScoreMap.has(id)) {
|
||||
combinedScoreMap.set(id, {
|
||||
item: result.item,
|
||||
score: normalizedScore,
|
||||
fieldMatches: [field],
|
||||
matchCount: result.count || 1
|
||||
});
|
||||
} else {
|
||||
const existing = combinedScoreMap.get(id);
|
||||
existing.score += normalizedScore;
|
||||
existing.fieldMatches.push(field);
|
||||
existing.matchCount += (result.count || 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to array and sort by best combined score
|
||||
const finalMatches = Array.from(combinedScoreMap.values())
|
||||
.map(entry => {
|
||||
// Apply penalty if item has no act_price or it's 0
|
||||
const hasPriceData = entry.item.act_price && parseFloat(entry.item.act_price) > 0;
|
||||
const priceDataPenalty = hasPriceData ? 1.0 : 0.5; // 50% penalty if no price
|
||||
|
||||
return {
|
||||
...entry,
|
||||
// Boost score for items that matched in multiple fields, penalize for missing price
|
||||
finalScore: entry.score * (1 + (entry.fieldMatches.length * 0.2)) * priceDataPenalty,
|
||||
hasPriceData
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.finalScore - a.finalScore)
|
||||
.slice(0, 5);
|
||||
|
||||
// Always push the textract line item, even if no matches found
|
||||
// This ensures all invoice lines are processed
|
||||
matches.push({
|
||||
matches: finalMatches,
|
||||
textractLineItem: lineItem,
|
||||
hasMatch: finalMatches.length > 0
|
||||
});
|
||||
|
||||
searchStats.push(lineStats);
|
||||
|
||||
})
|
||||
|
||||
// // Output search statistics table
|
||||
// console.log('\n═══════════════════════════════════════════════════════════════════════');
|
||||
// console.log(' FUSE.JS SEARCH STATISTICS');
|
||||
// console.log('═══════════════════════════════════════════════════════════════════════\n');
|
||||
|
||||
// searchStats.forEach(lineStat => {
|
||||
// console.log(`📄 Line Item #${lineStat.lineNumber}:`);
|
||||
// console.log('─'.repeat(75));
|
||||
|
||||
// if (lineStat.searches.length > 0) {
|
||||
// const tableData = lineStat.searches.map(search => ({
|
||||
// 'Search Type': search.type,
|
||||
// 'Search Term': search.term.substring(0, 40) + (search.term.length > 40 ? '...' : ''),
|
||||
// 'Results': search.results
|
||||
// }));
|
||||
// console.table(tableData);
|
||||
// } else {
|
||||
// console.log(' No searches performed for this line item.\n');
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Summary statistics
|
||||
// const totalSearches = searchStats.reduce((sum, stat) => sum + stat.searches.length, 0);
|
||||
// const totalResults = searchStats.reduce((sum, stat) =>
|
||||
// sum + stat.searches.reduce((s, search) => s + search.results, 0), 0);
|
||||
// const avgResultsPerSearch = totalSearches > 0 ? (totalResults / totalSearches).toFixed(2) : 0;
|
||||
|
||||
// console.log('═══════════════════════════════════════════════════════════════════════');
|
||||
// console.log(' SUMMARY');
|
||||
// console.log('═══════════════════════════════════════════════════════════════════════');
|
||||
// console.table({
|
||||
// 'Total Line Items': processedData.lineItems.length,
|
||||
// 'Total Searches Performed': totalSearches,
|
||||
// 'Total Results Found': totalResults,
|
||||
// 'Average Results per Search': avgResultsPerSearch
|
||||
// });
|
||||
// console.log('═══════════════════════════════════════════════════════════════════════\n');
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
const bodyshopHasDmsKey = (bodyshop) =>
|
||||
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid;
|
||||
|
||||
|
||||
module.exports = {
|
||||
generateBillFormData,
|
||||
normalizePrice
|
||||
}
|
||||
Reference in New Issue
Block a user