Files
accounted/lib/documents/underlag-import.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

287 lines
9.9 KiB
TypeScript

/**
* Underlag import: attach a folder of receipt files to already-migrated
* verifikat, using nothing but the voucher reference in each filename.
*
* A SIE file carries the ledger but not the underlag, so a migrating customer
* has to bring the receipts over separately. Systems that export both name each
* receipt after its verifikat (`A31_<id>.pdf`), and the SIE import preserved
* that same identity on every entry, so the pairing is a lookup rather than an
* interpretation. No AI, no amount matching, no date windows.
*
* This module only PLANS. Nothing here writes: the plan goes back to the user,
* who approves it, and each approved row is then attached one file at a time.
* That split is deliberate: linking a document to a posted verifikat makes it
* räkenskapsinformation, which can never be re-pointed (BFL 7 kap), so a bulk
* write with no preview would be an unrecoverable mistake by design.
*
* EVERY plan is scoped to one fiscal year, which the user declares. That is not
* ceremony. Source systems restart voucher numbering every year and a filename
* carries no year, so `A31` alone does not identify a verifikat. An earlier
* version resolved company-wide and treated "only one candidate exists" as
* proof of identity: with a partial migration, or with the year's A31 among the
* vouchers the importer skipped (empty, single-line, unbalanced), that silently
* attached a 2023 receipt to a 2025 verifikat, permanently. Cardinality is not
* identity. Scoping cannot make the year inferable, so it makes it asserted:
* a file can only ever land in the year the user named.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { parseVoucherRefFromFileName } from '@/lib/documents/filename-voucher-ref'
import {
buildVoucherIndex,
candidatesForNumber,
candidatesForRef,
fetchFiscalPeriods,
fetchVouchersForNumbers,
hasSourceRefVouchers,
sourceVoucherLabel,
voucherLabel,
type FiscalPeriodRow,
type VoucherRow,
} from '@/lib/documents/voucher-ref-resolver'
export type UnderlagPlanStatus =
/** Exactly one open verifikat: safe to pre-select. */
| 'matched'
/** Resolved, but the filename gave no series: a human confirms the pick. */
| 'needs_confirmation'
/** The ref exists in several fiscal years: the user picks which one. */
| 'ambiguous'
/** The only candidate sits in a closed or locked period: the DB will refuse. */
| 'period_locked'
/** Parsed a ref, but no migrated verifikat carries it. */
| 'no_match'
/** The filename carries no readable voucher reference. */
| 'unparsed'
export interface UnderlagPlanCandidate {
journal_entry_id: string
/** Our own label after renumbering, e.g. `A47`. */
voucher_label: string | null
/** The label the source system used, i.e. what the filename says, e.g. `A31`. */
source_voucher_label: string | null
entry_date: string
description: string | null
period_locked: boolean
}
export interface UnderlagPlanRow {
file_name: string
status: UnderlagPlanStatus
parsed_ref: { series: string | null; number: number } | null
/** The single resolved target, when there is exactly one. */
journal_entry_id: string | null
/** Every candidate, so an ambiguous row can be resolved by hand. */
candidates: UnderlagPlanCandidate[]
}
export interface UnderlagPlanSummary {
total: number
matched: number
needs_confirmation: number
ambiguous: number
period_locked: number
no_match: number
unparsed: number
}
export interface UnderlagPlan {
rows: UnderlagPlanRow[]
summary: UnderlagPlanSummary
/** The fiscal year every row in this plan was resolved against. */
fiscal_period_id: string
/**
* True when the SELECTED fiscal year holds no migrated entry carrying a
* source voucher ref. Either that year was never imported from SIE, or the
* import predates the columns (added 2026-04-21 and never backfilled), in
* which case filename matching cannot work for it at all and the UI must say
* so instead of showing 400 misses. Often it just means the wrong year is
* selected, which is the first thing worth telling the user.
*/
no_source_refs: boolean
}
function isPeriodLocked(period: FiscalPeriodRow | undefined): boolean {
return period ? period.is_closed || period.locked_at !== null : false
}
function toCandidate(entry: VoucherRow, periods: Map<string, FiscalPeriodRow>): UnderlagPlanCandidate {
return {
journal_entry_id: entry.id,
voucher_label: voucherLabel(entry),
source_voucher_label: sourceVoucherLabel(entry),
entry_date: entry.entry_date,
description: entry.description ?? null,
period_locked: isPeriodLocked(periods.get(entry.fiscal_period_id)),
}
}
function emptySummary(): UnderlagPlanSummary {
return {
total: 0,
matched: 0,
needs_confirmation: 0,
ambiguous: 0,
period_locked: 0,
no_match: 0,
unparsed: 0,
}
}
/**
* Build the match plan for a set of filenames, inside one declared fiscal year.
* Reads only: no upload, no link.
*
* The same function backs the preview and the per-file attach check, so the
* server can never link a file to a verifikat the preview would not have
* proposed.
*/
export async function buildUnderlagPlan(
supabase: SupabaseClient,
companyId: string,
fileNames: string[],
fiscalPeriodId: string,
): Promise<UnderlagPlan> {
const parsed = fileNames.map((fileName) => ({
fileName,
ref: parseVoucherRefFromFileName(fileName),
}))
const numbers = parsed.map((p) => p.ref?.number).filter((n): n is number => n != null)
const [vouchers, periodRows] = await Promise.all([
fetchVouchersForNumbers(supabase, companyId, numbers, fiscalPeriodId),
fetchFiscalPeriods(supabase, companyId),
])
// The single most important line in this module: candidates outside the
// declared year are dropped BEFORE the index is built, so no downstream
// branch can ever see, count or propose one.
const index = buildVoucherIndex(
vouchers.filter((entry) => entry.fiscal_period_id === fiscalPeriodId),
)
const periods = new Map(periodRows.map((p) => [p.id, p]))
const summary = emptySummary()
summary.total = parsed.length
const rows: UnderlagPlanRow[] = parsed.map(({ fileName, ref }) => {
if (!ref) {
summary.unparsed++
return {
file_name: fileName,
status: 'unparsed',
parsed_ref: null,
journal_entry_id: null,
candidates: [],
}
}
const parsedRef = { series: ref.series, number: ref.number }
const entries = ref.series
? candidatesForRef(index, { series: ref.series, number: ref.number })
: candidatesForNumber(index, ref.number)
const candidates = entries.map((entry) => toCandidate(entry, periods))
if (candidates.length === 0) {
summary.no_match++
return {
file_name: fileName,
status: 'no_match',
parsed_ref: parsedRef,
journal_entry_id: null,
candidates: [],
}
}
if (candidates.length > 1) {
// Inside one fiscal year a source ref should be unique, so this is either
// a re-imported year or a series-less filename hitting several series.
// Hand the choice back rather than pick.
summary.ambiguous++
return {
file_name: fileName,
status: 'ambiguous',
parsed_ref: parsedRef,
journal_entry_id: null,
candidates,
}
}
const only = candidates[0]
if (only.period_locked) {
summary.period_locked++
return {
file_name: fileName,
status: 'period_locked',
parsed_ref: parsedRef,
journal_entry_id: only.journal_entry_id,
candidates,
}
}
if (!ref.autoSelectable) {
summary.needs_confirmation++
return {
file_name: fileName,
status: 'needs_confirmation',
parsed_ref: parsedRef,
journal_entry_id: only.journal_entry_id,
candidates,
}
}
summary.matched++
return {
file_name: fileName,
status: 'matched',
parsed_ref: parsedRef,
journal_entry_id: only.journal_entry_id,
candidates,
}
})
// Only worth a round-trip when nothing landed: the answer distinguishes
// "wrong filenames" from "this year holds no source refs to match against",
// which most often means the wrong year is selected.
const no_source_refs =
summary.matched + summary.needs_confirmation + summary.ambiguous + summary.period_locked === 0
? !(await hasSourceRefVouchers(supabase, companyId, fiscalPeriodId))
: false
return { rows, summary, fiscal_period_id: fiscalPeriodId, no_source_refs }
}
/**
* Whether attaching `fileName` to `journalEntryId` is permitted by the plan.
*
* Guards the attach route against a stale or wrong client: the file the browser
* uploads must land on a verifikat the preview would propose for that name.
* `fiscalPeriodId` is the caller-supplied declared year, which the route has
* ALREADY asserted equal to the target entry's own period before calling this;
* this function only decides the filename-to-target question inside that year.
*
* `override` marks a deliberate manual assignment. It is honored ONLY when the
* filename is unresolvable in the declared year (no parse, or no candidate):
* a filename the resolver CAN place must land where it points, override or
* not, otherwise a lying client could scatter cleanly-named underlag across
* arbitrary same-year verifikat. The shipped UI only ever overrides rows whose
* filenames resolved to nothing, so this costs it no capability.
*/
export async function planPermitsAttach(
supabase: SupabaseClient,
companyId: string,
fileName: string,
journalEntryId: string,
fiscalPeriodId: string,
override: boolean,
): Promise<boolean> {
const plan = await buildUnderlagPlan(supabase, companyId, [fileName], fiscalPeriodId)
const row = plan.rows[0]
if (!row) return false
if (row.candidates.some((candidate) => candidate.journal_entry_id === journalEntryId)) {
return true
}
return override && row.candidates.length === 0
}