From 64454dce2ad722fb8d6e51b7ab843c76a683cd7f Mon Sep 17 00:00:00 2001 From: Patrick Fic Date: Tue, 10 Feb 2026 11:59:53 -0800 Subject: [PATCH] IO-3515 add client side polling for now, cost centers. --- .../bill-enter-modal.container.jsx | 125 +++++++++++++++--- .../bill-form/bill-form.lines.component.jsx | 12 ++ server/ai/bill-ocr/bill-ocr-generator.js | 19 ++- server/ai/bill-ocr/bill-ocr.js | 55 ++++++-- 4 files changed, 186 insertions(+), 25 deletions(-) diff --git a/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx b/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx index f15e7c6a2..6f8d7ca0d 100644 --- a/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx +++ b/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx @@ -51,10 +51,12 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop, const [updatePartsOrderLines] = useMutation(MUTATION_MARK_RETURN_RECEIVED); const [updateInventoryLines] = useMutation(UPDATE_INVENTORY_LINES); const [loading, setLoading] = useState(false); + const [scanLoading, setScanLoading] = useState(false); const client = useApolloClient(); const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false); const notification = useNotification(); const fileInputRef = useRef(null); + const pollingIntervalRef = useRef(null); const { treatments: { Enhanced_Payroll, Imgproxy } @@ -390,6 +392,12 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop, const handleCancel = () => { const r = window.confirm(t("general.labels.cancel")); if (r === true) { + // Clean up polling on cancel + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + setScanLoading(false); toggleModalVisible(); } }; @@ -398,14 +406,82 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop, if (enterAgain) form.submit(); }, [enterAgain, form]); + // Polling function for multipage PDF status + const pollJobStatus = async (jobId) => { + try { + const { data } = await axios.get(`/ai/bill-ocr/status/${jobId}`); + + if (data.status === 'COMPLETED') { + // Stop polling + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + setScanLoading(false); + + // Update form with the extracted data + if (data.data && data.data.billForm) { + form.setFieldsValue(data.data.billForm); + notification.success({ + title: "AI Scan Complete", + message: "Invoice data has been extracted successfully" + }); + } + } else if (data.status === 'FAILED') { + // Stop polling on failure + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + setScanLoading(false); + + notification.error({ + title: "AI Scan Failed", + message: data.error || "Failed to process the invoice" + }); + } + // If status is IN_PROGRESS, continue polling + } catch (error) { + console.error("Error polling job status:", error); + + // Stop polling on error + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + setScanLoading(false); + + notification.error({ + title: "AI Scan Error", + message: error.response?.data?.message || error.message || "Failed to check scan status" + }); + } + }; + useEffect(() => { if (billEnterModal.open) { form.setFieldsValue(formValues); } else { form.resetFields(); + // Clean up polling on modal close + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + setScanLoading(false); } }, [billEnterModal.open, form, formValues]); + // Cleanup on unmount + useEffect(() => { + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, []); + return ( { const file = e.target.files?.[0]; if (file) { + setScanLoading(true); const formdata = new FormData(); formdata.append("billScan", file); formdata.append("jobid", billEnterModal.context.job.id); @@ -437,38 +514,54 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop, //formdata.append("skipTextract", "true"); // For testing purposes axios .post("/ai/bill-ocr", formdata) - .then(({ data }) => { - console.log("*** ~ BillEnterModalContainer ~ response:", data.data.billForm); - //Stored in data.data + .then(({ data, status }) => { + if (status === 202) { + // Multipage PDF - start polling + notification.info({ + title: "Processing Invoice", + message: "This is a multipage document. Processing may take a few moments..." + }); + + // Start polling every 3 seconds + pollingIntervalRef.current = setInterval(() => { + pollJobStatus(data.jobId); + }, 3000); + + // Initial poll + pollJobStatus(data.jobId); + } else if (status === 200) { + // Single page - immediate response + setScanLoading(false); - form.setFieldsValue(data.data.billForm); + form.setFieldsValue(data.data.billForm); + notification.success({ + title: "AI Scan Complete", + message: "Invoice data has been extracted successfully" + }); + } }) .catch((error) => { console.error("*** ~ BillEnterModalContainer ~ error:", error); + setScanLoading(false); + notification.error({ + title: "AI Scan Failed", + message: error.response?.data?.message || error.message || "Failed to process invoice" + }); }); } // Reset the input so the same file can be selected again e.target.value = ""; }} /> - setGenerateLabel(e.target.checked)}> {t("bills.labels.generatepartslabel")} diff --git a/client/src/components/bill-form/bill-form.lines.component.jsx b/client/src/components/bill-form/bill-form.lines.component.jsx index 5b4a5ca8c..c6b2c8d0e 100644 --- a/client/src/components/bill-form/bill-form.lines.component.jsx +++ b/client/src/components/bill-form/bill-form.lines.component.jsx @@ -210,6 +210,18 @@ export function BillEnterModalLinesComponent({ }), formInput: () => }, + { + title: t("billlines.fields.confidence"), + dataIndex: "confidence", + editable: true, + width: "4rem", + formItemProps: (field) => ({ + key: `${field.index}confidence`, + name: [field.name, "confidence"], + label: t("billlines.fields.confidence") + }), + formInput: () => + }, { title: t("billlines.fields.quantity"), dataIndex: "quantity", diff --git a/server/ai/bill-ocr/bill-ocr-generator.js b/server/ai/bill-ocr/bill-ocr-generator.js index b366cb2ca..18c3552af 100644 --- a/server/ai/bill-ocr/bill-ocr-generator.js +++ b/server/ai/bill-ocr/bill-ocr-generator.js @@ -162,6 +162,9 @@ async function generateBillFormData({ processedData, jobid, bodyshopid, partsord bodyshop{ id md_responsibility_centers + cdk_dealerid + pbs_serialnumber + rr_dealerid } joblines { id @@ -171,6 +174,7 @@ async function generateBillFormData({ processedData, jobid, bodyshopid, partsord db_price oem_partno alt_partno + part_type } } parts_orders_by_pk(id: $partsorderid) { @@ -186,6 +190,7 @@ async function generateBillFormData({ processedData, jobid, bodyshopid, partsord act_price oem_partno alt_partno + part_type } } } @@ -325,13 +330,21 @@ async function generateBillFormData({ processedData, jobid, bodyshopid, partsord } } + 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, // convert to integer? "actual_price": normalizePrice(actualPrice), "actual_cost": normalizePrice(actualCost), - "cost_center": "SETBYCLIENT", //Needs to get set by client side. + "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, //Needs to get set by client side. "applicable_taxes": { //Not sure what to do with these? "federal": false, "state": false, @@ -579,6 +592,10 @@ function joblineFuzzySearch({ fuseToSearch, processedData }) { return matches } +const bodyshopHasDmsKey = (bodyshop) => + bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid; + + module.exports = { generateBillFormData } diff --git a/server/ai/bill-ocr/bill-ocr.js b/server/ai/bill-ocr/bill-ocr.js index adee2c3c3..69d79d8a6 100644 --- a/server/ai/bill-ocr/bill-ocr.js +++ b/server/ai/bill-ocr/bill-ocr.js @@ -111,7 +111,7 @@ async function handleBillOcr(request, response) { } else { // Start the Textract job (non-blocking) for multi-page documents console.log('PDF => 2+ pages, processing asynchronously'); - const jobInfo = await startTextractJob(uploadedFile.buffer); + const jobInfo = await startTextractJob(uploadedFile.buffer, { jobid, bodyshopid, partsorderid }); response.status(202).send({ success: true, @@ -154,13 +154,50 @@ async function handleBillOcrStatus(request, response) { } if (jobStatus.status === 'COMPLETED') { - //TODO: This needs to be stored in the redis cache and pulled when it's processed. - //const billForm = await generateBillFormData({ jobid, bodyshopid, partsorderid }); - + // Generate billForm on-demand if not already generated + let billForm = jobStatus.data?.billForm; + + if (!billForm && jobStatus.context) { + try { + console.log('Generating bill form data on-demand...'); + billForm = await generateBillFormData({ + processedData: jobStatus.data, + jobid: jobStatus.context.jobid, + bodyshopid: jobStatus.context.bodyshopid, + partsorderid: jobStatus.context.partsorderid, + req: request // Now we have request context! + }); + + // Cache the billForm back to Redis for future requests + await setTextractJob({ + redisPubClient, + textractJobId, + jobData: { + ...jobStatus, + data: { + ...jobStatus.data, + billForm + } + } + }); + } catch (error) { + console.error('Error generating bill form data:', error); + response.status(500).send({ + status: 'COMPLETED', + error: 'Data processed but failed to generate bill form', + message: error.message, + data: jobStatus.data // Still return the raw processed data + }); + return; + } + } + response.status(200).send({ status: 'COMPLETED', - data: jobStatus.data - // data: { ...jobStatus.data, billForm } + data: { + ...jobStatus.data, + billForm + } }); } else if (jobStatus.status === 'FAILED') { response.status(500).send({ @@ -199,7 +236,7 @@ async function processSinglePageDocument(pdfBuffer) { }; } -async function startTextractJob(pdfBuffer) { +async function startTextractJob(pdfBuffer, context = {}) { // Upload PDF to S3 temporarily for Textract async processing const s3Bucket = process.env.AWS_AI_BUCKET; const snsTopicArn = process.env.AWS_TEXTRACT_SNS_TOPIC_ARN; @@ -254,7 +291,8 @@ async function startTextractJob(pdfBuffer) { status: 'IN_PROGRESS', s3Key: s3Key, uploadId: uploadId, - startedAt: new Date().toISOString() + startedAt: new Date().toISOString(), + context: context // Store the context for later use } } ); @@ -349,6 +387,7 @@ async function handleTextractNotification(message) { // Retrieve the results const { processedData, originalResponse } = await retrieveTextractResults(textractJobId); + // Store the processed data - billForm will be generated on-demand in the status endpoint await setTextractJob( { redisPubClient,