Add/csv import options (#420)
* feat(import): add customer and supplier parsing functionality - Implemented customer file parsing in `lib/import/customers/parser.ts` with support for Excel and CSV formats. - Created types for detected customer columns and parsed customer rows in `lib/import/customers/types.ts`. - Added tests for customer classification logic in `lib/import/shared/__tests__/classify.test.ts`. - Developed classification functions for customers and suppliers in `lib/import/shared/classify.ts`. - Introduced shared column utility functions in `lib/import/shared/column-utils.ts`. - Implemented supplier file parsing in `lib/import/suppliers/parser.ts` with validation for various fields. - Created types for detected supplier columns and parsed supplier rows in `lib/import/suppliers/types.ts`. - Added tests for supplier column detection and parsing in `lib/import/suppliers/__tests__/column-detector.test.ts` and `lib/import/suppliers/__tests__/parser.test.ts`. * fix(labels): update 'Svenskt företag' to 'Svenskt företag eller organisation' for clarity * feat(import): refactor encoding handling for Swedish files and add tests for character preservation * feat(recapt): implement clearRecaptIdentity function and integrate into logout flow * feat(bookkeeping): implement copy functionality and next voucher sequence retrieval * feat(import): enhance customer and supplier import functionality with normalization and event handling
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { prepareContent } from '../../shared/encoding'
|
||||
|
||||
export const camt053Format: BankFileFormat = {
|
||||
id: 'camt053',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue, GenericCSVColumnMapping } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { prepareContent } from '../../shared/encoding'
|
||||
import { parseCSVLine } from './nordea'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { prepareContent } from '../../shared/encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { prepareContent } from '../../shared/encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { prepareContent } from '../../shared/encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
import { parseCSVLine } from './nordea'
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { prepareContent } from '../../shared/encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
import { parseCSVLine } from './nordea'
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { prepareContent } from '../../shared/encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { prepareContent } from '../../shared/encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { prepareContent } from '../../shared/encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { prepareContent } from '../../shared/encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { prepareContent } from '../../shared/encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
import { parseCSVLine } from './nordea'
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { detectCustomerColumns } from '../column-detector'
|
||||
|
||||
describe('detectCustomerColumns', () => {
|
||||
it('detects Swedish customer register headers', () => {
|
||||
const headers = ['Namn', 'Orgnr', 'E-post', 'Telefon', 'Adress', 'Postnr', 'Ort']
|
||||
const result = detectCustomerColumns(headers)
|
||||
expect(result.name_col).toBe(0)
|
||||
expect(result.org_number_col).toBe(1)
|
||||
expect(result.email_col).toBe(2)
|
||||
expect(result.phone_col).toBe(3)
|
||||
expect(result.address_line1_col).toBe(4)
|
||||
expect(result.postal_code_col).toBe(5)
|
||||
expect(result.city_col).toBe(6)
|
||||
expect(result.confidence).toBeGreaterThanOrEqual(0.8)
|
||||
})
|
||||
|
||||
it('detects English headers', () => {
|
||||
const headers = ['Customer Name', 'Organization Number', 'Email', 'Phone']
|
||||
const result = detectCustomerColumns(headers)
|
||||
expect(result.name_col).toBe(0)
|
||||
expect(result.org_number_col).toBe(1)
|
||||
expect(result.email_col).toBe(2)
|
||||
expect(result.phone_col).toBe(3)
|
||||
})
|
||||
|
||||
it('handles missing optional columns', () => {
|
||||
const headers = ['Kundnamn']
|
||||
const result = detectCustomerColumns(headers)
|
||||
expect(result.name_col).toBe(0)
|
||||
expect(result.email_col).toBeNull()
|
||||
expect(result.org_number_col).toBeNull()
|
||||
})
|
||||
|
||||
it('does not match the same column twice', () => {
|
||||
const headers = ['Namn', 'Adress', 'C/O']
|
||||
const result = detectCustomerColumns(headers)
|
||||
expect(result.address_line1_col).toBe(1)
|
||||
expect(result.address_line2_col).toBe(2)
|
||||
})
|
||||
|
||||
it('returns low confidence when name not matched', () => {
|
||||
const headers = ['ColA', 'ColB']
|
||||
const result = detectCustomerColumns(headers)
|
||||
expect(result.confidence).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { parseCustomersFile } 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, 'Kunder')
|
||||
// Returning a Node Buffer; ArrayBuffer view is interchangeable with XLSX.read
|
||||
const out = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer
|
||||
return out
|
||||
}
|
||||
|
||||
describe('parseCustomersFile', () => {
|
||||
it('parses a basic Swedish customer register', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'Orgnr', 'E-post', 'Telefon', 'Adress', 'Postnr', 'Ort'],
|
||||
['Acme AB', '5560217780', 'kontakt@acme.se', '0701234567', 'Storgatan 1', '11122', 'Stockholm'],
|
||||
['Beta AB', '5562345678', 'info@beta.se', '0709876543', 'Vasagatan 2', '11329', 'Stockholm'],
|
||||
])
|
||||
|
||||
const result = parseCustomersFile(buffer, 'kunder.xlsx')
|
||||
|
||||
expect(result.total_rows).toBe(2)
|
||||
expect(result.rows[0].name).toBe('Acme AB')
|
||||
expect(result.rows[0].org_number).toBe('5560217780')
|
||||
expect(result.rows[0].email).toBe('kontakt@acme.se')
|
||||
expect(result.rows[0].postal_code).toBe('11122')
|
||||
expect(result.rows[0].city).toBe('Stockholm')
|
||||
expect(result.rows[0].is_valid).toBe(true)
|
||||
})
|
||||
|
||||
it('auto-classifies customer_type by org_number length', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'Orgnr'],
|
||||
['Acme AB', '5560217780'], // 10-digit company
|
||||
['Sven Svensson', '198001011234'], // 12-digit personnummer
|
||||
])
|
||||
|
||||
const result = parseCustomersFile(buffer, 'mixed.xlsx')
|
||||
expect(result.rows[0].customer_type).toBe('swedish_business')
|
||||
expect(result.rows[1].customer_type).toBe('individual')
|
||||
})
|
||||
|
||||
it('classifies eu_business from VAT number prefix', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'VAT'],
|
||||
['Müller GmbH', 'DE123456789'],
|
||||
])
|
||||
|
||||
const result = parseCustomersFile(buffer, 'eu.xlsx')
|
||||
expect(result.rows[0].customer_type).toBe('eu_business')
|
||||
expect(result.rows[0].vat_number).toBe('DE123456789')
|
||||
})
|
||||
|
||||
it('skips rows with empty name', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'E-post'],
|
||||
['Acme AB', 'a@a.se'],
|
||||
['', 'b@b.se'],
|
||||
['Beta AB', 'c@c.se'],
|
||||
])
|
||||
|
||||
const result = parseCustomersFile(buffer, 'sparse.xlsx')
|
||||
expect(result.total_rows).toBe(2)
|
||||
expect(result.rows.map((r) => r.name)).toEqual(['Acme AB', 'Beta AB'])
|
||||
})
|
||||
|
||||
it('flags invalid email format', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'E-post'],
|
||||
['Acme AB', 'not-an-email'],
|
||||
])
|
||||
|
||||
const result = parseCustomersFile(buffer, 'bad-email.xlsx')
|
||||
expect(result.rows[0].is_valid).toBe(false)
|
||||
expect(result.rows[0].validation_errors).toContain('Ogiltig e-postadress')
|
||||
})
|
||||
|
||||
it('parses payment terms with day suffix', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'Betalningsvillkor'],
|
||||
['Acme AB', '45 dagar'],
|
||||
['Beta AB', ''],
|
||||
])
|
||||
|
||||
const result = parseCustomersFile(buffer, 'terms.xlsx')
|
||||
expect(result.rows[0].default_payment_terms).toBe(45)
|
||||
expect(result.rows[1].default_payment_terms).toBe(30) // default fallback
|
||||
})
|
||||
|
||||
it('returns warning when zero rows match', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'Orgnr'],
|
||||
])
|
||||
|
||||
const result = parseCustomersFile(buffer, 'empty.xlsx')
|
||||
expect(result.total_rows).toBe(0)
|
||||
expect(result.warnings.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('honors explicit customer_type column', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'Kundtyp'],
|
||||
['Acme AB', 'aktiebolag'],
|
||||
['Sven', 'privatperson'],
|
||||
])
|
||||
|
||||
const result = parseCustomersFile(buffer, 'types.xlsx')
|
||||
expect(result.rows[0].customer_type).toBe('swedish_business')
|
||||
expect(result.rows[1].customer_type).toBe('individual')
|
||||
})
|
||||
|
||||
it('preserves row_index pointing to spreadsheet row', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn'],
|
||||
['Acme AB'],
|
||||
['Beta AB'],
|
||||
])
|
||||
|
||||
const result = parseCustomersFile(buffer, 'rows.xlsx')
|
||||
expect(result.rows[0].row_index).toBe(2) // header is row 1
|
||||
expect(result.rows[1].row_index).toBe(3)
|
||||
})
|
||||
|
||||
it('preserves Swedish characters when reading a UTF-8 CSV', () => {
|
||||
const csv = new TextEncoder().encode(
|
||||
'Namn,Ort\nAcme AB,GÖTEBORG\nBeta AB,HISINGS KÄRRA\n',
|
||||
).buffer
|
||||
const result = parseCustomersFile(csv, 'kunder.csv')
|
||||
expect(result.rows[0].city).toBe('GÖTEBORG')
|
||||
expect(result.rows[1].city).toBe('HISINGS KÄRRA')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { findColumn } from '../shared/column-utils'
|
||||
import type { DetectedCustomerColumns } from './types'
|
||||
|
||||
const NAME_KEYWORDS = [
|
||||
'kundnamn', 'kund namn', 'namn', 'name', 'kund', 'customer', 'customer name',
|
||||
'företag', 'foretag', 'company', 'företagsnamn', 'foretagsnamn',
|
||||
]
|
||||
|
||||
const ORG_NUMBER_KEYWORDS = [
|
||||
'orgnr', 'org nr', 'organisationsnummer', 'organisationsnr', 'org',
|
||||
'personnr', 'personnummer', 'org number', 'organization number',
|
||||
]
|
||||
|
||||
const CUSTOMER_TYPE_KEYWORDS = [
|
||||
'kundtyp', 'kund typ', 'typ', 'type', 'customer type', 'customer_type',
|
||||
]
|
||||
|
||||
const EMAIL_KEYWORDS = [
|
||||
'epost', 'e post', 'email', 'mail', 'e mail', 'e-post',
|
||||
]
|
||||
|
||||
const PHONE_KEYWORDS = [
|
||||
'telefon', 'tel', 'phone', 'mobil', 'mobile', 'telefonnummer',
|
||||
]
|
||||
|
||||
const ADDRESS_LINE1_KEYWORDS = [
|
||||
'adress', 'address', 'gatuadress', 'street', 'gata',
|
||||
'address line 1', 'address1', 'adressrad 1',
|
||||
]
|
||||
|
||||
const ADDRESS_LINE2_KEYWORDS = [
|
||||
'address line 2', 'address2', 'adressrad 2', 'c o', 'co',
|
||||
]
|
||||
|
||||
const POSTAL_CODE_KEYWORDS = [
|
||||
'postnr', 'postnummer', 'postal code', 'postal_code', 'zip', 'zip code',
|
||||
]
|
||||
|
||||
const CITY_KEYWORDS = ['ort', 'stad', 'city', 'postort']
|
||||
|
||||
const COUNTRY_KEYWORDS = ['land', 'country']
|
||||
|
||||
const VAT_NUMBER_KEYWORDS = [
|
||||
'vat', 'vatnr', 'vat nr', 'vat number', 'momsnummer', 'momsregistreringsnummer',
|
||||
'momsregnr', 'moms nr',
|
||||
]
|
||||
|
||||
const PAYMENT_TERMS_KEYWORDS = [
|
||||
'betalningsvillkor', 'betalvillkor', 'payment terms', 'kredittid', 'kreditdagar',
|
||||
'dagar', 'förfallodagar', 'forfallodagar',
|
||||
]
|
||||
|
||||
const NOTES_KEYWORDS = [
|
||||
'anteckning', 'anteckningar', 'notes', 'kommentar', 'kommentarer', 'comment',
|
||||
'note', 'beskrivning',
|
||||
]
|
||||
|
||||
/**
|
||||
* Detect customer-register columns from headers.
|
||||
* Header-only matching: register imports always have headers, and the column
|
||||
* structure varies too much to do data-driven fallbacks reliably.
|
||||
*/
|
||||
export function detectCustomerColumns(headers: string[]): DetectedCustomerColumns {
|
||||
const taken = new Set<number>()
|
||||
|
||||
const name_col = findColumn(headers, NAME_KEYWORDS, taken) ?? -1
|
||||
const org_number_col = findColumn(headers, ORG_NUMBER_KEYWORDS, taken)
|
||||
const customer_type_col = findColumn(headers, CUSTOMER_TYPE_KEYWORDS, taken)
|
||||
const email_col = findColumn(headers, EMAIL_KEYWORDS, taken)
|
||||
const phone_col = findColumn(headers, PHONE_KEYWORDS, taken)
|
||||
const address_line1_col = findColumn(headers, ADDRESS_LINE1_KEYWORDS, taken)
|
||||
const address_line2_col = findColumn(headers, ADDRESS_LINE2_KEYWORDS, taken)
|
||||
const postal_code_col = findColumn(headers, POSTAL_CODE_KEYWORDS, taken)
|
||||
const city_col = findColumn(headers, CITY_KEYWORDS, taken)
|
||||
const country_col = findColumn(headers, COUNTRY_KEYWORDS, taken)
|
||||
const vat_number_col = findColumn(headers, VAT_NUMBER_KEYWORDS, taken)
|
||||
const payment_terms_col = findColumn(headers, PAYMENT_TERMS_KEYWORDS, taken)
|
||||
const notes_col = findColumn(headers, NOTES_KEYWORDS, taken)
|
||||
|
||||
// Confidence: name is required; bonus from how many other columns matched.
|
||||
let confidence = 0
|
||||
if (name_col >= 0) {
|
||||
const matched = [
|
||||
org_number_col, email_col, phone_col, address_line1_col,
|
||||
postal_code_col, city_col, vat_number_col, payment_terms_col,
|
||||
].filter((c) => c !== null).length
|
||||
confidence = 0.55 + Math.min(matched, 6) * 0.075
|
||||
}
|
||||
|
||||
return {
|
||||
name_col: name_col >= 0 ? name_col : 0,
|
||||
org_number_col,
|
||||
customer_type_col,
|
||||
email_col,
|
||||
phone_col,
|
||||
address_line1_col,
|
||||
address_line2_col,
|
||||
postal_code_col,
|
||||
city_col,
|
||||
country_col,
|
||||
vat_number_col,
|
||||
payment_terms_col,
|
||||
notes_col,
|
||||
confidence: Math.min(Math.round(confidence * 100) / 100, 1),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { CustomerType } from '@/types'
|
||||
import { detectCustomerColumns } from './column-detector'
|
||||
import { cellOrNull, parsePaymentTerms } from '../shared/column-utils'
|
||||
import { classifyCustomer } from '../shared/classify'
|
||||
import { readBestSheet } from '../shared/workbook-reader'
|
||||
import type {
|
||||
DetectedCustomerColumns,
|
||||
ParsedCustomerRow,
|
||||
} from './types'
|
||||
|
||||
const VALID_CUSTOMER_TYPES: CustomerType[] = [
|
||||
'individual',
|
||||
'swedish_business',
|
||||
'eu_business',
|
||||
'non_eu_business',
|
||||
]
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
function normalizeCustomerType(value: string | null): CustomerType | null {
|
||||
if (!value) return null
|
||||
const lower = value.toLowerCase().trim()
|
||||
if (lower === 'individual' || lower === 'privat' || lower === 'privatperson' || lower === 'person') {
|
||||
return 'individual'
|
||||
}
|
||||
if (lower === 'swedish_business' || lower === 'swedish' || lower === 'företag' || lower === 'foretag' || lower === 'business' || lower === 'ab' || lower === 'aktiebolag') {
|
||||
return 'swedish_business'
|
||||
}
|
||||
if (lower === 'eu_business' || lower === 'eu') {
|
||||
return 'eu_business'
|
||||
}
|
||||
if (lower === 'non_eu_business' || lower === 'non-eu' || lower === 'utomeu' || lower === 'utländsk' || lower === 'utlandsk') {
|
||||
return 'non_eu_business'
|
||||
}
|
||||
return VALID_CUSTOMER_TYPES.includes(lower as CustomerType)
|
||||
? (lower as CustomerType)
|
||||
: null
|
||||
}
|
||||
|
||||
function normalizeCountry(value: string | null): string {
|
||||
if (!value) return 'Sweden'
|
||||
const trimmed = value.trim()
|
||||
const lower = trimmed.toLowerCase()
|
||||
if (lower === 'se' || lower === 'sverige' || lower === 'sweden') return 'Sweden'
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a customer-register file (Excel or CSV) and return structured rows.
|
||||
*
|
||||
* @param buffer - Raw file buffer
|
||||
* @param filename - Original filename
|
||||
* @param columnOverrides - Optional manual column mapping
|
||||
*/
|
||||
export function parseCustomersFile(
|
||||
buffer: ArrayBuffer,
|
||||
filename: string,
|
||||
columnOverrides?: DetectedCustomerColumns,
|
||||
): {
|
||||
filename: string
|
||||
sheet_name: string
|
||||
total_rows: number
|
||||
detected_columns: DetectedCustomerColumns
|
||||
headers: string[]
|
||||
preview_rows: string[][]
|
||||
rows: ParsedCustomerRow[]
|
||||
warnings: string[]
|
||||
} {
|
||||
const { sheetName, rawData } = readBestSheet(buffer, filename)
|
||||
|
||||
if (rawData.length < 2) {
|
||||
const fallbackColumns: DetectedCustomerColumns = columnOverrides ?? {
|
||||
name_col: 0,
|
||||
org_number_col: null,
|
||||
customer_type_col: null,
|
||||
email_col: null,
|
||||
phone_col: null,
|
||||
address_line1_col: null,
|
||||
address_line2_col: null,
|
||||
postal_code_col: null,
|
||||
city_col: null,
|
||||
country_col: null,
|
||||
vat_number_col: null,
|
||||
payment_terms_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 || detectCustomerColumns(headers)
|
||||
|
||||
const rows: ParsedCustomerRow[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
for (let i = 0; i < dataRows.length; i++) {
|
||||
const row = dataRows[i]
|
||||
const name = cellOrNull(row[columns.name_col])
|
||||
if (!name) continue // skip empty rows silently
|
||||
|
||||
const orgNumber = columns.org_number_col !== null
|
||||
? cellOrNull(row[columns.org_number_col])
|
||||
: null
|
||||
const email = columns.email_col !== null
|
||||
? cellOrNull(row[columns.email_col])
|
||||
: null
|
||||
const phone = columns.phone_col !== null
|
||||
? cellOrNull(row[columns.phone_col])
|
||||
: null
|
||||
const addressLine1 = columns.address_line1_col !== null
|
||||
? cellOrNull(row[columns.address_line1_col])
|
||||
: null
|
||||
const addressLine2 = columns.address_line2_col !== null
|
||||
? cellOrNull(row[columns.address_line2_col])
|
||||
: null
|
||||
const postalCode = columns.postal_code_col !== null
|
||||
? cellOrNull(row[columns.postal_code_col])
|
||||
: null
|
||||
const city = columns.city_col !== null
|
||||
? cellOrNull(row[columns.city_col])
|
||||
: null
|
||||
const countryRaw = columns.country_col !== null
|
||||
? cellOrNull(row[columns.country_col])
|
||||
: null
|
||||
const country = normalizeCountry(countryRaw)
|
||||
const vatNumber = columns.vat_number_col !== null
|
||||
? cellOrNull(row[columns.vat_number_col])
|
||||
: null
|
||||
const paymentTermsRaw = columns.payment_terms_col !== null
|
||||
? row[columns.payment_terms_col]
|
||||
: null
|
||||
const notes = columns.notes_col !== null
|
||||
? cellOrNull(row[columns.notes_col])
|
||||
: null
|
||||
|
||||
const explicitType = columns.customer_type_col !== null
|
||||
? normalizeCustomerType(cellOrNull(row[columns.customer_type_col]))
|
||||
: null
|
||||
const customerType: CustomerType =
|
||||
explicitType ?? classifyCustomer({
|
||||
org_number: orgNumber,
|
||||
vat_number: vatNumber,
|
||||
country: countryRaw,
|
||||
})
|
||||
|
||||
const validationErrors: string[] = []
|
||||
if (email && !EMAIL_RE.test(email)) {
|
||||
validationErrors.push('Ogiltig e-postadress')
|
||||
}
|
||||
if (orgNumber && !/^[\d\s\-]{6,20}$/.test(orgNumber)) {
|
||||
validationErrors.push('Ogiltigt org-/personnummer')
|
||||
}
|
||||
|
||||
rows.push({
|
||||
row_index: i + 2, // 1-based + header
|
||||
name,
|
||||
customer_type: customerType,
|
||||
org_number: orgNumber,
|
||||
email,
|
||||
phone,
|
||||
address_line1: addressLine1,
|
||||
address_line2: addressLine2,
|
||||
postal_code: postalCode,
|
||||
city,
|
||||
country,
|
||||
vat_number: vatNumber,
|
||||
default_payment_terms: parsePaymentTerms(paymentTermsRaw, 30),
|
||||
notes,
|
||||
is_valid: validationErrors.length === 0,
|
||||
validation_errors: validationErrors,
|
||||
})
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
warnings.push('Inga giltiga kundrader hittades. Kontrollera att namnkolumnen är korrekt mappad.')
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
sheet_name: sheetName,
|
||||
total_rows: rows.length,
|
||||
detected_columns: columns,
|
||||
headers,
|
||||
preview_rows: dataRows.slice(0, 5),
|
||||
rows,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { CustomerType } from '@/types'
|
||||
|
||||
/** Result of auto-detecting columns in a customer register file. */
|
||||
export interface DetectedCustomerColumns {
|
||||
name_col: number
|
||||
org_number_col: number | null
|
||||
customer_type_col: number | null
|
||||
email_col: number | null
|
||||
phone_col: number | null
|
||||
address_line1_col: number | null
|
||||
address_line2_col: number | null
|
||||
postal_code_col: number | null
|
||||
city_col: number | null
|
||||
country_col: number | null
|
||||
vat_number_col: number | null
|
||||
payment_terms_col: number | null
|
||||
notes_col: number | null
|
||||
/** 0-1 confidence score for the detection */
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/** A single parsed row from the customer register file. */
|
||||
export interface ParsedCustomerRow {
|
||||
row_index: number
|
||||
name: string
|
||||
customer_type: CustomerType
|
||||
org_number: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
address_line1: string | null
|
||||
address_line2: string | null
|
||||
postal_code: string | null
|
||||
city: string | null
|
||||
country: string
|
||||
vat_number: string | null
|
||||
default_payment_terms: number
|
||||
notes: string | null
|
||||
is_valid: boolean
|
||||
validation_errors: string[]
|
||||
}
|
||||
|
||||
/** Customer-row + dedup annotation produced by the API route. */
|
||||
export interface AnnotatedCustomerRow extends ParsedCustomerRow {
|
||||
duplicate_match: {
|
||||
customer_id: string
|
||||
matched_by: 'org_number' | 'email'
|
||||
existing_name: string
|
||||
} | null
|
||||
}
|
||||
|
||||
/** Full result from parsing a customer register file. */
|
||||
export interface CustomerImportParseResult {
|
||||
filename: string
|
||||
sheet_name: string
|
||||
total_rows: number
|
||||
detected_columns: DetectedCustomerColumns
|
||||
headers: string[]
|
||||
preview_rows: string[][]
|
||||
rows: AnnotatedCustomerRow[]
|
||||
duplicate_count: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/** Input for executing the customer import. */
|
||||
export interface CustomerImportExecuteInput {
|
||||
rows: ParsedCustomerRow[]
|
||||
update_duplicates: boolean
|
||||
}
|
||||
|
||||
/** Result of executing the customer import. */
|
||||
export interface CustomerImportExecuteResult {
|
||||
success: boolean
|
||||
created: number
|
||||
updated: number
|
||||
skipped: number
|
||||
failed: number
|
||||
errors: { row_index: number; name: string; reason: string }[]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as XLSX from 'xlsx'
|
||||
import { detectColumns } from './column-detector'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { readWorkbookFromBuffer } from '../shared/workbook-reader'
|
||||
import type {
|
||||
DetectedColumns,
|
||||
ParsedOpeningBalanceRow,
|
||||
@@ -43,7 +44,7 @@ export function parseOpeningBalanceFile(
|
||||
filename: string,
|
||||
columnOverrides?: DetectedColumns,
|
||||
): OpeningBalanceParseResult {
|
||||
const workbook = XLSX.read(buffer, { type: 'array' })
|
||||
const workbook = readWorkbookFromBuffer(buffer, filename)
|
||||
|
||||
// Pick the sheet with the most rows (heuristic for multi-sheet workbooks)
|
||||
let bestSheet = workbook.SheetNames[0]
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { classifyCustomer, classifySupplier } from '../classify'
|
||||
|
||||
describe('classifyCustomer', () => {
|
||||
it('classifies 12-digit personnummer as individual', () => {
|
||||
expect(classifyCustomer({
|
||||
org_number: '198001011234',
|
||||
vat_number: null,
|
||||
})).toBe('individual')
|
||||
})
|
||||
|
||||
it('classifies 10-digit Swedish org as swedish_business', () => {
|
||||
expect(classifyCustomer({
|
||||
org_number: '5560217780',
|
||||
vat_number: null,
|
||||
})).toBe('swedish_business')
|
||||
})
|
||||
|
||||
it('classifies non-SE EU VAT prefix as eu_business', () => {
|
||||
expect(classifyCustomer({
|
||||
org_number: null,
|
||||
vat_number: 'DE123456789',
|
||||
})).toBe('eu_business')
|
||||
})
|
||||
|
||||
it('classifies non-EU VAT prefix as non_eu_business', () => {
|
||||
expect(classifyCustomer({
|
||||
org_number: null,
|
||||
vat_number: 'NO12345678',
|
||||
})).toBe('non_eu_business')
|
||||
})
|
||||
|
||||
it('keeps SE VAT prefix as swedish_business', () => {
|
||||
expect(classifyCustomer({
|
||||
org_number: '5560217780',
|
||||
vat_number: 'SE556021778001',
|
||||
})).toBe('swedish_business')
|
||||
})
|
||||
|
||||
it('classifies 10-digit personnummer-month-pattern as individual', () => {
|
||||
// Third digit is 0 (month 01), classic personnummer pattern
|
||||
expect(classifyCustomer({
|
||||
org_number: '8001011234',
|
||||
vat_number: null,
|
||||
})).toBe('individual')
|
||||
})
|
||||
|
||||
it('falls back to swedish_business when no signals', () => {
|
||||
expect(classifyCustomer({
|
||||
org_number: null,
|
||||
vat_number: null,
|
||||
})).toBe('swedish_business')
|
||||
})
|
||||
|
||||
it('uses country code when VAT missing', () => {
|
||||
expect(classifyCustomer({
|
||||
org_number: null,
|
||||
vat_number: null,
|
||||
country: 'DE',
|
||||
})).toBe('eu_business')
|
||||
})
|
||||
|
||||
it('detects Norway as non-EU', () => {
|
||||
expect(classifyCustomer({
|
||||
org_number: null,
|
||||
vat_number: null,
|
||||
country: 'Norge',
|
||||
})).toBe('non_eu_business')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifySupplier', () => {
|
||||
it('never returns individual', () => {
|
||||
expect(classifySupplier({
|
||||
org_number: '198001011234',
|
||||
vat_number: null,
|
||||
})).toBe('swedish_business')
|
||||
})
|
||||
|
||||
it('classifies non-SE EU VAT prefix as eu_business', () => {
|
||||
expect(classifySupplier({
|
||||
org_number: null,
|
||||
vat_number: 'FR12345678901',
|
||||
})).toBe('eu_business')
|
||||
})
|
||||
|
||||
it('classifies post-Brexit GB VAT as non_eu_business', () => {
|
||||
expect(classifySupplier({
|
||||
org_number: null,
|
||||
vat_number: 'GB123456789',
|
||||
})).toBe('non_eu_business')
|
||||
})
|
||||
|
||||
it('classifies XI (Northern Ireland) VAT as eu_business', () => {
|
||||
expect(classifySupplier({
|
||||
org_number: null,
|
||||
vat_number: 'XI123456789',
|
||||
})).toBe('eu_business')
|
||||
})
|
||||
|
||||
it('falls back to swedish_business by default', () => {
|
||||
expect(classifySupplier({
|
||||
org_number: null,
|
||||
vat_number: null,
|
||||
})).toBe('swedish_business')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
decodeFileContent,
|
||||
decodeStringContent,
|
||||
hasEncodingIssues,
|
||||
} from '../encoding'
|
||||
|
||||
describe('decodeStringContent', () => {
|
||||
it('recovers UTF-8-as-Latin-1 mojibake for lowercase Swedish chars', () => {
|
||||
expect(decodeStringContent('Malmö')).toBe('Malmö')
|
||||
expect(decodeStringContent('Ã¥re')).toBe('Åre'.toLowerCase())
|
||||
expect(decodeStringContent('Linköping')).toBe('Linköping')
|
||||
})
|
||||
|
||||
it('recovers UTF-8-as-Latin-1 mojibake for uppercase Swedish chars', () => {
|
||||
// The middle char is U+0096 (control), invisible in most renderings → "GÃTEBORG"
|
||||
expect(decodeStringContent('GÃ\u0096TEBORG')).toBe('GÖTEBORG')
|
||||
expect(decodeStringContent('HISINGS KÃ\u0084RRA')).toBe('HISINGS KÄRRA')
|
||||
expect(decodeStringContent('Ã\u0085NGE')).toBe('ÅNGE')
|
||||
})
|
||||
|
||||
it('is a no-op on already-correct Swedish strings', () => {
|
||||
expect(decodeStringContent('GÖTEBORG')).toBe('GÖTEBORG')
|
||||
expect(decodeStringContent('Malmö')).toBe('Malmö')
|
||||
expect(decodeStringContent('STOCKHOLM')).toBe('STOCKHOLM')
|
||||
expect(decodeStringContent('')).toBe('')
|
||||
})
|
||||
|
||||
it('is idempotent (running twice equals running once)', () => {
|
||||
const once = decodeStringContent('Malmö')
|
||||
const twice = decodeStringContent(once)
|
||||
expect(twice).toBe(once)
|
||||
expect(twice).toBe('Malmö')
|
||||
})
|
||||
|
||||
it('preserves non-Swedish strings unchanged', () => {
|
||||
expect(decodeStringContent('Café')).toBe('Café')
|
||||
expect(decodeStringContent('München')).toBe('München')
|
||||
expect(decodeStringContent('123 Main St')).toBe('123 Main St')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasEncodingIssues', () => {
|
||||
it('detects U+FFFD replacement characters', () => {
|
||||
expect(hasEncodingIssues('Foo\uFFFDbar')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects all six Swedish mojibake patterns', () => {
|
||||
expect(hasEncodingIssues('Malmö')).toBe(true) // ö
|
||||
expect(hasEncodingIssues('Ã¥re')).toBe(true) // å
|
||||
expect(hasEncodingIssues('älg')).toBe(true) // ä
|
||||
expect(hasEncodingIssues('GÃ\u0096TEBORG')).toBe(true) // Ö
|
||||
expect(hasEncodingIssues('Ã\u0085NGE')).toBe(true) // Å
|
||||
expect(hasEncodingIssues('Ã\u0084RRA')).toBe(true) // Ä
|
||||
})
|
||||
|
||||
it('returns false for clean strings', () => {
|
||||
expect(hasEncodingIssues('Stockholm')).toBe(false)
|
||||
expect(hasEncodingIssues('Malmö')).toBe(false)
|
||||
expect(hasEncodingIssues('Café')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeFileContent', () => {
|
||||
function buf(bytes: number[]): ArrayBuffer {
|
||||
return new Uint8Array(bytes).buffer
|
||||
}
|
||||
|
||||
it('decodes UTF-8 bytes correctly', () => {
|
||||
const utf8 = new TextEncoder().encode('GÖTEBORG').buffer
|
||||
expect(decodeFileContent(utf8)).toBe('GÖTEBORG')
|
||||
})
|
||||
|
||||
it('falls back to Windows-1252 when UTF-8 decode is invalid', () => {
|
||||
// 0xD6 = Ö in Windows-1252; lone 0xD6 is not valid UTF-8 start byte
|
||||
const cp1252 = buf([0x47, 0xd6, 0x54, 0x45, 0x42, 0x4f, 0x52, 0x47])
|
||||
expect(decodeFileContent(cp1252)).toBe('GÖTEBORG')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { readWorkbookFromBuffer } from '../workbook-reader'
|
||||
|
||||
function bufFromBytes(bytes: number[]): ArrayBuffer {
|
||||
return new Uint8Array(bytes).buffer
|
||||
}
|
||||
|
||||
function rowsOf(workbook: XLSX.WorkBook): string[][] {
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]]
|
||||
return XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: false }) as string[][]
|
||||
}
|
||||
|
||||
describe('readWorkbookFromBuffer', () => {
|
||||
it('decodes UTF-8 CSV with Swedish characters correctly', () => {
|
||||
const csv = new TextEncoder().encode('Namn,Ort\nAcme,GÖTEBORG\nBeta,KÄRRA\n').buffer
|
||||
const wb = readWorkbookFromBuffer(csv, 'lev.csv')
|
||||
expect(rowsOf(wb)).toEqual([
|
||||
['Namn', 'Ort'],
|
||||
['Acme', 'GÖTEBORG'],
|
||||
['Beta', 'KÄRRA'],
|
||||
])
|
||||
})
|
||||
|
||||
it('decodes UTF-8 CSV with BOM', () => {
|
||||
const bom = [0xef, 0xbb, 0xbf]
|
||||
const body = Array.from(new TextEncoder().encode('Namn,Ort\nAcme,GÖTEBORG\n'))
|
||||
const wb = readWorkbookFromBuffer(bufFromBytes([...bom, ...body]), 'lev.csv')
|
||||
expect(rowsOf(wb)).toEqual([
|
||||
['Namn', 'Ort'],
|
||||
['Acme', 'GÖTEBORG'],
|
||||
])
|
||||
})
|
||||
|
||||
it('decodes Windows-1252 CSV with Swedish characters', () => {
|
||||
// "Namn,Ort\nAcme,GÖTEBORG\nBeta,KÄRRA\n" in Windows-1252:
|
||||
// Ö = 0xD6, Ä = 0xC4
|
||||
const bytes = [
|
||||
0x4e, 0x61, 0x6d, 0x6e, 0x2c, 0x4f, 0x72, 0x74, 0x0a,
|
||||
0x41, 0x63, 0x6d, 0x65, 0x2c, 0x47, 0xd6, 0x54, 0x45, 0x42, 0x4f, 0x52, 0x47, 0x0a,
|
||||
0x42, 0x65, 0x74, 0x61, 0x2c, 0x4b, 0xc4, 0x52, 0x52, 0x41, 0x0a,
|
||||
]
|
||||
const wb = readWorkbookFromBuffer(bufFromBytes(bytes), 'lev.csv')
|
||||
expect(rowsOf(wb)).toEqual([
|
||||
['Namn', 'Ort'],
|
||||
['Acme', 'GÖTEBORG'],
|
||||
['Beta', 'KÄRRA'],
|
||||
])
|
||||
})
|
||||
|
||||
it('reads xlsx files via the binary path', () => {
|
||||
const ws = XLSX.utils.aoa_to_sheet([
|
||||
['Namn', 'Ort'],
|
||||
['Acme', 'GÖTEBORG'],
|
||||
])
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')
|
||||
const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer
|
||||
|
||||
const result = readWorkbookFromBuffer(buffer, 'data.xlsx')
|
||||
expect(rowsOf(result)).toEqual([
|
||||
['Namn', 'Ort'],
|
||||
['Acme', 'GÖTEBORG'],
|
||||
])
|
||||
})
|
||||
|
||||
it('treats non-csv extensions as binary spreadsheets', () => {
|
||||
// .xls and .ods both go through the binary path; xlsx handles encoding internally
|
||||
const ws = XLSX.utils.aoa_to_sheet([['A'], ['Ö']])
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')
|
||||
const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer
|
||||
|
||||
const result = readWorkbookFromBuffer(buffer, 'data.xls')
|
||||
expect(rowsOf(result)).toEqual([['A'], ['Ö']])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { CustomerType, SupplierType } from '@/types'
|
||||
|
||||
/**
|
||||
* EU VAT number prefixes (excluding SE).
|
||||
* Source: https://taxation-ec.europa.eu/online-services/check-vat-number-vies_en
|
||||
*/
|
||||
export const EU_VAT_PREFIXES = new Set([
|
||||
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES',
|
||||
'FI', 'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV',
|
||||
'MT', 'NL', 'PL', 'PT', 'RO', 'SI', 'SK', 'XI', // XI = Northern Ireland
|
||||
])
|
||||
|
||||
/**
|
||||
* Strip whitespace, dashes, and dots from an org/personnummer for length checks.
|
||||
*/
|
||||
function digits(value: string | null): string {
|
||||
if (!value) return ''
|
||||
return value.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the third digit of a Swedish org/personnummer.
|
||||
*
|
||||
* Personnummer: month digit (00-12) — third digit ≤ 1.
|
||||
* Företag: third digit ≥ 2 (per Skatteverket's allocation rules).
|
||||
*/
|
||||
function looksLikePersonnummer(orgNumber: string | null): boolean {
|
||||
const d = digits(orgNumber)
|
||||
// 12 digits = full personnummer (YYYYMMDDXXXX)
|
||||
if (d.length === 12) return true
|
||||
// 10 digits — disambiguate by month (positions 3-4 are month, 01-12)
|
||||
if (d.length === 10) {
|
||||
const month = parseInt(d.substring(2, 4), 10)
|
||||
if (month >= 1 && month <= 12 && d[2] <= '1') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function vatPrefix(vatNumber: string | null): string | null {
|
||||
if (!vatNumber) return null
|
||||
const cleaned = vatNumber.trim().toUpperCase()
|
||||
const match = cleaned.match(/^([A-Z]{2})/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-classify a customer based on org_number + vat_number heuristics.
|
||||
*
|
||||
* Precedence:
|
||||
* 1. Non-SE EU VAT prefix → 'eu_business'
|
||||
* 2. Non-EU letter prefix → 'non_eu_business'
|
||||
* 3. Personnummer-shaped org → 'individual'
|
||||
* 4. Default → 'swedish_business'
|
||||
*/
|
||||
export function classifyCustomer(args: {
|
||||
org_number: string | null
|
||||
vat_number: string | null
|
||||
country?: string | null
|
||||
}): CustomerType {
|
||||
const prefix = vatPrefix(args.vat_number)
|
||||
if (prefix && prefix !== 'SE') {
|
||||
if (EU_VAT_PREFIXES.has(prefix)) return 'eu_business'
|
||||
return 'non_eu_business'
|
||||
}
|
||||
|
||||
const country = args.country?.trim().toUpperCase()
|
||||
if (country && country !== 'SE' && country !== 'SVERIGE' && country !== 'SWEDEN') {
|
||||
// Country-based fallback when VAT is missing.
|
||||
if (country.length === 2 && EU_VAT_PREFIXES.has(country)) return 'eu_business'
|
||||
if (country.length >= 3) {
|
||||
// Common Swedish names for non-EU jurisdictions; heuristic is best-effort
|
||||
if (/norge|norway|usa|kanada|canada|storbritannien|uk|united kingdom/i.test(country)) {
|
||||
return 'non_eu_business'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (looksLikePersonnummer(args.org_number)) return 'individual'
|
||||
return 'swedish_business'
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-classify a supplier. Suppliers cannot be 'individual' — Swedish business
|
||||
* with personnummer is still 'swedish_business' (a sole trader supplier).
|
||||
*/
|
||||
export function classifySupplier(args: {
|
||||
org_number: string | null
|
||||
vat_number: string | null
|
||||
country?: string | null
|
||||
}): SupplierType {
|
||||
const prefix = vatPrefix(args.vat_number)
|
||||
if (prefix && prefix !== 'SE') {
|
||||
if (EU_VAT_PREFIXES.has(prefix)) return 'eu_business'
|
||||
return 'non_eu_business'
|
||||
}
|
||||
|
||||
const country = args.country?.trim().toUpperCase()
|
||||
if (country && country !== 'SE' && country !== 'SVERIGE' && country !== 'SWEDEN') {
|
||||
if (country.length === 2 && EU_VAT_PREFIXES.has(country)) return 'eu_business'
|
||||
if (/norge|norway|usa|kanada|canada|storbritannien|uk|united kingdom/i.test(country)) {
|
||||
return 'non_eu_business'
|
||||
}
|
||||
}
|
||||
|
||||
return 'swedish_business'
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Shared column-detection helpers for register imports
|
||||
* (customers, suppliers, future: articles).
|
||||
*/
|
||||
|
||||
export function normalize(header: string): string {
|
||||
return header.toLowerCase().trim().replace(/[_\-./]/g, ' ')
|
||||
}
|
||||
|
||||
export function matchesKeywords(header: string, keywords: string[]): boolean {
|
||||
const normalized = normalize(header)
|
||||
return keywords.some((kw) => normalized === kw || normalized.includes(kw))
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first column index whose header matches one of `keywords`,
|
||||
* skipping any indices already taken by other columns.
|
||||
*/
|
||||
export function findColumn(
|
||||
headers: string[],
|
||||
keywords: string[],
|
||||
taken: Set<number>,
|
||||
): number | null {
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
if (taken.has(i)) continue
|
||||
if (matchesKeywords(headers[i], keywords)) {
|
||||
taken.add(i)
|
||||
return i
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Trim a string-or-blank cell, returning null when empty. */
|
||||
export function cellOrNull(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null
|
||||
const str = String(value).trim()
|
||||
return str === '' ? null : str
|
||||
}
|
||||
|
||||
/** Parse an integer payment term ("30 dagar" → 30) with a default fallback. */
|
||||
export function parsePaymentTerms(value: unknown, fallback: number): number {
|
||||
const str = cellOrNull(value)
|
||||
if (!str) return fallback
|
||||
const match = str.match(/-?\d+/)
|
||||
if (!match) return fallback
|
||||
const n = parseInt(match[0], 10)
|
||||
if (isNaN(n) || n < 0 || n > 365) return fallback
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an org/personal number to its dedup key (digits only).
|
||||
* Returns null for empty input or strings that contain no digits.
|
||||
*/
|
||||
export function normalizeOrgNumber(value: string | null): string | null {
|
||||
if (!value) return null
|
||||
return value.replace(/\D/g, '') || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an email to its dedup key (trimmed + lowercased).
|
||||
* Returns null for empty/whitespace-only input.
|
||||
*/
|
||||
export function normalizeEmail(value: string | null): string | null {
|
||||
if (!value) return null
|
||||
return value.trim().toLowerCase() || null
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Encoding detection and conversion for Swedish bank files.
|
||||
* Encoding detection and conversion for Swedish import files.
|
||||
*
|
||||
* Swedish bank exports use either UTF-8 or Windows-1252 (ISO-8859-1).
|
||||
* Used by bank file, supplier, customer, and opening-balance parsers.
|
||||
* Swedish data exports use either UTF-8 or Windows-1252 (ISO-8859-1).
|
||||
* We detect encoding by checking for valid Swedish characters.
|
||||
*/
|
||||
|
||||
@@ -12,37 +13,39 @@
|
||||
* (U+FFFD) or garbled Swedish chars, fall back to Windows-1252.
|
||||
*/
|
||||
export function decodeFileContent(buffer: ArrayBuffer): string {
|
||||
// Try UTF-8 first
|
||||
const utf8Decoder = new TextDecoder('utf-8', { fatal: false })
|
||||
const utf8Result = utf8Decoder.decode(buffer)
|
||||
|
||||
// Check if UTF-8 decode produced valid Swedish text
|
||||
if (!hasEncodingIssues(utf8Result)) {
|
||||
return utf8Result
|
||||
}
|
||||
|
||||
// Fall back to Windows-1252 (superset of ISO-8859-1)
|
||||
const latin1Decoder = new TextDecoder('windows-1252', { fatal: false })
|
||||
return latin1Decoder.decode(buffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a string that may have been incorrectly decoded as UTF-8
|
||||
* when the source was actually Windows-1252.
|
||||
* Re-decode a string that suffered the canonical "UTF-8 bytes read as Latin-1"
|
||||
* mojibake (e.g. "Malmö" → "Malmö", "GÖTEBORG" → "GÖTEBORG").
|
||||
*
|
||||
* Mechanism: each char in the input is a codepoint that was originally a UTF-8
|
||||
* byte misinterpreted as a Latin-1/Windows-1252 character. We pack those chars
|
||||
* back into a byte sequence and decode the bytes as UTF-8 to recover the
|
||||
* original text.
|
||||
*
|
||||
* No-op when the string is already clean (no garbled patterns).
|
||||
*/
|
||||
export function decodeStringContent(content: string): string {
|
||||
// If the string already contains valid Swedish chars, return as-is
|
||||
if (!hasEncodingIssues(content)) {
|
||||
return content
|
||||
}
|
||||
|
||||
// Try re-encoding as Latin-1 and decoding as Windows-1252
|
||||
try {
|
||||
const bytes = new Uint8Array(content.length)
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
bytes[i] = content.charCodeAt(i) & 0xff
|
||||
}
|
||||
const decoder = new TextDecoder('windows-1252', { fatal: false })
|
||||
const decoder = new TextDecoder('utf-8', { fatal: false })
|
||||
return decoder.decode(bytes)
|
||||
} catch {
|
||||
return content
|
||||
@@ -52,8 +55,7 @@ export function decodeStringContent(content: string): string {
|
||||
/**
|
||||
* Check if a string has encoding issues (garbled Swedish characters).
|
||||
*/
|
||||
function hasEncodingIssues(text: string): boolean {
|
||||
// U+FFFD = replacement character (means invalid UTF-8 byte sequences)
|
||||
export function hasEncodingIssues(text: string): boolean {
|
||||
if (text.includes('\uFFFD')) return true
|
||||
|
||||
// Common garbled patterns when Windows-1252 is read as UTF-8:
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as XLSX from 'xlsx'
|
||||
import { decodeFileContent } from './encoding'
|
||||
|
||||
/**
|
||||
* Read a workbook from a raw file buffer, with correct encoding handling
|
||||
* for CSV files.
|
||||
*
|
||||
* For binary spreadsheet formats (.xlsx, .xls, .ods), xlsx handles encoding
|
||||
* via the embedded codepage and we pass the buffer through as `type: 'array'`.
|
||||
*
|
||||
* For CSV files, xlsx with `type: 'array'` decodes bytes as Latin-1, which
|
||||
* mangles UTF-8 multi-byte sequences (e.g. Ö → Ö). We instead detect the
|
||||
* source encoding (UTF-8 with optional BOM, or Windows-1252) and decode to
|
||||
* a string before handing it to xlsx as `type: 'string'`.
|
||||
*/
|
||||
export function readWorkbookFromBuffer(buffer: ArrayBuffer, filename: string): XLSX.WorkBook {
|
||||
const ext = filename.toLowerCase().split('.').pop() ?? ''
|
||||
if (ext === 'csv') {
|
||||
const content = decodeFileContent(buffer)
|
||||
return XLSX.read(content, { type: 'string' })
|
||||
}
|
||||
return XLSX.read(buffer, { type: 'array' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the workbook from `buffer` and return raw rows from its largest sheet.
|
||||
*
|
||||
* Picks the sheet with the most rows (a heuristic that handles files where
|
||||
* the header sheet isn't the first one). Returns rows as a 2D string array
|
||||
* with the header row included; cells default to empty string.
|
||||
*/
|
||||
export function readBestSheet(
|
||||
buffer: ArrayBuffer,
|
||||
filename: string,
|
||||
): { sheetName: string; rawData: string[][] } {
|
||||
const workbook = readWorkbookFromBuffer(buffer, filename)
|
||||
|
||||
let bestSheet = workbook.SheetNames[0]
|
||||
let bestRowCount = 0
|
||||
for (const name of workbook.SheetNames) {
|
||||
const sheet = workbook.Sheets[name]
|
||||
const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1')
|
||||
const rowCount = range.e.r - range.s.r + 1
|
||||
if (rowCount > bestRowCount) {
|
||||
bestRowCount = rowCount
|
||||
bestSheet = name
|
||||
}
|
||||
}
|
||||
|
||||
const sheet = workbook.Sheets[bestSheet]
|
||||
const rawData: string[][] = XLSX.utils.sheet_to_json(sheet, {
|
||||
header: 1,
|
||||
defval: '',
|
||||
raw: false,
|
||||
})
|
||||
|
||||
return { sheetName: bestSheet, rawData }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { detectSupplierColumns } from '../column-detector'
|
||||
|
||||
describe('detectSupplierColumns', () => {
|
||||
it('detects Swedish supplier register headers', () => {
|
||||
const headers = ['Namn', 'Orgnr', 'Bankgiro', 'Plusgiro', 'IBAN', 'BIC', 'E-post']
|
||||
const result = detectSupplierColumns(headers)
|
||||
expect(result.name_col).toBe(0)
|
||||
expect(result.org_number_col).toBe(1)
|
||||
expect(result.bankgiro_col).toBe(2)
|
||||
expect(result.plusgiro_col).toBe(3)
|
||||
expect(result.iban_col).toBe(4)
|
||||
expect(result.bic_col).toBe(5)
|
||||
expect(result.email_col).toBe(6)
|
||||
expect(result.confidence).toBeGreaterThanOrEqual(0.8)
|
||||
})
|
||||
|
||||
it('handles supplier-specific keyword "Leverantör"', () => {
|
||||
const headers = ['Leverantör', 'Orgnummer', 'Bankgiro']
|
||||
const result = detectSupplierColumns(headers)
|
||||
expect(result.name_col).toBe(0)
|
||||
expect(result.org_number_col).toBe(1)
|
||||
expect(result.bankgiro_col).toBe(2)
|
||||
})
|
||||
|
||||
it('does not confuse plusgiro with bankgiro', () => {
|
||||
const headers = ['Namn', 'Plusgiro', 'Bankgiro']
|
||||
const result = detectSupplierColumns(headers)
|
||||
expect(result.plusgiro_col).toBe(1)
|
||||
expect(result.bankgiro_col).toBe(2)
|
||||
})
|
||||
|
||||
it('returns confidence 0 with no name column', () => {
|
||||
const headers = ['ColA', 'ColB']
|
||||
const result = detectSupplierColumns(headers)
|
||||
expect(result.confidence).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { parseSuppliersFile } 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, 'Leverantörer')
|
||||
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer
|
||||
}
|
||||
|
||||
describe('parseSuppliersFile', () => {
|
||||
it('parses Swedish supplier register with bankgiro/iban', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'Orgnr', 'Bankgiro', 'Plusgiro', 'IBAN', 'BIC'],
|
||||
['Acme AB', '5560217780', '123-4567', '12 34 56-7', 'SE3550000000054910000003', 'ESSESESS'],
|
||||
])
|
||||
|
||||
const result = parseSuppliersFile(buffer, 'lev.xlsx')
|
||||
|
||||
expect(result.rows[0].name).toBe('Acme AB')
|
||||
expect(result.rows[0].bankgiro).toBe('123-4567')
|
||||
expect(result.rows[0].plusgiro).toBe('123456-7')
|
||||
expect(result.rows[0].iban).toBe('SE3550000000054910000003')
|
||||
expect(result.rows[0].bic).toBe('ESSESESS')
|
||||
expect(result.rows[0].is_valid).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies eu_business by VAT prefix', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'VAT'],
|
||||
['Müller GmbH', 'DE123456789'],
|
||||
])
|
||||
const result = parseSuppliersFile(buffer, 'eu.xlsx')
|
||||
expect(result.rows[0].supplier_type).toBe('eu_business')
|
||||
})
|
||||
|
||||
it('flags invalid IBAN format', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'IBAN'],
|
||||
['Acme AB', 'NOT-AN-IBAN'],
|
||||
])
|
||||
const result = parseSuppliersFile(buffer, 'bad-iban.xlsx')
|
||||
expect(result.rows[0].is_valid).toBe(false)
|
||||
expect(result.rows[0].validation_errors).toContain('Ogiltigt IBAN')
|
||||
})
|
||||
|
||||
it('defaults currency to SEK when missing or invalid', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'Valuta'],
|
||||
['Acme AB', ''],
|
||||
['Beta AB', 'XYZ'],
|
||||
['Gamma AB', 'EUR'],
|
||||
])
|
||||
const result = parseSuppliersFile(buffer, 'curr.xlsx')
|
||||
expect(result.rows[0].default_currency).toBe('SEK')
|
||||
expect(result.rows[1].default_currency).toBe('SEK')
|
||||
expect(result.rows[2].default_currency).toBe('EUR')
|
||||
})
|
||||
|
||||
it('skips rows with empty name', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn'],
|
||||
['Acme AB'],
|
||||
[''],
|
||||
['Beta AB'],
|
||||
])
|
||||
const result = parseSuppliersFile(buffer, 'sparse.xlsx')
|
||||
expect(result.total_rows).toBe(2)
|
||||
})
|
||||
|
||||
it('cleans bankgiro number formatting', () => {
|
||||
const buffer = buildXlsx([
|
||||
['Namn', 'Bankgiro'],
|
||||
['Acme AB', '5402 9685'],
|
||||
])
|
||||
const result = parseSuppliersFile(buffer, 'bg.xlsx')
|
||||
expect(result.rows[0].bankgiro).toBe('54029685')
|
||||
})
|
||||
|
||||
it('preserves Swedish characters when reading a UTF-8 CSV', () => {
|
||||
const csv = new TextEncoder().encode(
|
||||
'Namn,Ort\nDinel AB,GÖTEBORG\nHisings AB,HISINGS KÄRRA\n',
|
||||
).buffer
|
||||
const result = parseSuppliersFile(csv, 'lev.csv')
|
||||
expect(result.rows[0].city).toBe('GÖTEBORG')
|
||||
expect(result.rows[1].city).toBe('HISINGS KÄRRA')
|
||||
})
|
||||
|
||||
it('preserves Swedish characters when reading a Windows-1252 CSV', () => {
|
||||
// Ö = 0xD6, Ä = 0xC4 in Windows-1252
|
||||
const bytes = [
|
||||
0x4e, 0x61, 0x6d, 0x6e, 0x2c, 0x4f, 0x72, 0x74, 0x0a, // "Namn,Ort\n"
|
||||
0x41, 0x63, 0x6d, 0x65, 0x2c, 0x47, 0xd6, 0x54, 0x45, 0x42, 0x4f, 0x52, 0x47, 0x0a, // "Acme,GÖTEBORG\n"
|
||||
0x42, 0x65, 0x74, 0x61, 0x2c, 0x4b, 0xc4, 0x52, 0x52, 0x41, 0x0a, // "Beta,KÄRRA\n"
|
||||
]
|
||||
const result = parseSuppliersFile(new Uint8Array(bytes).buffer, 'lev.csv')
|
||||
expect(result.rows[0].city).toBe('GÖTEBORG')
|
||||
expect(result.rows[1].city).toBe('KÄRRA')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { findColumn } from '../shared/column-utils'
|
||||
import type { DetectedSupplierColumns } from './types'
|
||||
|
||||
const NAME_KEYWORDS = [
|
||||
'leverantörsnamn', 'leverantorsnamn', 'leverantör', 'leverantor', 'namn', 'name',
|
||||
'supplier', 'supplier name', 'företag', 'foretag', 'company', 'vendor',
|
||||
]
|
||||
|
||||
const ORG_NUMBER_KEYWORDS = [
|
||||
'orgnr', 'org nr', 'organisationsnummer', 'organisationsnr', 'org',
|
||||
'personnr', 'personnummer', 'org number', 'organization number',
|
||||
]
|
||||
|
||||
const SUPPLIER_TYPE_KEYWORDS = [
|
||||
'leverantörstyp', 'leverantorstyp', 'typ', 'type', 'supplier type',
|
||||
'supplier_type',
|
||||
]
|
||||
|
||||
const EMAIL_KEYWORDS = ['epost', 'e post', 'email', 'mail', 'e mail', 'e-post']
|
||||
|
||||
const PHONE_KEYWORDS = [
|
||||
'telefon', 'tel', 'phone', 'mobil', 'mobile', 'telefonnummer',
|
||||
]
|
||||
|
||||
const ADDRESS_LINE1_KEYWORDS = [
|
||||
'adress', 'address', 'gatuadress', 'street', 'gata',
|
||||
'address line 1', 'address1', 'adressrad 1',
|
||||
]
|
||||
|
||||
const ADDRESS_LINE2_KEYWORDS = [
|
||||
'address line 2', 'address2', 'adressrad 2', 'c o', 'co',
|
||||
]
|
||||
|
||||
const POSTAL_CODE_KEYWORDS = [
|
||||
'postnr', 'postnummer', 'postal code', 'postal_code', 'zip', 'zip code',
|
||||
]
|
||||
|
||||
const CITY_KEYWORDS = ['ort', 'stad', 'city', 'postort']
|
||||
|
||||
const COUNTRY_KEYWORDS = ['land', 'country']
|
||||
|
||||
const VAT_NUMBER_KEYWORDS = [
|
||||
'vat', 'vatnr', 'vat nr', 'vat number', 'momsnummer', 'momsregistreringsnummer',
|
||||
'momsregnr', 'moms nr',
|
||||
]
|
||||
|
||||
const BANKGIRO_KEYWORDS = [
|
||||
'bankgiro', 'bg', 'bgnr', 'bg nr', 'bankgironr', 'bankgironummer',
|
||||
]
|
||||
|
||||
const PLUSGIRO_KEYWORDS = [
|
||||
'plusgiro', 'pg', 'pgnr', 'pg nr', 'postgiro', 'plusgironummer',
|
||||
]
|
||||
|
||||
const BANK_ACCOUNT_KEYWORDS = [
|
||||
'bankkonto', 'kontonummer', 'bank account', 'bank_account', 'clearing',
|
||||
]
|
||||
|
||||
const IBAN_KEYWORDS = ['iban', 'iban nr']
|
||||
|
||||
const BIC_KEYWORDS = ['bic', 'swift', 'swift code', 'bic code']
|
||||
|
||||
const PAYMENT_TERMS_KEYWORDS = [
|
||||
'betalningsvillkor', 'betalvillkor', 'payment terms', 'kredittid', 'kreditdagar',
|
||||
'dagar', 'förfallodagar', 'forfallodagar',
|
||||
]
|
||||
|
||||
const CURRENCY_KEYWORDS = ['valuta', 'currency', 'curr']
|
||||
|
||||
const NOTES_KEYWORDS = [
|
||||
'anteckning', 'anteckningar', 'notes', 'kommentar', 'kommentarer', 'comment',
|
||||
'note', 'beskrivning',
|
||||
]
|
||||
|
||||
export function detectSupplierColumns(headers: string[]): DetectedSupplierColumns {
|
||||
const taken = new Set<number>()
|
||||
|
||||
const name_col = findColumn(headers, NAME_KEYWORDS, taken) ?? -1
|
||||
const org_number_col = findColumn(headers, ORG_NUMBER_KEYWORDS, taken)
|
||||
const supplier_type_col = findColumn(headers, SUPPLIER_TYPE_KEYWORDS, taken)
|
||||
const email_col = findColumn(headers, EMAIL_KEYWORDS, taken)
|
||||
const phone_col = findColumn(headers, PHONE_KEYWORDS, taken)
|
||||
const address_line1_col = findColumn(headers, ADDRESS_LINE1_KEYWORDS, taken)
|
||||
const address_line2_col = findColumn(headers, ADDRESS_LINE2_KEYWORDS, taken)
|
||||
const postal_code_col = findColumn(headers, POSTAL_CODE_KEYWORDS, taken)
|
||||
const city_col = findColumn(headers, CITY_KEYWORDS, taken)
|
||||
const country_col = findColumn(headers, COUNTRY_KEYWORDS, taken)
|
||||
const vat_number_col = findColumn(headers, VAT_NUMBER_KEYWORDS, taken)
|
||||
const bankgiro_col = findColumn(headers, BANKGIRO_KEYWORDS, taken)
|
||||
const plusgiro_col = findColumn(headers, PLUSGIRO_KEYWORDS, taken)
|
||||
const bank_account_col = findColumn(headers, BANK_ACCOUNT_KEYWORDS, taken)
|
||||
const iban_col = findColumn(headers, IBAN_KEYWORDS, taken)
|
||||
const bic_col = findColumn(headers, BIC_KEYWORDS, taken)
|
||||
const payment_terms_col = findColumn(headers, PAYMENT_TERMS_KEYWORDS, taken)
|
||||
const default_currency_col = findColumn(headers, CURRENCY_KEYWORDS, taken)
|
||||
const notes_col = findColumn(headers, NOTES_KEYWORDS, taken)
|
||||
|
||||
let confidence = 0
|
||||
if (name_col >= 0) {
|
||||
const matched = [
|
||||
org_number_col, email_col, phone_col, address_line1_col,
|
||||
postal_code_col, city_col, vat_number_col, bankgiro_col, iban_col,
|
||||
payment_terms_col,
|
||||
].filter((c) => c !== null).length
|
||||
confidence = 0.55 + Math.min(matched, 6) * 0.075
|
||||
}
|
||||
|
||||
return {
|
||||
name_col: name_col >= 0 ? name_col : 0,
|
||||
org_number_col,
|
||||
supplier_type_col,
|
||||
email_col,
|
||||
phone_col,
|
||||
address_line1_col,
|
||||
address_line2_col,
|
||||
postal_code_col,
|
||||
city_col,
|
||||
country_col,
|
||||
vat_number_col,
|
||||
bankgiro_col,
|
||||
plusgiro_col,
|
||||
bank_account_col,
|
||||
iban_col,
|
||||
bic_col,
|
||||
payment_terms_col,
|
||||
default_currency_col,
|
||||
notes_col,
|
||||
confidence: Math.min(Math.round(confidence * 100) / 100, 1),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { SupplierType } from '@/types'
|
||||
import { detectSupplierColumns } from './column-detector'
|
||||
import { cellOrNull, parsePaymentTerms } from '../shared/column-utils'
|
||||
import { classifySupplier } from '../shared/classify'
|
||||
import { readBestSheet } from '../shared/workbook-reader'
|
||||
import type {
|
||||
DetectedSupplierColumns,
|
||||
ParsedSupplierRow,
|
||||
} from './types'
|
||||
|
||||
const VALID_SUPPLIER_TYPES: SupplierType[] = [
|
||||
'swedish_business',
|
||||
'eu_business',
|
||||
'non_eu_business',
|
||||
]
|
||||
|
||||
const VALID_CURRENCIES = new Set(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'])
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
function normalizeSupplierType(value: string | null): SupplierType | null {
|
||||
if (!value) return null
|
||||
const lower = value.toLowerCase().trim()
|
||||
if (lower === 'swedish_business' || lower === 'swedish' || lower === 'svensk' || lower === 'företag' || lower === 'foretag' || lower === 'business' || lower === 'ab' || lower === 'aktiebolag') {
|
||||
return 'swedish_business'
|
||||
}
|
||||
if (lower === 'eu_business' || lower === 'eu') {
|
||||
return 'eu_business'
|
||||
}
|
||||
if (lower === 'non_eu_business' || lower === 'non-eu' || lower === 'utomeu' || lower === 'utländsk' || lower === 'utlandsk') {
|
||||
return 'non_eu_business'
|
||||
}
|
||||
return VALID_SUPPLIER_TYPES.includes(lower as SupplierType)
|
||||
? (lower as SupplierType)
|
||||
: null
|
||||
}
|
||||
|
||||
function normalizeCountry(value: string | null): string {
|
||||
if (!value) return 'SE'
|
||||
const trimmed = value.trim()
|
||||
const lower = trimmed.toLowerCase()
|
||||
if (lower === 'se' || lower === 'sverige' || lower === 'sweden') return 'SE'
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function normalizeCurrency(value: string | null): string {
|
||||
if (!value) return 'SEK'
|
||||
const upper = value.trim().toUpperCase()
|
||||
return VALID_CURRENCIES.has(upper) ? upper : 'SEK'
|
||||
}
|
||||
|
||||
function cleanGiroNumber(value: string | null): string | null {
|
||||
if (!value) return null
|
||||
const cleaned = value.replace(/[\s.]/g, '')
|
||||
return cleaned === '' ? null : cleaned
|
||||
}
|
||||
|
||||
export function parseSuppliersFile(
|
||||
buffer: ArrayBuffer,
|
||||
filename: string,
|
||||
columnOverrides?: DetectedSupplierColumns,
|
||||
): {
|
||||
filename: string
|
||||
sheet_name: string
|
||||
total_rows: number
|
||||
detected_columns: DetectedSupplierColumns
|
||||
headers: string[]
|
||||
preview_rows: string[][]
|
||||
rows: ParsedSupplierRow[]
|
||||
warnings: string[]
|
||||
} {
|
||||
const { sheetName, rawData } = readBestSheet(buffer, filename)
|
||||
|
||||
if (rawData.length < 2) {
|
||||
const fallbackColumns: DetectedSupplierColumns = columnOverrides ?? {
|
||||
name_col: 0,
|
||||
org_number_col: null,
|
||||
supplier_type_col: null,
|
||||
email_col: null,
|
||||
phone_col: null,
|
||||
address_line1_col: null,
|
||||
address_line2_col: null,
|
||||
postal_code_col: null,
|
||||
city_col: null,
|
||||
country_col: null,
|
||||
vat_number_col: null,
|
||||
bankgiro_col: null,
|
||||
plusgiro_col: null,
|
||||
bank_account_col: null,
|
||||
iban_col: null,
|
||||
bic_col: null,
|
||||
payment_terms_col: null,
|
||||
default_currency_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 || detectSupplierColumns(headers)
|
||||
|
||||
const rows: ParsedSupplierRow[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
for (let i = 0; i < dataRows.length; i++) {
|
||||
const row = dataRows[i]
|
||||
const name = cellOrNull(row[columns.name_col])
|
||||
if (!name) continue
|
||||
|
||||
const get = (col: number | null) =>
|
||||
col !== null ? cellOrNull(row[col]) : null
|
||||
|
||||
const orgNumber = get(columns.org_number_col)
|
||||
const email = get(columns.email_col)
|
||||
const phone = get(columns.phone_col)
|
||||
const addressLine1 = get(columns.address_line1_col)
|
||||
const addressLine2 = get(columns.address_line2_col)
|
||||
const postalCode = get(columns.postal_code_col)
|
||||
const city = get(columns.city_col)
|
||||
const countryRaw = get(columns.country_col)
|
||||
const country = normalizeCountry(countryRaw)
|
||||
const vatNumber = get(columns.vat_number_col)
|
||||
const bankgiro = cleanGiroNumber(get(columns.bankgiro_col))
|
||||
const plusgiro = cleanGiroNumber(get(columns.plusgiro_col))
|
||||
const bankAccount = get(columns.bank_account_col)
|
||||
const iban = get(columns.iban_col)?.replace(/\s/g, '').toUpperCase() ?? null
|
||||
const bic = get(columns.bic_col)?.replace(/\s/g, '').toUpperCase() ?? null
|
||||
const paymentTermsRaw = columns.payment_terms_col !== null
|
||||
? row[columns.payment_terms_col]
|
||||
: null
|
||||
const currencyRaw = get(columns.default_currency_col)
|
||||
const notes = get(columns.notes_col)
|
||||
|
||||
const explicitType = columns.supplier_type_col !== null
|
||||
? normalizeSupplierType(cellOrNull(row[columns.supplier_type_col]))
|
||||
: null
|
||||
const supplierType: SupplierType =
|
||||
explicitType ?? classifySupplier({
|
||||
org_number: orgNumber,
|
||||
vat_number: vatNumber,
|
||||
country: countryRaw,
|
||||
})
|
||||
|
||||
const validationErrors: string[] = []
|
||||
if (email && !EMAIL_RE.test(email)) {
|
||||
validationErrors.push('Ogiltig e-postadress')
|
||||
}
|
||||
if (orgNumber && !/^[\d\s\-]{6,20}$/.test(orgNumber)) {
|
||||
validationErrors.push('Ogiltigt org-/personnummer')
|
||||
}
|
||||
if (iban && !/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(iban)) {
|
||||
validationErrors.push('Ogiltigt IBAN')
|
||||
}
|
||||
|
||||
rows.push({
|
||||
row_index: i + 2,
|
||||
name,
|
||||
supplier_type: supplierType,
|
||||
org_number: orgNumber,
|
||||
email,
|
||||
phone,
|
||||
address_line1: addressLine1,
|
||||
address_line2: addressLine2,
|
||||
postal_code: postalCode,
|
||||
city,
|
||||
country,
|
||||
vat_number: vatNumber,
|
||||
bankgiro,
|
||||
plusgiro,
|
||||
bank_account: bankAccount,
|
||||
iban,
|
||||
bic,
|
||||
default_payment_terms: parsePaymentTerms(paymentTermsRaw, 30),
|
||||
default_currency: normalizeCurrency(currencyRaw),
|
||||
notes,
|
||||
is_valid: validationErrors.length === 0,
|
||||
validation_errors: validationErrors,
|
||||
})
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
warnings.push('Inga giltiga leverantörsrader hittades. Kontrollera att namnkolumnen är korrekt mappad.')
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
sheet_name: sheetName,
|
||||
total_rows: rows.length,
|
||||
detected_columns: columns,
|
||||
headers,
|
||||
preview_rows: dataRows.slice(0, 5),
|
||||
rows,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { SupplierType } from '@/types'
|
||||
|
||||
/** Result of auto-detecting columns in a supplier register file. */
|
||||
export interface DetectedSupplierColumns {
|
||||
name_col: number
|
||||
org_number_col: number | null
|
||||
supplier_type_col: number | null
|
||||
email_col: number | null
|
||||
phone_col: number | null
|
||||
address_line1_col: number | null
|
||||
address_line2_col: number | null
|
||||
postal_code_col: number | null
|
||||
city_col: number | null
|
||||
country_col: number | null
|
||||
vat_number_col: number | null
|
||||
bankgiro_col: number | null
|
||||
plusgiro_col: number | null
|
||||
bank_account_col: number | null
|
||||
iban_col: number | null
|
||||
bic_col: number | null
|
||||
payment_terms_col: number | null
|
||||
default_currency_col: number | null
|
||||
notes_col: number | null
|
||||
/** 0-1 confidence score for the detection */
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/** A single parsed row from the supplier register file. */
|
||||
export interface ParsedSupplierRow {
|
||||
row_index: number
|
||||
name: string
|
||||
supplier_type: SupplierType
|
||||
org_number: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
address_line1: string | null
|
||||
address_line2: string | null
|
||||
postal_code: string | null
|
||||
city: string | null
|
||||
country: string
|
||||
vat_number: string | null
|
||||
bankgiro: string | null
|
||||
plusgiro: string | null
|
||||
bank_account: string | null
|
||||
iban: string | null
|
||||
bic: string | null
|
||||
default_payment_terms: number
|
||||
default_currency: string
|
||||
notes: string | null
|
||||
is_valid: boolean
|
||||
validation_errors: string[]
|
||||
}
|
||||
|
||||
/** Supplier-row + dedup annotation produced by the API route. */
|
||||
export interface AnnotatedSupplierRow extends ParsedSupplierRow {
|
||||
duplicate_match: {
|
||||
supplier_id: string
|
||||
matched_by: 'org_number' | 'email'
|
||||
existing_name: string
|
||||
} | null
|
||||
}
|
||||
|
||||
/** Full result from parsing a supplier register file. */
|
||||
export interface SupplierImportParseResult {
|
||||
filename: string
|
||||
sheet_name: string
|
||||
total_rows: number
|
||||
detected_columns: DetectedSupplierColumns
|
||||
headers: string[]
|
||||
preview_rows: string[][]
|
||||
rows: AnnotatedSupplierRow[]
|
||||
duplicate_count: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/** Input for executing the supplier import. */
|
||||
export interface SupplierImportExecuteInput {
|
||||
rows: ParsedSupplierRow[]
|
||||
update_duplicates: boolean
|
||||
}
|
||||
|
||||
/** Result of executing the supplier import. */
|
||||
export interface SupplierImportExecuteResult {
|
||||
success: boolean
|
||||
created: number
|
||||
updated: number
|
||||
skipped: number
|
||||
failed: number
|
||||
errors: { row_index: number; name: string; reason: string }[]
|
||||
}
|
||||
Reference in New Issue
Block a user