fix(bookkeeping): push grundbok series filter into the RPC so pagination total is honest (#798) (#823)
The verifikationslista filtered by voucher series in the API route, AFTER the list_fiscal_period_entries_with_related RPC had already paged — recomputing `count = entries.length` from the current page only. That clobbered the RPC's window-function total_count, so whenever a user filtered the grundbok by series the paginator's total collapsed to the matching subset of one page and 'next page' disabled early — the list appeared to stop after ~one page. - New migration adds p_series to the RPC and applies the filter inside the matching CTE, so COUNT(*) OVER () reflects the filtered set. DROP+CREATE (a new param changes the function identity); p_series DEFAULT NULL keeps existing callers unaffected. Re-GRANTs EXECUTE to authenticated to match the original definition, since DROP+CREATE drops prior grants. - The route passes p_series and drops the post-filter recompute; entries and count now come straight from the RPC. - Extends list-filters.pg.test.ts: series=B over a 2A/3B period returns the 3 B entries with total_count=3, and a short page (limit 2) still reports total 3 — the regression that broke pagination. Note: hosted DB does not auto-apply migrations on merge — apply 20260629160000 to prod after merge. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4872c0f242
commit
663e1232a4
@@ -3,11 +3,15 @@ import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany, insertBalancedLines } from '@/tests/pg/fixtures'
|
||||
|
||||
// Covers the p_exclude_draft / p_collapse_corrections params added to
|
||||
// list_fiscal_period_entries_with_related (migration 20260621130500).
|
||||
// Covers the p_exclude_draft / p_collapse_corrections / p_series params on
|
||||
// list_fiscal_period_entries_with_related (migrations 20260621130500 +
|
||||
// 20260629160000).
|
||||
// - exclude_draft: drafts kept off the committed list (own "Utkast" surface).
|
||||
// - collapse_corrections: a correction group renders as ONE row — the live
|
||||
// correction; the storno and the reversed original it replaced are hidden.
|
||||
// - series: voucher-series filter; pushed into the RPC so total_count reflects
|
||||
// the filtered set (the route used to post-filter and recompute count from
|
||||
// one page, breaking pagination — #798).
|
||||
// total_count must stay in lockstep with the filtered set so pagination holds.
|
||||
describe('list_fiscal_period_entries_with_related: draft + correction filters', () => {
|
||||
// Insert a journal_entry directly so we can set the storno/correction link
|
||||
@@ -21,6 +25,7 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters',
|
||||
sourceType: string
|
||||
voucherNumber: number
|
||||
description: string
|
||||
voucherSeries?: string
|
||||
reversesId?: string
|
||||
correctionOfId?: string
|
||||
withLines?: boolean
|
||||
@@ -30,7 +35,7 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters',
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status, reverses_id, correction_of_id)
|
||||
VALUES ($1,$2,$3,$4,$5,'A','2026-06-01',$6,$7,$8,$9,$10)`,
|
||||
VALUES ($1,$2,$3,$4,$5,$11,'2026-06-01',$6,$7,$8,$9,$10)`,
|
||||
[
|
||||
id,
|
||||
p.userId,
|
||||
@@ -42,6 +47,7 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters',
|
||||
p.status,
|
||||
p.reversesId ?? null,
|
||||
p.correctionOfId ?? null,
|
||||
p.voucherSeries ?? 'A',
|
||||
],
|
||||
)
|
||||
if (p.withLines) await insertBalancedLines(id)
|
||||
@@ -51,13 +57,30 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters',
|
||||
async function callRpc(
|
||||
companyId: string,
|
||||
periodId: string,
|
||||
opts: { status?: string | null; excludeDraft?: boolean; collapse?: boolean } = {},
|
||||
opts: {
|
||||
status?: string | null
|
||||
excludeDraft?: boolean
|
||||
collapse?: boolean
|
||||
series?: string | null
|
||||
limit?: number
|
||||
} = {},
|
||||
) {
|
||||
const { rows } = await getPool().query<{ entry: { id: string }; total_count: string }>(
|
||||
const { rows } = await getPool().query<{
|
||||
entry: { id: string; voucher_series: string }
|
||||
total_count: string
|
||||
}>(
|
||||
`SELECT entry, total_count
|
||||
FROM list_fiscal_period_entries_with_related(
|
||||
$1, $2, true, $3, NULL, NULL, 'desc', 100, 0, $4, $5)`,
|
||||
[companyId, periodId, opts.status ?? null, opts.excludeDraft ?? false, opts.collapse ?? false],
|
||||
$1, $2, true, $3, NULL, NULL, 'desc', $6, 0, $4, $5, $7)`,
|
||||
[
|
||||
companyId,
|
||||
periodId,
|
||||
opts.status ?? null,
|
||||
opts.excludeDraft ?? false,
|
||||
opts.collapse ?? false,
|
||||
opts.limit ?? 100,
|
||||
opts.series ?? null,
|
||||
],
|
||||
)
|
||||
return rows
|
||||
}
|
||||
@@ -97,4 +120,33 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters',
|
||||
const rows = await callRpc(companyId, fiscalPeriodId, { status: 'draft', excludeDraft: true })
|
||||
expect(rows.map((r) => r.entry.id)).toEqual([draft])
|
||||
})
|
||||
|
||||
it('filters by voucher series with a total_count over the filtered set (#798)', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
|
||||
// 2 entries in series A, 3 in series B.
|
||||
for (const n of [1, 2]) {
|
||||
await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'manual', voucherNumber: n, voucherSeries: 'A', withLines: true, description: `A${n}` })
|
||||
}
|
||||
for (const n of [1, 2, 3]) {
|
||||
await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'manual', voucherNumber: n, voucherSeries: 'B', withLines: true, description: `B${n}` })
|
||||
}
|
||||
|
||||
// No series filter: all 5.
|
||||
const all = await callRpc(companyId, fiscalPeriodId, {})
|
||||
expect(Number(all[0]!.total_count)).toBe(5)
|
||||
|
||||
// series=B returns only the 3 B entries, and total_count is the filtered 3.
|
||||
const seriesB = await callRpc(companyId, fiscalPeriodId, { series: 'B' })
|
||||
expect(seriesB).toHaveLength(3)
|
||||
expect(seriesB.every((r) => r.entry.voucher_series === 'B')).toBe(true)
|
||||
expect(Number(seriesB[0]!.total_count)).toBe(3)
|
||||
|
||||
// The #798 regression: with a page smaller than the filtered set, the page
|
||||
// truncates but total_count still reports the full filtered count (3) — the
|
||||
// paginator can reach every B entry instead of stopping after one page.
|
||||
const firstPage = await callRpc(companyId, fiscalPeriodId, { series: 'B', limit: 2 })
|
||||
expect(firstPage).toHaveLength(2)
|
||||
expect(Number(firstPage[0]!.total_count)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,6 +88,9 @@ export async function GET(request: Request) {
|
||||
p_offset: offset,
|
||||
p_exclude_draft: excludeDraft,
|
||||
p_collapse_corrections: collapseCorrections,
|
||||
// Series filter lives in the RPC now (#798): filtering here after the RPC
|
||||
// paged would recompute count from one page only, breaking pagination.
|
||||
p_series: seriesFilter,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
@@ -95,18 +98,8 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const rows = data ?? []
|
||||
let entries = rows.map((r: { entry: unknown }) => r.entry) as Array<{ voucher_series?: string }>
|
||||
let count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0
|
||||
|
||||
// The list_fiscal_period_entries_with_related RPC doesn't accept a series
|
||||
// filter, so post-filter here. Recompute count from the filtered set so
|
||||
// the paginator stays consistent; consequence: when a series filter is
|
||||
// applied, the cross-period follow-up surfacing is still on but the
|
||||
// visible total drops to the matching subset.
|
||||
if (seriesFilter) {
|
||||
entries = entries.filter((e) => (e?.voucher_series ?? 'A') === seriesFilter)
|
||||
count = entries.length
|
||||
}
|
||||
const entries = rows.map((r: { entry: unknown }) => r.entry)
|
||||
const count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0
|
||||
|
||||
return NextResponse.json({ data: entries, count })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
-- Verifikationslista: push the voucher-series filter into the RPC.
|
||||
--
|
||||
-- The grundbok view filtered by voucher series in the API route, AFTER the RPC
|
||||
-- returned a page — recomputing `count = entries.length` from the current page
|
||||
-- only (app/api/bookkeeping/journal-entries/route.ts). That clobbered the RPC's
|
||||
-- window-function `total_count`, so whenever a user filtered by series the
|
||||
-- paginator's total collapsed to the matching subset of one page and "next
|
||||
-- page" disabled early — the list appeared to stop after ~one page (#798).
|
||||
--
|
||||
-- Fix: add p_series so the filter lives inside the `matching` CTE and
|
||||
-- `COUNT(*) OVER ()` is computed on the filtered set. The route then drops its
|
||||
-- post-filter recompute and trusts the RPC's total_count.
|
||||
--
|
||||
-- Adding a parameter changes the function identity, so DROP the current 11-arg
|
||||
-- signature first (CREATE OR REPLACE cannot add params) — otherwise PostgREST
|
||||
-- sees two overloads. p_series defaults to NULL, so existing callers are
|
||||
-- unaffected.
|
||||
|
||||
DROP FUNCTION IF EXISTS public.list_fiscal_period_entries_with_related(
|
||||
uuid, uuid, boolean, text, date, date, text, int, int, boolean, boolean
|
||||
);
|
||||
|
||||
CREATE FUNCTION public.list_fiscal_period_entries_with_related(
|
||||
p_company_id uuid,
|
||||
p_period_id uuid,
|
||||
p_include_related boolean DEFAULT true,
|
||||
p_status text DEFAULT NULL,
|
||||
p_date_from date DEFAULT NULL,
|
||||
p_date_to date DEFAULT NULL,
|
||||
p_sort_date text DEFAULT 'desc',
|
||||
p_limit int DEFAULT 50,
|
||||
p_offset int DEFAULT 0,
|
||||
p_exclude_draft boolean DEFAULT false,
|
||||
p_collapse_corrections boolean DEFAULT false,
|
||||
p_series text DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
entry jsonb,
|
||||
total_count bigint
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY INVOKER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
WITH period AS (
|
||||
SELECT period_start, period_end
|
||||
FROM public.fiscal_periods
|
||||
WHERE id = p_period_id AND company_id = p_company_id
|
||||
),
|
||||
matching AS (
|
||||
SELECT je.*
|
||||
FROM public.journal_entries je
|
||||
CROSS JOIN period p
|
||||
WHERE je.company_id = p_company_id
|
||||
AND (
|
||||
je.fiscal_period_id = p_period_id
|
||||
OR (
|
||||
p_include_related
|
||||
AND je.source_type IN ('invoice_paid','invoice_cash_payment','credit_note')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = je.source_id
|
||||
AND i.company_id = p_company_id
|
||||
AND i.invoice_date BETWEEN p.period_start AND p.period_end
|
||||
)
|
||||
)
|
||||
OR (
|
||||
p_include_related
|
||||
AND je.source_type IN ('supplier_invoice_paid','supplier_invoice_cash_payment','supplier_credit_note')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.supplier_invoices si
|
||||
WHERE si.id = je.source_id
|
||||
AND si.company_id = p_company_id
|
||||
AND si.invoice_date BETWEEN p.period_start AND p.period_end
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (p_status IS NULL OR je.status = p_status)
|
||||
-- Hide cancelled by default; show them only when caller asks explicitly.
|
||||
AND (je.status <> 'cancelled' OR p_status = 'cancelled')
|
||||
-- Voucher-series filter (single letter A–Z). Inside the CTE so total_count
|
||||
-- below reflects the filtered set — this is the #798 fix.
|
||||
AND (p_series IS NULL OR je.voucher_series = p_series)
|
||||
-- Drafts live on their own surface; exclude them only on the committed
|
||||
-- list. Ignored when the caller asked for an explicit status (so a
|
||||
-- status='draft' request is never self-cancelled) — mirrors the route's
|
||||
-- direct-query path.
|
||||
AND (NOT p_exclude_draft OR p_status IS NOT NULL OR je.status <> 'draft')
|
||||
-- Collapse correction groups to the live correction: drop the storno and
|
||||
-- the reversed original that a posted correction replaced.
|
||||
AND (
|
||||
NOT p_collapse_corrections
|
||||
OR (
|
||||
je.source_type <> 'storno'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.journal_entries c
|
||||
WHERE c.company_id = p_company_id
|
||||
AND c.source_type = 'correction'
|
||||
AND c.status = 'posted'
|
||||
AND c.correction_of_id = je.id
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
|
||||
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
|
||||
),
|
||||
matching_with_total AS (
|
||||
SELECT m.*, COUNT(*) OVER () AS total
|
||||
FROM matching m
|
||||
),
|
||||
paged AS (
|
||||
SELECT *
|
||||
FROM matching_with_total
|
||||
ORDER BY
|
||||
CASE WHEN p_sort_date = 'asc' THEN entry_date END ASC NULLS LAST,
|
||||
CASE WHEN p_sort_date = 'desc' THEN entry_date END DESC NULLS LAST,
|
||||
voucher_series,
|
||||
voucher_number
|
||||
LIMIT p_limit OFFSET p_offset
|
||||
)
|
||||
SELECT
|
||||
(to_jsonb(p.*) - 'total')
|
||||
|| jsonb_build_object(
|
||||
'lines', COALESCE(
|
||||
(SELECT jsonb_agg(to_jsonb(l.*) ORDER BY l.sort_order)
|
||||
FROM public.journal_entry_lines l
|
||||
WHERE l.journal_entry_id = p.id),
|
||||
'[]'::jsonb
|
||||
),
|
||||
'out_of_period', (p.fiscal_period_id IS DISTINCT FROM p_period_id)
|
||||
) AS entry,
|
||||
p.total AS total_count
|
||||
FROM paged p
|
||||
ORDER BY
|
||||
CASE WHEN p_sort_date = 'asc' THEN p.entry_date END ASC NULLS LAST,
|
||||
CASE WHEN p_sort_date = 'desc' THEN p.entry_date END DESC NULLS LAST,
|
||||
p.voucher_series,
|
||||
p.voucher_number;
|
||||
$$;
|
||||
|
||||
-- DROP+CREATE drops any prior GRANTs, so re-grant EXECUTE to authenticated to
|
||||
-- match the original definition (20260421120000). (Supabase default privileges
|
||||
-- also cover this, but be explicit so the grundbok can't lose access.)
|
||||
GRANT EXECUTE ON FUNCTION public.list_fiscal_period_entries_with_related(
|
||||
uuid, uuid, boolean, text, date, date, text, int, int, boolean, boolean, text
|
||||
) TO authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user