diff --git a/src/app/api/dn-batches/tick/route.test.ts b/src/app/api/dn-batches/tick/route.test.ts index 7bb6e1f..4a3895e 100644 --- a/src/app/api/dn-batches/tick/route.test.ts +++ b/src/app/api/dn-batches/tick/route.test.ts @@ -310,6 +310,15 @@ describe("POST /api/dn-batches/tick — list failure", () => { }); describe("POST /api/dn-batches/tick — happy path", () => { + it('runs a new upload before older completed batches queued for PO retries',async()=>{ + vi.stubEnv('DN_STAGED_INGESTION_ENABLED','true');vi.stubEnv('DN_TICK_BATCH_SIZE','1'); + dnBatchesGetFullList.mockImplementation(async(opts:{filter?:string}={})=>opts.filter?.includes("status='analysing'")?[]:[ + {id:'old-retry',stage:'complete',uploaded_at:'2026-10-01'}, + {id:'new-scan',stage:'queued',uploaded_at:'2026-10-19'}, + ]); + await POST(postWithValidAuth()); + expect(runStagedBatchMock.mock.calls[0][1]).toBe('new-scan'); + }); it("uses the staged runner only when the flag is exactly true", async () => { vi.stubEnv("DN_STAGED_INGESTION_ENABLED", "true"); diff --git a/src/app/api/dn-batches/tick/route.ts b/src/app/api/dn-batches/tick/route.ts index 6c6a738..9aa5384 100644 --- a/src/app/api/dn-batches/tick/route.ts +++ b/src/app/api/dn-batches/tick/route.ts @@ -63,13 +63,20 @@ export async function POST(request: Request) { catch (error) { console.warn("[dn-tick] PO retry scheduling deferred", {error:String(error)}); } } - let candidates: { id: string }[] = []; + let candidates: { id: string; stage?: string }[] = []; try { candidates = await pb.collection("dn_batches").getFullList({ filter: `status='inbox'`, - fields: "id", + fields: "id,stage", sort: "uploaded_at", }); + // New scans first. A batch already read once (stage complete) is only back + // for a PO recheck; those used to sort ahead of today's uploads by their + // older uploaded_at and could hold a new scan in the queue for hours. + candidates = [ + ...candidates.filter((c) => c.stage !== "complete"), + ...candidates.filter((c) => c.stage === "complete"), + ]; } catch (err) { console.error("[dn-tick] list failed", err); return NextResponse.json({ error: "list_failed" }, { status: 500 }); diff --git a/src/lib/dn-pipeline/resolve-entities.test.ts b/src/lib/dn-pipeline/resolve-entities.test.ts index 5d57d0e..bd311c6 100644 --- a/src/lib/dn-pipeline/resolve-entities.test.ts +++ b/src/lib/dn-pipeline/resolve-entities.test.ts @@ -185,3 +185,24 @@ it('does not auto-link an exact PO when the printed supplier identifies another expect(result.autoResolved).toBe(false);expect(result.purchaseOrderId).toBeNull(); expect(result.candidates.some(candidate=>candidate.evidence.reason==='supplier_conflict')).toBe(true); }); + +describe('duplicate supplier records with a unique site PO',()=>{ + const duplicateSuppliers=[{id:'old-drift',name:'Drift Laezonia'},{id:'new-drift',name:' DRIFT LAEZONIA '}]; + const driftInput=(poNumber='MEGCPO9480'):EntityResolutionInput=>({siteId:'hills',poNumber,supplierName:'Drift Laezonia',suppliers:duplicateSuppliers, + purchaseOrders:[{id:'po9480',site:'hills',purchase_order_number:'MEGCPO9480',supplier_ref:'new-drift'}], + aliases:[{supplier:'old-drift',normalised_key:'drift laezonia',approved:true}]}); + it.each(['MEGCPO9480','MEGCP09480','MEGP09480'])('uses the actual PO supplier for %s despite an old alias',poNumber=>{ + expect(resolveEntities(driftInput(poNumber))).toMatchObject({supplierId:'new-drift',purchaseOrderId:'po9480',autoResolved:true}); + }); + it('handles the prefix variant with duplicate names and no approved alias',()=>{ + expect(resolveEntities({...driftInput('MEGP09480'),aliases:[]})).toMatchObject({supplierId:'new-drift',purchaseOrderId:'po9480',autoResolved:true}); + }); + it('keeps same-name companies with conflicting VAT numbers separate',()=>{ + const i=driftInput();i.suppliers=[{...duplicateSuppliers[0],vat_number:'1111111111'},{...duplicateSuppliers[1],vat_number:'2222222222'}]; + expect(resolveEntities(i)).toMatchObject({purchaseOrderId:null,autoResolved:false}); + }); + it('never repairs an order sequence digit or selects another site',()=>{ + expect(resolveEntities(driftInput('MEGP09481')).purchaseOrderId).toBeNull(); + expect(resolveEntities({...driftInput(),siteId:'cedar'}).purchaseOrderId).toBeNull(); + }); +}); diff --git a/src/lib/dn-pipeline/resolve-entities.ts b/src/lib/dn-pipeline/resolve-entities.ts index e3d2272..871e38b 100644 --- a/src/lib/dn-pipeline/resolve-entities.ts +++ b/src/lib/dn-pipeline/resolve-entities.ts @@ -125,7 +125,9 @@ export function resolveEntities(input: EntityResolutionInput): EntityResolution const supplier = supplierForPo(po, suppliers); const relationSupplierId = relationId(po.supplier_ref) ?? po.supplier_id ?? null; const resolvedSupplierId = supplier?.id ?? relationSupplierId; - if (supplierMatch && resolvedSupplierId && supplierMatch.supplier.id !== resolvedSupplierId) { + // Duplicate supplier records for one company (same name, no conflicting + // registration/VAT) are not a conflict: the PO's own supplier wins. + if (supplierMatch && resolvedSupplierId && !sameSupplierIdentity(supplierMatch.supplier, supplier ?? resolvedSupplierId, suppliers)) { return {supplierId:supplierMatch.supplier.id,purchaseOrderId:null,method:"unresolved",autoResolved:false,supplierName:supplierMatch.supplier.name, candidates:[{kind:"purchase_order",id:po.id,label:po.purchase_order_number,evidence:{reason:"supplier_conflict",input:poNumber,printedSupplier:supplierMatch.supplier.id,poSupplier:resolvedSupplierId}}]}; } @@ -194,15 +196,19 @@ export function resolveEntities(input: EntityResolutionInput): EntityResolution ); // Correct known prefix OCR variants only when the supplier is independently // identified and the printed order sequence matches exactly. Never guess digits. + // The supplier may be one of several duplicate records for the same company, + // so a PO held by any record of that company counts as the supplier's. + const supplierGroup = supplierIdentityGroup(supplier, supplierNameRaw, suppliers); const sequence = /^meg(?:c?p[oq0]|cp)(\d{4,})$/i.exec(targetPo)?.[1]; - const prefixMatches = supplier && sequence ? eligiblePos.filter(po => { + const prefixMatches = supplierGroup.length && sequence ? openPurchaseOrders(allPos).filter(po => { const canonical = /^megcpo(\d{4,})$/.exec(normalisePoNumber(po.purchase_order_number)); - return canonical && canonical[1] === sequence; + return canonical && canonical[1] === sequence && supplierGroup.some(member => poBelongsToSupplier(po, member)); }) : []; if (prefixMatches.length === 1) { const po = prefixMatches[0]; - return { supplierId, purchaseOrderId: po.id, method: "po_ocr_prefix", autoResolved: true, - supplierName: supplierDisplayName, candidates: [{kind:"purchase_order",id:po.id,label:po.purchase_order_number, + const poSupplier = supplierForPo(po, suppliers); + return { supplierId: poSupplier?.id ?? relationId(po.supplier_ref) ?? po.supplier_id ?? supplierId, purchaseOrderId: po.id, method: "po_ocr_prefix", autoResolved: true, + supplierName: poSupplier?.name ?? supplierDisplayName, candidates: [{kind:"purchase_order",id:po.id,label:po.purchase_order_number, evidence:{method:"po_ocr_prefix",input:poNumber,reason:"Confirmed supplier and unchanged printed order digits"}}] }; } const suggestions = targetPo @@ -317,6 +323,32 @@ function findSupplier( return null; } +/** + * Two supplier records describe one company when they are the same record, or + * their names normalise identically and neither carries a registration/VAT + * number the other contradicts. Same-name companies with different numbers + * stay separate. + */ +function sameSupplierIdentity(a: EntitySupplier, b: EntitySupplier | string, suppliers: readonly EntitySupplier[]): boolean { + const other = typeof b === "string" ? suppliers.find((supplier) => supplier.id === b) : b; + if (!other) return false; + if (a.id === other.id) return true; + const name = normaliseSupplierKey(a.name); + if (!name || name !== normaliseSupplierKey(other.name)) return false; + const left = supplierIdentifiers(a), right = supplierIdentifiers(other); + return !left.size || !right.size || [...left].some((id) => right.has(id)); +} + +/** The matched supplier plus any duplicate records of the same company; by printed name when no single record matched. */ +function supplierIdentityGroup(supplier: EntitySupplier | null, nameRaw: string, suppliers: readonly EntitySupplier[]): EntitySupplier[] { + if (supplier) return suppliers.some((s) => s.id === supplier.id) + ? suppliers.filter((s) => sameSupplierIdentity(supplier, s, suppliers)) + : [supplier]; + const name = normaliseSupplierKey(nameRaw); + const named = name ? suppliers.filter((s) => normaliseSupplierKey(s.name) === name) : []; + return named.length > 1 && named.every((s) => sameSupplierIdentity(named[0], s, suppliers)) ? named : []; +} + function supplierIdentifiers(supplier: EntitySupplier): Set { const values = Object.entries(supplier) .filter(([key]) => /registration|vat|tax/i.test(key)) diff --git a/src/lib/inbox-two/model.test.ts b/src/lib/inbox-two/model.test.ts index 4d1f31a..dc0f6ab 100644 --- a/src/lib/inbox-two/model.test.ts +++ b/src/lib/inbox-two/model.test.ts @@ -4,6 +4,11 @@ import { buildInboxRows, filterInboxRows } from './model'; const batch = { id: 'batch0000000001', site: 'site00000000001', stage: 'complete', created: '2026-10-09T08:00:00Z', expand: { site: { name: 'Cedar Gate' } } }; const note = { id: 'note00000000001', batch: batch.id, status: 'candidate', stage: 'ready_to_post', doc_type: 'delivery_note', supplier: 'supplier', purchase_order: 'order', doc_number: '60084', supplier_name_raw: 'Global Precast', checks: [], created: batch.created }; describe('Inbox 2 work list', () => { + it('distinguishes a queued scan from an actively read scan',()=>{ + const [row]=buildInboxRows([{...batch,status:'inbox',stage:'queued'}],[]); + expect(row.summary).toContain('Waiting'); + expect(row.supplier).not.toContain('reading'); + }); it('puts an open site question ahead of an apparent ready stage', () => { const rows = buildInboxRows([batch], [{ ...note, checks: [{ id: 'site_decision', passed: false, reason: 'How long is each pipe?', detail: { question: 'How long is each pipe?', askedAt: '2026-10-09T09:00:00Z' } }] }]); expect(rows[0]).toMatchObject({ category: 'attention', question: 'How long is each pipe?', action: 'Answer question', since: '2026-10-09T09:00:00Z' }); diff --git a/src/lib/inbox-two/model.ts b/src/lib/inbox-two/model.ts index e79e385..91e6c7f 100644 --- a/src/lib/inbox-two/model.ts +++ b/src/lib/inbox-two/model.ts @@ -25,9 +25,11 @@ export function buildInboxRows(batches: Row[], notes: Row[]): InboxRow[] { const common = { batchId, site, siteId: str(batch.site), since: str(batch.uploaded_at || batch.created), net: null, po: '', question: '' }; if (stage !== 'complete') { const failed = stage === 'failed', grouping = stage === 'split_review'; + // Not yet picked up by the tick: say so rather than claim it is being read. + const waiting = !failed && !grouping && batch.status === 'inbox'; rows.push({ ...common, id: batchId, kind: 'upload', category: failed || grouping ? 'attention' : 'processing', - number: str(batch.file) || 'New scan', supplier: failed ? 'Reading needs another try' : grouping ? 'Check which pages belong together' : 'We’re reading your scan', - summary: failed ? 'Your upload is saved. Open it to retry reading.' : grouping ? 'Confirm the pages before we check the delivery.' : 'Your upload is saved. No action needed while it is being read.', + number: str(batch.file) || 'New scan', supplier: failed ? 'Reading needs another try' : grouping ? 'Check which pages belong together' : waiting ? 'Your scan is in the queue' : 'We’re reading your scan', + summary: failed ? 'Your upload is saved. Open it to retry reading.' : grouping ? 'Confirm the pages before we check the delivery.' : waiting ? 'Waiting its turn to be read. Your upload is saved; no action needed.' : 'Your upload is saved. No action needed while it is being read.', action: failed ? 'Retry reading' : grouping ? 'Check pages' : 'View progress', pages: Number(batch.page_count || 0), href: `/dashboard/financial/delivery-notes/inbox/${batchId}` }); continue;