fix(deliveries): unblock new scans and match duplicate Drift supplier POs

- Tick runs new scans before batches requeued for PO rechecks; old
  uploaded_at dates kept rechecks ahead of today's scans for hours.
- Supplier records with the same normalised name and no conflicting
  registration/VAT count as one company, so PO9480 (held by the second
  Drift Laezonia record) no longer fails as a supplier conflict.
- Inbox shows a queued scan as waiting rather than being read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-19 14:27:52 +02:00
parent 0a7138f60c
commit 8ef3c8f780
6 changed files with 85 additions and 9 deletions

View file

@ -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");

View file

@ -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 });

View file

@ -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();
});
});

View file

@ -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<string> {
const values = Object.entries(supplier)
.filter(([key]) => /registration|vat|tax/i.test(key))

View file

@ -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' });

View file

@ -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;