feat(import/export): article import + register export (xlsx/csv) (#750)

* feat(import/export): article import + register export (xlsx/csv)

Add CSV/Excel import for the article register (artiklar), mirroring the
existing customer/supplier import pipeline, plus Excel + CSV export for
articles, customers and suppliers.

Import (lib/import/articles + app/api/import/articles):
- Column auto-detection tuned to Fortnox/Visma/Bokio export headers,
  Swedish-decimal price parsing, VAT snapped to {0,6,12,25}, type/unit
  normalization.
- Dedup by article number then name; 23505 soft-skip; auto-number
  backfill; revenue-account override kept only when active, otherwise
  dropped with a warning (never mutates the chart of accounts).
- New "Artiklar" flow in the /import hub.

Export (app/api/export/* + lib/export/register-export):
- Read-only xlsx (default) / csv (?format=csv, UTF-8 BOM) downloads.
- Headers chosen so files round-trip back through the importer.
- "Exportera" menu added to the articles, customers and suppliers pages.

Refs #746. Direct Fortnox/Visma API article fetch tracked in #749.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import/export): address PR review — lint ratchet + export hardening

- xlsx-export: keep `SheetSpec<any>` on the eslint-disabled line (fixes the
  core-only lint ratchet regression: no-explicit-any 16 -> 15) and define
  UTF8_BOM as an explicit `` escape instead of a raw BOM character.
- export routes (articles/customers/suppliers): move the data queries inside
  the try/catch, add `Cache-Control: no-store`, and emit a `register exported`
  audit log line (entity, format, rowCount).
- articles parse route: validate `column_overrides` against a Zod schema before
  trusting it to drive the parser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import): drop öre-round pattern on article column-detector confidence

The confidence score is a 0-1 heuristic, not money, and is only compared
against the 0.8 skip-mapping threshold. Removing the Math.round(x*100)/100
form clears the core-only antipattern ratchet (naive-ore-round 660 -> 659).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(import): flag adjusted VAT rows in the article import edit step

Surface VAT snapping/defaulting per row, not just as a file-level warning:
the parser sets `vat_rate_adjusted`, the edit step highlights those rows'
VAT selector and shows a count banner, and confirming a rate clears the flag.
Addresses the Swedish-compliance review note that silent snapping could
otherwise store a wrong VAT rate at scale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-17 14:38:44 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7aa37fd3b8
commit 2d6ddeafc5
28 changed files with 2614 additions and 85 deletions
+43
View File
@@ -1430,6 +1430,49 @@ export const SupplierImportExecuteSchema = z.object({
update_duplicates: z.boolean(),
})
const ImportedArticleRowSchema = z.object({
row_index: z.number().int(),
name: z.string().min(1),
name_en: z.string().nullable(),
article_number: z.string().nullable(),
type: ArticleTypeSchema,
unit: z.string(),
price_excl_vat: nonNegativeAmount,
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.
revenue_account: z.string().nullable(),
cost_price: nonNegativeAmount.nullable(),
ean: z.string().nullable(),
housework_type: z.string().nullable(),
notes: z.string().nullable(),
})
export const ArticleImportExecuteSchema = z.object({
rows: z.array(ImportedArticleRowSchema).min(1, 'At least one row is required'),
update_duplicates: z.boolean(),
})
// Validates the optional column-mapping override posted to the parse route, so
// a malformed/hostile blob can't drive the parser with non-numeric or
// unexpected column indices. Mirrors DetectedArticleColumns.
const articleColumnIndex = z.number().int().min(0).nullable()
export const ArticleColumnOverridesSchema = z.object({
name_col: z.number().int().min(0),
article_number_col: articleColumnIndex,
name_en_col: articleColumnIndex,
type_col: articleColumnIndex,
unit_col: articleColumnIndex,
price_col: articleColumnIndex,
vat_rate_col: articleColumnIndex,
revenue_account_col: articleColumnIndex,
cost_price_col: articleColumnIndex,
ean_col: articleColumnIndex,
housework_type_col: articleColumnIndex,
notes_col: articleColumnIndex,
confidence: z.number(),
})
// ============================================================
// Salary schemas
// ============================================================
+47
View File
@@ -0,0 +1,47 @@
import {
reportToWorkbook,
exportFilename,
UTF8_BOM,
} from '@/lib/reports/xlsx-export'
export type RegisterExportFormat = 'xlsx' | 'csv'
const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
/** Normalize the `?format=` query param to a supported export format. */
export function parseExportFormat(raw: string | null): RegisterExportFormat {
return raw === 'csv' ? 'csv' : 'xlsx'
}
/**
* Build the download body + headers for a register export (customers,
* suppliers, articles) in either xlsx or csv. CSV is a single sheet with a
* UTF-8 BOM so Excel renders åäö correctly; both formats share the same
* column/row spec so files round-trip back through the importer.
*/
export function buildRegisterExport(
spec: Parameters<typeof reportToWorkbook>[0],
opts: { format: RegisterExportFormat; slug: string; companyName: string; date: string },
): { buffer: Buffer; contentType: string; filename: string } {
const { format, slug, companyName, date } = opts
if (format === 'csv') {
const buf = reportToWorkbook(spec, { bookType: 'csv' })
return {
buffer: Buffer.concat([Buffer.from(UTF8_BOM, 'utf-8'), buf]),
contentType: 'text/csv; charset=utf-8',
filename: exportFilename(slug, companyName, date, 'csv'),
}
}
return {
buffer: reportToWorkbook(spec),
contentType: XLSX_MIME,
filename: exportFilename(slug, companyName, date, 'xlsx'),
}
}
/** Today's date as `YYYY-MM-DD` for export filenames. */
export function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
import { detectArticleColumns } from '../column-detector'
describe('detectArticleColumns', () => {
it('detects a rich Swedish header at high confidence', () => {
const cols = detectArticleColumns([
'Artikelnummer', 'Benämning', 'Typ', 'Enhet', 'Pris', 'Moms', 'Försäljningskonto',
])
expect(cols.name_col).toBe(1)
expect(cols.article_number_col).toBe(0)
expect(cols.type_col).toBe(2)
expect(cols.unit_col).toBe(3)
expect(cols.price_col).toBe(4)
expect(cols.vat_rate_col).toBe(5)
expect(cols.revenue_account_col).toBe(6)
expect(cols.confidence).toBeGreaterThanOrEqual(0.8)
})
it('keeps price, VAT and account in distinct columns (no keyword collision)', () => {
const cols = detectArticleColumns([
'Benämning', 'Pris exkl moms', 'Moms %', 'Försäljningskonto',
])
expect(cols.price_col).toBe(1) // "Pris exkl moms" → price, not VAT
expect(cols.vat_rate_col).toBe(2)
expect(cols.revenue_account_col).toBe(3)
expect(cols.price_col).not.toBe(cols.vat_rate_col)
})
it('does not read Fortnox "Momskod" as the article number', () => {
const cols = detectArticleColumns(['Benämning', 'Momskod'])
expect(cols.vat_rate_col).toBe(1)
expect(cols.article_number_col).toBeNull()
})
it('claims EAN before the generic article number', () => {
const cols = detectArticleColumns(['Benämning', 'EAN-nummer', 'Artikelnummer'])
expect(cols.ean_col).toBe(1)
expect(cols.article_number_col).toBe(2)
})
it('detects the English name column separately from the main name', () => {
const cols = detectArticleColumns(['Benämning', 'Benämning engelska'])
expect(cols.name_col).toBe(0)
expect(cols.name_en_col).toBe(1)
})
it('detects Fortnox-style headers', () => {
const cols = detectArticleColumns([
'Artikelnr', 'Benämning', 'Försäljningspris', 'Inköpspris', 'Momskod', 'Enhet',
])
expect(cols.article_number_col).toBe(0)
expect(cols.price_col).toBe(2)
expect(cols.cost_price_col).toBe(3)
expect(cols.vat_rate_col).toBe(4)
})
it('reports low confidence when only a name column is present', () => {
const cols = detectArticleColumns(['Benämning'])
expect(cols.name_col).toBe(0)
expect(cols.confidence).toBeLessThan(0.8)
})
})
@@ -0,0 +1,192 @@
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 a valid 3xxx revenue account and drops a non-3xxx one', () => {
const buffer = buildXlsx([
['Benämning', 'Försäljningskonto'],
['A', '3001'],
['B', '1930'],
])
const result = parseArticlesFile(buffer, 'konto.xlsx')
expect(result.rows[0].revenue_account).toBe('3001')
expect(result.rows[1].revenue_account).toBeNull()
expect(result.warnings.some((w) => w.toLowerCase().includes('försäljningskonto'))).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')
})
})
+119
View File
@@ -0,0 +1,119 @@
import { findColumn } from '../shared/column-utils'
import type { DetectedArticleColumns } from './types'
// Keyword lists cover the export headers of Fortnox, Visma and Bokio so files
// from those systems auto-map. Header-only matching (register imports always
// have a header row).
const NAME_KEYWORDS = [
'benämning', 'benamning', 'artikelnamn', 'artikel namn', 'namn', 'name',
'produktnamn', 'produkt namn', 'product name', 'article name', 'beskrivning',
'description', 'title',
]
const NAME_EN_KEYWORDS = [
'engelska', 'english', 'name en', 'name english', 'name_english',
'engelskt namn', 'benämning engelska',
]
// Note: bare 'kod'/'code' are deliberately excluded — they collide with
// Fortnox's "Momskod" (a VAT column). EAN is detected first so "EAN-nummer"
// is claimed before the generic 'nummer'/'number' here.
const ARTICLE_NUMBER_KEYWORDS = [
'artikelnummer', 'artikelnr', 'artnr', 'art nr', 'art no', 'artikelkod',
'article code', 'sku', 'nummer', 'number',
]
const TYPE_KEYWORDS = ['typ', 'type', 'artikeltyp', 'article type', 'varutyp']
const UNIT_KEYWORDS = ['enhet', 'unit', 'enh', 'uom', 'måttenhet', 'mattenhet']
const VAT_RATE_KEYWORDS = [
'momssats', 'momskod', 'moms %', 'moms', 'momsprocent', 'vat rate', 'vat code',
'vat %', 'vat', 'tax rate',
]
const REVENUE_ACCOUNT_KEYWORDS = [
'försäljningskonto', 'forsaljningskonto', 'intäktskonto', 'intaktskonto',
'bokföringskonto', 'bokforingskonto', 'sales account', 'revenue account',
'kontering', 'coding', 'konto', 'account',
]
const COST_PRICE_KEYWORDS = [
'inköpspris', 'inkopspris', 'självkostnad', 'sjalvkostnad', 'kostpris',
'kostnadspris', 'purchase price', 'cost price', 'cost',
]
const PRICE_KEYWORDS = [
'försäljningspris', 'forsaljningspris', 'pris exkl moms', 'pris exkl. moms',
'à-pris', 'a-pris', 'apris', 'styckpris', 'nettopris', 'net price',
'unit price', 'pris', 'price', 'belopp', 'sales price',
]
const EAN_KEYWORDS = ['ean', 'ean-kod', 'streckkod', 'gtin', 'barcode']
const HOUSEWORK_KEYWORDS = [
'rot/rut', 'rot rut', 'arbetstyp', 'husarbete', 'housework', 'rot', 'rut',
]
const NOTES_KEYWORDS = [
'anteckning', 'anteckningar', 'kommentar', 'kommentarer', 'comment', 'notes',
'note', 'övrigt', 'ovrigt',
]
/**
* Detect article-register columns from headers.
*
* Detection order matters: more specific columns are claimed first (via the
* shared `taken` set) so a generic keyword can't swallow them — e.g. EAN before
* the article number ("EAN-nummer" must not be read as the article number), and
* the English name before the generic name column.
*/
export function detectArticleColumns(headers: string[]): DetectedArticleColumns {
const taken = new Set<number>()
// Order matters (shared `taken` set): claim specific columns before generic
// ones. EAN before the article number ("EAN-nummer"), the price columns
// before VAT (so "Pris exkl moms" isn't read as the VAT column), and the
// generic name column dead last.
const name_en_col = findColumn(headers, NAME_EN_KEYWORDS, taken)
const ean_col = findColumn(headers, EAN_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)
const price_col = findColumn(headers, PRICE_KEYWORDS, taken)
const vat_rate_col = findColumn(headers, VAT_RATE_KEYWORDS, taken)
const type_col = findColumn(headers, TYPE_KEYWORDS, taken)
const unit_col = findColumn(headers, UNIT_KEYWORDS, taken)
const housework_type_col = findColumn(headers, HOUSEWORK_KEYWORDS, taken)
const notes_col = findColumn(headers, NOTES_KEYWORDS, taken)
const name_col = findColumn(headers, NAME_KEYWORDS, taken) ?? -1
// Confidence: name is required; bonus from how many other columns matched.
let confidence = 0
if (name_col >= 0) {
const matched = [
article_number_col, price_col, vat_rate_col, unit_col,
revenue_account_col, type_col,
].filter((c) => c !== null).length
confidence = 0.55 + Math.min(matched, 6) * 0.075
}
return {
name_col: name_col >= 0 ? name_col : 0,
article_number_col,
name_en_col,
type_col,
unit_col,
price_col,
vat_rate_col,
revenue_account_col,
cost_price_col,
ean_col,
housework_type_col,
notes_col,
// Confidence is a 0–1 heuristic score (not money), only compared against the
// 0.8 skip-mapping threshold — no öre rounding needed.
confidence: Math.min(confidence, 1),
}
}
+222
View File
@@ -0,0 +1,222 @@
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,
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
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
const { rate: vatRate, note: vatNote } = normalizeVatRate(cell(row, columns.vat_rate_col))
if (vatNote) vatNoteCount++
// Keep only well-formed BAS class-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 (/^3\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,
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 försäljningskonto (måste vara 3xxx) som ignorerades.`)
}
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,
}
}
+87
View File
@@ -0,0 +1,87 @@
import type { ArticleType } from '@/types'
/** Result of auto-detecting columns in an article register file. */
export interface DetectedArticleColumns {
name_col: number
article_number_col: number | null
name_en_col: number | null
type_col: number | null
unit_col: number | null
price_col: number | null
vat_rate_col: number | null
revenue_account_col: number | null
cost_price_col: number | null
ean_col: number | null
housework_type_col: number | null
notes_col: number | null
/** 0-1 confidence score for the detection */
confidence: number
}
/** A single parsed row from the article register file. */
export interface ParsedArticleRow {
row_index: number
name: string
name_en: string | null
article_number: string | null
type: ArticleType
unit: string
/** Always stored EXCLUDING VAT. */
price_excl_vat: number
/** Integer percent, snapped to one of 0 | 6 | 12 | 25. */
vat_rate: number
/**
* True when the VAT rate was inferred (snapped to the nearest statutory rate
* or defaulted from an unparseable cell). Drives a "verify this" hint in the
* edit step; cleared once the operator confirms the rate. Not persisted.
*/
vat_rate_adjusted: boolean
/** Optional BAS class-3 revenue-account override (validated server-side). */
revenue_account: string | null
cost_price: number | null
ean: string | null
housework_type: string | null
notes: string | null
is_valid: boolean
validation_errors: string[]
}
/** Article-row + dedup annotation produced by the parse route. */
export interface AnnotatedArticleRow extends ParsedArticleRow {
duplicate_match: {
article_id: string
matched_by: 'article_number' | 'name'
existing_name: string
} | null
}
/** Full result from parsing an article register file. */
export interface ArticleImportParseResult {
filename: string
sheet_name: string
total_rows: number
detected_columns: DetectedArticleColumns
headers: string[]
preview_rows: string[][]
rows: AnnotatedArticleRow[]
duplicate_count: number
warnings: string[]
}
/** Input for executing the article import. */
export interface ArticleImportExecuteInput {
rows: ParsedArticleRow[]
update_duplicates: boolean
}
/** Result of executing the article import. */
export interface ArticleImportExecuteResult {
success: boolean
created: number
updated: number
skipped: number
failed: number
errors: { row_index: number; name: string; reason: string }[]
/** Non-fatal notes (e.g. dropped revenue-account overrides). */
warnings: string[]
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Shared column-detection helpers for register imports
* (customers, suppliers, future: articles).
* (customers, suppliers, articles).
*/
export function normalize(header: string): string {
+26 -3
View File
@@ -109,7 +109,7 @@ function displayLength(value: CellValue, format: ColumnFormat): number {
// single type parameter. Per-sheet type safety still applies inside each
// `SheetSpec<TRow>` declaration.
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
export function reportToWorkbook<_T = unknown>(spec: ReadonlyArray<SheetSpec<any>>): Buffer {
export function reportToWorkbook<_T = unknown>(spec: ReadonlyArray<SheetSpec<any>>, options: { bookType?: 'xlsx' | 'csv' } = {}): Buffer {
if (spec.length === 0) {
throw new Error('reportToWorkbook: at least one sheet spec is required')
}
@@ -176,11 +176,17 @@ export function reportToWorkbook<_T = unknown>(spec: ReadonlyArray<SheetSpec<any
XLSX.utils.book_append_sheet(workbook, worksheet, truncatedName)
}
// `XLSX.write` with `type: 'buffer'` returns a Node Buffer.
const out = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
// `XLSX.write` with `type: 'buffer'` returns a Node Buffer. `bookType: 'csv'`
// emits only the first sheet (CSV is single-sheet) — fine for the flat,
// single-sheet register exports that use this option.
const bookType = options.bookType ?? 'xlsx'
const out = XLSX.write(workbook, { type: 'buffer', bookType }) as Buffer
return out
}
/** UTF-8 byte-order mark (U+FEFF) so Excel opens CSV exports with åäö intact. */
export const UTF8_BOM = '\uFEFF'
// ─────────────────────────────────────────────────────────────────────────────
// Column helpers — small declarative builders so route files read cleanly.
// ─────────────────────────────────────────────────────────────────────────────
@@ -248,3 +254,20 @@ export function xlsxFilename(reportSlug: string, companyName: string, period: st
const parts = [reportSlug, companySlug, periodCompact].filter(Boolean)
return `${parts.join('-')}.xlsx`
}
/**
* Build a download filename `<slug>-<companySlug>-<dateYYYYMMDD>.<ext>`.
* Like `xlsxFilename` but with a caller-chosen extension (`'xlsx'` | `'csv'`),
* for register exports that offer both formats.
*/
export function exportFilename(
slug: string,
companyName: string,
date: string,
ext: 'xlsx' | 'csv',
): string {
const companySlug = slugifyCompanyName(companyName)
const dateCompact = (date || '').replace(/-/g, '')
const parts = [slug, companySlug, dateCompact].filter(Boolean)
return `${parts.join('-')}.${ext}`
}