Files
accounted/lib/import/articles/__tests__/parser.test.ts
T
Jakob Wennberg 79240cb2ed 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>
2026-08-17 19:47:29 +02:00

267 lines
9.2 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import * as XLSX from 'xlsx'
import { parseArticlesFile } from '../parser'
function buildXlsx(rows: (string | number)[][]): ArrayBuffer {
const ws = XLSX.utils.aoa_to_sheet(rows)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Artiklar')
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer
}
describe('parseArticlesFile', () => {
it('parses a basic Swedish article register', () => {
const buffer = buildXlsx([
['Benämning', 'Artikelnummer', 'Pris', 'Moms', 'Enhet', 'Typ'],
['Konsulttimme', 'A-100', '950', '25', 'tim', 'tjänst'],
['Skruv', 'A-200', '2,50', '25', 'st', 'vara'],
])
const result = parseArticlesFile(buffer, 'artiklar.xlsx')
expect(result.total_rows).toBe(2)
expect(result.rows[0].name).toBe('Konsulttimme')
expect(result.rows[0].article_number).toBe('A-100')
expect(result.rows[0].price_excl_vat).toBe(950)
expect(result.rows[0].vat_rate).toBe(25)
expect(result.rows[0].unit).toBe('tim')
expect(result.rows[0].type).toBe('tjanst')
expect(result.rows[1].type).toBe('vara')
expect(result.rows[1].price_excl_vat).toBe(2.5)
expect(result.rows[0].is_valid).toBe(true)
// A clean, valid VAT rate is not flagged as adjusted.
expect(result.rows[0].vat_rate_adjusted).toBe(false)
})
it('detects Fortnox-style export headers', () => {
const buffer = buildXlsx([
['Artikelnummer', 'Benämning', 'Försäljningspris', 'Momskod', 'Försäljningskonto', 'Enhet'],
['100', 'Webdesign', '1 200', '25', '3001', 'st'],
])
const result = parseArticlesFile(buffer, 'fortnox.xlsx')
const r = result.rows[0]
expect(r.article_number).toBe('100')
expect(r.name).toBe('Webdesign')
expect(r.price_excl_vat).toBe(1200) // "1 200" → 1200
expect(r.vat_rate).toBe(25)
expect(r.revenue_account).toBe('3001')
})
it('parses Swedish decimal prices', () => {
const buffer = buildXlsx([
['Benämning', 'Pris'],
['A', '1 234,56'],
['B', '1.234,50'],
['C', '500'],
])
const result = parseArticlesFile(buffer, 'priser.xlsx')
expect(result.rows[0].price_excl_vat).toBe(1234.56)
expect(result.rows[1].price_excl_vat).toBe(1234.5)
expect(result.rows[2].price_excl_vat).toBe(500)
})
it('snaps VAT to the nearest statutory rate and warns', () => {
const buffer = buildXlsx([
['Benämning', 'Moms'],
['A', '25%'],
['B', '7'], // → 6
['C', ''], // → 25 default
])
const result = parseArticlesFile(buffer, 'moms.xlsx')
expect(result.rows[0].vat_rate).toBe(25)
expect(result.rows[1].vat_rate).toBe(6)
expect(result.rows[2].vat_rate).toBe(25)
// The "7" → 6 snap should surface a file-level warning.
expect(result.warnings.some((w) => w.includes('momssats'))).toBe(true)
// Per-row flag: only the snapped row (7 → 6) is marked adjusted.
expect(result.rows[0].vat_rate_adjusted).toBe(false) // "25%" is already valid
expect(result.rows[1].vat_rate_adjusted).toBe(true) // 7 → 6
expect(result.rows[2].vat_rate_adjusted).toBe(false) // empty → default 25
})
it('defaults an unparseable momskod to 25 with a warning', () => {
const buffer = buildXlsx([
['Benämning', 'Momskod'],
['A', 'MP1'],
])
const result = parseArticlesFile(buffer, 'momskod.xlsx')
expect(result.rows[0].vat_rate).toBe(25)
expect(result.rows[0].vat_rate_adjusted).toBe(true)
expect(result.warnings.some((w) => w.toLowerCase().includes('moms'))).toBe(true)
})
it('normalizes article type and falls back to tjanst', () => {
const buffer = buildXlsx([
['Benämning', 'Typ'],
['A', 'Produkt'],
['B', 'service'],
['C', ''],
])
const result = parseArticlesFile(buffer, 'typ.xlsx')
expect(result.rows[0].type).toBe('vara')
expect(result.rows[1].type).toBe('tjanst')
expect(result.rows[2].type).toBe('tjanst')
})
it('falls back to "st" when no unit is given', () => {
const buffer = buildXlsx([
['Benämning'],
['A'],
])
const result = parseArticlesFile(buffer, 'unit.xlsx')
expect(result.rows[0].unit).toBe('st')
})
it('keeps class 1-3 posting accounts and drops a class-4 account', () => {
const buffer = buildXlsx([
['Benämning', 'Försäljningskonto'],
['A', '3001'],
['B', '1930'],
['C', '4000'],
])
const result = parseArticlesFile(buffer, 'konto.xlsx')
expect(result.rows[0].revenue_account).toBe('3001')
expect(result.rows[1].revenue_account).toBe('1930')
expect(result.rows[2].revenue_account).toBeNull()
expect(result.warnings.some((w) => w.toLowerCase().includes('bokföringskonto'))).toBe(true)
})
it('treats a blank cost price as null (not 0)', () => {
const buffer = buildXlsx([
['Benämning', 'Inköpspris'],
['A', ''],
['B', '100'],
])
const result = parseArticlesFile(buffer, 'cost.xlsx')
expect(result.rows[0].cost_price).toBeNull()
expect(result.rows[1].cost_price).toBe(100)
})
it('warns when the price column looks incl-VAT', () => {
const buffer = buildXlsx([
['Benämning', 'Pris inkl moms'],
['A', '125'],
])
const result = parseArticlesFile(buffer, 'brutto.xlsx')
expect(result.warnings.some((w) => w.toLowerCase().includes('inkl'))).toBe(true)
})
it('skips rows with empty name and preserves row_index', () => {
const buffer = buildXlsx([
['Benämning'],
['A'],
[''],
['C'],
])
const result = parseArticlesFile(buffer, 'sparse.xlsx')
expect(result.total_rows).toBe(2)
expect(result.rows.map((r) => r.name)).toEqual(['A', 'C'])
expect(result.rows[0].row_index).toBe(2)
expect(result.rows[1].row_index).toBe(4)
})
it('flags a negative price as invalid', () => {
const buffer = buildXlsx([
['Benämning', 'Pris'],
['A', '-50'],
])
const result = parseArticlesFile(buffer, 'neg.xlsx')
expect(result.rows[0].is_valid).toBe(false)
expect(result.rows[0].validation_errors).toContain('Priset kan inte vara negativt')
})
it('returns a warning when zero rows match', () => {
const buffer = buildXlsx([['Benämning']])
const result = parseArticlesFile(buffer, 'empty.xlsx')
expect(result.total_rows).toBe(0)
expect(result.warnings.length).toBeGreaterThan(0)
})
it('preserves Swedish characters when reading a UTF-8 CSV', () => {
const csv = new TextEncoder().encode(
'Benämning,Enhet\nMöbel,st\nKärra,st\n',
).buffer
const result = parseArticlesFile(csv, 'artiklar.csv')
expect(result.rows[0].name).toBe('Möbel')
expect(result.rows[1].name).toBe('Kärra')
})
})
describe('parseArticlesFile currency (Valuta) column', () => {
it('parses and normalizes a Valuta column', () => {
const buffer = buildXlsx([
['Benämning', 'Försäljningspris', 'Valuta'],
['EU-konsulting', '950', 'eur'],
['Svensk tjänst', '500', 'SEK'],
['Utan valuta', '100', ''],
])
const result = parseArticlesFile(buffer, 'valuta.xlsx')
expect(result.detected_columns.currency_col).toBe(2)
expect(result.rows[0].currency).toBe('EUR')
expect(result.rows[1].currency).toBe('SEK')
// Blank cell = not specified: the execute route imports it as SEK.
expect(result.rows[2].currency).toBeNull()
expect(result.warnings).toEqual([])
})
it('drops malformed currency codes with a warning', () => {
const buffer = buildXlsx([
['Benämning', 'Pris', 'Valuta'],
['A', '100', 'EURO'],
['B', '200', 'EUR'],
])
const result = parseArticlesFile(buffer, 'valuta.xlsx')
expect(result.rows[0].currency).toBeNull()
expect(result.rows[1].currency).toBe('EUR')
expect(result.warnings.some((w) => w.includes('valutakod'))).toBe(true)
})
it('does not let Valuta steal the price or VAT columns', () => {
const buffer = buildXlsx([
['Benämning', 'Valuta', 'Försäljningspris', 'Moms %'],
['A', 'EUR', '100', '25'],
])
const result = parseArticlesFile(buffer, 'valuta.xlsx')
expect(result.detected_columns.currency_col).toBe(1)
expect(result.rows[0].price_excl_vat).toBe(100)
expect(result.rows[0].vat_rate).toBe(25)
})
})
describe('parseArticlesFile ROT/RUT (housework) column', () => {
it('normalizes work-type codes and bare kinds, and drops boolean-style values', () => {
const buffer = buildXlsx([
['Benämning', 'Pris', 'Husarbete'],
['Städning', '450', 'stad'],
['Fönsterputs', '500', 'RUT'],
// A boolean "Rot"/"Husarbete" column exported by other systems: '1'/'0'
// are not values the invoice editor can interpret, so they must not be
// stored (this is what produced 178 junk rows on prod).
['Bygg', '600', '1'],
['Skruv', '2', '0'],
])
const result = parseArticlesFile(buffer, 'husarbete.xlsx')
expect(result.detected_columns.housework_type_col).toBe(2)
expect(result.rows[0].housework_type).toBe('STAD')
expect(result.rows[1].housework_type).toBe('RUT')
expect(result.rows[2].housework_type).toBeNull()
expect(result.rows[3].housework_type).toBeNull()
// Dropping a non-empty value is surfaced, never silent.
expect(result.warnings.some((w) => w.includes('2 rader hade ett ROT/RUT-värde'))).toBe(true)
})
})