MEGAINFRA-WEBAPP/scripts/n8n/po-build-callback.js
DEls c55676bff0 fix(po): photographed POs get their order date from the PO Creation Date
Text PDFs already get it from pdftotext; photos and scans have no text,
so 4 of 5 undated POs were photos that do print a PO Creation Date.
- n8n PO reader (stored patch): asked for order_date = 'PO Creation
  Date' (day/month/year), never Required By or Printed; the callback
  passes it on.
- process-batch accepts extracted.order_date (YYYY-MM-DD); the PDF text
  wins when both exist.
- create_purchase_order / update_purchase_order / move_to_purchase_orders
  take order_date, so agents record it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:45:14 +02:00

78 lines
3.2 KiB
JavaScript

const sourceItem = $input.item;
if (!sourceItem) {
throw new Error('Build Callback Payload: no input item');
}
const parsed = (sourceItem.json && typeof sourceItem.json.output === 'object' && sourceItem.json.output !== null)
? sourceItem.json.output
: sourceItem.json;
// This item's id is the purchase_orders row Atomic Claim just claimed --
// the placeholder created at upload time (src/lib/po-placeholder.ts).
// There is no separate batch row anymore.
const purchase_order_id = $('Atomic Claim').item.json.id;
const po_number_extracted = String(parsed.po_number || '').trim();
const supplier = String(parsed.supplier || '').trim();
const project_code = String(parsed.project_code || '').trim();
if (!po_number_extracted) {
throw new Error('AI extraction returned an empty PO number');
}
const po_number_normalized = po_number_extracted
.replace(/^p\.?o\.?([-#.]|\s+)?/i, '')
.replace(/\s+/g, ' ')
.trim()
.toUpperCase();
if (!po_number_normalized) {
throw new Error('AI Agent po_number normalises to empty string: "' + po_number_extracted + '"');
}
const required_by_date = parsed.required_by_date || undefined;
// "PO Creation Date" printed on the PO: when it was ordered. Money is dated by it.
const order_date = /^\d{4}-\d{2}-\d{2}$/.test(String(parsed.order_date || '')) ? String(parsed.order_date) : undefined;
const ordered_by = parsed.ordered_by || undefined;
const rawItems = Array.isArray(parsed.items) ? parsed.items : [];
const items = rawItems.map(it => {
if (!it) return null;
return {
...it,
qty: Number(it.qty),
rate: Number(it.rate),
material_code: (it.material_code && String(it.material_code).trim() !== '' && String(it.material_code).toLowerCase() !== 'null') ? String(it.material_code).trim() : '',
cost_code: (it.cost_code && String(it.cost_code).trim() !== '' && String(it.cost_code).toLowerCase() !== 'null') ? String(it.cost_code).trim() : '',
disc_percent: (it.disc_percent !== null && it.disc_percent !== undefined && !isNaN(it.disc_percent)) ? Number(it.disc_percent) : 0,
vat_percent: (it.vat_percent !== null && it.vat_percent !== undefined && !isNaN(it.vat_percent)) ? Number(it.vat_percent) : 15
};
}).filter(it =>
it &&
typeof it.description === 'string' && it.description.trim().length > 0 &&
!isNaN(it.qty) && it.qty >= 0 &&
!isNaN(it.rate) && it.rate >= 0
);
if (items.length === 0) {
throw new Error('AI Agent returned no valid line items (all missing description/qty/rate)');
}
// Duplicate detection now lives server-side in
// /api/purchase-orders/process-batch (merge onto the existing PO, delete
// this placeholder) -- it already owns the purchase_orders schema and is
// covered by tests. n8n no longer queries or writes purchase_orders
// directly to pre-empt it.
//
// purchase_order_number is the exact field name the route validates --
// the old contract used po_number here, which would fail every upload.
return {
json: {
purchase_order_id,
extracted: {
purchase_order_number: po_number_normalized,
po_number_extracted,
supplier,
project_code,
required_by_date,
order_date,
ordered_by,
items,
raw_ocr: { source: 'ai-agent', model: 'MiniMax-M3' },
},
},
};