fix(reports): stable total order on SIE-export fetchAllRows paging (#793 hardening) (#824)

#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) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-29 21:39:40 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0e8698f538
commit 4872c0f242
2 changed files with 16 additions and 7 deletions
+3 -3
View File
@@ -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
+13 -4
View File
@@ -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<string, JournalEntryLine[]>()
for (const line of allLines) {