feat(import): detect and import the article register's Valuta column (#1183)

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>
This commit is contained in:
Jakob Wennberg
2026-07-25 12:59:23 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent aead2bc1d1
commit 36a1df4f6b
10 changed files with 180 additions and 8 deletions
+7
View File
@@ -2166,6 +2166,10 @@ const ImportedArticleRowSchema = z.object({
type: ArticleTypeSchema,
unit: z.string(),
price_excl_vat: nonNegativeAmount,
// ISO 4217 shape only; the execute route validates against the currencies
// table and drops unknown codes (mirrors revenue_account). Optional so rows
// parsed before this field existed still validate.
currency: z.string().regex(/^[A-Z]{3}$/).nullable().optional(),
vat_rate: vatRatePercent,
// The execute route re-validates against the chart of accounts (and drops
// unknown/inactive overrides), so a loose nullable string is enough here.
@@ -2192,6 +2196,9 @@ export const ArticleColumnOverridesSchema = z.object({
type_col: articleColumnIndex,
unit_col: articleColumnIndex,
price_col: articleColumnIndex,
// Optional + defaulted so a mapping payload from a client rendered before
// this column existed still validates.
currency_col: articleColumnIndex.optional().default(null),
vat_rate_col: articleColumnIndex,
revenue_account_col: articleColumnIndex,
cost_price_col: articleColumnIndex,
@@ -192,3 +192,50 @@ describe('parseArticlesFile', () => {
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)
})
})
+6
View File
@@ -61,6 +61,10 @@ const NOTES_KEYWORDS = [
'note', 'övrigt', 'ovrigt',
]
// Matches our own register export header ("Valuta", #1166) plus common
// English variants. Deliberately NO bare 'kod'/'code': collides with Momskod.
const CURRENCY_KEYWORDS = ['valuta', 'valutakod', 'currency', 'currency code']
/**
* Detect article-register columns from headers.
*
@@ -78,6 +82,7 @@ export function detectArticleColumns(headers: string[]): DetectedArticleColumns
// generic name column dead last.
const name_en_col = findColumn(headers, NAME_EN_KEYWORDS, taken)
const ean_col = findColumn(headers, EAN_KEYWORDS, taken)
const currency_col = findColumn(headers, CURRENCY_KEYWORDS, taken)
const article_number_col = findColumn(headers, ARTICLE_NUMBER_KEYWORDS, taken)
const revenue_account_col = findColumn(headers, REVENUE_ACCOUNT_KEYWORDS, taken)
const cost_price_col = findColumn(headers, COST_PRICE_KEYWORDS, taken)
@@ -106,6 +111,7 @@ export function detectArticleColumns(headers: string[]): DetectedArticleColumns
type_col,
unit_col,
price_col,
currency_col,
vat_rate_col,
revenue_account_col,
cost_price_col,
+16
View File
@@ -101,6 +101,7 @@ export function parseArticlesFile(
type_col: null,
unit_col: null,
price_col: null,
currency_col: null,
vat_rate_col: null,
revenue_account_col: null,
cost_price_col: null,
@@ -139,6 +140,7 @@ export function parseArticlesFile(
let vatNoteCount = 0
let droppedAccountCount = 0
let droppedCurrencyCount = 0
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i]
@@ -154,6 +156,16 @@ export function parseArticlesFile(
const priceRaw = cell(row, columns.price_col)
const price = priceRaw !== null ? parseAmount(priceRaw) : 0
// Keep only well-formed ISO 4217 codes; the execute route validates them
// against the currencies table. null = column absent/blank (imports as SEK).
const currencyRaw = cell(row, columns.currency_col)
let currency: string | null = null
if (currencyRaw) {
const normalized = currencyRaw.trim().toUpperCase()
if (/^[A-Z]{3}$/.test(normalized)) currency = normalized
else droppedCurrencyCount++
}
const { rate: vatRate, note: vatNote } = normalizeVatRate(cell(row, columns.vat_rate_col))
if (vatNote) vatNoteCount++
@@ -186,6 +198,7 @@ export function parseArticlesFile(
type,
unit,
price_excl_vat: price,
currency,
vat_rate: vatRate,
// A note means the rate was snapped or defaulted: flag it for review.
vat_rate_adjusted: vatNote !== null,
@@ -205,6 +218,9 @@ export function parseArticlesFile(
if (droppedAccountCount > 0) {
warnings.push(`${droppedAccountCount} rad${droppedAccountCount === 1 ? '' : 'er'} hade ett ogiltigt bokföringskonto (måste vara klass 1-3) som ignorerades.`)
}
if (droppedCurrencyCount > 0) {
warnings.push(`${droppedCurrencyCount} rad${droppedCurrencyCount === 1 ? '' : 'er'} hade en ogiltig valutakod (måste vara tre bokstäver, t.ex. EUR) som ignorerades: priset importeras som SEK.`)
}
if (rows.length === 0) {
warnings.push('Inga giltiga artiklar hittades. Kontrollera att namn-/benämningskolumnen är korrekt mappad.')
}
+4
View File
@@ -8,6 +8,7 @@ export interface DetectedArticleColumns {
type_col: number | null
unit_col: number | null
price_col: number | null
currency_col: number | null
vat_rate_col: number | null
revenue_account_col: number | null
cost_price_col: number | null
@@ -28,6 +29,9 @@ export interface ParsedArticleRow {
unit: string
/** Always stored EXCLUDING VAT. */
price_excl_vat: number
/** ISO 4217 price currency from the file's Valuta column; null = not in the
* file (imports as SEK). Validated against the currencies table at execute. */
currency: string | null
/** Integer percent, snapped to one of 0 | 6 | 12 | 25. */
vat_rate: number
/**