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:
parent
0a7138f60c
commit
8ef3c8f780
6 changed files with 85 additions and 9 deletions
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue