/** Shared fuzzy match: account/folder name ↔ tenant name/slug. */ export function normalizeName(raw: string): string { return raw .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .trim() .replace(/\s+/g, ' '); } export function scoreNameToTenant( accountName: string, tenantName: string, tenantSlug: string ): number { const f = normalizeName(accountName); const n = normalizeName(tenantName); const s = normalizeName(tenantSlug.replace(/-/g, ' ')); if (!f || (!n && !s)) return 0; if (f === n || f === s) return 100; if (n && (f.includes(n) || n.includes(f))) return 85; if (s && (f.includes(s) || s.includes(f))) return 80; const ft = new Set(f.split(' ').filter((t) => t.length > 2)); const nt = new Set( [...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2) ); if (!ft.size || !nt.size) return 0; let hit = 0; for (const t of nt) if (ft.has(t)) hit += 1; const ratio = hit / nt.size; if (ratio >= 0.8) return 70; if (ratio >= 0.5) return 55; return Math.round(ratio * 50); } export type NamedAccount = { id: string; name: string }; export type AccountMatch = { tenantId: string; tenantName: string; tenantSlug: string; accountId: string | null; accountName: string | null; score: number; status: 'matched' | 'ambiguous' | 'unmatched'; candidates?: { id: string; name: string; score: number }[]; }; export function matchAccountsToTenants( tenants: { id: string; name: string; slug: string }[], accounts: NamedAccount[], opts?: { threshold?: number; ambiguousGap?: number } ): AccountMatch[] { const THRESHOLD = opts?.threshold ?? 55; const AMBIGUOUS_GAP = opts?.ambiguousGap ?? 8; type Scored = { account: NamedAccount; tenantId: string; score: number }; const all: Scored[] = []; for (const t of tenants) { for (const a of accounts) { const score = scoreNameToTenant(a.name, t.name, t.slug); if (score >= THRESHOLD) { all.push({ account: a, tenantId: t.id, score }); } } } all.sort((a, b) => b.score - a.score); const usedAccounts = new Set(); const usedTenants = new Set(); const assigned = new Map(); for (const row of all) { if (usedAccounts.has(row.account.id) || usedTenants.has(row.tenantId)) { continue; } const rivals = all.filter( (x) => x.tenantId === row.tenantId && x.account.id !== row.account.id && !usedAccounts.has(x.account.id) && row.score - x.score < AMBIGUOUS_GAP && x.score >= THRESHOLD ); if (rivals.length) continue; assigned.set(row.tenantId, { account: row.account, score: row.score }); usedAccounts.add(row.account.id); usedTenants.add(row.tenantId); } return tenants.map((t) => { const hit = assigned.get(t.id); if (hit) { return { tenantId: t.id, tenantName: t.name, tenantSlug: t.slug, accountId: hit.account.id, accountName: hit.account.name, score: hit.score, status: 'matched' as const, }; } const cands = all .filter((x) => x.tenantId === t.id) .slice(0, 5) .map((x) => ({ id: x.account.id, name: x.account.name, score: x.score, })); if (cands.length) { return { tenantId: t.id, tenantName: t.name, tenantSlug: t.slug, accountId: null, accountName: null, score: cands[0].score, status: 'ambiguous' as const, candidates: cands, }; } return { tenantId: t.id, tenantName: t.name, tenantSlug: t.slug, accountId: null, accountName: null, score: 0, status: 'unmatched' as const, }; }); }