diff --git a/DECISIONS.md b/DECISIONS.md index cbb7e9f0..cd201e06 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1024,3 +1024,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-16] Restyled QuickReviewDialog's inbox-picker trigger to the same full-width dropzone-footer row as TransactionBookingDialog even though it did not share the orphan-button layout: both surfaces come from #1620 and should present the same underlag affordance; the alternative (leaving a small outline button in one dialog and a footer row in the other) would split the visual language of one control. Presentation only, disabled-while-booking kept (PR #1628). [2026-08-17] Full-archive direct download resurfaced as an ImportRow + small centered dialog on /import's Exportera tab (row "Komplett arkiv", hash #full-archive), not by re-mounting the orphaned components/settings/BackupDownloadForm.tsx: the form was pre-frame card styling with a duplicate cloud-backup section, while the export tab's existing SIE dialog sets the house pattern (ImportRow -> sm:max-w-md dialog). Its logic (estimate, 413 handling, last-download stamp) ported into components/import/FullArchiveDialog.tsx; the orphan and its dead settings_backup_download i18n namespace deleted. The dialog reuses FiscalYearSelector despite design.md's "legacy, no new uses" line: FyPicker is a toolbar context chip, and the SIE dialog in the same file already uses FiscalYearSelector for the identical dialog-form slot, so matching it beats introducing a third pattern. Row gated to owner/admin because GET /api/reports/full-archive enforces that role server-side; showing members a download that can only 403 helps nobody. [2026-08-17] Article picker now overwrites the line's ROT/RUT (deduction_type + work_type) from the article's housework_type, INCLUDING clearing it when the article has none: article-defines-the-row is the established applyArticle semantic (description/price/unit already overwrite), and keeping a RUT flag when switching a row to a material article would silently claim a deduction on material (HUSFL labor-only rule). Kundkort personnummer prefill is a server-side fallback in buildInvoiceWriteData (typed > stored draft > kundkort), never a client prefill: customers.personal_number reaches the browser only as ciphertext/mask by design, so the editor just relaxes the required-mark and says where the number will come from. +[2026-08-17] Arcim's "610 bilagor i Hela historiken men 100 i räkenskapsåret" in the full-archive dialog is NOT a pagination bug: verified against prod, exactly 100 documents are linked to posted vouchers in the single (extended) fiscal year and 510 are unlinked inbox/receipt docs, which scope=all includes by design (same split as cloud backup's year-ZIPs vs Grunddata.zip). Kept the semantics, fixed two things instead: estimateArchiveSize's period branch ran one unpaginated read with one flat IN() over every entry id (undercounts past the PostgREST row cap, URL blowup past ~a few hundred ids) -> now CHILD_FK_CHUNK-chunked and fetchAllRows-paginated like writeDocuments already was; and the dialog now states per scope which document set is counted, so the gap reads as intent, not as a bug. diff --git a/components/import/FullArchiveDialog.tsx b/components/import/FullArchiveDialog.tsx index 132d4245..d6a0e000 100644 --- a/components/import/FullArchiveDialog.tsx +++ b/components/import/FullArchiveDialog.tsx @@ -175,15 +175,24 @@ export function FullArchiveDialog({
- +
+ + {/* The two scopes count different document sets (all documents vs + only those linked to posted vouchers in the year), so a company + with unlinked inbox receipts sees very different counts. Say so, + or the gap reads as a pagination bug. */} +

+ {scope === 'all' ? t('archive_scope_all_note') : t('archive_scope_period_note')} +

+
{scope === 'period' && ( { expect(result.document_count).toBe(0) expect(result.total_bytes).toBe(8 * 1024 * 1024) }) + + it('paginates the all-mode document read past the page cap', async () => { + const firstPage = Array.from({ length: 1000 }, () => ({ file_size_bytes: 1_000 })) + enqueueMany([ + { data: firstPage }, // full first page forces a second fetch + { data: [{ file_size_bytes: 1_000 }] }, + ]) + + const result = await estimateArchiveSize(supabase as any, 'company-1', 'all') + + expect(result.document_count).toBe(1001) + expect(result.document_bytes).toBe(1_001_000) + }) + + it('chunks the period-mode entry-id filter and sums across chunks', async () => { + // 250 posted entries -> three IN() chunks of max 100 ids. An unchunked + // implementation consumes a single document response and undercounts. + const entryIds = Array.from({ length: 250 }, (_, i) => ({ id: `e${i}` })) + enqueueMany([ + { data: entryIds }, // journal_entries for periodEntryIds + { data: [{ file_size_bytes: 100 }] }, // chunk 1 + { data: [{ file_size_bytes: 200 }] }, // chunk 2 + { data: [{ file_size_bytes: 300 }] }, // chunk 3 + ]) + + const result = await estimateArchiveSize(supabase as any, 'company-1', 'period', 'p-1') + + expect(result.document_count).toBe(3) + expect(result.document_bytes).toBe(600) + }) }) diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index c49a8c7e..844f7fba 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -262,10 +262,7 @@ export async function estimateArchiveSize( periodId?: string ): Promise<{ total_bytes: number; document_bytes: number; document_count: number }> { // Scope=all counts every document (linked or not), mirroring writeDocuments. - let query = supabase - .from('document_attachments') - .select('file_size_bytes, journal_entry_id', { count: 'exact' }) - .eq('company_id', companyId) + let rows: { file_size_bytes: number | null }[] if (scope === 'period') { if (!periodId) { @@ -286,15 +283,35 @@ export async function estimateArchiveSize( if (ids.length === 0) { return { total_bytes: ARCHIVE_OVERHEAD_BYTES, document_bytes: 0, document_count: 0 } } - query = query.in('journal_entry_id', ids) + // A busy year holds thousands of entries and can hold more than a page of + // documents: chunk the IN() list (PostgREST URL limit) and paginate every + // chunk (PostgREST row cap). One flat IN() + single read undercounts as + // soon as either limit is hit. + rows = [] + for (let i = 0; i < ids.length; i += CHILD_FK_CHUNK) { + const chunk = ids.slice(i, i + CHILD_FK_CHUNK) + const chunkRows = await fetchAllRows<{ file_size_bytes: number | null }>(({ from, to }) => + supabase + .from('document_attachments') + .select('id, file_size_bytes') + .eq('company_id', companyId) + .in('journal_entry_id', chunk) + .order('id', { ascending: true }) + .range(from, to) + ) + rows.push(...chunkRows) + } + } else { + rows = await fetchAllRows<{ file_size_bytes: number | null }>(({ from, to }) => + supabase + .from('document_attachments') + .select('id, file_size_bytes') + .eq('company_id', companyId) + .order('id', { ascending: true }) + .range(from, to) + ) } - const { data, error } = await query - if (error) { - throw new Error(`Failed to estimate archive size: ${error.message}`) - } - - const rows = (data as { file_size_bytes: number | null }[]) || [] const documentBytes = rows.reduce((sum, r) => sum + (Number(r.file_size_bytes) || 0), 0) return { diff --git a/messages/en.json b/messages/en.json index 447f84d8..95ac02f2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1422,6 +1422,7 @@ "default_payment_terms_label": "Payment terms (days)", "default_account_label": "Default account", "default_account_placeholder": "e.g. 5410", + "default_account_clear": "Clear default account", "default_currency_label": "Default currency", "notes_label": "Notes", "notes_placeholder": "Internal notes about the supplier...", @@ -7055,6 +7056,8 @@ "archive_scope_label": "Scope", "archive_scope_all": "Full history", "archive_scope_period": "Single fiscal year", + "archive_scope_all_note": "All accounting records, including documents not yet linked to any voucher (inbox and unmatched receipts).", + "archive_scope_period_note": "Documents linked to posted vouchers in the selected year. Unlinked documents are only included in Full history.", "archive_include_docs_label": "Include receipts and supporting documents", "archive_include_docs_help": "Voucher attachments (receipts, invoices, PDFs) are packed into the ZIP. Turn off for a smaller archive with bookkeeping data only.", "archive_calculating_size": "Calculating size…", diff --git a/messages/sv.json b/messages/sv.json index 28f1e6e2..1a72d2e8 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1422,6 +1422,7 @@ "default_payment_terms_label": "Betalningsvillkor (dagar)", "default_account_label": "Standardkonto", "default_account_placeholder": "T.ex. 5410", + "default_account_clear": "Rensa standardkonto", "default_currency_label": "Standardvaluta", "notes_label": "Anteckningar", "notes_placeholder": "Interna anteckningar om leverantören...", @@ -7055,6 +7056,8 @@ "archive_scope_label": "Omfattning", "archive_scope_all": "Hela historiken", "archive_scope_period": "Ett räkenskapsår", + "archive_scope_all_note": "Allt räkenskapsmaterial, även underlag som ännu inte är kopplade till något verifikat (inkorg och okopplade kvitton).", + "archive_scope_period_note": "Underlag kopplade till bokförda verifikat i det valda året. Okopplade underlag ingår bara i Hela historiken.", "archive_include_docs_label": "Inkludera kvitton och underlag", "archive_include_docs_help": "Bilagor till verifikationer (kvitton, fakturor, PDF:er) packas med i ZIP:en. Stäng av för ett mindre arkiv med bara bokföringsdata.", "archive_calculating_size": "Beräknar storlek…",