PR 2 of 2: fix/inbox-retry-extraction-rematch (#1631)
* fix(inbox): match suppliers on VAT number, not just org-nr and name An extracted document auto-links to a supplier by org_number, then by an exact (case-insensitive) name match. The extractor deliberately leaves orgNumber null unless the document carries a real Swedish organisationsnummer, so for every foreign supplier the name was the only key left: "ADOBE SYSTEMS SOFTWARE IRELAND LTD" prints nothing but a momsregistreringsnummer (IE6364992H), which the suppliers table already stores in vat_number and which no code path looked at. Adds vat_number as a match key between org_number and name, and collapses the five inlined copies of the lookup into lib/suppliers/match-supplier.ts: - invoice-inbox upload (sync and deferred worker) - invoice-inbox PUT /items/:id/extracted-data - MCP createDocumentInboxItem (org-nr only until now: gains VAT and name) - MCP gnubok_set_inbox_extracted_data - MCP gnubok_create_supplier_invoice_from_inbox, which additionally read supplierExt.organizationNumber, a key the extraction schema never writes, so its org-nr lookup could not fire at all VAT numbers are compared on a canonical key (uppercased alphanumerics), so formatting variants match and a prefix-less "556012579001" still matches "SE556012579001"; two different country prefixes never do. Also escapes LIKE metacharacters in the name lookup, so a supplier named "100 % Solutions" is no longer a wildcard pattern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(inbox): re-run the supplier match when the extraction is re-run "Tolka om" (POST /items/:id/retry-extraction) rewrote extracted_data and left matched_supplier_id untouched. It is the one affordance a user reaches for precisely when auto-linking failed, and it could not produce a link no matter how many times they pressed it: the match ran once, at first extraction, and never again. An item that arrived before its supplier existed stayed unlinked forever. The retry now runs the same shared matcher the other extraction paths use. Only a positive match is written, unlike those paths which also write null: a supplier the user picked by hand must survive a retry that finds nothing, which is the likelier case on a document that already failed to match once. Stacked on fix/supplier-match-vat-number for matchSupplierId(); merge that one first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(inbox): write the retry-extraction update as two literal payloads The conditional spread for matched_supplier_id was one more dynamic payload than the phantom-column guard's ceiling allows (380 > 379), and a spread is exactly the shape that guard cannot check. Two inline literal update calls keep every written column checkable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
3841ab9f54
commit
1cd33d9d97
@@ -163,6 +163,7 @@ describe('POST /items/:id/retry-extraction', () => {
|
||||
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: null }) // supplier name lookup: no match
|
||||
enqueue({ data: null, error: null }) // inbox update on success
|
||||
|
||||
serviceDownloadMock.mockResolvedValue({
|
||||
@@ -178,6 +179,80 @@ describe('POST /items/:id/retry-extraction', () => {
|
||||
expect(body.data.extracted_data.totals.total).toBe(125)
|
||||
})
|
||||
|
||||
it('re-links the supplier on retry, matching a foreign supplier by VAT number', async () => {
|
||||
// Re-running the extraction used to rewrite extracted_data and leave
|
||||
// matched_supplier_id untouched, so "Tolka om" could never repair a link
|
||||
// that failed the first time (e.g. because the supplier was created after
|
||||
// the document arrived).
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { is_sandbox: false }, error: null }) // sandbox check
|
||||
enqueue({
|
||||
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: [{ id: 'adobe-supplier', vat_number: 'IE6364992H' }],
|
||||
error: null,
|
||||
}) // vat_number scan
|
||||
enqueue({ data: null, error: null }) // inbox update on success
|
||||
|
||||
serviceDownloadMock.mockResolvedValue({
|
||||
data: new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' }),
|
||||
error: null,
|
||||
})
|
||||
|
||||
vi.mocked(extractInvoiceFields).mockResolvedValueOnce({
|
||||
...EXTRACTION_SUCCESS,
|
||||
data: {
|
||||
...EXTRACTION_SUCCESS.data,
|
||||
supplier: {
|
||||
...EXTRACTION_SUCCESS.data.supplier,
|
||||
name: 'Adobe Systems Software Ireland Ltd',
|
||||
vatNumber: 'IE6364992H',
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(200)
|
||||
|
||||
const update = findCall('invoice_inbox_items', 'update')?.[0] as Record<string, unknown>
|
||||
expect(update.matched_supplier_id).toBe('adobe-supplier')
|
||||
})
|
||||
|
||||
it('leaves a hand-picked supplier alone when the retry finds no match', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { is_sandbox: false }, error: null }) // sandbox check
|
||||
enqueue({
|
||||
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: null }) // supplier name lookup: no match
|
||||
enqueue({ data: null, error: null }) // inbox update on success
|
||||
|
||||
serviceDownloadMock.mockResolvedValue({
|
||||
data: new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' }),
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(extractInvoiceFields).mockResolvedValueOnce(EXTRACTION_SUCCESS as never)
|
||||
|
||||
const res = await retryRoute.handler(makeReq(), buildCtx(supabase))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(200)
|
||||
|
||||
const update = findCall('invoice_inbox_items', 'update')?.[0] as Record<string, unknown>
|
||||
expect(update).not.toHaveProperty('matched_supplier_id')
|
||||
})
|
||||
|
||||
it('marks the item as error and returns 500 when extraction throws', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
|
||||
@@ -1094,18 +1094,45 @@ export const invoiceInboxExtension: Extension = {
|
||||
fileName: doc.file_name,
|
||||
})
|
||||
|
||||
const { error: updateError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'received',
|
||||
error_message: null,
|
||||
extracted_data: extracted as unknown as Record<string, unknown>,
|
||||
// Retry is user-initiated and bypasses the page-count gate by
|
||||
// design: the user explicitly opted into the slow path.
|
||||
extraction_skipped: false,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
// Re-running the extraction has to re-run the match too, or the one
|
||||
// affordance the user reaches for when an item failed to auto-link
|
||||
// ("Tolka om") can never produce a link: this path rewrote
|
||||
// extracted_data and left matched_supplier_id untouched. Only a
|
||||
// positive match is written, so a supplier the user picked by hand
|
||||
// survives a retry that finds nothing.
|
||||
const matchedSupplierId = await matchSupplierId(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
extracted.supplier,
|
||||
)
|
||||
|
||||
// Two literal payloads instead of one with a conditional spread:
|
||||
// the phantom-column guard can only check columns written as inline
|
||||
// object literals, and matched_supplier_id should be checkable.
|
||||
const { error: updateError } = matchedSupplierId
|
||||
? await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'received',
|
||||
error_message: null,
|
||||
extracted_data: extracted as unknown as Record<string, unknown>,
|
||||
// Retry is user-initiated and bypasses the page-count gate by
|
||||
// design: the user explicitly opted into the slow path.
|
||||
extraction_skipped: false,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
: await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'received',
|
||||
error_message: null,
|
||||
extracted_data: extracted as unknown as Record<string, unknown>,
|
||||
extraction_skipped: false,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
|
||||
Reference in New Issue
Block a user