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
+38 -3
View File
@@ -23,6 +23,11 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import type { BASAccount, CreateArticleInput } from '@/types'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import {
ROT_WORK_TYPES,
RUT_WORK_TYPES,
normalizeHouseworkType,
} from '@/lib/invoices/rot-rut-rules'
// A row from the currencies reference table (lib migration
// 20260630110000_currencies_reference_table.sql).
@@ -202,11 +207,22 @@ export default function ArticleForm({
revenue_account: initialData?.revenue_account || '',
cost_price: initialData?.cost_price ?? undefined,
ean: initialData?.ean || '',
housework_type: initialData?.housework_type || '',
// Canonical form (work-type code, bare ROT/RUT, or ''): a stray value
// from an import must not sit invisibly in the select as "Ingen".
housework_type: normalizeHouseworkType(initialData?.housework_type) ?? '',
notes: initialData?.notes || '',
},
})
// Legacy articles carry only the kind ('ROT'/'RUT'), stored before this
// form offered Skatteverket work types. Keep that choice selectable so an
// edit never silently drops the flag; the user upgrades it to a real
// arbetstyp when they want the invoice row's work type pre-filled too.
const legacyHouseworkKind = (() => {
const v = normalizeHouseworkType(initialData?.housework_type)
return v === 'ROT' || v === 'RUT' ? v : null
})()
const type = watch('type')
const watchedName = watch('name')
const watchedUnit = watch('unit')
@@ -454,8 +470,27 @@ export default function ArticleForm({
onChange={(e) => field.onChange(e.target.value)}
>
<option value="">{t('housework_none')}</option>
<option value="ROT">{t('housework_rot')}</option>
<option value="RUT">{t('housework_rut')}</option>
{legacyHouseworkKind && (
<option value={legacyHouseworkKind}>
{legacyHouseworkKind === 'ROT'
? t('housework_legacy_rot')
: t('housework_legacy_rut')}
</option>
)}
<optgroup label={t('housework_rot')}>
{ROT_WORK_TYPES.map((w) => (
<option key={w.code} value={w.code}>
{w.label}
</option>
))}
</optgroup>
<optgroup label={t('housework_rut')}>
{RUT_WORK_TYPES.map((w) => (
<option key={w.code} value={w.code}>
{w.label}
</option>
))}
</optgroup>
</SettingsSelect>
)}
/>
+38 -12
View File
@@ -64,6 +64,7 @@ import {
RUT_MAX,
computeDeduction,
deductionTypeForWorkType,
parseArticleHouseworkType,
} from '@/lib/invoices/rot-rut-rules'
import { UNDECRYPTABLE_PERSONAL_NUMBER_MASK } from '@/lib/customers/mask-personal-number'
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
@@ -568,17 +569,27 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// The account override rides along regardless of rate; the engine ignores it
// for reverse-charge/export and validates it against the chart of accounts.
setValue(`items.${index}.revenue_account`, a.revenue_account ?? null, { shouldDirty: true })
// ROT/RUT: the article's housework_type (Skatteverket arbetstypskod)
// decides both the line's deduction kind and its work type. An article
// WITHOUT one re-defaults the row to no deduction, the same overwrite
// semantics as description/price above: a material article picked onto a
// previously RUT-flagged row must not keep claiming a deduction on
// material. Proformas/delivery notes/self-billing have no deduction model
// (their rows keep no ⋮ menu either), so they are left untouched.
// ROT/RUT: the article's housework_type decides the line's deduction kind
// and, when it is a Skatteverket arbetstypskod, its work type too. Legacy
// articles carry only the kind (`ROT`/`RUT`): those pre-fill the deduction
// and keep a same-kind arbetstyp already chosen on the row. An article
// WITHOUT any housework flag re-defaults the row to no deduction, the same
// overwrite semantics as description/price above: a material article
// picked onto a previously RUT-flagged row must not keep claiming a
// deduction on material. Proformas/delivery notes/self-billing have no
// deduction model (their rows keep no ⋮ menu either), so they are left
// untouched.
if (isInvoiceDoc) {
const kind = deductionTypeForWorkType(a.housework_type)
const { deductionType: kind, workType } = parseArticleHouseworkType(a.housework_type)
const currentWorkType = getValues(`items.${index}.work_type`) ?? null
const keepCurrentWorkType =
kind != null && !workType && deductionTypeForWorkType(currentWorkType) === kind
setValue(`items.${index}.deduction_type`, kind, { shouldDirty: true })
setValue(`items.${index}.work_type`, kind ? a.housework_type : null, { shouldDirty: true })
setValue(
`items.${index}.work_type`,
workType ?? (keepCurrentWorkType ? currentWorkType : null),
{ shouldDirty: true },
)
if (kind) {
// Same rule as the manual ⋮ menu: ROT/RUT och periodisering
// kombineras aldrig på samma rad; avdraget vinner.
@@ -637,9 +648,14 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// The typed unit price is in the invoice's currency: without this an
// EUR invoice line becomes an SEK article with the EUR number.
currency: getValues('currency'),
// Round-trip the ROT/RUT arbetstypskod so the saved article
// pre-fills the deduction the next time it is picked.
housework_type: item.deduction_type ? item.work_type ?? null : null,
// Round-trip the ROT/RUT flag so the saved article pre-fills the
// deduction the next time it is picked: the arbetstypskod when the
// row has one, otherwise the bare kind.
housework_type: item.deduction_type
? deductionTypeForWorkType(item.work_type) === item.deduction_type
? item.work_type
: item.deduction_type.toUpperCase()
: null,
}),
})
const result = await response.json()
@@ -1700,6 +1716,16 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
onValueChange={(v) => {
const next = v === 'none' ? null : (v as 'rot' | 'rut')
setValue(`items.${index}.deduction_type`, next, { shouldDirty: true })
// The arbetstyp lists are per kind: a ROT code
// must not survive a switch to RUT (the select
// would show it as empty while the payload kept
// the wrong code).
if (
next !== null &&
deductionTypeForWorkType(watchItems[index]?.work_type) !== next
) {
setValue(`items.${index}.work_type`, null)
}
if (next === null) {
setValue(`items.${index}.work_type`, null)
setValue(`items.${index}.labor_hours`, null)