36a1df4f6b
Fixes #1167. The register export gained a Valuta column in #1166 but the importer ignored it, so re-imported non-SEK articles silently became SEK, breaking the export -> edit -> re-import round-trip. - Column detector recognizes valuta/valutakod/currency (claimed before generic columns; no keyword collision with Momskod). - Parser normalizes to upper-case ISO shape, drops malformed codes with a file-level warning, and carries currency per row. - Execute route validates codes lazily against the currencies table (FK stays the backstop when the reference read fails), imports valid codes, defaults absent to SEK, and in merge mode only overwrites when the file explicitly carries a valid currency. - Edit step shows a muted currency marker next to non-SEK prices; manual column mapping offers Valuta. - Export docblock caveat removed. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
242 lines
8.2 KiB
TypeScript
242 lines
8.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)
|
|
})
|
|
})
|