From 4872c0f242b58c7301d6b28ac5d7bd9812d723ce Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:39:40 +0200 Subject: [PATCH] fix(reports): stable total order on SIE-export fetchAllRows paging (#793 hardening) (#824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #820 fixed the primary #793 truncation (the nested embedded-resource select hit PostgREST's row ceiling, exporting ~30 vouchers) by splitting entries and lines into two fetchAllRows calls. But both queries paged without a stable TOTAL order: the entries query ordered by voucher_number alone (not unique across voucher series) and the lines query had no .order() at all. Per the fetch-all.ts invariant, .range() paging without a unique total order can duplicate or skip rows across the 1000-row boundary — so a year with >1000 entries/lines and multiple series could still drop or double a voucher in the SIE file (BFL completeness). - Entries: order by voucher_series + voucher_number (unique per company+period). - Lines: order by the line PK id. - Both carry dedupeBy: r => r.id as defense-in-depth, mirroring the general ledger / trial balance fix in #811. - Give the large-period test's line fixtures unique ids so the >1-page dedupe path is exercised realistically (all 5000 lines survive). Co-authored-by: Claude Opus 4.8 (1M context) --- lib/reports/__tests__/sie-export.test.ts | 6 +++--- lib/reports/sie-export.ts | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/reports/__tests__/sie-export.test.ts b/lib/reports/__tests__/sie-export.test.ts index 3aaee489..41d1b02a 100644 --- a/lib/reports/__tests__/sie-export.test.ts +++ b/lib/reports/__tests__/sie-export.test.ts @@ -374,9 +374,9 @@ describe('generateSIEExport', () => { status: 'posted', })) - const lines = entries.flatMap((e) => [ - { journal_entry_id: e.id, account_number: '1510', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null }, - { journal_entry_id: e.id, account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null }, + const lines = entries.flatMap((e, i) => [ + { id: `l${i * 2 + 1}`, journal_entry_id: e.id, account_number: '1510', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null }, + { id: `l${i * 2 + 2}`, journal_entry_id: e.id, account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null }, ]) // fetchAllRows paginates at PAGE_SIZE = 1000; chunk the mock data so the diff --git a/lib/reports/sie-export.ts b/lib/reports/sie-export.ts index 38196025..a4d5ce2d 100644 --- a/lib/reports/sie-export.ts +++ b/lib/reports/sie-export.ts @@ -74,8 +74,15 @@ export async function generateSIEExport( q = q.neq('source_type', 'year_end') } - return q.order('voucher_number').range(from, to) - }) + // Stable TOTAL order: voucher_series + voucher_number is unique per + // company+period, so fetchAllRows paging can't duplicate or skip a voucher + // across the 1000-row boundary on large years (voucher_number alone is not + // unique across series). dedupeBy is defense-in-depth — see fetch-all.ts. + return q + .order('voucher_series', { ascending: true }) + .order('voucher_number', { ascending: true }) + .range(from, to) + }, { dedupeBy: (r) => r.id }) // Fetch all lines for those entries, filtered server-side via an inner join // so the same company/period/status (and year-end exclusion) constraints @@ -92,9 +99,11 @@ export async function generateSIEExport( q = q.neq('journal_entries.source_type', 'year_end') } + // Stable total order on the line PK so paging can't duplicate/skip a line + // across the 1000-row boundary; dedupeBy is the defense-in-depth net. // eslint-disable-next-line @typescript-eslint/no-explicit-any - return q.range(from, to) as any - }) + return q.order('id', { ascending: true }).range(from, to) as any + }, { dedupeBy: (r) => r.id }) const linesByEntryId = new Map() for (const line of allLines) {