79240cb2ed
* fix(articles): article ROT/RUT prefill was dead for every dashboard-created article Follow-up to #1634. The user re-tested and picking a RUT article still left the line on "Ingen": the article form has always stored the bare kind ('ROT'/'RUT'), while the prefill only recognised Skatteverket work-type codes (BYGG, STAD, ...). On prod every dashboard-created ROT/RUT article holds the bare kind, so the fix in #1634 never fired for a real user, and worse, since the helper returned null for those values, picking such an article CLEARED a deduction the user had set manually on the row. - rot-rut-rules: parseArticleHouseworkType() understands both vocabularies (code -> kind + arbetstyp; bare ROT/RUT -> kind only), plus normalizeHouseworkType()/HOUSEWORK_TYPE_VALUES/workTypeLabel(). - InvoiceEditor.applyArticle: kind-only articles pre-fill the deduction and keep a same-kind arbetstyp already chosen on the row; "Spara som artikel" round-trips the code or, lacking one, the kind. - ArticleForm: the ROT/RUT select now offers the real Skatteverket arbetstyper in ROT/RUT groups (its own hint always promised "förifyller arbetstyp"); legacy kind-only values stay selectable as "RUT (arbetstyp ej vald)" so an edit never silently drops the flag. Article detail renders "RUT · Städning" instead of the raw code. - API + MCP commit schemas normalize housework_type (case-insensitive code or ROT/RUT, '' clears) and reject anything else; the CSV article import normalizes the column the same way. Prod holds 178 articles with '0'/'1' from a boolean "Rot" column that the keyword detector mapped straight through; those now read as no flag everywhere and can no longer be created. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(articles): review follow-ups on #1651 - InvoiceEditor: switching a row's skattereduktion ROT<->RUT clears an arbetstyp from the other list, and Spara som artikel only round-trips a work type that belongs to the row's kind (CodeRabbit). - MCP update_article: null / '' / whitespace now clear housework_type (commit drops only undefined keys, so the old undefined mapping made the flag un-clearable); create keeps treating them as unset. Tests. - Article CSV import warns when a non-empty ROT/RUT value is dropped as not-an-arbetstyp instead of dropping it silently. Test. - Hint wording: arbetstyp is pre-filled only when the article carries one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
249 lines
8.8 KiB
TypeScript
249 lines
8.8 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'
|
|
import { normalizeHouseworkType } from '@/lib/invoices/rot-rut-rules'
|
|
|
|
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
|
|
let droppedHouseworkCount = 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)
|
|
// Canonical work-type code / bare ROT-RUT, or null: a boolean "Rot" column
|
|
// ('0'/'1'/'Ja') mapped by the keyword detector must not land as a value
|
|
// the invoice editor can never interpret.
|
|
const houseworkRaw = cell(row, columns.housework_type_col)
|
|
const houseworkType = normalizeHouseworkType(houseworkRaw)
|
|
if (houseworkRaw !== null && houseworkType === null) droppedHouseworkCount++
|
|
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 (droppedHouseworkCount > 0) {
|
|
warnings.push(`${droppedHouseworkCount} rad${droppedHouseworkCount === 1 ? '' : 'er'} hade ett ROT/RUT-värde som inte är en arbetstyp (t.ex. 0/1/Ja) och som ignorerades: sätt arbetstyp på artikeln efteråt.`)
|
|
}
|
|
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,
|
|
}
|
|
}
|