79240cb2ed
* 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>
267 lines
9.2 KiB
TypeScript
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)
|
|
})
|
|
})
|