feat(arsredovisning): manual override for medelantal anställda (Not 2) (#2420)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
59d5b7b366
commit
e85eac317c
@@ -159,6 +159,7 @@ function makeMinimalK3Data(): ArsredovisningData {
|
||||
parent_company_name: null,
|
||||
parent_company_org_number: null,
|
||||
parent_company_city: null,
|
||||
medelantal_anstallda_override: null,
|
||||
confirmations: {
|
||||
long_term_debt_over_five_years: true,
|
||||
securities_pledged: true,
|
||||
@@ -214,6 +215,7 @@ describe('ArsredovisningK3PDF', () => {
|
||||
it('renders the jämförelseår column when previous_period is set', async () => {
|
||||
const data = makeMinimalK3Data()
|
||||
data.previous_period = {
|
||||
id: 'fp-2024',
|
||||
name: '2024',
|
||||
period_start: '2024-01-01',
|
||||
period_end: '2024-12-31',
|
||||
|
||||
@@ -52,6 +52,7 @@ function makeSupabase(opts: {
|
||||
antalAktier?: number | null
|
||||
agmDate?: string | null
|
||||
previousPeriodId?: string | null
|
||||
medelantalOverride?: number | null
|
||||
}): ChainableMock {
|
||||
const from = vi.fn((table: string) => {
|
||||
if (table === 'fiscal_periods') {
|
||||
@@ -124,14 +125,16 @@ function makeSupabase(opts: {
|
||||
eq: () => ({
|
||||
maybeSingle: () =>
|
||||
Promise.resolve({
|
||||
data: opts.agmDate
|
||||
? {
|
||||
agm_date: opts.agmDate,
|
||||
description: null,
|
||||
important_events: null,
|
||||
resultatdisposition: null,
|
||||
}
|
||||
: null,
|
||||
data:
|
||||
opts.agmDate || opts.medelantalOverride != null
|
||||
? {
|
||||
agm_date: opts.agmDate ?? null,
|
||||
description: null,
|
||||
important_events: null,
|
||||
resultatdisposition: null,
|
||||
medelantal_anstallda_override: opts.medelantalOverride ?? null,
|
||||
}
|
||||
: null,
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
@@ -288,6 +291,32 @@ beforeEach(() => {
|
||||
plantStandardReports()
|
||||
})
|
||||
|
||||
describe('buildArsredovisningData: medelantal anställda override (ÅRL 5:20 §)', () => {
|
||||
it.each(['k2', 'k3'] as const)(
|
||||
'%s: without an override the note reports no employees',
|
||||
async (framework) => {
|
||||
const supabase = makeSupabase({ accountingFramework: framework })
|
||||
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
|
||||
const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
|
||||
const note = data.noter.find((n) => n.title === 'Medelantal anställda')
|
||||
expect(note?.body).toContain('inte haft några anställda')
|
||||
expect(data.disclosures.medelantal_anstallda_override).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['k2', 'k3'] as const)(
|
||||
'%s: a manual figure on the narrative replaces the computed note',
|
||||
async (framework) => {
|
||||
const supabase = makeSupabase({ accountingFramework: framework, medelantalOverride: 1 })
|
||||
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
|
||||
const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
|
||||
const note = data.noter.find((n) => n.title === 'Medelantal anställda')
|
||||
expect(note?.body).toBe('Under räkenskapsåret har medeltalet anställda uppgått till 1.')
|
||||
expect(data.disclosures.medelantal_anstallda_override).toBe(1)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('buildArsredovisningData: K3', () => {
|
||||
it('records accounting_framework=k3 in the output', async () => {
|
||||
const supabase = makeSupabase({ accountingFramework: 'k3' })
|
||||
|
||||
@@ -116,6 +116,7 @@ function makeLossYearData(framework: 'k2' | 'k3'): ArsredovisningData {
|
||||
parent_company_name: null,
|
||||
parent_company_org_number: null,
|
||||
parent_company_city: null,
|
||||
medelantal_anstallda_override: null,
|
||||
confirmations: {
|
||||
long_term_debt_over_five_years: true,
|
||||
securities_pledged: true,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The size metrics behind the ÅRL 1:3 § (större företag) and K2-relief
|
||||
* checks must disclose the same employee figure as Not 2 and the iXBRL
|
||||
* fact. The skeptic on the medelantal override found the metrics still
|
||||
* reading the FTE average while the note used the manual figure: a
|
||||
* SIE-migrated company with no employees rows and an override of 60 would
|
||||
* have validated as a mindre företag while its own document said 60.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { reportMetrics } from '../model'
|
||||
import type { buildArsredovisningData } from '../build-data'
|
||||
|
||||
type Report = Awaited<ReturnType<typeof buildArsredovisningData>>
|
||||
|
||||
function makeReport(overrides: {
|
||||
currentOverride: number | null
|
||||
withPrevious?: boolean
|
||||
}): Report {
|
||||
return {
|
||||
fiscal_period: {
|
||||
id: 'fp-2025',
|
||||
name: '2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
},
|
||||
previous_period: overrides.withPrevious
|
||||
? { id: 'fp-2024', name: '2024', period_start: '2024-01-01', period_end: '2024-12-31' }
|
||||
: null,
|
||||
forvaltningsberattelse: {
|
||||
flerarsoversikt: [
|
||||
{ year: '2025', net_revenue: 50_000_000 },
|
||||
{ year: '2024', net_revenue: 50_000_000 },
|
||||
],
|
||||
},
|
||||
balansrakning: {
|
||||
total_assets: 45_000_000,
|
||||
total_assets_previous: overrides.withPrevious ? 45_000_000 : null,
|
||||
},
|
||||
disclosures: {
|
||||
medelantal_anstallda_override: overrides.currentOverride,
|
||||
},
|
||||
} as unknown as Report
|
||||
}
|
||||
|
||||
const fullYearEmployee = {
|
||||
employment_start: '2020-01-01',
|
||||
employment_end: null,
|
||||
employment_degree: 100,
|
||||
}
|
||||
|
||||
describe('reportMetrics: employee figure follows the medelantal override', () => {
|
||||
it('uses the FTE average from employees when no override is set', () => {
|
||||
const metrics = reportMetrics(makeReport({ currentOverride: null }), [fullYearEmployee])
|
||||
expect(metrics.current.employees).toBe(1)
|
||||
})
|
||||
|
||||
it('uses the current period override for the current year', () => {
|
||||
const metrics = reportMetrics(makeReport({ currentOverride: 60 }), [])
|
||||
expect(metrics.current.employees).toBe(60)
|
||||
})
|
||||
|
||||
it('uses the previous period override for the jämförelseår', () => {
|
||||
const metrics = reportMetrics(
|
||||
makeReport({ currentOverride: 60, withPrevious: true }),
|
||||
[],
|
||||
60,
|
||||
)
|
||||
expect(metrics.previous?.employees).toBe(60)
|
||||
})
|
||||
|
||||
it('falls back to the FTE average for the previous year when it has no override', () => {
|
||||
const metrics = reportMetrics(
|
||||
makeReport({ currentOverride: 2, withPrevious: true }),
|
||||
[fullYearEmployee],
|
||||
null,
|
||||
)
|
||||
expect(metrics.current.employees).toBe(2)
|
||||
expect(metrics.previous?.employees).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type AnlaggningAsset,
|
||||
} from './anlaggningstillgangar-note'
|
||||
import { computeAssetNoteFigures, loadPostedSchedules } from './asset-note-figures'
|
||||
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
|
||||
import { resolveMedelantalAnstallda } from '@/lib/salary/medelantal'
|
||||
import type {
|
||||
ArsredovisningData,
|
||||
EgenKapitalRow,
|
||||
@@ -202,6 +202,7 @@ export async function buildArsredovisningData(
|
||||
const previousPeriod =
|
||||
prevPeriodRow && previousTb
|
||||
? {
|
||||
id: prevPeriodRow.id,
|
||||
name: prevPeriodRow.name,
|
||||
period_start: prevPeriodRow.period_start,
|
||||
period_end: prevPeriodRow.period_end,
|
||||
@@ -449,6 +450,7 @@ export async function buildArsredovisningData(
|
||||
parent_company_name: narrative?.parent_company_name ?? null,
|
||||
parent_company_org_number: narrative?.parent_company_org_number ?? null,
|
||||
parent_company_city: narrative?.parent_company_city ?? null,
|
||||
medelantal_anstallda_override: narrative?.medelantal_anstallda_override ?? null,
|
||||
confirmations: {
|
||||
long_term_debt_over_five_years:
|
||||
narrative?.long_term_debt_over_five_years_confirmed ?? false,
|
||||
@@ -759,7 +761,11 @@ async function buildK2Noter(
|
||||
// ÅRL 5:20 § requires the note for AB regardless of value: "0" must be
|
||||
// disclosed as "Inga anställda". For enskild firma the disclosure is
|
||||
// discretionary, so we still skip when medelantal === 0 there.
|
||||
const medelantal = computeMedelantalAnstallda(
|
||||
// A manual figure on arsredovisning_narratives wins over the FTE average:
|
||||
// salary booked without a Löner employee record (hand-booked, SIE import)
|
||||
// otherwise reads as "inga anställda" although the owner drew salary.
|
||||
const medelantal = resolveMedelantalAnstallda(
|
||||
narrative?.medelantal_anstallda_override,
|
||||
(employeesResult.data ?? []) as Array<{
|
||||
employment_start: string
|
||||
employment_end: string | null
|
||||
@@ -1157,10 +1163,12 @@ async function buildK3Noter(
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Medelantal anställda: FTE-weighted average per ÅRL 5:20 §. The note is
|
||||
// 5. Medelantal anställda: FTE-weighted average per ÅRL 5:20 §, or the
|
||||
// manual figure on arsredovisning_narratives when set. The note is
|
||||
// statutory for AB regardless of value (disclose "0" explicitly); for non-AB
|
||||
// entities we still skip when there are no employees.
|
||||
const medelantal = computeMedelantalAnstallda(
|
||||
const medelantal = resolveMedelantalAnstallda(
|
||||
narrative?.medelantal_anstallda_override,
|
||||
(employeesResult.data ?? []) as Array<{
|
||||
employment_start: string
|
||||
employment_end: string | null
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
type CanonicalAnnualReport,
|
||||
} from './compliance-types'
|
||||
import { buildIxbrlInput, type BuildIxbrlOptions } from '@/lib/bokslut/ixbrl/build-input'
|
||||
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
|
||||
import { resolveMedelantalAnstallda } from '@/lib/salary/medelantal'
|
||||
import { getMedelantalOverride } from './narrative-service'
|
||||
|
||||
export interface BuildCanonicalAnnualReportOptions extends BuildIxbrlOptions {
|
||||
stage?: AnnualReportValidationStage
|
||||
@@ -29,9 +30,17 @@ interface EmployeeRow {
|
||||
employment_degree: number
|
||||
}
|
||||
|
||||
function reportMetrics(
|
||||
/**
|
||||
* Size metrics for the ÅRL 1:3 § and K2-relief thresholds. The employee
|
||||
* figure is the same one the note and the iXBRL fact disclose: a manual
|
||||
* override on arsredovisning_narratives for the period wins over the FTE
|
||||
* average, so the eligibility verdict and the document cannot disagree
|
||||
* about how many people the company employs.
|
||||
*/
|
||||
export function reportMetrics(
|
||||
report: Awaited<ReturnType<typeof buildArsredovisningData>>,
|
||||
employees: EmployeeRow[],
|
||||
previousMedelantalOverride: number | null = null,
|
||||
): AnnualReportSizeMetrics {
|
||||
const currentOverview = report.forvaltningsberattelse.flerarsoversikt.find(
|
||||
(row) => row.year === report.fiscal_period.name,
|
||||
@@ -43,7 +52,8 @@ function reportMetrics(
|
||||
: null
|
||||
return {
|
||||
current: {
|
||||
employees: computeMedelantalAnstallda(
|
||||
employees: resolveMedelantalAnstallda(
|
||||
report.disclosures.medelantal_anstallda_override,
|
||||
employees,
|
||||
report.fiscal_period.period_start,
|
||||
report.fiscal_period.period_end,
|
||||
@@ -53,7 +63,8 @@ function reportMetrics(
|
||||
},
|
||||
previous: report.previous_period
|
||||
? {
|
||||
employees: computeMedelantalAnstallda(
|
||||
employees: resolveMedelantalAnstallda(
|
||||
previousMedelantalOverride,
|
||||
employees,
|
||||
report.previous_period.period_start,
|
||||
report.previous_period.period_end,
|
||||
@@ -106,7 +117,17 @@ export async function buildCanonicalAnnualReport(
|
||||
signed_at: signature.signed_at,
|
||||
}))
|
||||
|
||||
const metrics = reportMetrics(report, (employeesResult.data ?? []) as EmployeeRow[])
|
||||
// The jämförelseår's manual figure lives on that period's narrative row;
|
||||
// without it a SIE-migrated company with no employees rows would count
|
||||
// as 0 last year and dodge the two-year ÅRL 1:3 § test.
|
||||
const previousMedelantalOverride = report.previous_period
|
||||
? await getMedelantalOverride(supabase, companyId, report.previous_period.id)
|
||||
: null
|
||||
const metrics = reportMetrics(
|
||||
report,
|
||||
(employeesResult.data ?? []) as EmployeeRow[],
|
||||
previousMedelantalOverride,
|
||||
)
|
||||
const eligibility = evaluateAnnualReportEligibility({
|
||||
entityType: report.company.entity_type,
|
||||
framework: report.accounting_framework,
|
||||
|
||||
@@ -42,6 +42,10 @@ export interface NarrativeOverrides {
|
||||
parent_company_name: string | null
|
||||
parent_company_org_number: string | null
|
||||
parent_company_city: string | null
|
||||
/** ÅRL 5:20 §: manual medelantal anställda. Null → computed as an FTE
|
||||
* average over the employees table. A whole number replaces the computed
|
||||
* value in the note and the iXBRL fact for this period. */
|
||||
medelantal_anstallda_override: number | null
|
||||
long_term_debt_over_five_years_confirmed: boolean
|
||||
securities_pledged_confirmed: boolean
|
||||
contingent_liabilities_confirmed: boolean
|
||||
@@ -70,6 +74,7 @@ export interface NarrativeRow {
|
||||
parent_company_name: string | null
|
||||
parent_company_org_number: string | null
|
||||
parent_company_city: string | null
|
||||
medelantal_anstallda_override: number | null
|
||||
long_term_debt_over_five_years_confirmed: boolean
|
||||
securities_pledged_confirmed: boolean
|
||||
contingent_liabilities_confirmed: boolean
|
||||
@@ -85,7 +90,31 @@ const TABLE = 'arsredovisning_narratives'
|
||||
// of API responses. GDPR Art.25.2 / ISO A.8.3 data-minimization: callers
|
||||
// only need the narrative content + last-updated timestamp.
|
||||
const NARRATIVE_API_COLUMNS =
|
||||
'id, company_id, fiscal_period_id, description, important_events, resultatdisposition, proposed_dividend, agm_date, long_term_debt_over_five_years, securities_pledged, contingent_liabilities, parent_company_name, parent_company_org_number, parent_company_city, long_term_debt_over_five_years_confirmed, securities_pledged_confirmed, contingent_liabilities_confirmed, parent_company_confirmed, agm_disposition_outcome, agm_disposition_decision, updated_at'
|
||||
'id, company_id, fiscal_period_id, description, important_events, resultatdisposition, proposed_dividend, agm_date, long_term_debt_over_five_years, securities_pledged, contingent_liabilities, parent_company_name, parent_company_org_number, parent_company_city, medelantal_anstallda_override, long_term_debt_over_five_years_confirmed, securities_pledged_confirmed, contingent_liabilities_confirmed, parent_company_confirmed, agm_disposition_outcome, agm_disposition_decision, updated_at'
|
||||
|
||||
/**
|
||||
* The medelantal anställda override alone, for a period other than the one
|
||||
* being built (the iXBRL note shows the jämförelseår in the same table, so
|
||||
* last year's manual figure must win there too). Null when no row or no
|
||||
* override; errors are swallowed because a missing jämförelsetal must never
|
||||
* block the current year's document.
|
||||
*/
|
||||
export async function getMedelantalOverride(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
): Promise<number | null> {
|
||||
const { data, error } = await supabase
|
||||
.from(TABLE)
|
||||
.select('medelantal_anstallda_override')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.maybeSingle()
|
||||
if (error || !data) return null
|
||||
const value = (data as { medelantal_anstallda_override: number | null })
|
||||
.medelantal_anstallda_override
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted narrative overrides for a fiscal period. Returns null when
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface ArsredovisningData {
|
||||
* Null for the company's first fiscal year, or when the previous year's
|
||||
* trial balance could not be generated (a warning is emitted then). */
|
||||
previous_period: {
|
||||
id: string
|
||||
name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
@@ -155,6 +156,10 @@ export interface ArsredovisningData {
|
||||
parent_company_name: string | null
|
||||
parent_company_org_number: string | null
|
||||
parent_company_city: string | null
|
||||
/** ÅRL 5:20 §: manual medelantal anställda. Null means "computed from
|
||||
* the employees table"; the note and the iXBRL fact already reflect
|
||||
* whichever won. */
|
||||
medelantal_anstallda_override: number | null
|
||||
confirmations: {
|
||||
long_term_debt_over_five_years: boolean
|
||||
securities_pledged: boolean
|
||||
|
||||
@@ -12,7 +12,8 @@ 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 { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
|
||||
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 {
|
||||
@@ -89,6 +90,7 @@ export async function buildIxbrlInput(
|
||||
// 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
|
||||
@@ -99,6 +101,7 @@ export async function buildIxbrlInput(
|
||||
.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' }),
|
||||
@@ -326,16 +329,23 @@ export async function buildIxbrlInput(
|
||||
}
|
||||
|
||||
// ---- medelantal anställda ---------------------------------------------------
|
||||
// Compute BOTH years with the real FTE helper (the same one the PDF note
|
||||
// uses) over the employees table. The note-prose regex stays only as a
|
||||
// last-resort fallback when the employees query fails.
|
||||
// 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 } = await supabase
|
||||
.from('employees')
|
||||
.select('employment_start, employment_end, employment_degree')
|
||||
.eq('company_id', companyId)
|
||||
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) {
|
||||
medelantalAnstallda = extractMedelantal(pdfData.noter, null)
|
||||
// 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
|
||||
@@ -343,9 +353,19 @@ export async function buildIxbrlInput(
|
||||
employment_degree: number
|
||||
}>
|
||||
medelantalAnstallda = {
|
||||
current: computeMedelantalAnstallda(employees, period.period_start, period.period_end),
|
||||
current: resolveMedelantalAnstallda(
|
||||
pdfData.disclosures.medelantal_anstallda_override,
|
||||
employees,
|
||||
period.period_start,
|
||||
period.period_end,
|
||||
),
|
||||
previous: previousPeriod
|
||||
? computeMedelantalAnstallda(employees, previousPeriod.start, previousPeriod.end)
|
||||
? resolveMedelantalAnstallda(
|
||||
previousOverride,
|
||||
employees,
|
||||
previousPeriod.start,
|
||||
previousPeriod.end,
|
||||
)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeMedelantalAnstallda } from '../medelantal'
|
||||
import { computeMedelantalAnstallda, resolveMedelantalAnstallda } from '../medelantal'
|
||||
|
||||
describe('resolveMedelantalAnstallda', () => {
|
||||
const START = '2025-07-01'
|
||||
const END = '2026-06-30'
|
||||
// The support case: owner registered in Löner from 2026-01-01 in a
|
||||
// July-June year. 181 / 365 = 0.496 rounds to 0 although salary was
|
||||
// drawn all year.
|
||||
const halfYearOwner = [
|
||||
{ employment_start: '2026-01-01', employment_end: null, employment_degree: 100 },
|
||||
]
|
||||
|
||||
it('falls back to the FTE average when no override is set', () => {
|
||||
expect(resolveMedelantalAnstallda(null, halfYearOwner, START, END)).toBe(0)
|
||||
expect(resolveMedelantalAnstallda(undefined, halfYearOwner, START, END)).toBe(0)
|
||||
})
|
||||
|
||||
it('lets a manual figure replace the FTE average', () => {
|
||||
expect(resolveMedelantalAnstallda(1, halfYearOwner, START, END)).toBe(1)
|
||||
expect(resolveMedelantalAnstallda(3, [], START, END)).toBe(3)
|
||||
})
|
||||
|
||||
it('treats an explicit 0 as an override, not as "unset"', () => {
|
||||
const fullYear = [
|
||||
{ employment_start: '2020-01-01', employment_end: null, employment_degree: 100 },
|
||||
]
|
||||
expect(resolveMedelantalAnstallda(0, fullYear, START, END)).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores a negative or non-finite override', () => {
|
||||
const fullYear = [
|
||||
{ employment_start: '2020-01-01', employment_end: null, employment_degree: 100 },
|
||||
]
|
||||
expect(resolveMedelantalAnstallda(-1, fullYear, START, END)).toBe(1)
|
||||
expect(resolveMedelantalAnstallda(Number.NaN, fullYear, START, END)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeMedelantalAnstallda', () => {
|
||||
const START = '2025-01-01'
|
||||
|
||||
@@ -66,3 +66,21 @@ export function computeMedelantalAnstallda(
|
||||
|
||||
return Math.round(totalFteDays / periodDays)
|
||||
}
|
||||
|
||||
/**
|
||||
* The figure the årsredovisning discloses: a manual override from
|
||||
* arsredovisning_narratives when the user has set one, otherwise the FTE
|
||||
* average above. One resolver so the PDF note, the iXBRL fact, and any
|
||||
* other reader cannot disagree about which number wins.
|
||||
*/
|
||||
export function resolveMedelantalAnstallda(
|
||||
override: number | null | undefined,
|
||||
employees: EmployeePeriodInput[],
|
||||
periodStartIso: string,
|
||||
periodEndIso: string,
|
||||
): number {
|
||||
if (typeof override === 'number' && Number.isFinite(override) && override >= 0) {
|
||||
return Math.round(override)
|
||||
}
|
||||
return computeMedelantalAnstallda(employees, periodStartIso, periodEndIso)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user