Files
accounted/lib/import/articles/parser.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

223 lines
7.4 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,
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,
}
}