* feat(arsredovisning): manual override for medelantal anstallda (Not 2) Why the problem occurred: the ÅRL 5:20 § note was derived only from the employees table, and most aktiebolag that book salary never create a Löner employee record (hand-booked salary, SIE import, migrated history). In prod 148 of 195 aktiebolag with posted 70xx-73xx lines have no employees rows, so their note reads "inga anställda". The reporting company had one row created the same day with a start date halfway through a July-June year: 181/365 = 0.5 FTE rounds to 0. What was removed or simplified: nothing removed. One resolver (resolveMedelantalAnstallda: override, else FTE average) now feeds the K2 note, the K3 note and the iXBRL fact, so no reader can pick a different number. The override sits on arsredovisning_narratives next to the other ÅRL 5 kap. disclosures and rides the existing narrative GET/POST route, service and page save. Why this and not the proposed one: the request asked support to "enable override of Not 2" as free text. A whole number keeps the statutory sentence intact and the iXBRL MedelantaletAnstallda fact taggable; free text would allow a non-compliant note. Rounding 0.5 up globally was rejected (changes every company's note silently, does nothing for companies with no employees rows), as was backdating the hire date (fixes one company, misstates the fact). The iXBRL input also reads the previous period's override so the jämförelseår column shows what last year's document showed. Migration 20260908130127 is additive (nullable INTEGER with a CHECK) and is applied and tracked on staging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxiKQke7sY6mrAK9KX4hbD * fix(arsredovisning): size-threshold metrics use the medelantal override too Skeptic refutation on 442fa1bc5: reportMetrics in model.ts still read the FTE average from the employees table, so the ÅRL 1:3 § större- företag test and the K2 relief thresholds could disagree with the figure Not 2 and the iXBRL fact disclose. A SIE-migrated aktiebolag with no employees rows and an override of 60 both years, balansomslutning over 40 MSEK, would have validated as K2-eligible while its own document said 60 employees. Fix: the metrics resolve the employee figure the same way the note does (current period override from report.disclosures, previous period via getMedelantalOverride on that period's narrative row). previous_period on ArsredovisningData now carries the period id so the lookup needs no extra fiscal_periods read. The iXBRL employees-error fallback keeps the previous period's override instead of blanking the jämförelseår. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxiKQke7sY6mrAK9KX4hbD --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
450 lines
19 KiB
TypeScript
450 lines
19 KiB
TypeScript
/**
|
|
* Assemble IxbrlArsredovisningInput for a fiscal period.
|
|
*
|
|
* Reuses the same sources as the PDF builder (buildArsredovisningData) for
|
|
* narrative texts, noter and flerårsöversikt, and adds what iXBRL needs on
|
|
* top: trial balances for BOTH years mapped to risbs concepts
|
|
* (jämförelsesiffror: kontrollera 3006/3007), per-signer dates from the
|
|
* signature flow, and the fastställelseintyg undertecknare.
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
|
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
|
|
import { listSignatureRequests } from '@/lib/bokslut/arsredovisning/signature-service'
|
|
import { resolveMedelantalAnstallda } from '@/lib/salary/medelantal'
|
|
import { getMedelantalOverride } from '@/lib/bokslut/arsredovisning/narrative-service'
|
|
import { mapTrialBalancesToK2, type TrialBalancePair } from './k2-mapper'
|
|
import { resolveEntryPoint } from './taxonomy/entry-points'
|
|
import type {
|
|
EgetKapitalForandring,
|
|
FlerarsRow,
|
|
IxbrlArsredovisningInput,
|
|
IxbrlSigner,
|
|
Resultatdisposition,
|
|
} from './types'
|
|
import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
|
|
|
|
/** TA §4.3.4-4.3.5: "<leverantör> - <produkt>", version "<huvud>.<revision>". */
|
|
export const PROGRAMVARA_NAMN = 'Accounted - Accounted'
|
|
export const PROGRAMVARA_VERSION = '2026.1'
|
|
|
|
export interface BuildIxbrlOptions {
|
|
/** Undertecknare of the fastställelseintyg (chosen in the wizard). When
|
|
* omitted, the first signed board member is used. */
|
|
undertecknare?: { firstName: string; lastName: string; role: string }
|
|
/** Proposed dividend in SEK (0 = balansera allt). */
|
|
proposedDividend?: number
|
|
/** Override "today" for deterministic tests (ISO date). */
|
|
todayIso?: string
|
|
/** Reuse a canonical report instance so PDF and iXBRL cannot diverge. */
|
|
reportData?: ArsredovisningData
|
|
/** Reuse the version-bound signer roster when a canonical model is built. */
|
|
signatureRequests?: Awaited<ReturnType<typeof listSignatureRequests>>
|
|
}
|
|
|
|
function splitName(fullName: string): { firstName: string; lastName: string } {
|
|
const parts = fullName.trim().split(/\s+/)
|
|
if (parts.length === 1) return { firstName: parts[0], lastName: parts[0] }
|
|
return { firstName: parts.slice(0, -1).join(' '), lastName: parts[parts.length - 1] }
|
|
}
|
|
|
|
export async function buildIxbrlInput(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
fiscalPeriodId: string,
|
|
options: BuildIxbrlOptions = {},
|
|
): Promise<IxbrlArsredovisningInput> {
|
|
const warnings: string[] = []
|
|
|
|
// Two TB variants per year (see TrialBalancePair): the FULL trial balance
|
|
// (year-end closing included → 2099 booked, class 3-8 zeroed) drives the
|
|
// BR; the PRE-CLOSING trial balance (excludeFinalClosingEntry) drives the
|
|
// RR while retaining tax and appropriations. A single TB can
|
|
// never serve both: with bokslut booked every RR concept would map to 0,
|
|
// without it the BR would not tie.
|
|
const [pdfData, periodRow, currentTbFull, currentTbPreClosing, signatureRequests] =
|
|
await Promise.all([
|
|
options.reportData ?? buildArsredovisningData(supabase, companyId, fiscalPeriodId),
|
|
supabase
|
|
.from('fiscal_periods')
|
|
.select('id, period_start, period_end, previous_period_id')
|
|
.eq('id', fiscalPeriodId)
|
|
.eq('company_id', companyId)
|
|
.single(),
|
|
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' }),
|
|
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'exclude-final' }),
|
|
options.signatureRequests ?? listSignatureRequests(supabase, companyId, fiscalPeriodId),
|
|
])
|
|
|
|
if (periodRow.error || !periodRow.data) throw new Error('Fiscal period not found')
|
|
const period = periodRow.data
|
|
|
|
if (pdfData.accounting_framework !== 'k2') {
|
|
throw new Error(
|
|
'Digital inlämning stöds ännu inte för K3: generera PDF eller vänta på K3-stödet.',
|
|
)
|
|
}
|
|
const entryPoint = resolveEntryPoint('k2')
|
|
|
|
// Previous period: trial balances for jämförelsesiffror (same full/
|
|
// pre-closing split as the current year).
|
|
let previousPeriod: { start: string; end: string } | null = null
|
|
let previousPeriodId: string | null = null
|
|
let previousTb: TrialBalancePair | null = null
|
|
if (period.previous_period_id) {
|
|
const { data: prev } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id, period_start, period_end')
|
|
.eq('id', period.previous_period_id)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (prev) {
|
|
previousPeriod = { start: prev.period_start, end: prev.period_end }
|
|
previousPeriodId = prev.id
|
|
try {
|
|
const [prevFull, prevPreClosing] = await Promise.all([
|
|
generateTrialBalance(supabase, companyId, prev.id, { closingEntry: 'include' }),
|
|
generateTrialBalance(supabase, companyId, prev.id, { closingEntry: 'exclude-final' }),
|
|
])
|
|
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
|
|
} catch {
|
|
warnings.push(
|
|
'Jämförelsesiffror kunde inte hämtas för föregående räkenskapsår: balans- och resultaträkning visas utan jämförelseår (kontrollera-kod 3006/3007 kan utlösas).',
|
|
)
|
|
previousPeriod = null
|
|
}
|
|
}
|
|
}
|
|
|
|
const mapping = mapTrialBalancesToK2(
|
|
{ full: currentTbFull.rows, preClosing: currentTbPreClosing.rows },
|
|
previousTb,
|
|
)
|
|
warnings.push(...mapping.warnings)
|
|
|
|
// ---- flerårsöversikt (reuse PDF rows; whole SEK) -------------------------
|
|
// PDF rows are oldest-first; iXBRL columns newest-first. Each row needs the
|
|
// matching fiscal-period range so the document can declare period2/3 +
|
|
// balans2/3 contexts explicitly.
|
|
const { data: allPeriods } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id, name, period_start, period_end')
|
|
.eq('company_id', companyId)
|
|
.order('period_start', { ascending: false })
|
|
const periodByName = new Map(
|
|
((allPeriods ?? []) as Array<{ name: string; period_start: string; period_end: string }>).map(
|
|
(p) => [p.name, p],
|
|
),
|
|
)
|
|
const flerarsoversikt: FlerarsRow[] = []
|
|
const flerarsPerioder: Array<{ start: string; end: string }> = []
|
|
for (const row of [...pdfData.forvaltningsberattelse.flerarsoversikt].reverse()) {
|
|
const match = periodByName.get(row.year)
|
|
if (!match) continue // can't tag a year without a known period range
|
|
flerarsoversikt.push({
|
|
year: row.year,
|
|
nettoomsattning: Math.round(row.net_revenue),
|
|
resultatEfterFinansiellaPoster: Math.round(row.result_after_financial),
|
|
soliditetPct: row.soliditet_pct,
|
|
})
|
|
flerarsPerioder.push({ start: match.period_start, end: match.period_end })
|
|
}
|
|
// Column 0 must be the current period and column 1 the previous one:
|
|
// the document reuses period0/period1 contexts for them. Anything else
|
|
// (e.g. a missed periodByName lookup shifting the rows) means the period
|
|
// chain is inconsistent; drop the table rather than tag amounts against
|
|
// the wrong period.
|
|
const flerarsMisaligned =
|
|
(flerarsPerioder.length > 0 && flerarsPerioder[0].start !== period.period_start) ||
|
|
(flerarsPerioder.length > 1 &&
|
|
previousPeriod !== null &&
|
|
flerarsPerioder[1].start !== previousPeriod.start)
|
|
if (flerarsMisaligned) {
|
|
warnings.push(
|
|
'Flerårsöversikten kunde inte knytas till räkenskapsperioderna: tabellen utelämnas ur iXBRL-dokumentet.',
|
|
)
|
|
flerarsoversikt.length = 0
|
|
flerarsPerioder.length = 0
|
|
}
|
|
// Duplicate-fact consistency (TA §2.7.3): the flerårsöversikt repeats
|
|
// Nettoomsattning / ResultatEfterFinansiellaPoster in the same contexts
|
|
// (period0/period1) as the RR, and repeated facts must be value-identical
|
|
// or Bolagsverket rejects the filing. The PDF rows are computed from the
|
|
// income statement (ALL class-3 revenue), while nettoomsättning per ÅRL is
|
|
// strictly 3000-3799: so the current and previous year columns are
|
|
// overridden with the mapper outputs. Older years have no RR facts and
|
|
// keep the PDF values.
|
|
if (flerarsoversikt.length > 0) {
|
|
flerarsoversikt[0] = {
|
|
...flerarsoversikt[0],
|
|
nettoomsattning: mapping.rr['Nettoomsattning']?.current ?? 0,
|
|
resultatEfterFinansiellaPoster: mapping.totals.resultatEfterFinansiellaPoster.current,
|
|
}
|
|
if (flerarsoversikt.length > 1 && previousPeriod !== null) {
|
|
flerarsoversikt[1] = {
|
|
...flerarsoversikt[1],
|
|
nettoomsattning: mapping.rr['Nettoomsattning']?.previous ?? 0,
|
|
resultatEfterFinansiellaPoster:
|
|
mapping.totals.resultatEfterFinansiellaPoster.previous ?? 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- eget kapital-förändring ---------------------------------------------
|
|
const br = mapping.br
|
|
const at = (concept: string): { ib: number; ub: number } => ({
|
|
ib: br[concept]?.previous ?? 0,
|
|
ub: br[concept]?.current ?? 0,
|
|
})
|
|
const aktiekapital = at('Aktiekapital')
|
|
const balanserat = at('BalanseratResultat')
|
|
const aretsRes = at('AretsResultatEgetKapital')
|
|
const totalt = {
|
|
ib: mapping.totals.egetKapital.previous ?? 0,
|
|
ub: mapping.totals.egetKapital.current,
|
|
}
|
|
const ovrigaPoster = {
|
|
ib:
|
|
totalt.ib - aktiekapital.ib - balanserat.ib - aretsRes.ib,
|
|
ub:
|
|
totalt.ub - aktiekapital.ub - balanserat.ub - aretsRes.ub,
|
|
}
|
|
// Movement derivation: föregående års resultat balanseras; vad som därutöver
|
|
// lämnat balanserat resultat antas vara utdelning (vanligaste fallet).
|
|
const balanserasINyRakning = aretsRes.ib
|
|
const balanseratResidual = balanserat.ub - (balanserat.ib + balanserasINyRakning)
|
|
const utdelning = balanseratResidual < 0 ? -balanseratResidual : 0
|
|
const ovrigForandringBalanserat = balanseratResidual > 0 ? balanseratResidual : 0
|
|
const egetKapital: EgetKapitalForandring = {
|
|
aktiekapital,
|
|
balanseratResultat: balanserat,
|
|
aretsResultat: aretsRes,
|
|
totalt,
|
|
ovrigaPoster,
|
|
balanserasINyRakning,
|
|
utdelning,
|
|
forandringAktiekapital: aktiekapital.ub - aktiekapital.ib,
|
|
ovrigForandringBalanserat,
|
|
aretsResultatRorelse: aretsRes.ub,
|
|
}
|
|
|
|
// ---- resultatdisposition --------------------------------------------------
|
|
// BalanseratResultat is tagged in BR and the eget kapital-table for the
|
|
// same context: the disposition row must carry the identical value
|
|
// (TA §2.7.3), so fri överkursfond (2097) is its own row tagged with the
|
|
// separate Overkursfond concept instead of being folded into balanserat.
|
|
const proposedDividend = Math.max(
|
|
0,
|
|
Math.round(
|
|
options.proposedDividend ?? pdfData.forvaltningsberattelse.proposed_dividend,
|
|
),
|
|
)
|
|
const dispositionAmounts = pdfData.forvaltningsberattelse.resultatdisposition_amounts
|
|
const dispBalanserat = dispositionAmounts.retained_earnings
|
|
const dispOverkursfond = dispositionAmounts.share_premium_reserve
|
|
const dispArets = dispositionAmounts.current_year_result
|
|
const dispSumma = dispositionAmounts.total
|
|
if (proposedDividend > dispSumma) {
|
|
warnings.push(
|
|
`Föreslagen utdelning (${proposedDividend} kr) överstiger fritt eget kapital (${dispSumma} kr).`,
|
|
)
|
|
}
|
|
const resultatdisposition: Resultatdisposition = {
|
|
balanseratResultat: dispBalanserat,
|
|
overkursfond: dispOverkursfond,
|
|
aretsResultat: dispArets,
|
|
summa: dispSumma,
|
|
utdelning: proposedDividend,
|
|
balanserasINyRakning: dispSumma - proposedDividend,
|
|
kommentar: pdfData.forvaltningsberattelse.resultatdisposition || null,
|
|
}
|
|
|
|
// ---- underskrifter ---------------------------------------------------------
|
|
// Every active signature request becomes a signer row (the board must appear
|
|
// in the document), but ONLY actually-signed requests get a date: an unsigned
|
|
// request keeps signedDate null. Legal dates are never fabricated: the
|
|
// missing date renders as an omitted fact in the preview and preflight 1214
|
|
// blocks the submission path until everyone has signed.
|
|
const activeSignatureRequests = signatureRequests.filter(
|
|
(request) => request.status !== 'declined',
|
|
)
|
|
const signedRequests = activeSignatureRequests.filter((request) => request.status === 'signed')
|
|
const today = options.todayIso ?? new Date().toISOString().slice(0, 10)
|
|
const signers: IxbrlSigner[] = activeSignatureRequests.map((request) => {
|
|
const { firstName, lastName } = splitName(request.signer_name)
|
|
return {
|
|
firstName,
|
|
lastName,
|
|
role: request.role || null,
|
|
signedDate: request.signed_at ? request.signed_at.slice(0, 10) : null,
|
|
}
|
|
})
|
|
if (signers.length === 0) {
|
|
warnings.push(
|
|
'Inga underskrifter är registrerade: årsredovisningen måste skrivas under av styrelsen (och ev. VD) innan inlämning (kontrollera-kod 1107/1201).',
|
|
)
|
|
}
|
|
if (signedRequests.length !== activeSignatureRequests.length) {
|
|
warnings.push('Alla underskriftsförfrågningar är inte signerade ännu.')
|
|
}
|
|
const harVd = signers.some((signer) => /verkställande direktör|^vd$/i.test(signer.role ?? ''))
|
|
const latestSignatureDate = signers.reduce<string | null>(
|
|
(latest, signer) =>
|
|
signer.signedDate !== null && (latest === null || signer.signedDate > latest)
|
|
? signer.signedDate
|
|
: latest,
|
|
null,
|
|
)
|
|
|
|
// ---- fastställelseintyg ----------------------------------------------------
|
|
// A missing AGM date is NEVER replaced with today's date: it stays null,
|
|
// the document renders a placeholder and preflight 1103 blocks filing
|
|
// (mirrors Bolagsverket kontrollera 1103).
|
|
const agmDate = pdfData.forvaltningsberattelse.agm_date
|
|
if (!agmDate) {
|
|
warnings.push(
|
|
'Datum för årsstämma saknas: fastställelseintyget kan inte fyllas i (kontrollera-kod 1103).',
|
|
)
|
|
}
|
|
const agmDispositionOutcome = pdfData.forvaltningsberattelse.agm_disposition_outcome
|
|
const agmDispositionDecision = pdfData.forvaltningsberattelse.agm_disposition_decision
|
|
if (!agmDispositionOutcome) {
|
|
warnings.push('Årsstämmans beslut om resultatdisposition saknas i fastställelseintyget.')
|
|
}
|
|
const fallbackSigner = signers[0] ?? { firstName: '', lastName: '', role: null }
|
|
const undertecknare = options.undertecknare ?? {
|
|
firstName: fallbackSigner.firstName,
|
|
lastName: fallbackSigner.lastName,
|
|
role: fallbackSigner.role ?? 'Styrelseledamot',
|
|
}
|
|
if (!undertecknare.firstName) {
|
|
warnings.push('Undertecknare av fastställelseintyget saknas (kontrollera-kod 1169).')
|
|
}
|
|
|
|
// ---- allmänt om verksamheten: ensure säte is mentioned ---------------------
|
|
let allmant = pdfData.forvaltningsberattelse.description
|
|
if (pdfData.company.city && !/säte/i.test(allmant)) {
|
|
allmant = `${allmant}\n\nBolaget har sitt säte i ${pdfData.company.city}.`
|
|
}
|
|
|
|
// ---- medelantal anställda ---------------------------------------------------
|
|
// Compute BOTH years with the same resolver the PDF note uses: a manual
|
|
// override on arsredovisning_narratives for that period wins, otherwise
|
|
// the FTE average over the employees table. The note-prose regex stays
|
|
// only as a last-resort fallback when the employees query fails.
|
|
let medelantalAnstallda: { current: number; previous: number | null }
|
|
const [{ data: employeeRows, error: employeesError }, previousOverride] = await Promise.all([
|
|
supabase
|
|
.from('employees')
|
|
.select('employment_start, employment_end, employment_degree')
|
|
.eq('company_id', companyId),
|
|
previousPeriodId ? getMedelantalOverride(supabase, companyId, previousPeriodId) : null,
|
|
])
|
|
if (employeesError) {
|
|
// The note already embeds the current year's override; last year's
|
|
// manual figure is still worth showing when only the employees read
|
|
// failed.
|
|
medelantalAnstallda = extractMedelantal(pdfData.noter, previousOverride)
|
|
} else {
|
|
const employees = (employeeRows ?? []) as Array<{
|
|
employment_start: string
|
|
employment_end: string | null
|
|
employment_degree: number
|
|
}>
|
|
medelantalAnstallda = {
|
|
current: resolveMedelantalAnstallda(
|
|
pdfData.disclosures.medelantal_anstallda_override,
|
|
employees,
|
|
period.period_start,
|
|
period.period_end,
|
|
),
|
|
previous: previousPeriod
|
|
? resolveMedelantalAnstallda(
|
|
previousOverride,
|
|
employees,
|
|
previousPeriod.start,
|
|
previousPeriod.end,
|
|
)
|
|
: null,
|
|
}
|
|
}
|
|
|
|
return {
|
|
company: {
|
|
name: pdfData.company.name,
|
|
orgNumber: pdfData.company.org_number,
|
|
city: pdfData.company.city,
|
|
},
|
|
period: { start: period.period_start, end: period.period_end },
|
|
previousPeriod,
|
|
isFirstFiscalYear: previousPeriod === null,
|
|
rr: mapping.rr,
|
|
br: mapping.br,
|
|
totals: mapping.totals,
|
|
forvaltningsberattelse: {
|
|
allmantOmVerksamheten: allmant,
|
|
vasentligaHandelser: pdfData.forvaltningsberattelse.important_events,
|
|
flerarsoversikt,
|
|
flerarsPerioder,
|
|
egetKapital,
|
|
resultatdisposition,
|
|
},
|
|
noter: pdfData.noter.map((note) => ({ number: note.number, title: note.title, body: note.body })),
|
|
disclosures: {
|
|
longTermDebtOverFiveYears:
|
|
pdfData.disclosures.long_term_debt_over_five_years ?? 0,
|
|
securitiesPledged: pdfData.disclosures.securities_pledged?.trim() || 'Inga.',
|
|
contingentLiabilities:
|
|
pdfData.disclosures.contingent_liabilities?.trim() || 'Inga.',
|
|
parentCompany: pdfData.disclosures.parent_company_name
|
|
? [
|
|
`Moderföretag: ${pdfData.disclosures.parent_company_name}.`,
|
|
pdfData.disclosures.parent_company_org_number
|
|
? `Organisationsnummer: ${pdfData.disclosures.parent_company_org_number}.`
|
|
: '',
|
|
pdfData.disclosures.parent_company_city
|
|
? `Säte: ${pdfData.disclosures.parent_company_city}.`
|
|
: '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
: null,
|
|
},
|
|
medelantalAnstallda,
|
|
underskrifter: {
|
|
ort: pdfData.company.city ?? '',
|
|
dateringsdatum: latestSignatureDate,
|
|
signers,
|
|
harVd,
|
|
},
|
|
faststallelseintyg: {
|
|
arsstammaDatum: agmDate ?? null,
|
|
resultatdispositionOutcome: agmDispositionOutcome,
|
|
resultatdispositionDecision: agmDispositionDecision,
|
|
signerFirstName: undertecknare.firstName,
|
|
signerLastName: undertecknare.lastName,
|
|
signerRole: undertecknare.role,
|
|
genereratDatum: today,
|
|
},
|
|
programvara: { namn: PROGRAMVARA_NAMN, version: PROGRAMVARA_VERSION },
|
|
entryPointId: entryPoint.id,
|
|
// Dedupe: buildArsredovisningData now runs the same K2 mapping for the
|
|
// PDF statements, so its warnings overlap with the ones produced here.
|
|
warnings: Array.from(new Set([...pdfData.warnings, ...warnings])),
|
|
}
|
|
}
|
|
|
|
/** Pull the FTE figure out of the medelantal note body ("…uppgått till X."). */
|
|
function extractMedelantal(
|
|
noter: Array<{ title: string; body: string }>,
|
|
previous: number | null,
|
|
): { current: number; previous: number | null } {
|
|
const note = noter.find((n) => /medelantal.*anst/i.test(n.title))
|
|
if (!note) return { current: 0, previous }
|
|
const match = note.body.match(/uppgått till\s+([\d,.]+)/i)
|
|
if (!match) return { current: 0, previous }
|
|
const value = Number(match[1].replace(',', '.'))
|
|
return { current: Number.isFinite(value) ? value : 0, previous }
|
|
}
|