fix(articles): article ROT/RUT prefill was dead for every dashboard-created article (#1651)

* fix(articles): article ROT/RUT prefill was dead for every dashboard-created article

Follow-up to #1634. The user re-tested and picking a RUT article still left
the line on "Ingen": the article form has always stored the bare kind
('ROT'/'RUT'), while the prefill only recognised Skatteverket work-type codes
(BYGG, STAD, ...). On prod every dashboard-created ROT/RUT article holds the
bare kind, so the fix in #1634 never fired for a real user, and worse, since
the helper returned null for those values, picking such an article CLEARED a
deduction the user had set manually on the row.

- rot-rut-rules: parseArticleHouseworkType() understands both vocabularies
  (code -> kind + arbetstyp; bare ROT/RUT -> kind only), plus
  normalizeHouseworkType()/HOUSEWORK_TYPE_VALUES/workTypeLabel().
- InvoiceEditor.applyArticle: kind-only articles pre-fill the deduction and
  keep a same-kind arbetstyp already chosen on the row; "Spara som artikel"
  round-trips the code or, lacking one, the kind.
- ArticleForm: the ROT/RUT select now offers the real Skatteverket arbetstyper
  in ROT/RUT groups (its own hint always promised "förifyller arbetstyp");
  legacy kind-only values stay selectable as "RUT (arbetstyp ej vald)" so an
  edit never silently drops the flag. Article detail renders "RUT · Städning"
  instead of the raw code.
- API + MCP commit schemas normalize housework_type (case-insensitive code or
  ROT/RUT, '' clears) and reject anything else; the CSV article import
  normalizes the column the same way. Prod holds 178 articles with '0'/'1'
  from a boolean "Rot" column that the keyword detector mapped straight
  through; those now read as no flag everywhere and can no longer be created.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(articles): review follow-ups on #1651

- InvoiceEditor: switching a row's skattereduktion ROT<->RUT clears an
  arbetstyp from the other list, and Spara som artikel only round-trips a
  work type that belongs to the row's kind (CodeRabbit).
- MCP update_article: null / '' / whitespace now clear housework_type
  (commit drops only undefined keys, so the old undefined mapping made the
  flag un-clearable); create keeps treating them as unset. Tests.
- Article CSV import warns when a non-empty ROT/RUT value is dropped as
  not-an-arbetstyp instead of dropping it silently. Test.
- Hint wording: arbetstyp is pre-filled only when the article carries one.

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:
Jakob Wennberg
2026-08-17 19:47:29 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 1cd33d9d97
commit 79240cb2ed
15 changed files with 359 additions and 25 deletions
@@ -11,6 +11,12 @@ import {
deductionSekConverter,
deductionToSek,
deductionTypeForWorkType,
parseArticleHouseworkType,
normalizeHouseworkType,
workTypeLabel,
HOUSEWORK_TYPE_VALUES,
ROT_WORK_TYPES,
RUT_WORK_TYPES,
type ItemForDeduction,
type ValidateInvoiceItem,
} from '../rot-rut-rules'
@@ -380,3 +386,50 @@ describe('deductionTypeForWorkType', () => {
expect(deductionTypeForWorkType('SNICKERI')).toBeNull()
})
})
describe('parseArticleHouseworkType (articles.housework_type vocabularies)', () => {
it('a Skatteverket code decides both kind and work type', () => {
expect(parseArticleHouseworkType('STAD')).toEqual({ deductionType: 'rut', workType: 'STAD' })
expect(parseArticleHouseworkType('BYGG')).toEqual({ deductionType: 'rot', workType: 'BYGG' })
// Case-insensitive: the API stores upper-case, but older writers did not.
expect(parseArticleHouseworkType(' malning ')).toEqual({ deductionType: 'rot', workType: 'MALNING' })
})
it('the bare kind (what the article form stored before it offered work types) decides the kind only', () => {
// This is the exact prod value behind the 2026-08-17 report: picking a
// "RUT" article pre-filled nothing because only codes were recognised.
expect(parseArticleHouseworkType('RUT')).toEqual({ deductionType: 'rut', workType: null })
expect(parseArticleHouseworkType('rot')).toEqual({ deductionType: 'rot', workType: null })
})
it('anything else is not a housework flag', () => {
// '0'/'1' are what a boolean "Rot" CSV column produced on prod.
for (const v of ['0', '1', 'Ja', 'SNICKERI', '', null, undefined]) {
expect(parseArticleHouseworkType(v)).toEqual({ deductionType: null, workType: null })
}
})
})
describe('normalizeHouseworkType / HOUSEWORK_TYPE_VALUES', () => {
it('canonicalises to the code, the bare kind, or null', () => {
expect(normalizeHouseworkType('stad')).toBe('STAD')
expect(normalizeHouseworkType('Rut')).toBe('RUT')
expect(normalizeHouseworkType('1')).toBeNull()
expect(normalizeHouseworkType('')).toBeNull()
expect(normalizeHouseworkType(null)).toBeNull()
})
it('accepts exactly the two kinds plus every code in both lists', () => {
expect(HOUSEWORK_TYPE_VALUES).toHaveLength(2 + ROT_WORK_TYPES.length + RUT_WORK_TYPES.length)
for (const v of HOUSEWORK_TYPE_VALUES) expect(normalizeHouseworkType(v)).toBe(v)
})
})
describe('workTypeLabel', () => {
it('returns the Skatteverket label for known codes and null otherwise', () => {
expect(workTypeLabel('STAD')).toBe('Städning')
expect(workTypeLabel('VVS')).toBe('VVS-arbete')
expect(workTypeLabel('RUT')).toBeNull()
expect(workTypeLabel(null)).toBeNull()
})
})
+51
View File
@@ -146,6 +146,57 @@ export function deductionTypeForWorkType(code: string | null | undefined): Deduc
return null
}
/** Human label for a Skatteverket work-type code, or null for unknown codes. */
export function workTypeLabel(code: string | null | undefined): string | null {
if (!code) return null
const hit = [...ROT_WORK_TYPES, ...RUT_WORK_TYPES].find((w) => w.code === code)
return hit ? hit.label : null
}
/**
* The two vocabularies `articles.housework_type` has been written in:
* - a Skatteverket work-type code (`BYGG`, `STAD`, ...): the intended value,
* decides both the deduction kind and the line's arbetstyp;
* - the bare kind `ROT` / `RUT`: what the article form stored before it
* offered real work types (legacy rows), decides the kind only.
* Anything else (free text, `0`/`1` from a mis-mapped CSV column) is not a
* housework flag at all and normalizes to null.
*/
export interface ArticleHousework {
deductionType: DeductionType | null
/** Skatteverket work-type code, or null when only the kind is known. */
workType: string | null
}
export function parseArticleHouseworkType(value: string | null | undefined): ArticleHousework {
const raw = value?.trim().toUpperCase() ?? ''
if (!raw) return { deductionType: null, workType: null }
const kindFromCode = deductionTypeForWorkType(raw)
if (kindFromCode) return { deductionType: kindFromCode, workType: raw }
if (raw === 'ROT' || raw === 'RUT') return { deductionType: raw.toLowerCase() as DeductionType, workType: null }
return { deductionType: null, workType: null }
}
/**
* Canonical stored form of a housework_type input: the work-type code, the
* bare kind (`ROT`/`RUT`), or null. Case-insensitive; unknown values are
* null so the column never accumulates a third vocabulary again.
*/
export function normalizeHouseworkType(value: string | null | undefined): string | null {
const parsed = parseArticleHouseworkType(value)
if (parsed.workType) return parsed.workType
if (parsed.deductionType) return parsed.deductionType.toUpperCase()
return null
}
/** Accepted housework_type values: every work-type code plus the bare kinds. */
export const HOUSEWORK_TYPE_VALUES: readonly string[] = [
'ROT',
'RUT',
...ROT_WORK_TYPES.map((w) => w.code),
...RUT_WORK_TYPES.map((w) => w.code),
]
export interface ItemForDeduction {
/** Unit price (per `quantity`). Same field as invoice_items.unit_price. */
unit_price: number