b556475b01
The /sie-data route parsed only the newest fiscal year's SIE file for the import preview and the returned SIEData.parsed. Mid-year provider exports have few or zero vouchers in the newest year, so the first real Fortnox migration (3 fiscal years, 4153 vouchers) previewed "0 verifikationer" and drew an almost-empty migration theater while the import itself landed all 4153 vouchers from the older files. - New mergeParsedSIEFiles (lib/import/sie-merge.ts): pure, browser-clean whole-dataset merge (accounts union first-wins, vouchers concatenated, fiscal years union oldest-first re-indexed newest=0, balances and issues concatenated, dimensions deduped), with unit tests. - /sie-data parses each file exactly once, builds the preview from the merged parse and returns parsed: merged; response shape unchanged. Validation stays newest-file-only so no previously accepted dataset is newly rejected. - /preview drops latestOnly and computes sieStats from the merged parse: the connect step's "Hittade X konton och Y verifikationer" line renders from THESE stats, so this is where the founder-visible count was lying. - The migration theater spreads its account waves across ~10s and births an additional wave on each real step label during the SIE phase (progress <= 55), through a shared rate-limited gate, so the canvas keeps performing over a multi-minute run. Narration labels and progress remain the wizard's real values; reduced motion unchanged. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
159 lines
5.9 KiB
TypeScript
159 lines
5.9 KiB
TypeScript
/**
|
|
* Merge several parsed SIE files (one per fiscal year) into one
|
|
* whole-dataset view.
|
|
*
|
|
* Mid-year provider exports have few or zero vouchers in the newest fiscal
|
|
* year, so any preview or visualization built from a single file lies about
|
|
* the dataset. The merged parse is for whole-dataset PRESENTATION (account
|
|
* mapping, import preview counts, the migration theater model): the actual
|
|
* import still runs per raw file.
|
|
*
|
|
* Semantics:
|
|
* - accounts: union by number, first occurrence wins the name; missing
|
|
* sruCode/accountType are filled in from later duplicates.
|
|
* - vouchers: concatenated in file order (callers pass files oldest first).
|
|
* - opening/closing/result balances: concatenated AS-IS. Each record keeps
|
|
* the yearIndex relative to its SOURCE file's current year, so per-year
|
|
* balance math on the merged output is not meaningful: two files can both
|
|
* carry yearIndex 0 records for different calendar years.
|
|
* - header.fiscalYears: union deduped by start+end, sorted oldest first,
|
|
* re-indexed so the newest year is 0 (stats then reports the newest
|
|
* year's bounds, matching the single-file "current year" convention).
|
|
* - company name/orgnr: the most common non-empty value, first seen wins
|
|
* ties.
|
|
* - dimensions: deduped by dimension number; dimension values deduped by
|
|
* (dimension number, code); first occurrence wins.
|
|
* - issues: concatenated.
|
|
*
|
|
* Pure and browser-clean (no Node APIs): the client calls it too.
|
|
*/
|
|
|
|
import type {
|
|
FiscalYearInfo,
|
|
ParsedSIEFile,
|
|
SIEAccount,
|
|
SIEDimension,
|
|
SIEDimensionValue,
|
|
} from './types'
|
|
|
|
/** Most common non-empty value; earlier values win ties. */
|
|
function mostCommon(values: (string | null)[]): string | null {
|
|
const counts = new Map<string, number>()
|
|
let best: string | null = null
|
|
let bestCount = 0
|
|
for (const value of values) {
|
|
if (!value) continue
|
|
const count = (counts.get(value) ?? 0) + 1
|
|
counts.set(value, count)
|
|
if (count > bestCount) {
|
|
best = value
|
|
bestCount = count
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
function firstNonNull<T>(values: (T | null | undefined)[]): T | null {
|
|
for (const value of values) {
|
|
if (value !== null && value !== undefined) return value
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function mergeParsedSIEFiles(files: ParsedSIEFile[]): ParsedSIEFile {
|
|
if (files.length === 0) {
|
|
throw new Error('mergeParsedSIEFiles requires at least one parsed file')
|
|
}
|
|
if (files.length === 1) return files[0]
|
|
|
|
const headers = files.map((f) => f.header)
|
|
|
|
// Fiscal years: union by period, oldest first, newest re-indexed to 0.
|
|
const yearsByPeriod = new Map<string, FiscalYearInfo>()
|
|
for (const header of headers) {
|
|
for (const fy of header.fiscalYears) {
|
|
const key = `${fy.start}|${fy.end}`
|
|
if (!yearsByPeriod.has(key)) yearsByPeriod.set(key, fy)
|
|
}
|
|
}
|
|
const sortedYears = [...yearsByPeriod.values()].sort((a, b) =>
|
|
a.start.localeCompare(b.start),
|
|
)
|
|
const fiscalYears = sortedYears.map((fy, i) => ({
|
|
yearIndex: i - (sortedYears.length - 1),
|
|
start: fy.start,
|
|
end: fy.end,
|
|
}))
|
|
|
|
// Accounts: union by number, first name wins, missing metadata filled in.
|
|
const accountsByNumber = new Map<string, SIEAccount>()
|
|
for (const file of files) {
|
|
for (const account of file.accounts) {
|
|
const existing = accountsByNumber.get(account.number)
|
|
if (!existing) {
|
|
accountsByNumber.set(account.number, { ...account })
|
|
} else {
|
|
if (!existing.sruCode && account.sruCode) existing.sruCode = account.sruCode
|
|
if (!existing.accountType && account.accountType) {
|
|
existing.accountType = account.accountType
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const accounts = [...accountsByNumber.values()]
|
|
|
|
// Dimension registry: dedupe by identity, first occurrence wins.
|
|
const dimensionsByNo = new Map<number, SIEDimension>()
|
|
const dimensionValuesByKey = new Map<string, SIEDimensionValue>()
|
|
for (const file of files) {
|
|
for (const dim of file.dimensions) {
|
|
if (!dimensionsByNo.has(dim.sieDimNo)) dimensionsByNo.set(dim.sieDimNo, dim)
|
|
}
|
|
for (const value of file.dimensionValues) {
|
|
const key = `${value.sieDimNo}|${value.code}`
|
|
if (!dimensionValuesByKey.has(key)) dimensionValuesByKey.set(key, value)
|
|
}
|
|
}
|
|
|
|
const vouchers = files.flatMap((f) => f.vouchers)
|
|
const currentFiscalYear = fiscalYears.find((fy) => fy.yearIndex === 0)
|
|
|
|
return {
|
|
header: {
|
|
sieType: files.reduce(
|
|
(max, f) => (f.header.sieType > max ? f.header.sieType : max),
|
|
files[0].header.sieType,
|
|
),
|
|
// Any file flagged as already imported taints the merged view.
|
|
flagga: headers.some((h) => h.flagga === 1)
|
|
? 1
|
|
: firstNonNull(headers.map((h) => h.flagga)),
|
|
program: firstNonNull(headers.map((h) => h.program)),
|
|
programVersion: firstNonNull(headers.map((h) => h.programVersion)),
|
|
generatedDate: firstNonNull(headers.map((h) => h.generatedDate)),
|
|
format: firstNonNull(headers.map((h) => h.format)),
|
|
companyName: mostCommon(headers.map((h) => h.companyName)),
|
|
orgNumber: mostCommon(headers.map((h) => h.orgNumber)),
|
|
address: firstNonNull(headers.map((h) => h.address)),
|
|
fiscalYears,
|
|
currency: firstNonNull(headers.map((h) => h.currency || null)) ?? 'SEK',
|
|
kontoPlanType: firstNonNull(headers.map((h) => h.kontoPlanType)),
|
|
},
|
|
accounts,
|
|
openingBalances: files.flatMap((f) => f.openingBalances),
|
|
closingBalances: files.flatMap((f) => f.closingBalances),
|
|
resultBalances: files.flatMap((f) => f.resultBalances),
|
|
vouchers,
|
|
dimensions: [...dimensionsByNo.values()],
|
|
dimensionValues: [...dimensionValuesByKey.values()],
|
|
issues: files.flatMap((f) => f.issues),
|
|
stats: {
|
|
totalAccounts: accounts.length,
|
|
totalVouchers: vouchers.length,
|
|
totalTransactionLines: vouchers.reduce((sum, v) => sum + v.lines.length, 0),
|
|
fiscalYearStart: currentFiscalYear?.start ?? null,
|
|
fiscalYearEnd: currentFiscalYear?.end ?? null,
|
|
},
|
|
}
|
|
}
|