Files
accounted/lib/import/articles/parser.ts
T
Jakob Wennberg 36a1df4f6b 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>
2026-07-25 12:59:23 +02:00

239 lines
8.1 KiB
TypeScript

import type { ArticleType } from '@/types'
import { detectArticleColumns } from './column-detector'
import { cellOrNull } from '../shared/column-utils'
import { parseAmount } from '../opening-balance/parser'
import { readBestSheet } from '../shared/workbook-reader'
import type { DetectedArticleColumns, ParsedArticleRow } from './types'
const VALID_VAT_RATES = [0, 6, 12, 25] as const
/** Snap an arbitrary VAT percentage to the nearest Swedish statutory rate. */
function snapVatRate(n: number): number {
let best: number = VALID_VAT_RATES[0]
let bestDist = Math.abs(n - best)
for (const r of VALID_VAT_RATES) {
const d = Math.abs(n - r)
if (d < bestDist) {
best = r
bestDist = d
}
}
return best
}
/**
* Normalize a VAT cell to one of {0,6,12,25}. Handles "25%", "25", "0,25"
* (fraction), and Swedish decimal commas. Returns the snapped rate plus an
* optional human note when the raw value was non-empty but not already valid
* (e.g. a Fortnox `momskod` letter code or an unsupported percentage).
*/
function normalizeVatRate(raw: string | null): { rate: number; note: string | null } {
if (!raw) return { rate: 25, note: null }
const cleaned = raw
.replace(/%/g, '')
.replace(/\s/g, '')
.replace(/\.(?=\d{3})/g, '') // dot thousand-separator
.replace(',', '.') // Swedish decimal comma
.trim()
let n = parseFloat(cleaned)
// Unparseable (e.g. a Fortnox `momskod` like "MP1") → default with a note.
if (Number.isNaN(n)) {
return { rate: 25, note: `Kunde inte tolka momssats "${raw}": satt till 25 %` }
}
// Fraction form (0.25 → 25).
if (n > 0 && n < 1) n = n * 100
const snapped = snapVatRate(n)
const wasValid = (VALID_VAT_RATES as readonly number[]).includes(Math.round(n))
return {
rate: snapped,
note: wasValid ? null : `Momssats ${raw} avrundades till ${snapped} %`,
}
}
function normalizeArticleType(value: string | null): ArticleType {
if (!value) return 'tjanst'
const lower = value.toLowerCase().trim()
if (
lower === 'vara' || lower === 'varor' || lower === 'produkt' ||
lower === 'product' || lower === 'goods' || lower === 'artikel' ||
lower === 'lagervara' || lower === 'stock'
) {
return 'vara'
}
// Everything else (tjänst/service/…) maps to the DB default.
return 'tjanst'
}
const INCL_VAT_HEADER_RE = /brutto|inkl|incl|gross/i
/**
* Parse an article-register file (Excel or CSV) into structured rows.
*
* Prices are read as EXCLUDING VAT (what the `articles` table stores). When the
* matched price header looks like an incl-VAT column a file-level warning is
* emitted rather than silently converting (the rate isn't reliably known here).
*
* @param buffer - Raw file buffer
* @param filename - Original filename
* @param columnOverrides - Optional manual column mapping
*/
export function parseArticlesFile(
buffer: ArrayBuffer,
filename: string,
columnOverrides?: DetectedArticleColumns,
): {
filename: string
sheet_name: string
total_rows: number
detected_columns: DetectedArticleColumns
headers: string[]
preview_rows: string[][]
rows: ParsedArticleRow[]
warnings: string[]
} {
const { sheetName, rawData } = readBestSheet(buffer, filename)
if (rawData.length < 2) {
const fallbackColumns: DetectedArticleColumns = columnOverrides ?? {
name_col: 0,
article_number_col: null,
name_en_col: null,
type_col: null,
unit_col: null,
price_col: null,
currency_col: null,
vat_rate_col: null,
revenue_account_col: null,
cost_price_col: null,
ean_col: null,
housework_type_col: null,
notes_col: null,
confidence: 0,
}
return {
filename,
sheet_name: sheetName,
total_rows: 0,
detected_columns: fallbackColumns,
headers: rawData[0]?.map((h) => String(h)) || [],
preview_rows: [],
rows: [],
warnings: ['Filen innehåller för få rader.'],
}
}
const headers = rawData[0].map((h) => String(h))
const dataRows = rawData.slice(1)
const columns = columnOverrides || detectArticleColumns(headers)
const rows: ParsedArticleRow[] = []
const warnings: string[] = []
const cell = (row: string[], col: number | null): string | null =>
col !== null ? cellOrNull(row[col]) : null
// Surface incl-VAT price columns once for the whole file.
if (columns.price_col !== null && INCL_VAT_HEADER_RE.test(headers[columns.price_col] ?? '')) {
warnings.push(
`Priskolumnen "${headers[columns.price_col]}" verkar vara inkl. moms: priser importeras som exkl. moms. Kontrollera värdena.`,
)
}
let vatNoteCount = 0
let droppedAccountCount = 0
let droppedCurrencyCount = 0
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i]
const name = cell(row, columns.name_col)
if (!name) continue // skip empty rows silently
const articleNumber = cell(row, columns.article_number_col)
const nameEn = cell(row, columns.name_en_col)
const type = normalizeArticleType(cell(row, columns.type_col))
const unitRaw = cell(row, columns.unit_col)
const unit = unitRaw ?? 'st'
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++
// Keep only well-formed BAS class 1-3 overrides; the execute route validates
// them further against the chart of accounts.
const revenueRaw = cell(row, columns.revenue_account_col)
let revenueAccount: string | null = null
if (revenueRaw) {
const digits = revenueRaw.replace(/\s/g, '')
if (/^[123]\d{3}$/.test(digits)) revenueAccount = digits
else droppedAccountCount++
}
const costRaw = cell(row, columns.cost_price_col)
const costPrice = costRaw !== null ? parseAmount(costRaw) : null
const ean = cell(row, columns.ean_col)
const houseworkType = cell(row, columns.housework_type_col)
const notes = cell(row, columns.notes_col)
const validationErrors: string[] = []
if (price < 0) validationErrors.push('Priset kan inte vara negativt')
if (costPrice !== null && costPrice < 0) validationErrors.push('Inköpspriset kan inte vara negativt')
rows.push({
row_index: i + 2, // 1-based + header
name,
name_en: nameEn,
article_number: articleNumber,
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,
revenue_account: revenueAccount,
cost_price: costPrice,
ean,
housework_type: houseworkType,
notes,
is_valid: validationErrors.length === 0,
validation_errors: validationErrors,
})
}
if (vatNoteCount > 0) {
warnings.push(`${vatNoteCount} rad${vatNoteCount === 1 ? '' : 'er'} hade en momssats som avrundades till närmaste giltiga (0/6/12/25 %).`)
}
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.')
}
return {
filename,
sheet_name: sheetName,
total_rows: rows.length,
detected_columns: columns,
headers,
preview_rows: dataRows.slice(0, 5),
rows,
warnings,
}
}