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
@@ -0,0 +1,166 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { ReconciliationAttachment } from './schemas'
|
||||
|
||||
/**
|
||||
* The account_reconciliation_attachments table, read and written in one
|
||||
* place. Pure storage rows: the file bytes live in Supabase Storage and are
|
||||
* handled by attachments.ts, so the pärm and the archive can list underlag
|
||||
* without touching the bucket.
|
||||
*/
|
||||
|
||||
export interface AttachmentRow extends ReconciliationAttachment {
|
||||
storage_bucket: string
|
||||
storage_path: string
|
||||
}
|
||||
|
||||
function mapRow(row: Record<string, unknown>): AttachmentRow {
|
||||
return {
|
||||
id: row.id as string,
|
||||
account_key: row.account_key as string,
|
||||
through_date: row.through_date as string,
|
||||
file_name: row.file_name as string,
|
||||
mime_type: row.mime_type as string,
|
||||
size_bytes: Number(row.size_bytes ?? 0),
|
||||
storage_bucket: row.storage_bucket as string,
|
||||
storage_path: row.storage_path as string,
|
||||
sha256: row.sha256 as string,
|
||||
note: (row.note as string | null) ?? null,
|
||||
uploaded_by: row.uploaded_by as string,
|
||||
uploaded_at: row.uploaded_at as string,
|
||||
removed_at: (row.removed_at as string | null) ?? null,
|
||||
removed_by: (row.removed_by as string | null) ?? null,
|
||||
removed_reason: (row.removed_reason as string | null) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The public shape: no bucket or path (those are served through the file route). */
|
||||
export function toPublicAttachment(row: AttachmentRow): ReconciliationAttachment {
|
||||
const { storage_bucket: _bucket, storage_path: _path, ...rest } = row
|
||||
void _bucket
|
||||
void _path
|
||||
return rest
|
||||
}
|
||||
|
||||
export interface ListAttachmentsOptions {
|
||||
includeRemoved?: boolean
|
||||
}
|
||||
|
||||
/** Files for one account and balansdag, oldest first (the order they were attached). */
|
||||
export async function listAttachmentRows(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountKey: string,
|
||||
throughDate: string,
|
||||
options: ListAttachmentsOptions = {},
|
||||
): Promise<AttachmentRow[]> {
|
||||
let query = supabase
|
||||
.from('account_reconciliation_attachments')
|
||||
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_key', accountKey)
|
||||
.eq('through_date', throughDate)
|
||||
.order('uploaded_at', { ascending: true })
|
||||
if (!options.includeRemoved) query = query.is('removed_at', null)
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error(`Kunde inte hämta underlag: ${error.message}`)
|
||||
return ((data ?? []) as Record<string, unknown>[]).map(mapRow)
|
||||
}
|
||||
|
||||
/** Every active file with a balansdag inside [from, to], for the pärm and the archive. */
|
||||
export async function listAttachmentRowsInRange(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
options: ListAttachmentsOptions = {},
|
||||
): Promise<AttachmentRow[]> {
|
||||
let query = supabase
|
||||
.from('account_reconciliation_attachments')
|
||||
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
|
||||
.eq('company_id', companyId)
|
||||
.gte('through_date', from)
|
||||
.lte('through_date', to)
|
||||
.order('account_key', { ascending: true })
|
||||
.order('through_date', { ascending: true })
|
||||
.order('uploaded_at', { ascending: true })
|
||||
.limit(2000)
|
||||
if (!options.includeRemoved) query = query.is('removed_at', null)
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error(`Kunde inte hämta underlag: ${error.message}`)
|
||||
return ((data ?? []) as Record<string, unknown>[]).map(mapRow)
|
||||
}
|
||||
|
||||
export async function getAttachmentRow(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountKey: string,
|
||||
attachmentId: string,
|
||||
): Promise<AttachmentRow | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('account_reconciliation_attachments')
|
||||
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_key', accountKey)
|
||||
.eq('id', attachmentId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Kunde inte hämta underlag: ${error.message}`)
|
||||
return data ? mapRow(data as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
export interface InsertAttachmentInput {
|
||||
account_key: string
|
||||
through_date: string
|
||||
file_name: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
storage_bucket: string
|
||||
storage_path: string
|
||||
sha256: string
|
||||
note: string | null
|
||||
uploaded_by: string
|
||||
}
|
||||
|
||||
export async function insertAttachmentRow(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
input: InsertAttachmentInput,
|
||||
): Promise<AttachmentRow> {
|
||||
const { data, error } = await supabase
|
||||
.from('account_reconciliation_attachments')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
account_key: input.account_key,
|
||||
through_date: input.through_date,
|
||||
file_name: input.file_name,
|
||||
mime_type: input.mime_type,
|
||||
size_bytes: input.size_bytes,
|
||||
storage_bucket: input.storage_bucket,
|
||||
storage_path: input.storage_path,
|
||||
sha256: input.sha256,
|
||||
note: input.note,
|
||||
uploaded_by: input.uploaded_by,
|
||||
})
|
||||
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
|
||||
.single()
|
||||
if (error) throw new Error(`Kunde inte spara underlag: ${error.message}`)
|
||||
return mapRow(data as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** Removal is a stamp; the row and the file stay (BFL 7 kap.). Null when already removed or missing. */
|
||||
export async function stampAttachmentRemoved(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
attachmentId: string,
|
||||
input: { removed_by: string; reason: string | null },
|
||||
): Promise<AttachmentRow | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('account_reconciliation_attachments')
|
||||
.update({ removed_at: new Date().toISOString(), removed_by: input.removed_by, removed_reason: input.reason })
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', attachmentId)
|
||||
.is('removed_at', null)
|
||||
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Kunde inte ta bort underlag: ${error.message}`)
|
||||
return data ? mapRow(data as Record<string, unknown>) : null
|
||||
}
|
||||
Reference in New Issue
Block a user