feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2) A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an account was reconciled against to (account_key, through_date), before or after the sign-off, from every account body on the Avstämning page. Rows live in account_reconciliation_attachments (append-only, removal stamp by trigger, RLS like account_reconciliations), bytes in the documents bucket under the company prefix so its RLS applies unchanged, and the full archive copies them into bilagor/ with a hash manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867) * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) The bokslut checklist is a catalogue in code with one state row per period (bokslut_checklist_items): the steps the system can judge (sign-offs through balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are computed live and a stored row only overrides them; the manual steps are the konsult's ticks, with who and when. It sits on the wizard's Kontroll step and is dumped into the full archive. A hole between fiscal years (one-file SIE migrations) is now named on the bokslut readiness screen and on the import result screen, where the next file is one click away. Non-adjacent period links are #1849's fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
ebbdf96b74
commit
c62321988b
@@ -51,6 +51,11 @@ vi.mock('../journal-register', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// The bilagor step reads account_reconciliation_attachments through the
|
||||
// store; an empty list keeps the queued-mock order of these tests intact.
|
||||
vi.mock('@/lib/reconciliation/attachments-store', () => ({
|
||||
listAttachmentRowsInRange: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
vi.mock('../vat-declaration', () => ({
|
||||
calculateVatDeclaration: vi.fn().mockResolvedValue({
|
||||
period: { type: 'yearly', year: 2024, period: 1, start: '2024-01-01', end: '2024-12-31' },
|
||||
|
||||
@@ -9,6 +9,7 @@ import { generateJournalRegister } from './journal-register'
|
||||
import { calculateVatDeclaration } from './vat-declaration'
|
||||
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
||||
import { downloadDocumentObject } from '@/lib/core/documents/document-service'
|
||||
import { listAttachmentRowsInRange } from '@/lib/reconciliation/attachments-store'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import {
|
||||
@@ -171,6 +172,7 @@ export async function generateFullArchive(
|
||||
|
||||
if (options.include_documents !== false) {
|
||||
await writeDocuments(zip, supabase, companyId, periods, options.scope)
|
||||
await writeReconciliationAttachments(zip, supabase, companyId, periods)
|
||||
}
|
||||
|
||||
if (options.scope === 'all') {
|
||||
@@ -554,6 +556,94 @@ async function writeDocuments(
|
||||
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
|
||||
}
|
||||
|
||||
interface ReconciliationAttachmentManifestEntry {
|
||||
attachment_id: string
|
||||
account_key: string
|
||||
through_date: string
|
||||
file_name: string
|
||||
storage_path: string
|
||||
sha256: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
note: string | null
|
||||
uploaded_at: string
|
||||
removed_at: string | null
|
||||
removed_reason: string | null
|
||||
zip_path: string | null
|
||||
status: 'downloaded' | 'removed' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The underlag behind the reconciliation sign-offs (bokslutsbilagor): every
|
||||
* file attached to a balansdag inside the archived periods, laid out as
|
||||
* `bilagor/<period>/<account_key>/<through_date>_<file>`, plus a manifest
|
||||
* with the content hashes. Removed files are listed (with their stamp) but
|
||||
* not copied: the manifest is the record that they were attached and then
|
||||
* withdrawn. A failed read lands in the manifest rather than aborting the
|
||||
* archive, like writeDocuments.
|
||||
*/
|
||||
async function writeReconciliationAttachments(
|
||||
zip: JSZip,
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periods: FiscalPeriodRow[]
|
||||
): Promise<void> {
|
||||
const manifest: ReconciliationAttachmentManifestEntry[] = []
|
||||
const usedPaths = new Set<string>()
|
||||
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
|
||||
const from = sorted[0].period_start
|
||||
const to = sorted[sorted.length - 1].period_end
|
||||
|
||||
try {
|
||||
const rows = await listAttachmentRowsInRange(supabase, companyId, from, to, { includeRemoved: true })
|
||||
for (const row of rows) {
|
||||
const period = sorted.find((p) => row.through_date >= p.period_start && row.through_date <= p.period_end)
|
||||
const base = {
|
||||
attachment_id: row.id,
|
||||
account_key: row.account_key,
|
||||
through_date: row.through_date,
|
||||
file_name: row.file_name,
|
||||
storage_path: row.storage_path,
|
||||
sha256: row.sha256,
|
||||
mime_type: row.mime_type,
|
||||
size_bytes: row.size_bytes,
|
||||
note: row.note,
|
||||
uploaded_at: row.uploaded_at,
|
||||
removed_at: row.removed_at,
|
||||
removed_reason: row.removed_reason,
|
||||
}
|
||||
if (row.removed_at) {
|
||||
manifest.push({ ...base, zip_path: null, status: 'removed' })
|
||||
continue
|
||||
}
|
||||
if (!period) continue
|
||||
let zipPath = `bilagor/${periodLabel(period)}/${row.account_key.replace(':', '_')}/${row.through_date}_${row.file_name}`
|
||||
if (usedPaths.has(zipPath)) {
|
||||
const dot = zipPath.lastIndexOf('.')
|
||||
const suffix = `_${row.id.slice(0, 8)}`
|
||||
zipPath = dot > zipPath.lastIndexOf('/') ? `${zipPath.slice(0, dot)}${suffix}${zipPath.slice(dot)}` : `${zipPath}${suffix}`
|
||||
}
|
||||
usedPaths.add(zipPath)
|
||||
try {
|
||||
const { data, error } = await supabase.storage.from(row.storage_bucket).download(row.storage_path)
|
||||
if (error || !data) {
|
||||
manifest.push({ ...base, zip_path: null, status: 'error', error: error?.message || 'Download returned no data' })
|
||||
continue
|
||||
}
|
||||
zip.file(zipPath, await data.arrayBuffer())
|
||||
manifest.push({ ...base, zip_path: zipPath, status: 'downloaded' })
|
||||
} catch (err) {
|
||||
manifest.push({ ...base, zip_path: null, status: 'error', error: err instanceof Error ? err.message : 'Unknown error' })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Attachment listing failed: the archive still carries everything else.
|
||||
}
|
||||
|
||||
zip.folder('bilagor')!.file('manifest.json', JSON.stringify(manifest, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgREST returns a many-to-one embedded resource as either an object or an
|
||||
* array depending on schema introspection (FK is unique vs not). Normalize.
|
||||
@@ -988,6 +1078,9 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
|
||||
// through which date with the numbers as they stood, plus reopen stamps.
|
||||
// Part of the avstämningsdokumentation an auditor asks for; kept.
|
||||
{ name: 'account_reconciliations', file: 'account_reconciliations.json', orderBy: 'signed_at' },
|
||||
// The bokslut checklist per räkenskapsår (which closing steps were done,
|
||||
// by whom, when): the konsult's documented bokslutsarbete (Reko 760); kept.
|
||||
{ name: 'bokslut_checklist_items', file: 'bokslut_checklist_items.json', orderBy: 'updated_at', pageKey: 'item_key' },
|
||||
{ name: 'journal_entry_no_doc_required', file: 'journal_entry_no_doc_required.json', pageKey: 'journal_entry_id' },
|
||||
{ name: 'rot_rut_payout_requests', file: 'rot_rut_payout_requests.json', orderBy: 'created_at' },
|
||||
// No `denormalize`: rot_rut_payout_requests has no currency column either.
|
||||
@@ -1017,6 +1110,7 @@ export const ARCHIVE_COVERED_ELSEWHERE_TABLES: Record<string, string> = {
|
||||
voucher_sequences: 'revision/systemdokumentation.json (verifikationsserier)',
|
||||
audit_log: 'revision/behandlingshistorik.json',
|
||||
document_attachments: 'dokument/ + dokument/manifest.json',
|
||||
account_reconciliation_attachments: 'bilagor/ + bilagor/manifest.json',
|
||||
sie_imports: 'sie/imports.json + sie/original/',
|
||||
sie_account_mappings: 'sie/account_mappings.json',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user