fix(mcp): SQL-side filtering and pagination for list_verifikat_without_documents (#872)
The tool applied min_amount in memory after the PostgREST .range() page: total_count ignored the filter, and next_offset advanced by the filtered row count while the DB page consumed 'limit' rows — consecutive pages overlapped and full backlog coverage could not be proven (reported via agent.feedback). gross_amount is an aggregate over journal_entry_lines that PostgREST cannot filter on, so filtering/counting/pagination move into a new verifikat_without_documents RPC (SECURITY DEFINER with the PR #625 tenant-guard pattern; id tiebreak for total ordering; filter-respecting total_count). Also indexes document_attachments.journal_entry_id, which the anti-join and the link tools both hit. pg-real invariants: disjoint + complete pages under min_amount, filter-respecting totals, since filter, doc/draft exclusion, tenant guard both ways. Part of dev_docs/mcp_optimization_plan.md (P0-2). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4b5fe9ee57
commit
45438d4d64
@@ -0,0 +1,116 @@
|
||||
-- RPC: verifikat_without_documents — SQL-side filtering + pagination for the
|
||||
-- MCP tool gnubok_list_verifikat_without_documents.
|
||||
--
|
||||
-- Bug fix (dev_docs/mcp_optimization_plan.md P0-2): the tool applied
|
||||
-- min_amount IN MEMORY after the PostgREST .range() page, because
|
||||
-- gross_amount is an aggregate (sum of debit lines) PostgREST cannot filter
|
||||
-- on. Consequences: total_count ignored the filter, and next_offset advanced
|
||||
-- by the filtered row count while the DB page consumed `limit` rows — so the
|
||||
-- next page overlapped the previous page's tail. Agents paging a backlog got
|
||||
-- duplicates and could not prove full coverage.
|
||||
--
|
||||
-- This function computes gross_amount, applies since/min_amount, counts and
|
||||
-- paginates all in SQL, returning a filter-respecting total alongside the
|
||||
-- page. Ordering carries an id tiebreak so pagination is total and stable.
|
||||
--
|
||||
-- Tenant guard (mirrors 20260615120000): anon/authenticated callers must be
|
||||
-- members of p_company_id; service_role bypasses (MCP tools already scope by
|
||||
-- company_id).
|
||||
--
|
||||
-- pg-test: tests/pg/verifikat-without-documents-rpc.pg.test.ts (pagination
|
||||
-- invariants: disjoint pages, complete union, filter-respecting total, since
|
||||
-- filter, tenant guard).
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.verifikat_without_documents(
|
||||
p_company_id uuid,
|
||||
p_since date DEFAULT NULL,
|
||||
p_min_amount numeric DEFAULT 0,
|
||||
p_limit integer DEFAULT 20,
|
||||
p_offset integer DEFAULT 0
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
||||
v_limit integer := least(greatest(coalesce(p_limit, 20), 1), 100);
|
||||
v_offset integer := greatest(coalesce(p_offset, 0), 0);
|
||||
v_min numeric := greatest(coalesce(p_min_amount, 0), 0);
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
IF v_jwt_role IN ('anon', 'authenticated') THEN
|
||||
-- NOT IN (subquery) evaluates to UNKNOWN when either side yields NULL,
|
||||
-- which would silently skip this deny branch — use NOT EXISTS and reject
|
||||
-- a NULL company id outright.
|
||||
IF p_company_id IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM public.user_company_ids() AS c(id) WHERE c.id = p_company_id
|
||||
) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'VERIFIKAT_WITHOUT_DOCUMENTS_FORBIDDEN');
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
WITH candidates AS (
|
||||
SELECT
|
||||
je.id,
|
||||
je.voucher_series,
|
||||
je.voucher_number,
|
||||
je.entry_date,
|
||||
je.description,
|
||||
je.source_type,
|
||||
round(coalesce(sum(l.debit_amount), 0), 2) AS gross_amount
|
||||
FROM journal_entries je
|
||||
LEFT JOIN journal_entry_lines l ON l.journal_entry_id = je.id
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.status = 'posted'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM document_attachments d WHERE d.journal_entry_id = je.id
|
||||
)
|
||||
AND (p_since IS NULL OR je.entry_date >= p_since)
|
||||
GROUP BY je.id
|
||||
HAVING round(coalesce(sum(l.debit_amount), 0), 2) >= v_min
|
||||
),
|
||||
total AS (
|
||||
SELECT count(*) AS n FROM candidates
|
||||
),
|
||||
page AS (
|
||||
SELECT * FROM candidates
|
||||
ORDER BY entry_date DESC, voucher_number DESC, id DESC
|
||||
LIMIT v_limit OFFSET v_offset
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'ok', true,
|
||||
'total_count', (SELECT n FROM total),
|
||||
'verifikat', coalesce(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'journal_entry_id', p.id,
|
||||
'voucher_series', p.voucher_series,
|
||||
'voucher_number', p.voucher_number,
|
||||
'entry_date', p.entry_date,
|
||||
'description', p.description,
|
||||
'source_type', p.source_type,
|
||||
'gross_amount', p.gross_amount
|
||||
)
|
||||
ORDER BY p.entry_date DESC, p.voucher_number DESC, p.id DESC
|
||||
) FROM page p),
|
||||
'[]'::jsonb
|
||||
)
|
||||
)
|
||||
INTO v_result;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.verifikat_without_documents(uuid, date, numeric, integer, integer) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.verifikat_without_documents(uuid, date, numeric, integer, integer) TO authenticated, service_role;
|
||||
|
||||
-- Anti-join + link lookups both hit document_attachments by journal_entry_id;
|
||||
-- the FK carries no index by default.
|
||||
CREATE INDEX IF NOT EXISTS idx_document_attachments_journal_entry_id
|
||||
ON public.document_attachments (journal_entry_id);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user