IO-3515 additional cleanup, translations

This commit is contained in:
Patrick Fic
2026-02-19 14:15:57 -08:00
parent b2bc19c5c9
commit 21f43285bc
10 changed files with 159 additions and 88 deletions

View File

@@ -3015,6 +3015,48 @@
<folder_node> <folder_node>
<name>errors</name> <name>errors</name>
<children> <children>
<concept_node>
<name>calculating_totals</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>calculating_totals_generic</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>creating</name> <name>creating</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>
@@ -3642,6 +3684,27 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>generic_failure</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>multipage</name> <name>multipage</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>

View File

@@ -111,7 +111,6 @@ function BillEnterAiScan({
formdata.append("jobid", billEnterModal.context.job?.id); formdata.append("jobid", billEnterModal.context.job?.id);
formdata.append("bodyshopid", bodyshop.id); formdata.append("bodyshopid", bodyshop.id);
formdata.append("partsorderid", billEnterModal.context.parts_order?.id); formdata.append("partsorderid", billEnterModal.context.parts_order?.id);
//formdata.append("skipTextract", "true"); // For testing purposes
try { try {
const { data, status } = await axios.post("/ai/bill-ocr", formdata); const { data, status } = await axios.post("/ai/bill-ocr", formdata);
@@ -147,7 +146,7 @@ function BillEnterAiScan({
setScanLoading(false); setScanLoading(false);
notification.error({ notification.error({
title: t("bills.labels.ai.scanfailed"), title: t("bills.labels.ai.scanfailed"),
message: error.response?.data?.message || error.message || "Failed to process invoice" message: error.response?.data?.message || error.message || t("bills.labels.ai.generic_failure")
}); });
} }
} }

View File

@@ -119,6 +119,8 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
create_ppc, create_ppc,
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
original_actual_price, original_actual_price,
// eslint-disable-next-line no-unused-vars
confidence,
...restI ...restI
} = i; } = i;

View File

@@ -128,7 +128,7 @@ export function BillFormComponent({
]); ]);
useEffect(() => { useEffect(() => {
console.log("*** Form Watch - jobid changed:", jobIdFormWatch); // When the jobid is set by AI scan, we need to reload the lines. This prevents having to hoist the apollo query.
if (jobIdFormWatch !== null) { if (jobIdFormWatch !== null) {
if (form.getFieldValue("jobid") !== null && form.getFieldValue("jobid") !== undefined) { if (form.getFieldValue("jobid") !== null && form.getFieldValue("jobid") !== undefined) {
loadLines({ variables: { id: form.getFieldValue("jobid") } }); loadLines({ variables: { id: form.getFieldValue("jobid") } });
@@ -399,8 +399,8 @@ export function BillFormComponent({
totals = CalculateBillTotal(values); totals = CalculateBillTotal(values);
} catch (error) { } catch (error) {
notification.error({ notification.error({
title: "Error calculating totals", title: t("bills.errors.calculating_totals"),
message: error.message || "An error occurred while calculating bill totals.", message: error.message || t("bills.errors.calculating_totals_generic"),
key: "bill_totals_calculation_error" key: "bill_totals_calculation_error"
}); });
} }

View File

@@ -192,6 +192,8 @@
"return": "Return Items" "return": "Return Items"
}, },
"errors": { "errors": {
"calculating_totals": "Error Calculating Totals",
"calculating_totals_generic": "Please ensure all fields are properly completed. ",
"creating": "Error adding bill. {{error}}", "creating": "Error adding bill. {{error}}",
"deleting": "Error deleting bill. {{error}}", "deleting": "Error deleting bill. {{error}}",
"existinginventoryline": "This bill cannot be deleted as it is tied to items in inventory.", "existinginventoryline": "This bill cannot be deleted as it is tied to items in inventory.",
@@ -228,6 +230,7 @@
"overall": "Overall" "overall": "Overall"
}, },
"disclaimer_title": "AI Scan Beta Disclaimer", "disclaimer_title": "AI Scan Beta Disclaimer",
"generic_failure": "Failed to process invoice.",
"multipage": "The is a multi-page document. Processing will take a few moments.", "multipage": "The is a multi-page document. Processing will take a few moments.",
"processing": "Analyzing Bill", "processing": "Analyzing Bill",
"scan": "AI Bill Scanner", "scan": "AI Bill Scanner",

View File

@@ -192,6 +192,8 @@
"return": "" "return": ""
}, },
"errors": { "errors": {
"calculating_totals": "",
"calculating_totals_generic": "",
"creating": "", "creating": "",
"deleting": "", "deleting": "",
"existinginventoryline": "", "existinginventoryline": "",
@@ -228,6 +230,7 @@
"overall": "" "overall": ""
}, },
"disclaimer_title": "", "disclaimer_title": "",
"generic_failure": "",
"multipage": "", "multipage": "",
"processing": "", "processing": "",
"scan": "", "scan": "",

View File

@@ -192,6 +192,8 @@
"return": "" "return": ""
}, },
"errors": { "errors": {
"calculating_totals": "",
"calculating_totals_generic": "",
"creating": "", "creating": "",
"deleting": "", "deleting": "",
"existinginventoryline": "", "existinginventoryline": "",
@@ -228,6 +230,7 @@
"overall": "" "overall": ""
}, },
"disclaimer_title": "", "disclaimer_title": "",
"generic_failure": "",
"multipage": "", "multipage": "",
"processing": "", "processing": "",
"scan": "", "scan": "",

View File

@@ -19,93 +19,97 @@ const normalizePrice = (str) => {
if (typeof str !== 'string') return str; if (typeof str !== 'string') return str;
return str.replace(/[^0-9.-]+/g, ""); return str.replace(/[^0-9.-]+/g, "");
}; };
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") //More complex function. Not necessary at the moment, keeping for reference.
const decimalPattern = /\d+\.\d{1,2}/g; // const normalizePriceFinal = (str) => {
const decimalMatches = str.match(decimalPattern); // if (typeof str !== 'string') {
// // If it's already a number, format to 2 decimals
// const num = parseFloat(str);
// return isNaN(num) ? 0 : num;
// }
if (decimalMatches && decimalMatches.length > 0) { // // First, try to extract valid decimal number patterns (e.g., "123.45")
// Found valid decimal number(s) // const decimalPattern = /\d+\.\d{1,2}/g;
const numbers = decimalMatches.map(m => parseFloat(m)).filter(n => !isNaN(n) && n > 0); // const decimalMatches = str.match(decimalPattern);
if (numbers.length === 1) { // if (decimalMatches && decimalMatches.length > 0) {
return numbers[0]; // // Found valid decimal number(s)
} // const numbers = decimalMatches.map(m => parseFloat(m)).filter(n => !isNaN(n) && n > 0);
if (numbers.length > 1) { // if (numbers.length === 1) {
// Check if all numbers are the same (e.g., "47.57.47.57" -> [47.57, 47.57]) // return numbers[0];
const uniqueNumbers = [...new Set(numbers)]; // }
if (uniqueNumbers.length === 1) {
return uniqueNumbers[0];
}
// Check if numbers are very close (within 1% tolerance) // if (numbers.length > 1) {
const avg = numbers.reduce((a, b) => a + b, 0) / numbers.length; // // Check if all numbers are the same (e.g., "47.57.47.57" -> [47.57, 47.57])
const allClose = numbers.every(num => Math.abs(num - avg) / avg < 0.01); // const uniqueNumbers = [...new Set(numbers)];
// if (uniqueNumbers.length === 1) {
// return uniqueNumbers[0];
// }
if (allClose) { // // Check if numbers are very close (within 1% tolerance)
return avg; // const avg = numbers.reduce((a, b) => a + b, 0) / numbers.length;
} // const allClose = numbers.every(num => Math.abs(num - avg) / avg < 0.01);
// Return the first number (most likely correct) // if (allClose) {
return numbers[0]; // return avg;
} // }
}
// Fallback: Split on common delimiters and extract all potential numbers // // Return the first number (most likely correct)
const parts = str.split(/[\/|\\,;]/).map(part => part.trim()).filter(part => part.length > 0); // return numbers[0];
// }
// }
if (parts.length > 1) { // // Fallback: Split on common delimiters and extract all potential numbers
// Multiple values detected - extract and parse all valid numbers // const parts = str.split(/[\/|\\,;]/).map(part => part.trim()).filter(part => part.length > 0);
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) { // if (parts.length > 1) {
// No valid numbers found, try fallback to basic cleaning // // Multiple values detected - extract and parse all valid numbers
const cleaned = str.replace(/[^0-9.-]+/g, ""); // const numbers = parts
const parsed = parseFloat(cleaned); // .map(part => {
return isNaN(parsed) ? 0 : parsed; // 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 === 1) { // if (numbers.length === 0) {
return numbers[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;
// }
// Multiple valid numbers // if (numbers.length === 1) {
const uniqueNumbers = [...new Set(numbers)]; // return numbers[0];
// }
if (uniqueNumbers.length === 1) { // // Multiple valid numbers
return uniqueNumbers[0]; // const uniqueNumbers = [...new Set(numbers)];
}
// Check if numbers are very close (within 1% tolerance) // if (uniqueNumbers.length === 1) {
const avg = numbers.reduce((a, b) => a + b, 0) / numbers.length; // return uniqueNumbers[0];
const allClose = numbers.every(num => Math.abs(num - avg) / avg < 0.01); // }
if (allClose) { // // Check if numbers are very close (within 1% tolerance)
return avg; // 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;
// };
// 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%) // Helper function to calculate Textract OCR confidence (0-100%)
const calculateTextractConfidence = (textractLineItem) => { const calculateTextractConfidence = (textractLineItem) => {
@@ -149,6 +153,7 @@ const calculateTextractConfidence = (textractLineItem) => {
else if (field.normalizedLabel === standardizedFieldsnames.quantity) { else if (field.normalizedLabel === standardizedFieldsnames.quantity) {
weight = 3.5; weight = 3.5;
} }
// We generally ignore the key from textract. Keeping for future reference.
// else if (key === 'ITEM' || key === 'PRODUCT_CODE') { // else if (key === 'ITEM' || key === 'PRODUCT_CODE') {
// weight = 3; // Description and part number are most important // weight = 3; // Description and part number are most important
// } else if (key === 'PRICE' || key === 'UNIT_PRICE' || key === 'QUANTITY') { // } else if (key === 'PRICE' || key === 'UNIT_PRICE' || key === 'QUANTITY') {
@@ -179,7 +184,6 @@ const calculateTextractConfidence = (textractLineItem) => {
return Math.round(avgConfidence * 100) / 100; // Round to 2 decimal places return Math.round(avgConfidence * 100) / 100; // Round to 2 decimal places
}; };
// Helper function to calculate match confidence score (0-100%)
const calculateMatchConfidence = (matches, bestMatch) => { const calculateMatchConfidence = (matches, bestMatch) => {
if (!matches || matches.length === 0 || !bestMatch) { if (!matches || matches.length === 0 || !bestMatch) {
return 0; // No match = 0% confidence return 0; // No match = 0% confidence
@@ -217,7 +221,6 @@ const calculateMatchConfidence = (matches, bestMatch) => {
return Math.max(matchConfidence, 1); return Math.max(matchConfidence, 1);
}; };
// Helper function to calculate overall confidence combining OCR and match confidence
const calculateOverallConfidence = (ocrConfidence, matchConfidence) => { const calculateOverallConfidence = (ocrConfidence, matchConfidence) => {
// If there's no match, OCR confidence doesn't matter much // If there's no match, OCR confidence doesn't matter much
if (matchConfidence === 0) { if (matchConfidence === 0) {
@@ -318,7 +321,7 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
} }
`, { `, {
jobid, // TODO: Refactor back in parts orders 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. //Create fuses of line descriptions for matching.
@@ -378,10 +381,8 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
if (!job) { if (!job) {
throw new Error('Job not found for bill form data generation.'); throw new Error('Job not found for bill form data generation.');
} }
//Figure out which lines have a match and which don't.
//TODO: How do we handle freight lines and core charges? //TODO: How do we handle freight lines and core charges?
//Create the form data structure for the bill posting screen. //Create the form data structure for the bill posting screen.
const billFormData = { const billFormData = {
"jobid": jobid, "jobid": jobid,
@@ -392,10 +393,10 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
"total": normalizePrice(processedData.summary?.INVOICE_TOTAL?.value || processedData.summary?.TOTAL?.value), "total": normalizePrice(processedData.summary?.INVOICE_TOTAL?.value || processedData.summary?.TOTAL?.value),
"billlines": joblineMatches.map(jlMatchLine => { "billlines": joblineMatches.map(jlMatchLine => {
const { matches, textractLineItem, } = jlMatchLine const { matches, textractLineItem, } = jlMatchLine
//Matches should be prioritized, take the first one. //Matches should be pre-sorted, take the first one.
const matchToUse = matches.length > 0 ? matches[0] : null; const matchToUse = matches.length > 0 ? matches[0] : null;
// Calculate confidence scores (0-100%) // Calculate confidence scores
const ocrConfidence = calculateTextractConfidence(textractLineItem); const ocrConfidence = calculateTextractConfidence(textractLineItem);
const matchConfidence = calculateMatchConfidence(matches, matchToUse); const matchConfidence = calculateMatchConfidence(matches, matchToUse);
const overallConfidence = calculateOverallConfidence(ocrConfidence, matchConfidence); const overallConfidence = calculateOverallConfidence(ocrConfidence, matchConfidence);
@@ -452,7 +453,7 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
//TODO: Do we need to verify the lines to see if it is a unit price or total price (i.e. quantity * price) //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 = { const lineObject = {
"line_desc": matchToUse?.item?.line_desc || textractLineItem.ITEM?.value || "NO DESCRIPTION", "line_desc": matchToUse?.item?.line_desc || textractLineItem.ITEM?.value || "NO DESCRIPTION",
"quantity": textractLineItem.QUANTITY?.value, // convert to integer? "quantity": textractLineItem.QUANTITY?.value,
"actual_price": normalizePrice(actualPrice), "actual_price": normalizePrice(actualPrice),
"actual_cost": normalizePrice(actualCost), "actual_cost": normalizePrice(actualCost),
"cost_center": matchToUse?.item?.part_type "cost_center": matchToUse?.item?.part_type
@@ -470,7 +471,6 @@ async function generateBillFormData({ processedData, jobid: jobidFromProps, body
}, },
"joblineid": matchToUse?.item?.id || "noline", "joblineid": matchToUse?.item?.id || "noline",
"confidence": `T${overallConfidence} - O${ocrConfidence} - J${matchConfidence}` "confidence": `T${overallConfidence} - O${ocrConfidence} - J${matchConfidence}`
} }
return lineObject return lineObject
}) })

View File

@@ -1,5 +1,5 @@
const PDFDocument = require('pdf-lib').PDFDocument; const PDFDocument = require('pdf-lib').PDFDocument;
const TEXTRACT_REDIS_PREFIX = `textract:${process.env?.NODE_ENV === "production" ? "PROD" : "TEST"}` const TEXTRACT_REDIS_PREFIX = `textract:${process.env?.NODE_ENV}`
const TEXTRACT_JOB_TTL = 10 * 60; const TEXTRACT_JOB_TTL = 10 * 60;

View File

@@ -5,6 +5,4 @@ Required Infrastructure setup
TODO: TODO:
* Create a rome bucket for uploads, or move to the regular spot. * Create a rome bucket for uploads, or move to the regular spot.
* How to implement this across environments. * Add environment variables.
* How to prevent polling for a job that may have errored.
* Handling of HEIC files on upload.