feat(mcp): unify missing-document surfaces on one predicate (#876)
* feat(mcp): unify missing-document surfaces on one predicate (P1-3) The two MCP surfaces told different truths: the transactions tool keyed 'has underlag' on transactions.document_id while the verifikat tool keyed on document_attachments — and neither respected the source-type semantics, version chains, or journal_entry_no_doc_required waivers that lib/worklist's canonical count applies. Measured on prod: 22,046 waived verifikat still listed to agents, 2,370 doc-exempt source types listed, ~87 docs attached to transactions but never propagated to the verifikat, 1,100 transactions flagged missing-receipt although their verifikat HAS the underlag. One predicate now lives in SQL — posted, needs-doc source type (mirrors NEEDS_DOC_SOURCE_TYPES), no current-version doc, no waiver: - verifikat_without_documents RPC v2 adopts the canonical predicate. - New transactions_without_documents RPC: the bank-driven subset of the same predicate, joined through transactions.journal_entry_id — a strict subset of the verifikat surface by construction. Rows expose qualified transaction_id (P1-2 forward-compat); bare id deprecated. - Both tools become thin RPC wrappers; descriptions state the actual set relationship. - lib/worklist countVerifikatMissingDocument delegates to the RPC (previously three full-table pulls set-differenced client-side) — badge count and agent surfaces can no longer drift. - Backfill: propagate transaction-attached docs to their verifikat where the attachment was never linked (open periods only; never steals a doc linked to another verifikat). pg-real: fixture matrix (no-doc/with-doc/waived/stale-version/ doc-exempt-source/import), strict-subset assertion, per-source-type pin of the SQL list against the TS constant, tenant guard. Part of dev_docs/mcp_optimization_plan.md (P1-3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(mcp): explicit grants restated + count-call comment (#876 review) - Restate REVOKE/GRANT on verifikat_without_documents so the migration is self-contained (CREATE OR REPLACE preserves the 20260703130000 grants — verified on prod: authenticated + service_role only). - Comment on the p_limit:1 count call: total_count is computed over the full filtered set, independent of page size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
250cc7c450
commit
21512db81a
@@ -78,57 +78,27 @@ describe('countInboxDocuments', () => {
|
||||
})
|
||||
|
||||
describe('countVerifikatMissingDocument', () => {
|
||||
it('counts posted document-requiring entries with neither document nor exemption', async () => {
|
||||
// 6 posted entries: je-1 documented+exempt, je-2 documented, je-3 exempt
|
||||
// → je-4, je-5, je-6 missing.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'je-1' },
|
||||
{ id: 'je-2' },
|
||||
{ id: 'je-3' },
|
||||
{ id: 'je-4' },
|
||||
{ id: 'je-5' },
|
||||
{ id: 'je-6' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ journal_entry_id: 'je-1' },
|
||||
{ journal_entry_id: 'je-1' }, // second doc on the same entry — still one entry
|
||||
{ journal_entry_id: 'je-2' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [{ journal_entry_id: 'je-1' }, { journal_entry_id: 'je-3' }],
|
||||
})
|
||||
// Predicate semantics (needs-doc source types, current versions, waivers)
|
||||
// now live in the verifikat_without_documents RPC and are pinned by
|
||||
// tests/pg/document-surfaces-unification.pg.test.ts against real Postgres.
|
||||
// These tests cover only the delegation contract.
|
||||
it('delegates to the verifikat_without_documents RPC and returns its total', async () => {
|
||||
enqueue({ data: { ok: true, total_count: 3, verifikat: [] }, error: null })
|
||||
await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(3)
|
||||
expect(mockSupabase.rpc).toHaveBeenCalledWith('verifikat_without_documents', {
|
||||
p_company_id: COMPANY,
|
||||
p_limit: 1,
|
||||
p_offset: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores documents attached to entries outside the document-requiring set', async () => {
|
||||
// The doc on je-99 (e.g. a VAT-settlement entry) must not shrink the count.
|
||||
enqueue({ data: [{ id: 'je-1' }] })
|
||||
enqueue({ data: [{ journal_entry_id: 'je-99' }] })
|
||||
enqueue({ data: [] })
|
||||
await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(1)
|
||||
})
|
||||
|
||||
it('soft-fails to 0 when a paginated read errors', async () => {
|
||||
enqueue({ error: { message: 'boom' } })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
it('soft-fails to 0 when the RPC errors', async () => {
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(0)
|
||||
})
|
||||
|
||||
it('soft-fails to 0 (never a silent partial) when pagination errors mid-stream', async () => {
|
||||
// First page of entries is full (1000 = fetchAllRows page size), so a
|
||||
// second page is requested and errors. fetchAllRows must throw — the
|
||||
// count drops to a logged 0 rather than computing from a truncated set.
|
||||
enqueue({
|
||||
data: Array.from({ length: 1000 }, (_, i) => ({ id: `je-${i}` })),
|
||||
})
|
||||
enqueue({ data: [] }) // document_attachments page 1
|
||||
enqueue({ data: [] }) // exemptions page 1
|
||||
enqueue({ error: { message: 'mid-stream failure' } }) // entries page 2
|
||||
it('soft-fails to 0 on a not-ok envelope (tenant guard)', async () => {
|
||||
enqueue({ data: { ok: false, code: 'VERIFIKAT_WITHOUT_DOCUMENTS_FORBIDDEN' }, error: null })
|
||||
await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
+20
-43
@@ -10,7 +10,6 @@
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { SuggestedMatch } from './types'
|
||||
|
||||
const log = createLogger('worklist')
|
||||
@@ -161,55 +160,33 @@ export async function countSupplierInvoicesAwaitingApproval(
|
||||
* source types that have neither a current-version document nor a
|
||||
* journal_entry_no_doc_required exemption.
|
||||
*
|
||||
* Computed as an exact per-entry set difference (the home page previously
|
||||
* subtracted set SIZES, which both let documents on non-document-requiring
|
||||
* entries shrink the count and silently truncated at the PostgREST row cap).
|
||||
* All three reads paginate via fetchAllRows; row volume is bounded by the
|
||||
* company's posted-entry history (id-only columns).
|
||||
* Delegates to the verifikat_without_documents RPC — the SAME predicate the
|
||||
* MCP surfaces use (single truth in SQL; the RPC's needs-doc source-type
|
||||
* list mirrors NEEDS_DOC_SOURCE_TYPES, pinned by
|
||||
* tests/pg/document-surfaces-unification.pg.test.ts). Previously this
|
||||
* fetched three full id-column tables and set-differenced client-side.
|
||||
*/
|
||||
export async function countVerifikatMissingDocument(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const [entries, docs, exemptions] = await Promise.all([
|
||||
fetchAllRows<{ id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'posted')
|
||||
.in('source_type', [...NEEDS_DOC_SOURCE_TYPES])
|
||||
.order('id')
|
||||
.range(from, to),
|
||||
),
|
||||
fetchAllRows<{ journal_entry_id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('document_attachments')
|
||||
.select('journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_current_version', true)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.order('id')
|
||||
.range(from, to),
|
||||
),
|
||||
fetchAllRows<{ journal_entry_id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entry_no_doc_required')
|
||||
.select('journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.order('journal_entry_id')
|
||||
.range(from, to),
|
||||
),
|
||||
])
|
||||
|
||||
const withDoc = new Set(docs.map((d) => d.journal_entry_id))
|
||||
const exempt = new Set(exemptions.map((e) => e.journal_entry_id))
|
||||
let missing = 0
|
||||
for (const entry of entries) {
|
||||
if (!withDoc.has(entry.id) && !exempt.has(entry.id)) missing++
|
||||
// p_limit only sizes the page — total_count is computed over the FULL
|
||||
// filtered set inside the RPC (independent CTE), so 1 is the cheapest
|
||||
// valid page size for a count-only call.
|
||||
const { data, error } = await supabase.rpc('verifikat_without_documents', {
|
||||
p_company_id: companyId,
|
||||
p_limit: 1,
|
||||
p_offset: 0,
|
||||
})
|
||||
if (error) return logAndZero('verifikat_missing_document', companyId, error)
|
||||
const result = data as { ok?: boolean; code?: string; total_count?: number } | null
|
||||
if (!result?.ok) {
|
||||
return logAndZero('verifikat_missing_document', companyId, {
|
||||
message: result?.code ?? 'rpc returned not-ok',
|
||||
})
|
||||
}
|
||||
return missing
|
||||
return result.total_count ?? 0
|
||||
} catch (err) {
|
||||
return logAndZero(
|
||||
'verifikat_missing_document',
|
||||
|
||||
Reference in New Issue
Block a user