Files
accounted/lib/import/account-mapper.ts
T

366 lines
9.5 KiB
TypeScript

/**
* Account Mapping Engine
*
* Intelligently maps accounts from an imported SIE file to
* the user's BAS chart of accounts. Uses multiple strategies:
* 1. Exact account number match
* 2. Account name similarity (Levenshtein distance)
* 3. Account class consistency (5xxx → expense, etc.)
* 4. User-defined overrides
*/
import type { BASAccount } from '@/types'
import type {
SIEAccount,
AccountMapping,
AccountMatchType,
SIEAccountMappingRecord,
} from './types'
/**
* Calculate Levenshtein distance between two strings
* Used for name similarity scoring
*/
function levenshteinDistance(str1: string, str2: string): number {
const s1 = str1.toLowerCase()
const s2 = str2.toLowerCase()
if (s1.length === 0) return s2.length
if (s2.length === 0) return s1.length
const matrix: number[][] = []
// Initialize first column
for (let i = 0; i <= s1.length; i++) {
matrix[i] = [i]
}
// Initialize first row
for (let j = 0; j <= s2.length; j++) {
matrix[0][j] = j
}
// Fill in the rest of the matrix
for (let i = 1; i <= s1.length; i++) {
for (let j = 1; j <= s2.length; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
)
}
}
return matrix[s1.length][s2.length]
}
/**
* Calculate similarity score between two strings (0-1)
*/
function nameSimilarity(name1: string, name2: string): number {
if (!name1 || !name2) return 0
const distance = levenshteinDistance(name1, name2)
const maxLength = Math.max(name1.length, name2.length)
if (maxLength === 0) return 1
return 1 - distance / maxLength
}
/**
* Normalize Swedish account names for better matching
*/
function normalizeAccountName(name: string): string {
return name
.toLowerCase()
.replace(/[^\wåäö\s]/gi, '') // Remove special chars except Swedish
.replace(/\s+/g, ' ') // Normalize whitespace
.trim()
}
/**
* Get the account class (first digit) from an account number
*/
function getAccountClass(accountNumber: string): number {
const firstDigit = accountNumber.charAt(0)
return parseInt(firstDigit, 10) || 0
}
/**
* Get the account group (first two digits) from an account number
*/
function getAccountGroup(accountNumber: string): string {
return accountNumber.substring(0, 2)
}
/**
* Check if two accounts are in compatible classes
*/
function areClassesCompatible(sourceNumber: string, targetNumber: string): boolean {
const sourceClass = getAccountClass(sourceNumber)
const targetClass = getAccountClass(targetNumber)
// Same class is always compatible
if (sourceClass === targetClass) return true
// Allow some flexibility for related classes
// 1xxx (assets) can map to 1xxx
// 2xxx (equity/liabilities) can map to 2xxx
// 3xxx (revenue) can map to 3xxx
// 4xxx (cost of goods) can map to 4xxx or 5xxx
// 5xxx (external expenses) can map to 5xxx or 6xxx
// 6xxx (other external) can map to 6xxx or 5xxx
// 7xxx (personnel) can map to 7xxx
// 8xxx (financial) can map to 8xxx
if (sourceClass === 4 && targetClass === 5) return true
if (sourceClass === 5 && targetClass === 6) return true
if (sourceClass === 6 && targetClass === 5) return true
return false
}
/**
* Find the best matching BAS account for a source account
*/
function findBestMatch(
source: SIEAccount,
basAccounts: BASAccount[],
existingOverride?: AccountMapping
): AccountMapping | null {
// If there's a user override, use it
if (existingOverride) {
return {
...existingOverride,
isOverride: true,
}
}
let bestMatch: BASAccount | null = null
let bestScore = 0
let matchType: AccountMatchType = 'class'
for (const target of basAccounts) {
// Strategy 1: Exact account number match
if (source.number === target.account_number) {
return {
sourceAccount: source.number,
sourceName: source.name,
targetAccount: target.account_number,
targetName: target.account_name,
confidence: 1.0,
matchType: 'exact',
isOverride: false,
}
}
// Strategy 2: Name similarity
const nameSim = nameSimilarity(
normalizeAccountName(source.name),
normalizeAccountName(target.account_name)
)
// Strategy 3: Account class compatibility
const classCompatible = areClassesCompatible(source.number, target.account_number)
const sameGroup = getAccountGroup(source.number) === getAccountGroup(target.account_number)
// Calculate combined score
let score = 0
// Name similarity is most important
if (nameSim >= 0.9) {
score = 0.9 + (nameSim - 0.9) // 0.9 - 1.0
} else if (nameSim >= 0.7) {
score = 0.7 + (nameSim - 0.7) * 0.5 // 0.7 - 0.85
} else if (nameSim >= 0.5) {
score = 0.5 + (nameSim - 0.5) * 0.25 // 0.5 - 0.575
}
// Boost for same account group (first 2 digits)
if (sameGroup) {
score += 0.2
} else if (classCompatible) {
score += 0.1
}
// Penalize cross-class mappings
if (!classCompatible) {
score *= 0.5
}
if (score > bestScore) {
bestScore = score
bestMatch = target
matchType = nameSim >= 0.7 ? 'name' : 'class'
}
}
if (!bestMatch || bestScore < 0.3) {
return null
}
return {
sourceAccount: source.number,
sourceName: source.name,
targetAccount: bestMatch.account_number,
targetName: bestMatch.account_name,
confidence: Math.min(bestScore, 1.0),
matchType,
isOverride: false,
}
}
/**
* Suggest mappings for all source accounts
*/
export function suggestMappings(
sourceAccounts: SIEAccount[],
basAccounts: BASAccount[],
existingMappings?: SIEAccountMappingRecord[]
): AccountMapping[] {
// Convert existing mappings to a lookup map
const overrideMap = new Map<string, AccountMapping>()
if (existingMappings) {
for (const mapping of existingMappings) {
const basAccount = basAccounts.find((a) => a.account_number === mapping.target_account)
overrideMap.set(mapping.source_account, {
sourceAccount: mapping.source_account,
sourceName: mapping.source_name || '',
targetAccount: mapping.target_account,
targetName: basAccount?.account_name || mapping.target_account,
confidence: mapping.confidence,
matchType: mapping.match_type,
isOverride: true,
})
}
}
const mappings: AccountMapping[] = []
for (const source of sourceAccounts) {
const existingOverride = overrideMap.get(source.number)
const mapping = findBestMatch(source, basAccounts, existingOverride)
if (mapping) {
mappings.push(mapping)
} else {
// No match found - add with null target
mappings.push({
sourceAccount: source.number,
sourceName: source.name,
targetAccount: '',
targetName: '',
confidence: 0,
matchType: 'manual',
isOverride: false,
})
}
}
// Sort by confidence (low to high) so users see problematic mappings first
mappings.sort((a, b) => a.confidence - b.confidence)
return mappings
}
/**
* Validate that all accounts are mapped
*/
export function validateMappings(mappings: AccountMapping[]): {
valid: boolean
unmappedAccounts: string[]
lowConfidenceAccounts: string[]
} {
const unmapped = mappings.filter((m) => !m.targetAccount)
const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.5)
return {
valid: unmapped.length === 0,
unmappedAccounts: unmapped.map((m) => m.sourceAccount),
lowConfidenceAccounts: lowConfidence.map((m) => m.sourceAccount),
}
}
/**
* Get mapping statistics
*/
export function getMappingStats(mappings: AccountMapping[]): {
total: number
mapped: number
unmapped: number
exact: number
name: number
class: number
manual: number
lowConfidence: number
averageConfidence: number
} {
const total = mappings.length
const mapped = mappings.filter((m) => m.targetAccount).length
const unmapped = total - mapped
const exact = mappings.filter((m) => m.matchType === 'exact').length
const name = mappings.filter((m) => m.matchType === 'name').length
const classMatch = mappings.filter((m) => m.matchType === 'class').length
const manual = mappings.filter((m) => m.matchType === 'manual').length
const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.5).length
const confidenceSum = mappings
.filter((m) => m.targetAccount)
.reduce((sum, m) => sum + m.confidence, 0)
const averageConfidence = mapped > 0 ? confidenceSum / mapped : 0
return {
total,
mapped,
unmapped,
exact,
name,
class: classMatch,
manual,
lowConfidence,
averageConfidence,
}
}
/**
* Apply a user override to the mappings
*/
export function applyMappingOverride(
mappings: AccountMapping[],
sourceAccount: string,
targetAccount: string,
targetName: string
): AccountMapping[] {
return mappings.map((m) => {
if (m.sourceAccount === sourceAccount) {
return {
...m,
targetAccount,
targetName,
confidence: 1.0,
matchType: 'manual' as AccountMatchType,
isOverride: true,
}
}
return m
})
}
/**
* Convert mappings to a lookup map for quick access during import
*/
export function mappingsToMap(mappings: AccountMapping[]): Map<string, string> {
const map = new Map<string, string>()
for (const mapping of mappings) {
if (mapping.targetAccount) {
map.set(mapping.sourceAccount, mapping.targetAccount)
}
}
return map
}