IO-3515 add client side polling for now, cost centers.
This commit is contained in:
@@ -51,10 +51,12 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
|||||||
const [updatePartsOrderLines] = useMutation(MUTATION_MARK_RETURN_RECEIVED);
|
const [updatePartsOrderLines] = useMutation(MUTATION_MARK_RETURN_RECEIVED);
|
||||||
const [updateInventoryLines] = useMutation(UPDATE_INVENTORY_LINES);
|
const [updateInventoryLines] = useMutation(UPDATE_INVENTORY_LINES);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [scanLoading, setScanLoading] = useState(false);
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false);
|
const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false);
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
const pollingIntervalRef = useRef(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
treatments: { Enhanced_Payroll, Imgproxy }
|
treatments: { Enhanced_Payroll, Imgproxy }
|
||||||
@@ -390,6 +392,12 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
|||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
const r = window.confirm(t("general.labels.cancel"));
|
const r = window.confirm(t("general.labels.cancel"));
|
||||||
if (r === true) {
|
if (r === true) {
|
||||||
|
// Clean up polling on cancel
|
||||||
|
if (pollingIntervalRef.current) {
|
||||||
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
pollingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
setScanLoading(false);
|
||||||
toggleModalVisible();
|
toggleModalVisible();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -398,14 +406,82 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
|||||||
if (enterAgain) form.submit();
|
if (enterAgain) form.submit();
|
||||||
}, [enterAgain, form]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (billEnterModal.open) {
|
if (billEnterModal.open) {
|
||||||
form.setFieldsValue(formValues);
|
form.setFieldsValue(formValues);
|
||||||
} else {
|
} else {
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
|
// Clean up polling on modal close
|
||||||
|
if (pollingIntervalRef.current) {
|
||||||
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
pollingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
setScanLoading(false);
|
||||||
}
|
}
|
||||||
}, [billEnterModal.open, form, formValues]);
|
}, [billEnterModal.open, form, formValues]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pollingIntervalRef.current) {
|
||||||
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
pollingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={t("bills.labels.new")}
|
title={t("bills.labels.new")}
|
||||||
@@ -429,6 +505,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
|
setScanLoading(true);
|
||||||
const formdata = new FormData();
|
const formdata = new FormData();
|
||||||
formdata.append("billScan", file);
|
formdata.append("billScan", file);
|
||||||
formdata.append("jobid", billEnterModal.context.job.id);
|
formdata.append("jobid", billEnterModal.context.job.id);
|
||||||
@@ -437,38 +514,54 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
|||||||
//formdata.append("skipTextract", "true"); // For testing purposes
|
//formdata.append("skipTextract", "true"); // For testing purposes
|
||||||
axios
|
axios
|
||||||
.post("/ai/bill-ocr", formdata)
|
.post("/ai/bill-ocr", formdata)
|
||||||
.then(({ data }) => {
|
.then(({ data, status }) => {
|
||||||
console.log("*** ~ BillEnterModalContainer ~ response:", data.data.billForm);
|
if (status === 202) {
|
||||||
//Stored in data.data
|
// 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) => {
|
.catch((error) => {
|
||||||
console.error("*** ~ BillEnterModalContainer ~ error:", 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
|
// Reset the input so the same file can be selected again
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
form.setFieldsValue({ vendorid: "72634cde-8dfa-457c-8c04-08621e712d67" });
|
|
||||||
form.setFieldsValue({ vendorid: "72634cde-8dfa-457c-8c04-08621e712d67" });
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log("Form Values", form.getFieldsValue());
|
|
||||||
}, 1000);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Test form
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log("Fields Object", form.getFieldsValue());
|
console.log("Fields Object", form.getFieldsValue());
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}}
|
}}
|
||||||
|
loading={scanLoading}
|
||||||
|
disabled={scanLoading}
|
||||||
>
|
>
|
||||||
AI Scan (1 page only for now)
|
{scanLoading ? "Processing Invoice..." : "AI Scan"}
|
||||||
</Button>
|
</Button>
|
||||||
<Checkbox checked={generateLabel} onChange={(e) => setGenerateLabel(e.target.checked)}>
|
<Checkbox checked={generateLabel} onChange={(e) => setGenerateLabel(e.target.checked)}>
|
||||||
{t("bills.labels.generatepartslabel")}
|
{t("bills.labels.generatepartslabel")}
|
||||||
|
|||||||
@@ -210,6 +210,18 @@ export function BillEnterModalLinesComponent({
|
|||||||
}),
|
}),
|
||||||
formInput: () => <Input.TextArea disabled={disabled} autoSize />
|
formInput: () => <Input.TextArea disabled={disabled} autoSize />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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: () => <Input disabled={disabled} />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("billlines.fields.quantity"),
|
title: t("billlines.fields.quantity"),
|
||||||
dataIndex: "quantity",
|
dataIndex: "quantity",
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ async function generateBillFormData({ processedData, jobid, bodyshopid, partsord
|
|||||||
bodyshop{
|
bodyshop{
|
||||||
id
|
id
|
||||||
md_responsibility_centers
|
md_responsibility_centers
|
||||||
|
cdk_dealerid
|
||||||
|
pbs_serialnumber
|
||||||
|
rr_dealerid
|
||||||
}
|
}
|
||||||
joblines {
|
joblines {
|
||||||
id
|
id
|
||||||
@@ -171,6 +174,7 @@ async function generateBillFormData({ processedData, jobid, bodyshopid, partsord
|
|||||||
db_price
|
db_price
|
||||||
oem_partno
|
oem_partno
|
||||||
alt_partno
|
alt_partno
|
||||||
|
part_type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
parts_orders_by_pk(id: $partsorderid) {
|
parts_orders_by_pk(id: $partsorderid) {
|
||||||
@@ -186,6 +190,7 @@ async function generateBillFormData({ processedData, jobid, bodyshopid, partsord
|
|||||||
act_price
|
act_price
|
||||||
oem_partno
|
oem_partno
|
||||||
alt_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)
|
//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, // convert to integer?
|
||||||
"actual_price": normalizePrice(actualPrice),
|
"actual_price": normalizePrice(actualPrice),
|
||||||
"actual_cost": normalizePrice(actualCost),
|
"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?
|
"applicable_taxes": { //Not sure what to do with these?
|
||||||
"federal": false,
|
"federal": false,
|
||||||
"state": false,
|
"state": false,
|
||||||
@@ -579,6 +592,10 @@ function joblineFuzzySearch({ fuseToSearch, processedData }) {
|
|||||||
return matches
|
return matches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bodyshopHasDmsKey = (bodyshop) =>
|
||||||
|
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid;
|
||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
generateBillFormData
|
generateBillFormData
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ async function handleBillOcr(request, response) {
|
|||||||
} else {
|
} else {
|
||||||
// Start the Textract job (non-blocking) for multi-page documents
|
// Start the Textract job (non-blocking) for multi-page documents
|
||||||
console.log('PDF => 2+ pages, processing asynchronously');
|
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({
|
response.status(202).send({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -154,13 +154,50 @@ async function handleBillOcrStatus(request, response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (jobStatus.status === 'COMPLETED') {
|
if (jobStatus.status === 'COMPLETED') {
|
||||||
//TODO: This needs to be stored in the redis cache and pulled when it's processed.
|
// Generate billForm on-demand if not already generated
|
||||||
//const billForm = await generateBillFormData({ jobid, bodyshopid, partsorderid });
|
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({
|
response.status(200).send({
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
data: jobStatus.data
|
data: {
|
||||||
// data: { ...jobStatus.data, billForm }
|
...jobStatus.data,
|
||||||
|
billForm
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else if (jobStatus.status === 'FAILED') {
|
} else if (jobStatus.status === 'FAILED') {
|
||||||
response.status(500).send({
|
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
|
// Upload PDF to S3 temporarily for Textract async processing
|
||||||
const s3Bucket = process.env.AWS_AI_BUCKET;
|
const s3Bucket = process.env.AWS_AI_BUCKET;
|
||||||
const snsTopicArn = process.env.AWS_TEXTRACT_SNS_TOPIC_ARN;
|
const snsTopicArn = process.env.AWS_TEXTRACT_SNS_TOPIC_ARN;
|
||||||
@@ -254,7 +291,8 @@ async function startTextractJob(pdfBuffer) {
|
|||||||
status: 'IN_PROGRESS',
|
status: 'IN_PROGRESS',
|
||||||
s3Key: s3Key,
|
s3Key: s3Key,
|
||||||
uploadId: uploadId,
|
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
|
// Retrieve the results
|
||||||
const { processedData, originalResponse } = await retrieveTextractResults(textractJobId);
|
const { processedData, originalResponse } = await retrieveTextractResults(textractJobId);
|
||||||
|
|
||||||
|
// Store the processed data - billForm will be generated on-demand in the status endpoint
|
||||||
await setTextractJob(
|
await setTextractJob(
|
||||||
{
|
{
|
||||||
redisPubClient,
|
redisPubClient,
|
||||||
|
|||||||
Reference in New Issue
Block a user