/** * useAddressFixer — rule-based "magic fix" engine for shipping addresses. * * Pure, side-effect-free analysis of an editable address (the `shippingForm` * shape used on the commissioning page). Produces: * - `fixes` atomic, explainable corrections for the CURRENT values * - `warnings` things we cannot fix automatically but the user should see * - `recommendations` full-address variants (recommended chain, alternatives, * single fixes, cosmetic cleanup) the user can pick from * * The engine is deliberately conservative: every fix carries a confidence and a * human-readable reason. Low-confidence (cosmetic) fixes never enter the * "Recommended" variant — they only show up in a separate "cosmetic" variant. * * Used by: pages/integrations/commission.vue (address-edit modal "Magic fix"). */ export type AddressFixConfidence = 'high' | 'medium' | 'low' export interface FixableAddress { name: string name2: string address: string houseNumber: string address2: string postalCode: string city: string state: string country: string countryName: string countryIso3: string } export type AddressFixField = keyof FixableAddress export const ADDRESS_FIX_FIELDS: AddressFixField[] = [ 'name', 'name2', 'address', 'houseNumber', 'address2', 'postalCode', 'city', 'state', 'country', 'countryName', 'countryIso3' ] export interface AddressFixAlternative { title: string reason?: string changes: Partial } export interface AddressFix { id: string title: string reason: string confidence: AddressFixConfidence changes: Partial alternatives?: AddressFixAlternative[] } export interface AddressWarning { id: string title: string reason: string level: 'warning' | 'info' } export interface AppliedAddressFix { id: string title: string reason: string confidence: AddressFixConfidence } export interface AddressRecommendation { id: string kind: 'recommended' | 'alternative' | 'single' | 'cosmetic' label: string address: FixableAddress fixes: AppliedAddressFix[] changedFields: AddressFixField[] } export interface AddressFixReport { fixes: AddressFix[] warnings: AddressWarning[] recommendations: AddressRecommendation[] hasSuggestions: boolean } export interface AddressFixOptions { /** 'dhl' | 'dpd' | anything else → DHL limits */ carrier?: string /** Optional country master list ({ CountryCode, Name, ISOCountryCodeAlpha3 }) to resolve names/ISO3 */ countries?: any[] /** Include low-confidence (cosmetic) fixes in the analysis result */ includeLow?: boolean /** * Externally resolved state (e.g. an online postal-code → state lookup). * When the address has NO state anywhere (not even misplaced in another * field), this becomes a "state-from-lookup" proposal; when a state IS set * but contradicts the hint, a warning is raised instead. */ stateHint?: { code: string; name?: string; source?: string } | null } // --------------------------------------------------------------------------- // Reference data // --------------------------------------------------------------------------- /** Countries where the house number is written BEFORE the street name. */ const NUMBER_FIRST_COUNTRIES = new Set(['US', 'CA', 'GB', 'AU', 'NZ', 'IE', 'FR', 'LU']) export const US_STATES: Record = { AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California', CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', DC: 'District of Columbia', FL: 'Florida', GA: 'Georgia', HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa', KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland', MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi', MO: 'Missouri', MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire', NJ: 'New Jersey', NM: 'New Mexico', NY: 'New York', NC: 'North Carolina', ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania', RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota', TN: 'Tennessee', TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia', WA: 'Washington', WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', PR: 'Puerto Rico', GU: 'Guam', VI: 'Virgin Islands', AS: 'American Samoa', MP: 'Northern Mariana Islands' } export const CA_PROVINCES: Record = { AB: 'Alberta', BC: 'British Columbia', MB: 'Manitoba', NB: 'New Brunswick', NL: 'Newfoundland and Labrador', NS: 'Nova Scotia', NT: 'Northwest Territories', NU: 'Nunavut', ON: 'Ontario', PE: 'Prince Edward Island', QC: 'Quebec', SK: 'Saskatchewan', YT: 'Yukon' } export const AU_STATES: Record = { NSW: 'New South Wales', VIC: 'Victoria', QLD: 'Queensland', WA: 'Western Australia', SA: 'South Australia', TAS: 'Tasmania', ACT: 'Australian Capital Territory', NT: 'Northern Territory' } const STATE_MAPS: Record> = { US: US_STATES, CA: CA_PROVINCES, AU: AU_STATES } /** Minimal country meta so a country change can be applied without the master list. */ const COUNTRY_META: Record = { DE: { name: 'Germany', iso3: 'DEU' }, AT: { name: 'Austria', iso3: 'AUT' }, CH: { name: 'Switzerland', iso3: 'CHE' }, NL: { name: 'Netherlands', iso3: 'NLD' }, BE: { name: 'Belgium', iso3: 'BEL' }, LU: { name: 'Luxembourg', iso3: 'LUX' }, FR: { name: 'France', iso3: 'FRA' }, IT: { name: 'Italy', iso3: 'ITA' }, ES: { name: 'Spain', iso3: 'ESP' }, PT: { name: 'Portugal', iso3: 'PRT' }, GB: { name: 'United Kingdom', iso3: 'GBR' }, IE: { name: 'Ireland', iso3: 'IRL' }, DK: { name: 'Denmark', iso3: 'DNK' }, SE: { name: 'Sweden', iso3: 'SWE' }, NO: { name: 'Norway', iso3: 'NOR' }, FI: { name: 'Finland', iso3: 'FIN' }, PL: { name: 'Poland', iso3: 'POL' }, CZ: { name: 'Czech Republic', iso3: 'CZE' }, SK: { name: 'Slovakia', iso3: 'SVK' }, HU: { name: 'Hungary', iso3: 'HUN' }, SI: { name: 'Slovenia', iso3: 'SVN' }, HR: { name: 'Croatia', iso3: 'HRV' }, RO: { name: 'Romania', iso3: 'ROU' }, BG: { name: 'Bulgaria', iso3: 'BGR' }, GR: { name: 'Greece', iso3: 'GRC' }, LI: { name: 'Liechtenstein', iso3: 'LIE' }, EE: { name: 'Estonia', iso3: 'EST' }, LV: { name: 'Latvia', iso3: 'LVA' }, LT: { name: 'Lithuania', iso3: 'LTU' }, US: { name: 'United States', iso3: 'USA' }, CA: { name: 'Canada', iso3: 'CAN' }, AU: { name: 'Australia', iso3: 'AUS' }, NZ: { name: 'New Zealand', iso3: 'NZL' }, JP: { name: 'Japan', iso3: 'JPN' }, CN: { name: 'China', iso3: 'CHN' }, TR: { name: 'Turkey', iso3: 'TUR' }, MX: { name: 'Mexico', iso3: 'MEX' }, BR: { name: 'Brazil', iso3: 'BRA' }, IL: { name: 'Israel', iso3: 'ISR' }, ZA: { name: 'South Africa', iso3: 'ZAF' }, IN: { name: 'India', iso3: 'IND' }, SG: { name: 'Singapore', iso3: 'SGP' }, KR: { name: 'South Korea', iso3: 'KOR' }, AE: { name: 'United Arab Emirates', iso3: 'ARE' } } /** Country names (en/de/native, lower-case) → ISO2. Used to spot country names typed into other fields. */ const COUNTRY_NAME_TO_CODE: Record = { 'germany': 'DE', 'deutschland': 'DE', 'allemagne': 'DE', 'alemania': 'DE', 'brd': 'DE', 'austria': 'AT', 'österreich': 'AT', 'oesterreich': 'AT', 'osterreich': 'AT', 'switzerland': 'CH', 'schweiz': 'CH', 'suisse': 'CH', 'svizzera': 'CH', 'netherlands': 'NL', 'niederlande': 'NL', 'nederland': 'NL', 'holland': 'NL', 'the netherlands': 'NL', 'belgium': 'BE', 'belgien': 'BE', 'belgique': 'BE', 'belgië': 'BE', 'belgie': 'BE', 'luxembourg': 'LU', 'luxemburg': 'LU', 'france': 'FR', 'frankreich': 'FR', 'italy': 'IT', 'italien': 'IT', 'italia': 'IT', 'spain': 'ES', 'spanien': 'ES', 'españa': 'ES', 'espana': 'ES', 'portugal': 'PT', 'united kingdom': 'GB', 'great britain': 'GB', 'großbritannien': 'GB', 'grossbritannien': 'GB', 'england': 'GB', 'scotland': 'GB', 'wales': 'GB', 'northern ireland': 'GB', 'uk': 'GB', 'u.k.': 'GB', 'ireland': 'IE', 'irland': 'IE', 'denmark': 'DK', 'dänemark': 'DK', 'danmark': 'DK', 'sweden': 'SE', 'schweden': 'SE', 'sverige': 'SE', 'norway': 'NO', 'norwegen': 'NO', 'norge': 'NO', 'finland': 'FI', 'finnland': 'FI', 'suomi': 'FI', 'poland': 'PL', 'polen': 'PL', 'polska': 'PL', 'czech republic': 'CZ', 'czechia': 'CZ', 'tschechien': 'CZ', 'česko': 'CZ', 'slovakia': 'SK', 'slowakei': 'SK', 'hungary': 'HU', 'ungarn': 'HU', 'slovenia': 'SI', 'slowenien': 'SI', 'croatia': 'HR', 'kroatien': 'HR', 'romania': 'RO', 'rumänien': 'RO', 'bulgaria': 'BG', 'bulgarien': 'BG', 'greece': 'GR', 'griechenland': 'GR', 'liechtenstein': 'LI', 'estonia': 'EE', 'estland': 'EE', 'latvia': 'LV', 'lettland': 'LV', 'lithuania': 'LT', 'litauen': 'LT', 'united states': 'US', 'united states of america': 'US', 'usa': 'US', 'u.s.a.': 'US', 'u.s.': 'US', 'america': 'US', 'vereinigte staaten': 'US', 'canada': 'CA', 'kanada': 'CA', 'australia': 'AU', 'australien': 'AU', 'new zealand': 'NZ', 'neuseeland': 'NZ', 'japan': 'JP', 'china': 'CN', 'turkey': 'TR', 'türkei': 'TR', 'türkiye': 'TR', 'mexico': 'MX', 'mexiko': 'MX', 'brazil': 'BR', 'brasilien': 'BR', 'israel': 'IL', 'south africa': 'ZA', 'südafrika': 'ZA', 'india': 'IN', 'indien': 'IN', 'singapore': 'SG', 'singapur': 'SG', 'south korea': 'KR', 'südkorea': 'KR', 'united arab emirates': 'AE', 'vae': 'AE', 'uae': 'AE' } /** Postal-code shape per country (after normalisation). */ const POSTAL_PATTERNS: Record = { DE: { re: /^\d{5}$/, hint: '5 digits' }, AT: { re: /^\d{4}$/, hint: '4 digits' }, CH: { re: /^\d{4}$/, hint: '4 digits' }, LI: { re: /^\d{4}$/, hint: '4 digits' }, NL: { re: /^\d{4} [A-Z]{2}$/, hint: '4 digits + 2 letters (1234 AB)' }, BE: { re: /^\d{4}$/, hint: '4 digits' }, LU: { re: /^\d{4}$/, hint: '4 digits' }, DK: { re: /^\d{4}$/, hint: '4 digits' }, FR: { re: /^\d{5}$/, hint: '5 digits' }, IT: { re: /^\d{5}$/, hint: '5 digits' }, ES: { re: /^\d{5}$/, hint: '5 digits' }, PT: { re: /^\d{4}-\d{3}$/, hint: '1234-567' }, PL: { re: /^\d{2}-\d{3}$/, hint: '12-345' }, CZ: { re: /^\d{3} \d{2}$/, hint: '123 45' }, SK: { re: /^\d{3} \d{2}$/, hint: '123 45' }, SE: { re: /^\d{3} \d{2}$/, hint: '123 45' }, NO: { re: /^\d{4}$/, hint: '4 digits' }, FI: { re: /^\d{5}$/, hint: '5 digits' }, HU: { re: /^\d{4}$/, hint: '4 digits' }, SI: { re: /^\d{4}$/, hint: '4 digits' }, HR: { re: /^\d{5}$/, hint: '5 digits' }, RO: { re: /^\d{6}$/, hint: '6 digits' }, BG: { re: /^\d{4}$/, hint: '4 digits' }, GR: { re: /^\d{3} \d{2}$/, hint: '123 45' }, EE: { re: /^\d{5}$/, hint: '5 digits' }, LV: { re: /^LV-\d{4}$/, hint: 'LV-1234' }, LT: { re: /^LT-\d{5}$/, hint: 'LT-12345' }, GB: { re: /^[A-Z]{1,2}\d[A-Z\d]? \d[A-Z]{2}$/, hint: 'UK postcode (SW1A 1AA)' }, IE: { re: /^[A-Z]\d{2} [A-Z\d]{4}$/, hint: 'Eircode (D02 X285)' }, US: { re: /^\d{5}(?:-\d{4})?$/, hint: '5 digits (12345 or 12345-6789)' }, CA: { re: /^[A-Z]\d[A-Z] \d[A-Z]\d$/, hint: 'A1A 1A1' }, AU: { re: /^\d{4}$/, hint: '4 digits' }, NZ: { re: /^\d{4}$/, hint: '4 digits' }, JP: { re: /^\d{3}-\d{4}$/, hint: '123-4567' }, CN: { re: /^\d{6}$/, hint: '6 digits' }, IN: { re: /^\d{6}$/, hint: '6 digits' }, BR: { re: /^\d{5}-\d{3}$/, hint: '12345-678' }, MX: { re: /^\d{5}$/, hint: '5 digits' }, TR: { re: /^\d{5}$/, hint: '5 digits' }, IL: { re: /^\d{5,7}$/, hint: '5–7 digits' }, ZA: { re: /^\d{4}$/, hint: '4 digits' }, KR: { re: /^\d{5}$/, hint: '5 digits' }, SG: { re: /^\d{6}$/, hint: '6 digits' } } /** Carrier field length limits (DHL Parcel DE Shipping API v2 / DPD). */ const CARRIER_LIMITS: Record> = { dhl: { name: 50, name2: 50, address: 50, houseNumber: 10, address2: 50, postalCode: 10, city: 40, state: 20 }, dpd: { name: 35, name2: 35, address: 35, houseNumber: 8, address2: 35, postalCode: 9, city: 35, state: 20 } } const FIELD_LABELS: Record = { name: 'Name', name2: 'Name 2', address: 'Street', houseNumber: 'Nr.', address2: 'Address 2', postalCode: 'Postal', city: 'City', state: 'State', country: 'Country', countryName: 'Country name', countryIso3: 'Country ISO3' } export const addressFieldLabel = (f: AddressFixField) => FIELD_LABELS[f] || f // --------------------------------------------------------------------------- // Regex building blocks // --------------------------------------------------------------------------- // House number: "5", "5a", "5 a", "12B", "5-7", "5/3", "12bis", "5 - 7" const HOUSE_NO_SRC = '\\d{1,5}(?:[a-zA-Z]{1,2}(?![a-zA-Z])|\\s[a-zA-Z](?![a-zA-Z])|\\s?(?:bis|ter)(?![a-zA-Z]))?(?:\\s?[-/]\\s?\\d{1,5}(?:[a-zA-Z](?![a-zA-Z]))?)?' const HOUSE_NO_EXACT_RE = new RegExp(`^${HOUSE_NO_SRC}$`, 'i') // Prefixes people type in front of a house number const HOUSE_NO_PREFIX_RE = /^(?:nr\.?|no\.?|n°|num\.?|number|haus\s*nr\.?|haus|hausnummer|hausnr\.?|#)\s*/i // Street where the number comes LAST (DE/AT/CH/NL/…): " [ ]" const STREET_NUMBER_LAST_RE = new RegExp(`^(.+?)[\\s,]+(${HOUSE_NO_SRC})(?:[\\s,]+(.*))?$`, 'i') // Street where the number comes FIRST (US/GB/FR/…): " " const STREET_NUMBER_FIRST_RE = new RegExp(`^(${HOUSE_NO_SRC})[\\s,]+([A-Za-zÀ-ÿ#].*)$`, 'i') // Unit / floor / apartment suffixes that belong into Address 2 const UNIT_SUFFIX_RE = /[\s,]+((?:\d{1,3}\.?\s+)?(?:apt|apartment|suite|ste|unit|floor|fl|bldg|building|room|rm|dept|department|#|og|eg|dg|ug|whg|wohnung|stock|etage|zimmer|app|appartement|appartment|top|tür|tuer|stiege|geb|gebäude|gebaeude|haus|block|trakt|aufgang|hinterhaus|vorderhaus|seitenflügel|gartenhaus|bei|c\/o)\.?\s*[^,]*)$/i const CO_RE = /(?:^|[\s,])(c\/o\.?|care of|bei|z\.?\s?hd\.?|z\.hd\.|per adresse|p\.a\.)\s+(.+)$/i const PO_BOX_RE = /\b(postfach|po\s*box|p\.?\s?o\.?\s?box|boite postale|bo[iî]te postale|apartado|casella postale|postbus|postboks)\b/i const PACKSTATION_RE = /\b(packstation|postfiliale|paketshop|paketbox|dhl\s*shop|pickup\s*point|parcel\s*shop|locker)\b/i const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/ const PHONE_RE = /(?:\+|00)\s?\d[\d\s/().-]{6,}\d|\b(?:tel|tel\.|phone|mobil|mobile|handy|fon)\s*[:.]?\s*[+\d][\d\s/().-]{5,}\d/i const EMAIL_LABEL_RE = /\b(?:e-?mail|mail)\s*[:.]?\s*/i // --------------------------------------------------------------------------- // String helpers // --------------------------------------------------------------------------- const str = (v: any) => (v === null || v === undefined) ? '' : String(v) const clean = (v: any) => str(v) .replace(/[ \t\r\n]+/g, ' ') .replace(/\s{2,}/g, ' ') .replace(/\s+([,;])/g, '$1') .replace(/^[\s,;"'“”]+|[\s,;"'“”]+$/g, '') .trim() const hasLetter = (v: string) => /[A-Za-zÀ-ÿ]/.test(v) const hasDigit = (v: string) => /\d/.test(v) const isHouseNumber = (v: string) => HOUSE_NO_EXACT_RE.test(clean(v)) const normKey = (v: string) => clean(v).toLowerCase().replace(/[.\s,-]+/g, ' ').trim() const same = (a: string, b: string) => normKey(a) === normKey(b) && normKey(a) !== '' const stripHouseNoPrefix = (v: string) => clean(v).replace(HOUSE_NO_PREFIX_RE, '').trim() const tidyHouseNo = (v: string) => clean(v).replace(/\s*([-/])\s*/g, '$1').replace(/^(\d+)\s+([a-zA-Z])$/, '$1$2') const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const TITLE_KEEP: Record = { gmbh: 'GmbH', ag: 'AG', kg: 'KG', ohg: 'OHG', ug: 'UG', ek: 'e.K.', 'e.k.': 'e.K.', mbh: 'mbH', 'co.': 'Co.', co: 'Co', llc: 'LLC', inc: 'Inc', 'inc.': 'Inc.', ltd: 'Ltd', 'ltd.': 'Ltd.', plc: 'PLC', sa: 'SA', sl: 'SL', 'sl.': 'SL.', srl: 'SRL', bv: 'BV', 'b.v.': 'B.V.', nv: 'NV', sarl: 'SARL', sas: 'SAS', se: 'SE', 'dr.': 'Dr.', dr: 'Dr.', 'prof.': 'Prof.', von: 'von', van: 'van', de: 'de', der: 'der', den: 'den', du: 'du', la: 'la', le: 'le', di: 'di', da: 'da', y: 'y', und: 'und', and: 'and', usa: 'USA', uk: 'UK', po: 'PO', ny: 'NY', nyc: 'NYC', la_: 'LA', dc: 'DC', st: 'St', 'st.': 'St.', nw: 'NW', ne: 'NE', sw: 'SW', se_: 'SE' } const toTitleCase = (v: string) => { const words = clean(v).toLowerCase().split(' ') return words.map((w, i) => { const keep = TITLE_KEEP[w] if (keep && !(i === 0 && ['von', 'van', 'de', 'der', 'den', 'du', 'la', 'le', 'di', 'da', 'y', 'und', 'and'].includes(w))) return keep // hyphenated / slashed parts each capitalised: "müller-lüdenscheidt" return w.split(/([-/'’])/).map((p) => p.length && !/[-/'’]/.test(p) ? p.charAt(0).toUpperCase() + p.slice(1) : p).join('') }).join(' ') } const isShouty = (v: string) => { const letters = v.replace(/[^A-Za-zÀ-ÿ]/g, '') return letters.length >= 4 && letters === letters.toUpperCase() } const isAllLower = (v: string) => { const letters = v.replace(/[^A-Za-zÀ-ÿ]/g, '') return letters.length >= 4 && letters === letters.toLowerCase() } const countryCodeOf = (addr: FixableAddress) => { const c = str(addr.country).toUpperCase() if (c) return c const iso3 = str(addr.countryIso3).toUpperCase() const hit = Object.entries(COUNTRY_META).find(([, m]) => m.iso3 === iso3) return hit ? hit[0] : '' } const resolveCountryMeta = (code: string, countries?: any[]) => { const upper = str(code).toUpperCase() const fromList = (countries || []).find((c: any) => str(c?.CountryCode).toUpperCase() === upper) if (fromList) { return { name: str(fromList.Name || upper), iso3: str(fromList.ISOCountryCodeAlpha3 || COUNTRY_META[upper]?.iso3 || '') } } return COUNTRY_META[upper] || { name: upper, iso3: '' } } // --------------------------------------------------------------------------- // Street parsing // --------------------------------------------------------------------------- interface StreetParse { street: string; houseNumber: string; rest: string; order: 'last' | 'first' } /** Split " []" (number-last, DE style). */ const parseNumberLast = (text: string): StreetParse | null => { const t = clean(text) const m = t.match(STREET_NUMBER_LAST_RE) if (!m) return null const street = clean(m[1]) if (!hasLetter(street)) return null // Numbers that are part of the street name ("Straße des 17. Juni") are followed by a dot return { street, houseNumber: tidyHouseNo(m[2]), rest: clean(m[3] || ''), order: 'last' } } /** Split " " (number-first, US style). */ const parseNumberFirst = (text: string): StreetParse | null => { const t = clean(text) const m = t.match(STREET_NUMBER_FIRST_RE) if (!m) return null let street = clean(m[2]) let rest = '' const u = street.match(UNIT_SUFFIX_RE) if (u && clean(street.slice(0, u.index)).length > 0) { rest = clean(u[1]) street = clean(street.slice(0, u.index)) } if (!hasLetter(street)) return null return { street, houseNumber: tidyHouseNo(m[1]), rest, order: 'first' } } /** Parse a street+number combination according to the country's convention; returns primary + alternative. */ const parseStreet = (text: string, country: string): { primary: StreetParse | null; alternative: StreetParse | null } => { const numberFirst = NUMBER_FIRST_COUNTRIES.has(country) const first = parseNumberFirst(text) const last = parseNumberLast(text) if (numberFirst) return { primary: first || last, alternative: first && last ? last : null } return { primary: last || first, alternative: last && first ? first : null } } /** Pull a unit/floor suffix off a street that has no number: "Hauptstraße Apt 4" → rest */ const splitUnitFromStreet = (street: string) => { const m = clean(street).match(UNIT_SUFFIX_RE) if (!m || m.index === undefined) return null const base = clean(street.slice(0, m.index)) if (!base || !hasLetter(base)) return null return { street: base, rest: clean(m[1]) } } // --------------------------------------------------------------------------- // State detection // --------------------------------------------------------------------------- interface StateHit { code: string; name: string; rest: string } /** Find a state/province code or full name inside `text`. `mode: 'trailing'` only accepts hits at the end. */ const findStateIn = (text: string, map: Record, mode: 'anywhere' | 'trailing'): StateHit | null => { const t = clean(text) if (!t) return null // 1) upper-case code token ("Miami FL", "Miami, FL", "FL") const codeRe = mode === 'trailing' ? /(?:^|[\s,])([A-Z]{2,3})\.?$/ : /(?:^|[\s,])([A-Z]{2,3})\.?(?=$|[\s,])/ const cm = t.match(codeRe) if (cm && map[cm[1]]) { const rest = clean(t.replace(new RegExp(`(?:^|[\\s,])${cm[1]}\\.?(?=$|[\\s,])`), ' ')) return { code: cm[1], name: map[cm[1]], rest } } // 2) full name ("Los Angeles, California", "Florida") const names = Object.entries(map).sort((a, b) => b[1].length - a[1].length) for (const [code, name] of names) { const re = mode === 'trailing' ? new RegExp(`(?:^|[\\s,])${escapeRe(name)}\\.?$`, 'i') : new RegExp(`(?:^|[\\s,])${escapeRe(name)}\\.?(?=$|[\\s,])`, 'i') const nm = t.match(re) if (nm) { const rest = clean(t.replace(re, ' ')) // A city that IS the state name ("Washington") is not a state hint if (mode === 'trailing' && !rest) continue return { code, name, rest } } } return null } const stateCodeFromValue = (value: string, map: Record) => { const v = clean(value) if (!v) return null const upper = v.toUpperCase().replace(/\.$/, '') if (map[upper]) return { code: upper, name: map[upper] } const byName = Object.entries(map).find(([, n]) => n.toLowerCase() === v.toLowerCase()) return byName ? { code: byName[0], name: byName[1] } : null } // --------------------------------------------------------------------------- // Postal helpers // --------------------------------------------------------------------------- const POSTAL_COUNTRY_PREFIX_RE = /^(?:D|DE|A|AT|CH|NL|B|BE|F|FR|I|IT|E|ES|L|LU|DK|PL|CZ|SK|S|SE|N|NO|FIN|FI|GB|UK|US|USA|P|PT|H|HU|SLO|SI|HR|RO|BG|GR|EST|EE|IRL|IE|CDN|CA|AUS|AU|J|JP|TR)\s*[-–—]\s*(?=[A-Za-z0-9])/i /** Normalise a postal code for the given country. Returns the normalised value (may equal the input). */ const normalisePostal = (postal: string, country: string): string => { let p = clean(postal).toUpperCase() if (!p) return p // Country prefix "D-10115", "CH-8000", "A-1010" const stripped = p.replace(POSTAL_COUNTRY_PREFIX_RE, '') if (stripped !== p && stripped.length >= 3) p = stripped const pat = POSTAL_PATTERNS[country] const digitsOnly = p.replace(/\D/g, '') switch (country) { case 'NL': { const m = p.replace(/\s+/g, '').match(/^(\d{4})([A-Z]{2})$/) if (m) p = `${m[1]} ${m[2]}` break } case 'GB': { const compact = p.replace(/\s+/g, '') if (/^[A-Z]{1,2}\d[A-Z\d]?\d[A-Z]{2}$/.test(compact)) p = `${compact.slice(0, -3)} ${compact.slice(-3)}` break } case 'IE': { const compact = p.replace(/\s+/g, '') if (/^[A-Z]\d{2}[A-Z\d]{4}$/.test(compact)) p = `${compact.slice(0, 3)} ${compact.slice(3)}` break } case 'CA': { const compact = p.replace(/\s+/g, '') if (/^[A-Z]\d[A-Z]\d[A-Z]\d$/.test(compact)) p = `${compact.slice(0, 3)} ${compact.slice(3)}` break } case 'US': { if (/^\d{9}$/.test(digitsOnly) && /^[\d\s-]+$/.test(p)) p = `${digitsOnly.slice(0, 5)}-${digitsOnly.slice(5)}` else if (/^\d{5}$/.test(digitsOnly) && /^[\d\s-]+$/.test(p)) p = digitsOnly else if (/^\d{1,4}$/.test(digitsOnly) && /^\d+$/.test(p)) p = digitsOnly.padStart(5, '0') // leading zeros dropped by spreadsheets break } case 'PL': { if (/^\d{5}$/.test(digitsOnly) && /^[\d\s-]+$/.test(p)) p = `${digitsOnly.slice(0, 2)}-${digitsOnly.slice(2)}` break } case 'PT': { if (/^\d{7}$/.test(digitsOnly) && /^[\d\s-]+$/.test(p)) p = `${digitsOnly.slice(0, 4)}-${digitsOnly.slice(4)}` break } case 'CZ': case 'SK': case 'SE': case 'GR': { if (/^\d{5}$/.test(digitsOnly) && /^[\d\s-]+$/.test(p)) p = `${digitsOnly.slice(0, 3)} ${digitsOnly.slice(3)}` break } case 'JP': { if (/^\d{7}$/.test(digitsOnly) && /^[\d\s-]+$/.test(p)) p = `${digitsOnly.slice(0, 3)}-${digitsOnly.slice(3)}` break } case 'BR': { if (/^\d{8}$/.test(digitsOnly) && /^[\d\s.-]+$/.test(p)) p = `${digitsOnly.slice(0, 5)}-${digitsOnly.slice(5)}` break } case 'LV': { if (/^\d{4}$/.test(digitsOnly)) p = `LV-${digitsOnly}` break } case 'LT': { if (/^\d{5}$/.test(digitsOnly)) p = `LT-${digitsOnly}` break } default: { // Plain numeric postal codes: drop inner spaces/dots ("10 115" → "10115") if (pat && /^\^\\d\{\d(?:,\d)?\}\$$/.test(pat.re.source) && /^[\d\s.]+$/.test(p)) p = digitsOnly // Leading zeros lost (DE "1067" → "01067") — only when the country has a fixed-length numeric pattern if (country === 'DE' && /^\d{4}$/.test(p)) p = '0' + p } } return p } const postalMatches = (postal: string, country: string) => { const pat = POSTAL_PATTERNS[country] if (!pat) return true return pat.re.test(clean(postal).toUpperCase()) } /** Regex source that matches one postal code of the country (loose, for extraction out of free text). */ const postalLooseSrc = (country: string) => { switch (country) { case 'GB': return '[A-Z]{1,2}\\d[A-Z\\d]?\\s?\\d[A-Z]{2}' case 'IE': return '[A-Z]\\d{2}\\s?[A-Z\\d]{4}' case 'CA': return '[A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d' case 'NL': return '\\d{4}\\s?[A-Z]{2}' case 'US': return '\\d{5}(?:-\\d{4})?' case 'PL': return '\\d{2}-?\\d{3}' case 'PT': return '\\d{4}-?\\d{3}' case 'CZ': case 'SK': case 'SE': case 'GR': return '\\d{3}\\s?\\d{2}' case 'JP': return '\\d{3}-?\\d{4}' case 'BR': return '\\d{5}-?\\d{3}' case 'LV': return '(?:LV-)?\\d{4}' case 'LT': return '(?:LT-)?\\d{5}' case 'RO': case 'CN': case 'IN': case 'SG': return '\\d{6}' case 'DE': case 'FR': case 'IT': case 'ES': case 'FI': case 'HR': case 'EE': case 'MX': case 'TR': case 'KR': return '\\d{5}' case 'AT': case 'CH': case 'LI': case 'BE': case 'LU': case 'DK': case 'NO': case 'HU': case 'SI': case 'BG': case 'AU': case 'NZ': case 'ZA': return '\\d{4}' default: return '\\d{4,6}' } } // --------------------------------------------------------------------------- // Rules // --------------------------------------------------------------------------- type Rule = (a: FixableAddress, ctx: RuleContext) => AddressFix | AddressFix[] | null | undefined interface RuleContext { country: string limits: Record countries?: any[] includeLow: boolean stateHint?: { code: string; name?: string; source?: string } | null } const F = (id: string, title: string, reason: string, confidence: AddressFixConfidence, changes: Partial, alternatives?: AddressFixAlternative[]): AddressFix => ({ id, title, reason, confidence, changes, ...(alternatives && alternatives.length ? { alternatives } : {}) }) /** Merge `extra` into address2 unless already present. */ const mergeAddress2 = (existing: string, extra: string) => { const e = clean(existing) const x = clean(extra) if (!x) return e if (!e) return x if (normKey(e).includes(normKey(x))) return e return `${e}, ${x}` } // 0a. DHL Packstation — canonical layout: Street "Packstation", Nr. = 3-digit // station number, Name 2 = 6-10 digit DHL Postnummer. People scramble these // three tokens across Street/Nr./Address 2/Name 2/Name, so scan everywhere. const rulePackstation: Rule = (a) => { const scan: AddressFixField[] = ['address', 'houseNumber', 'address2', 'name2', 'name', 'city'] const joined = scan.map((f) => clean(a[f])).join(' | ') if (!/packstation/i.test(joined)) return null let station = '' let postnummer = '' for (const f of scan) { const v = clean(a[f]) if (!v) continue if (!station) { const near = v.match(/packstation\s*[:#.-]?\s*(\d{3})(?!\d)/i) || v.match(/(? clean(str(v) .replace(/packstation\s*[:#.-]?\s*\d{3}(?!\d)/gi, ' ') .replace(/(? = { address: 'Packstation', houseNumber: station } const name2Rest = strip(a.name2) const a2Rest = strip(a.address2) if (postnummer) { changes.name2 = postnummer changes.address2 = name2Rest && !same(name2Rest, postnummer) ? mergeAddress2(a2Rest, name2Rest) : a2Rest } else { if (str(a.address2) !== a2Rest) changes.address2 = a2Rest if (str(a.name2) !== name2Rest) changes.name2 = name2Rest } const nameRest = strip(a.name) if (nameRest !== clean(a.name) && nameRest) changes.name = nameRest const cityRest = strip(a.city) if (cityRest !== clean(a.city) && cityRest) changes.city = cityRest return F('packstation', 'Format as DHL Packstation address', `Detected a DHL Packstation${station ? ` (station ${station}` : ''}${postnummer ? `, Postnummer ${postnummer})` : station ? ')' : ''}. DHL expects: Street "Packstation", Nr. = station number${postnummer ? ', Name 2 = the Postnummer' : ''}.${postnummer ? '' : ' No 6-10 digit Postnummer found — DHL needs it in Name 2.'}`, postnummer ? 'high' : 'medium', changes) } // 0b. Street written into Name 2 (or Address 2) while the Street field holds a // (often duplicated) person/company name. // German/Dutch street suffixes are glued to the name ("Bahnhofstr.", "Ringstra\u00dfe") // \u2192 match at the END of a word; international street words stand alone. const STREET_SUFFIX_RE = /(stra\u00dfe|strasse|str\.?|weg|allee|platz|gasse|ring|damm|ufer|chaussee|steig|pfad|zeile|markt|straat|laan|gracht)(?=$|[\s,.:;])/i const STREET_TOKEN_RE = /\b(street|road|rd\.?|lane|ln\.?|drive|dr\.?|court|ct\.?|avenue|ave\.?|blvd\.?|boulevard|rue|calle|carrer|via|viale|piazza|plein)\b/i const hasStreetWord = (v: string) => STREET_SUFFIX_RE.test(v) || STREET_TOKEN_RE.test(v) const UNIT_WORD_START_RE = /^(?:og|eg|dg|ug|whg|wohnung|apt|apartment|suite|ste|unit|floor|fl|etage|stock|zimmer|top|stiege|geb|c\/o|bei|hinterhaus|vorderhaus)\b/i const looksLikeStreetText = (v: string) => { const t = clean(v) if (!t || !hasLetter(t) || UNIT_WORD_START_RE.test(t)) return false if (hasStreetWord(t)) return true const p = parseNumberLast(t) || parseNumberFirst(t) return !!(p && p.houseNumber && p.street.length >= 4 && !UNIT_WORD_START_RE.test(p.street)) } const looksLikeNameText = (v: string) => { const t = clean(v) return !!t && hasLetter(t) && !hasDigit(t) && !hasStreetWord(t) } const ruleStreetInName2: Rule = (a, ctx) => { const street = clean(a.address) const name = clean(a.name) const streetIsDupName = !!street && !!name && same(street, name) const streetIsNamey = !!street && looksLikeNameText(street) && !looksLikeStreetText(street) // Only act when the Street field is clearly NOT a street: empty, a duplicate // of the recipient name, or plain name-like text if (street && !streetIsDupName && !streetIsNamey) return null for (const src of ['name2', 'address2'] as AddressFixField[]) { const v = clean(a[src]) if (!v || !looksLikeStreetText(v)) continue const parsed = parseStreet(v, ctx.country).primary const changes: Partial = { address: parsed ? parsed.street : v } if (parsed?.houseNumber && (!clean(a.houseNumber) || same(a.houseNumber, parsed.houseNumber))) changes.houseNumber = parsed.houseNumber if (parsed?.rest && src !== 'address2') changes.address2 = mergeAddress2(a.address2, parsed.rest) let reason: string let confidence: AddressFixConfidence = 'high' if (streetIsDupName) { changes[src] = (parsed?.rest && src === 'address2') ? parsed.rest : '' reason = `${FIELD_LABELS[src]} contains the street ("${v}") while the Street field just repeats the recipient name ("${street}"). Move the street into place and drop the duplicated name.` } else if (!street) { changes[src] = (parsed?.rest && src === 'address2') ? parsed.rest : '' reason = `The Street field is empty and ${FIELD_LABELS[src]} contains the street ("${v}").` } else { // A different name in the Street field: keep it as Name 2 (c/o line) changes[src] = (parsed?.rest && src === 'address2') ? parsed.rest : '' if (src !== 'name2' || !same(street, clean(a.name2))) changes.name2 = street reason = `"${v}" in ${FIELD_LABELS[src]} is a street while "${street}" in the Street field looks like a name — swap them (the name moves to Name 2).` confidence = 'medium' } return F('street-in-' + src, `Move street from ${FIELD_LABELS[src]} to Street`, reason, confidence, changes) } return null } // 0. Swapped / misplaced fields --------------------------------------------- const ruleSwappedFields: Rule = (a) => { const out: AddressFix[] = [] const street = clean(a.address) const nr = clean(a.houseNumber) const postal = clean(a.postalCode) const city = clean(a.city) // Street field holds only a house number, Nr. field holds the street name if (street && isHouseNumber(stripHouseNoPrefix(street)) && nr && hasLetter(nr) && !isHouseNumber(nr) && !hasDigit(nr)) { out.push(F('swap-street-nr', 'Swap Street and Nr.', `"${street}" looks like a house number and "${nr}" like a street name — the fields seem swapped.`, 'high', { address: nr, houseNumber: tidyHouseNo(stripHouseNoPrefix(street)) })) } else if (street && isHouseNumber(stripHouseNoPrefix(street)) && !nr) { out.push(F('street-is-number', 'Move number from Street to Nr.', `The Street field only contains "${street}", which is a house number. The street name is missing.`, 'medium', { address: '', houseNumber: tidyHouseNo(stripHouseNoPrefix(street)) })) } // Postal and City swapped ("Berlin" in postal, "10115" in city) if (postal && city && hasLetter(postal) && !hasDigit(postal) && /^\d[\d\s-]*$/.test(city)) { out.push(F('swap-postal-city', 'Swap Postal and City', `"${postal}" is not a postal code and "${city}" is — the fields seem swapped.`, 'high', { postalCode: city, city: postal })) } // Name empty but Name 2 filled (but never promote a street-looking Name 2) if (!clean(a.name) && clean(a.name2) && !looksLikeStreetText(clean(a.name2))) { out.push(F('name2-to-name', 'Use Name 2 as recipient name', 'The recipient name is empty but Name 2 is filled — the carrier needs the name in the first line.', 'high', { name: clean(a.name2), name2: '' })) } // Street empty but Address 2 holds a street if (!street && clean(a.address2) && hasLetter(a.address2)) { out.push(F('address2-to-street', 'Use Address 2 as street', 'The Street field is empty but Address 2 contains text — it is probably the street.', 'medium', { address: clean(a.address2), address2: '' })) } return out } // 1. House number field contains street (+ number) ------------------------ const ruleHouseNumberContainsStreet: Rule = (a, ctx) => { const nr = clean(a.houseNumber) if (!nr || isHouseNumber(stripHouseNoPrefix(nr))) return null if (!hasDigit(nr) || !hasLetter(nr)) return null // Starts with the number in a number-last country ("5 OG 2", "5 Hauptstraße"): // that is number+extra noise, not a street — handled by ruleHouseNumberNoise. if (!NUMBER_FIRST_COUNTRIES.has(ctx.country) && /^\d/.test(stripHouseNoPrefix(nr))) return null const street = clean(a.address) const { primary, alternative } = parseStreet(nr, ctx.country) if (!primary) return null const changes: Partial = { houseNumber: primary.houseNumber } let reason = `The Nr. field contains "${nr}" — a street name and a house number.` const alternatives: AddressFixAlternative[] = [] if (!street || same(street, nr) || same(street, primary.street)) { changes.address = primary.street reason += ` Split it into Street "${primary.street}" and Nr. "${primary.houseNumber}".` } else { // Street field already has a different street → keep it, take only the number; offer replacing as alternative reason += ` Keep Street "${street}" and use only the number "${primary.houseNumber}".` alternatives.push({ title: `Replace street with "${primary.street}"`, reason: `Use "${primary.street}" from the Nr. field as street and "${primary.houseNumber}" as number.`, changes: { address: primary.street, houseNumber: primary.houseNumber } }) } if (primary.rest) changes.address2 = mergeAddress2(a.address2, primary.rest) if (alternative && alternative.houseNumber !== primary.houseNumber && !/^\d/.test(alternative.street) && !/\d$/.test(alternative.street)) { alternatives.push({ title: `Read as "${alternative.street}" Nr. "${alternative.houseNumber}"`, reason: 'Alternative reading with the house number on the other side.', changes: { address: alternative.street, houseNumber: alternative.houseNumber, ...(alternative.rest ? { address2: mergeAddress2(a.address2, alternative.rest) } : {}) } }) } return F('nr-contains-street', 'Split street out of the Nr. field', reason, 'high', changes, alternatives) } // 2. Street field contains the house number ------------------------------ const ruleStreetContainsNumber: Rule = (a, ctx) => { const street = clean(a.address) if (!street || !hasDigit(street)) return null if (PO_BOX_RE.test(street)) return null const nr = stripHouseNoPrefix(a.houseNumber) const { primary, alternative } = parseStreet(street, ctx.country) if (!primary) return null // "Straße des 17. Juni" (number inside the name, no separate number) — parse returned street with the number const changes: Partial = { address: primary.street } const alternatives: AddressFixAlternative[] = [] let reason: string let confidence: AddressFixConfidence = 'high' if (!nr) { changes.houseNumber = primary.houseNumber reason = `The Street field contains the house number: "${street}". Split into Street "${primary.street}" and Nr. "${primary.houseNumber}".` if (primary.order !== (NUMBER_FIRST_COUNTRIES.has(ctx.country) ? 'first' : 'last')) confidence = 'medium' } else if (same(nr, primary.houseNumber)) { reason = `"${primary.houseNumber}" is written in the Street field AND in the Nr. field. Remove the duplicate from the street.` } else if (same(street, nr)) { changes.houseNumber = primary.houseNumber reason = `Street and Nr. both contain "${street}". Split into Street "${primary.street}" and Nr. "${primary.houseNumber}".` } else { // Conflicting numbers: keep the explicit Nr. field, strip number from street; alternative uses the street's number reason = `The Street field "${street}" contains the number "${primary.houseNumber}" but the Nr. field says "${nr}". Keep Nr. "${nr}" and clean the street.` confidence = 'medium' alternatives.push({ title: `Use "${primary.houseNumber}" from the street instead of "${nr}"`, reason: `Take the number that was written together with the street name.`, changes: { address: primary.street, houseNumber: primary.houseNumber } }) } if (primary.rest) changes.address2 = mergeAddress2(a.address2, primary.rest) if (alternative && (alternative.houseNumber !== primary.houseNumber || alternative.street !== primary.street) && !/^\d/.test(alternative.street) && !/\d$/.test(alternative.street)) { alternatives.push({ title: `Read as "${alternative.street}" Nr. "${alternative.houseNumber}"`, reason: 'Alternative reading with the house number on the other side.', changes: { address: alternative.street, houseNumber: alternative.houseNumber, ...(alternative.rest ? { address2: mergeAddress2(a.address2, alternative.rest) } : {}) } }) } return F('street-contains-nr', 'Split house number out of the Street field', reason, confidence, changes, alternatives) } // 3. Unit / floor info inside the street (no number involved) -------------- const ruleUnitInStreet: Rule = (a) => { const street = clean(a.address) if (!street) return null const split = splitUnitFromStreet(street) if (!split) return null // c/o inside the street goes to Name 2 (DHL: name2 = c/o line) const co = split.rest.match(CO_RE) || (`, ${split.rest}`).match(CO_RE) if (co && !clean(a.name2)) { return F('street-co-to-name2', 'Move "c/o" from Street to Name 2', `"${split.rest}" is a care-of line and belongs into Name 2, not the street.`, 'medium', { address: split.street, name2: clean(split.rest) }) } return F('street-unit-to-address2', 'Move floor/unit info to Address 2', `"${split.rest}" is apartment/floor information. Carriers expect it in Address 2, not in the street.`, 'medium', { address: split.street, address2: mergeAddress2(a.address2, split.rest) }) } // 4. House number noise ("Nr. 5", "#5", "5 OG 2", "5 a") ------------------ const ruleHouseNumberNoise: Rule = (a) => { const raw = clean(a.houseNumber) if (!raw) return null const noPrefix = stripHouseNoPrefix(raw) if (isHouseNumber(noPrefix)) { const tidy = tidyHouseNo(noPrefix) if (tidy !== raw) { const prefixRemoved = noPrefix !== raw return F('nr-tidy', prefixRemoved ? 'Remove prefix from Nr.' : 'Tidy house number', prefixRemoved ? `"${raw}" → "${tidy}": the carrier only wants the number itself.` : `"${raw}" → "${tidy}".`, prefixRemoved ? 'high' : 'low', { houseNumber: tidy }) } return null } // "5 OG 2", "12 Apt 4B", "5, 2. Stock" → number + rest const m = noPrefix.match(new RegExp(`^(${HOUSE_NO_SRC})[\\s,]+(.+)$`, 'i')) if (m) { const rest = clean(m[2]) if (hasLetter(rest)) { // Rest looks like a street name typed after the number → it belongs into Street const STREETY_RE = /(?:straße|strasse|str\.?|weg|allee|platz|gasse|ring|damm|ufer|chaussee|steig|pfad|street|st\.?|road|rd\.?|lane|ln\.?|drive|dr\.?|court|ct\.?|avenue|ave\.?|blvd\.?|boulevard|way|rue|calle|via)$/i if (!hasDigit(rest) && STREETY_RE.test(rest) && (!clean(a.address) || same(a.address, noPrefix))) { return F('nr-has-street-after', 'Split street out of the Nr. field', `The Nr. field contains "${raw}" — number and street. Use Street "${rest}" and Nr. "${tidyHouseNo(m[1])}".`, 'high', { houseNumber: tidyHouseNo(m[1]), address: rest }) } return F('nr-extra-to-address2', 'Move extra info from Nr. to Address 2', `"${raw}" contains "${rest}" besides the number. Only "${tidyHouseNo(m[1])}" goes into Nr.; the rest belongs into Address 2.`, 'high', { houseNumber: tidyHouseNo(m[1]), address2: mergeAddress2(a.address2, rest) }) } } return null } // 5. House number sitting in Address 2 ----------------------------------- const ruleHouseNumberInAddress2: Rule = (a) => { const nr = clean(a.houseNumber) const street = clean(a.address) const a2 = clean(a.address2) if (nr || !a2 || !street || hasDigit(street)) return null const candidate = stripHouseNoPrefix(a2) if (isHouseNumber(candidate)) { return F('address2-is-nr', 'Use Address 2 as house number', `Nr. is empty and Address 2 only contains "${a2}", which is a house number.`, 'high', { houseNumber: tidyHouseNo(candidate), address2: '' }) } // "Nr. 5, 2. OG" → 5 + rest const m = candidate.match(new RegExp(`^(${HOUSE_NO_SRC})[\\s,]+(.+)$`, 'i')) if (m && a2 !== candidate) { return F('address2-has-nr', 'Take house number from Address 2', `Nr. is empty and Address 2 starts with the number "${tidyHouseNo(m[1])}".`, 'medium', { houseNumber: tidyHouseNo(m[1]), address2: clean(m[2]) }) } return null } // 6. Postal / city combined ----------------------------------------------- const rulePostalCityCombined: Rule = (a, ctx) => { const postal = clean(a.postalCode) const city = clean(a.city) const out: AddressFix[] = [] const loose = postalLooseSrc(ctx.country) const stateMap = STATE_MAPS[ctx.country] // Postal field: "10115 Berlin" / "Berlin 10115" / "NY 10128" / "D-10115 Berlin" if (postal && hasLetter(postal) && hasDigit(postal)) { const p = postal.replace(POSTAL_COUNTRY_PREFIX_RE, '') const lead = p.match(new RegExp(`^(${loose})[\\s,]+([A-Za-zÀ-ÿ].*)$`, 'i')) const trail = p.match(new RegExp(`^([A-Za-zÀ-ÿ].*?)[\\s,]+(${loose})$`, 'i')) if (stateMap) { const st = p.match(new RegExp(`^([A-Z]{2,3})[\\s,]+(${loose})$`, 'i')) if (st && stateMap[st[1].toUpperCase()]) { out.push(F('postal-has-state', 'Split state out of Postal', `Postal "${postal}" contains the state "${st[1].toUpperCase()}" (${stateMap[st[1].toUpperCase()]}).`, 'high', { postalCode: normalisePostal(st[2], ctx.country), ...(clean(a.state) ? {} : { state: st[1].toUpperCase() }) })) } } if (lead && !(stateMap && stateMap[clean(lead[2]).toUpperCase()])) { const cityPart = clean(lead[2]) out.push(F('postal-has-city', 'Split city out of Postal', `Postal "${postal}" also contains the city "${cityPart}".`, 'high', { postalCode: normalisePostal(lead[1], ctx.country), ...(!city || same(city, cityPart) ? { city: cityPart } : {}) })) } else if (trail && !(stateMap && stateMap[clean(trail[1]).toUpperCase()])) { const cityPart = clean(trail[1]) out.push(F('postal-has-city', 'Split city out of Postal', `Postal "${postal}" also contains the city "${cityPart}".`, 'high', { postalCode: normalisePostal(trail[2], ctx.country), ...(!city || same(city, cityPart) ? { city: cityPart } : {}) })) } } // City field: "10115 Berlin", "Berlin 10115", "New York, NY 10001", "London SW1A 1AA" if (city && hasDigit(city)) { const c = city.replace(POSTAL_COUNTRY_PREFIX_RE, '') const lead = c.match(new RegExp(`^(${loose})[\\s,]+([A-Za-zÀ-ÿ].*)$`, 'i')) const trail = c.match(new RegExp(`^(.*?[A-Za-zÀ-ÿ].*?)[\\s,]+(${loose})$`, 'i')) const hit = lead ? { postal: lead[1], city: clean(lead[2]) } : (trail ? { postal: trail[2], city: clean(trail[1]) } : null) if (hit) { const normalised = normalisePostal(hit.postal, ctx.country) const changes: Partial = { city: hit.city } let reason = `City "${city}" contains the postal code "${hit.postal}".` if (!postal || same(postal, normalised) || same(postal, hit.postal)) { changes.postalCode = normalised } else { reason += ` The Postal field says "${postal}" — keeping that and only cleaning the city.` } out.push(F('city-has-postal', 'Split postal code out of City', reason, postal && !same(postal, normalised) && !same(postal, hit.postal) ? 'medium' : 'high', changes, postal && !same(postal, normalised) && !same(postal, hit.postal) ? [{ title: `Use "${normalised}" from the city as postal code`, changes: { city: hit.city, postalCode: normalised } }] : [])) } } // Street / Address 2 carry " " tail (full address pasted into one line) for (const field of ['address', 'address2'] as AddressFixField[]) { const v = clean(a[field]) if (!v || !hasDigit(v)) continue let m = v.match(new RegExp(`^(.*?)[\\s,]+(${loose})[\\s,]+([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ .'-]*)$`, 'i')) if (!m && field === 'address2') { // Address 2 IS " " with nothing else const full = v.match(new RegExp(`^(${loose})[\\s,]+([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ .'-]*)$`, 'i')) if (full) m = [full[0], '', full[1], full[2]] as any } if (!m) continue const head = clean(m[1]) const postalPart = m[2] const cityPart = clean(m[3]) if (field === 'address' && !hasLetter(head)) continue // (address2 may end up empty — that is fine) if (!postalMatches(normalisePostal(postalPart, ctx.country), ctx.country)) continue // Only when postal/city are empty or agree — otherwise it's just noise at the end of the field const agreePostal = !postal || same(postal, normalisePostal(postalPart, ctx.country)) const agreeCity = !city || same(city, cityPart) const changes: Partial = { [field]: head } as any if (agreePostal) changes.postalCode = normalisePostal(postalPart, ctx.country) if (agreeCity) changes.city = cityPart out.push(F(`${field}-has-postal-city`, `Remove "${postalPart} ${cityPart}" from ${FIELD_LABELS[field]}`, `${FIELD_LABELS[field]} "${v}" ends with postal code and city. ${agreePostal || agreeCity ? 'Move them into their own fields.' : 'Postal/City are already filled with other values — just remove the duplicate.'}`, agreePostal && agreeCity ? 'high' : 'medium', changes)) } return out } // 7. Postal format normalisation ----------------------------------------- const rulePostalFormat: Rule = (a, ctx) => { const postal = clean(a.postalCode) if (!postal) return null if (hasLetter(postal) && hasDigit(postal) && !['NL', 'GB', 'IE', 'CA', 'LV', 'LT'].includes(ctx.country) && !POSTAL_COUNTRY_PREFIX_RE.test(postal)) return null // handled by combined rule const normalised = normalisePostal(postal, ctx.country) if (normalised === postal) return null const prefixRemoved = POSTAL_COUNTRY_PREFIX_RE.test(postal) const valid = postalMatches(normalised, ctx.country) // A normalisation that ADDS digits (leading-zero restore) is a guess → medium const digitsChanged = normalised.replace(/\D/g, '') !== postal.replace(/\D/g, '').replace(POSTAL_COUNTRY_PREFIX_RE, '') return F('postal-format', prefixRemoved ? 'Remove country prefix from postal code' : 'Normalise postal code', `"${postal}" → "${normalised}"${prefixRemoved ? ' (the country is already selected; carriers reject the prefix)' : ` (${POSTAL_PATTERNS[ctx.country]?.hint || 'standard format'})`}.`, valid && !digitsChanged ? 'high' : 'medium', { postalCode: normalised }) } // 8. State (US / CA / AU) -------------------------------------------------- const ruleState: Rule = (a, ctx) => { const map = STATE_MAPS[ctx.country] const out: AddressFix[] = [] const state = clean(a.state) if (!map) { return null } // State written as full name / lower-case / with dot if (state) { const resolved = stateCodeFromValue(state, map) if (resolved && resolved.code !== state) { out.push(F('state-to-code', `Use state code "${resolved.code}"`, `"${state}" → "${resolved.code}" (${resolved.name}). The carrier expects the 2-letter code.`, 'high', { state: resolved.code })) } return out } // State missing → look for it in other fields (postal, city, address2, name2, address) const sources: { field: AddressFixField; mode: 'anywhere' | 'trailing' }[] = [ { field: 'postalCode', mode: 'anywhere' }, { field: 'city', mode: 'trailing' }, { field: 'address2', mode: 'anywhere' }, { field: 'name2', mode: 'anywhere' }, { field: 'address', mode: 'trailing' } ] for (const src of sources) { const v = clean(a[src.field]) if (!v) continue const hit = findStateIn(v, map, src.mode) if (!hit) continue const changes: Partial = { state: hit.code } // Strip the state out of the field it was found in (keep postal digits etc.) if (src.field === 'postalCode') { const digits = hit.rest.replace(POSTAL_COUNTRY_PREFIX_RE, '') if (digits) changes.postalCode = normalisePostal(digits, ctx.country) } else if (src.field === 'city') { if (hit.rest) changes.city = hit.rest } else { changes[src.field] = hit.rest as any } out.push(F('state-from-field', `Set state "${hit.code}" (${hit.name})`, `Found "${hit.name}" in ${FIELD_LABELS[src.field]} "${v}". ${ctx.country === 'US' ? 'DHL requires a state for US destinations.' : 'Use it as the state/province.'}`, 'high', changes)) break } // State missing EVERYWHERE (not even misplaced in another field) → fall back // to the externally resolved hint (online postal-code / city lookup). if (!out.length && ctx.stateHint?.code) { const code = clean(ctx.stateHint.code).toUpperCase() if (map[code]) { out.push(F('state-from-lookup', `Set state "${code}" (${map[code]})`, `No state found anywhere in the address. ${ctx.stateHint.source || 'An online postal-code lookup'} resolves the destination to ${map[code]} (${code}).${ctx.country === 'US' ? ' DHL requires a state for US destinations.' : ''}`, 'high', { state: code })) } } return out } // 9. Country names inside fields + country mismatch ---------------------- const ruleCountryInFields: Rule = (a, ctx) => { const out: AddressFix[] = [] const names = Object.keys(COUNTRY_NAME_TO_CODE).sort((x, y) => y.length - x.length) for (const field of ['city', 'address2', 'postalCode', 'address', 'name2'] as AddressFixField[]) { const v = clean(a[field]) if (!v || v.length < 2) continue for (const n of names) { // Short tokens (uk/usa/vae) must be stand-alone and upper-case; long names are matched case-insensitively as whole words const short = n.length <= 4 const re = short ? new RegExp(`(?:^|[\\s,(/-])(${escapeRe(n.toUpperCase())})(?=$|[\\s,)/-])`) : new RegExp(`(?:^|[\\s,(/-])(${escapeRe(n)})(?=$|[\\s,)/-])`, 'i') const m = v.match(re) if (!m) continue const code = COUNTRY_NAME_TO_CODE[n] const rest = clean(v.replace(re, ' ')) if (field === 'city' && !rest) continue // city IS the country name? leave it if (field === 'address' && !hasLetter(rest)) continue const meta = resolveCountryMeta(code, ctx.countries) const changes: Partial = { [field]: rest } as any if (code !== ctx.country) { changes.country = code changes.countryName = meta.name changes.countryIso3 = meta.iso3 out.push(F(`country-${field}`, `Change country to ${meta.name}`, `${FIELD_LABELS[field]} "${v}" mentions "${m[1]}" but the selected country is ${a.countryName || ctx.country || 'not set'}. Switch to ${meta.name} and remove it from ${FIELD_LABELS[field]}.`, 'medium', changes, [{ title: `Keep ${a.countryName || ctx.country}, only remove "${m[1]}"`, changes: { [field]: rest } as any }])) } else { out.push(F(`country-${field}`, `Remove "${m[1]}" from ${FIELD_LABELS[field]}`, `The country is already set to ${meta.name}; "${m[1]}" in ${FIELD_LABELS[field]} is redundant and confuses the carrier.`, 'high', changes)) } break } } return out } // 10. City noise --------------------------------------------------------- const ruleCityNoise: Rule = (a, ctx) => { const city = clean(a.city) if (!city) return null const map = STATE_MAPS[ctx.country] const state = clean(a.state) // City still carries the already-set state ("Miami, FL" with state FL) if (map && state) { const hit = findStateIn(city, map, 'trailing') if (hit && hit.code === state && hit.rest) { return F('city-has-state', `Remove "${state}" from City`, `City "${city}" repeats the state "${state}", which is already set in the State field.`, 'high', { city: hit.rest }) } } // District / "OT" suffix in brackets is fine; trailing "Germany" handled by country rule. return null } // 11. Name handling ------------------------------------------------------- const ruleName: Rule = (a, ctx) => { const out: AddressFix[] = [] const name = clean(a.name) const name2 = clean(a.name2) if (name && name2 && same(name, name2)) { out.push(F('name2-dup', 'Clear duplicated Name 2', `Name 2 repeats the recipient name "${name}".`, 'high', { name2: '' })) } // "Max Mustermann c/o Firma" → Name + Name 2 const co = name.match(CO_RE) if (co && co.index !== undefined && !name2) { const head = clean(name.slice(0, co.index)) if (head) { out.push(F('name-co', 'Move "c/o" part to Name 2', `"${clean(co[0])}" is a care-of line; carriers expect it in Name 2.`, 'medium', { name: head, name2: clean(co[0]) })) } } // Too long → split into Name 2 const limit = ctx.limits.name if (name.length > limit && !name2 && !out.some((f) => f.id === 'name-co')) { const cut = name.lastIndexOf(' ', limit) if (cut > 10) { out.push(F('name-split', `Split name (max ${limit} chars)`, `The name is ${name.length} characters long; the carrier allows ${limit}. Move "${name.slice(cut + 1)}" to Name 2.`, 'medium', { name: name.slice(0, cut), name2: name.slice(cut + 1) })) } } // Email / phone typed into the name line if (EMAIL_RE.test(name) || PHONE_RE.test(name)) { const stripped = clean(name.replace(EMAIL_LABEL_RE, '').replace(EMAIL_RE, ' ').replace(PHONE_RE, ' ')) if (stripped && stripped !== name) { out.push(F('name-contact', 'Remove phone/email from Name', `"${name}" contains contact details that must not be printed in the name line.`, 'medium', { name: stripped })) } } return out } // 12. Address 2 / Name 2 noise --------------------------------------------- const ruleAddress2Noise: Rule = (a) => { const out: AddressFix[] = [] const a2 = clean(a.address2) const street = clean(a.address) const nr = clean(a.houseNumber) if (a2) { if (same(a2, street) || (street && nr && same(a2, `${street} ${nr}`)) || (street && nr && same(a2, `${nr} ${street}`))) { out.push(F('address2-dup', 'Clear duplicated Address 2', `Address 2 repeats the street "${a2}".`, 'high', { address2: '' })) } else if (nr && same(a2, nr)) { out.push(F('address2-dup-nr', 'Clear duplicated house number in Address 2', `Address 2 repeats the house number "${nr}".`, 'high', { address2: '' })) } else if (EMAIL_RE.test(a2) || PHONE_RE.test(a2)) { const stripped = clean(a2.replace(EMAIL_LABEL_RE, '').replace(EMAIL_RE, ' ').replace(PHONE_RE, ' ')) out.push(F('address2-contact', stripped ? 'Remove phone/email from Address 2' : 'Clear phone/email from Address 2', `Address 2 "${a2}" contains contact details. They are not part of the address label.`, 'medium', { address2: stripped })) } else { const co = a2.match(CO_RE) if (co && !clean(a.name2) && co.index === 0) { out.push(F('address2-co', 'Move "c/o" from Address 2 to Name 2', `"${a2}" is a care-of line; DHL prints Name 2 as the c/o line.`, 'medium', { address2: '', name2: a2 })) } } } const name2 = clean(a.name2) if (name2 && (EMAIL_RE.test(name2) || PHONE_RE.test(name2))) { const stripped = clean(name2.replace(EMAIL_LABEL_RE, '').replace(EMAIL_RE, ' ').replace(PHONE_RE, ' ')) out.push(F('name2-contact', stripped ? 'Remove phone/email from Name 2' : 'Clear phone/email from Name 2', `Name 2 "${name2}" contains contact details.`, 'medium', { name2: stripped })) } return out } // 13. Length limits ------------------------------------------------------- const ruleLengthLimits: Rule = (a, ctx) => { const out: AddressFix[] = [] const street = clean(a.address) const limit = ctx.limits.address if (street.length > limit && !hasDigit(street)) { const cut = street.lastIndexOf(' ', limit) if (cut > 8 && !clean(a.address2)) { out.push(F('street-split', `Shorten street (max ${limit} chars)`, `The street is ${street.length} characters; the carrier allows ${limit}. Move "${street.slice(cut + 1)}" to Address 2.`, 'medium', { address: street.slice(0, cut), address2: street.slice(cut + 1) })) } } return out } // 14. Cosmetic: whitespace / punctuation -------------------------------- const ruleWhitespace: Rule = (a) => { const changes: Partial = {} const touched: string[] = [] for (const f of ['name', 'name2', 'address', 'houseNumber', 'address2', 'postalCode', 'city', 'state'] as AddressFixField[]) { const raw = str(a[f]) const c = clean(raw) if (c !== raw) { changes[f] = c as any touched.push(FIELD_LABELS[f]) } } if (!touched.length) return null return F('whitespace', 'Clean up spacing/punctuation', `Extra spaces, line breaks or stray commas in ${touched.join(', ')}.`, 'medium', changes) } // 15. Cosmetic: casing + abbreviations (low confidence) ------------------ const ruleCasing: Rule = (a, ctx) => { if (!ctx.includeLow) return null const out: AddressFix[] = [] const changes: Partial = {} const touched: string[] = [] for (const f of ['name', 'name2', 'address', 'address2', 'city'] as AddressFixField[]) { const v = clean(a[f]) if (!v) continue if (isShouty(v) || isAllLower(v)) { const t = toTitleCase(v) if (t !== v) { changes[f] = t as any touched.push(`${FIELD_LABELS[f]} "${v}" → "${t}"`) } } } if (touched.length) out.push(F('casing', 'Fix ALL CAPS / lower-case', touched.join('; '), 'low', changes)) // "Hauptstr." → "Hauptstraße" (DE/AT/CH only) if (['DE', 'AT', 'CH', 'LI', 'LU'].includes(ctx.country)) { const street = clean(a.address) const expanded = street .replace(/(\S)str\.(?=$|\s)/i, (m, p1) => `${p1}${/^[A-Z]$/.test(m.charAt(1)) ? 'Straße' : 'straße'}`) .replace(/(^|\s)Str\.(?=$|\s)/, '$1Straße') .replace(/(^|\s)str\.(?=$|\s)/, '$1straße') .replace(/(\S)strasse(?=$|\s)/i, '$1straße') if (expanded !== street) { out.push(F('street-abbrev', 'Expand "Str." to "Straße"', `"${street}" → "${expanded}" — optional, carriers accept both.`, 'low', { address: expanded })) } } // State upper-case (cosmetic when no map) const st = clean(a.state) if (st && st.length <= 3 && st !== st.toUpperCase()) { out.push(F('state-upper', 'Upper-case state', `"${st}" → "${st.toUpperCase()}".`, 'low', { state: st.toUpperCase() })) } return out } const RULES: Rule[] = [ rulePackstation, ruleStreetInName2, ruleSwappedFields, ruleHouseNumberContainsStreet, ruleStreetContainsNumber, ruleUnitInStreet, ruleHouseNumberNoise, ruleHouseNumberInAddress2, ruleCountryInFields, rulePostalCityCombined, rulePostalFormat, ruleState, ruleCityNoise, ruleName, ruleAddress2Noise, ruleLengthLimits, ruleWhitespace, ruleCasing ] // --------------------------------------------------------------------------- // Warnings (not auto-fixable) // --------------------------------------------------------------------------- const collectWarnings = (a: FixableAddress, ctx: RuleContext): AddressWarning[] => { const out: AddressWarning[] = [] const all = `${a.name} ${a.name2} ${a.address} ${a.houseNumber} ${a.address2}` const missing: string[] = [] if (!clean(a.name)) missing.push('Name') if (!clean(a.address)) missing.push('Street') if (!clean(a.houseNumber)) missing.push('Nr.') if (!clean(a.postalCode)) missing.push('Postal') if (!clean(a.city)) missing.push('City') if (!ctx.country) missing.push('Country') if (missing.length) out.push({ id: 'missing', level: 'warning', title: `Missing: ${missing.join(', ')}`, reason: 'Required by the carrier for label creation.' }) if (PO_BOX_RE.test(all)) out.push({ id: 'pobox', level: 'warning', title: 'PO box / Postfach address', reason: 'Parcel carriers cannot deliver to a PO box. A street address is required.' }) if (/packstation/i.test(all)) { const hasPostnummer = /(? lim) out.push({ id: `len-${f}`, level: 'warning', title: `${FIELD_LABELS[f]} too long (${len}/${lim})`, reason: `The carrier allows at most ${lim} characters; the label call will be rejected or truncated.` }) } const street = clean(a.address) if (street && !hasLetter(street)) out.push({ id: 'street-no-letters', level: 'warning', title: 'Street has no letters', reason: 'The street name seems to be missing.' }) return out } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- const CONF_RANK: Record = { high: 0, medium: 1, low: 2 } export const normaliseFixableAddress = (input: any): FixableAddress => { const out: any = {} for (const f of ADDRESS_FIX_FIELDS) out[f] = str(input?.[f]) if (out.country) out.country = out.country.toUpperCase() return out as FixableAddress } const buildContext = (a: FixableAddress, opts: AddressFixOptions, includeLow: boolean): RuleContext => { const carrier = str(opts.carrier).toLowerCase() === 'dpd' ? 'dpd' : 'dhl' return { country: countryCodeOf(a), limits: CARRIER_LIMITS[carrier], countries: opts.countries, includeLow, stateHint: opts.stateHint || null } } /** Run every rule once against the given address and return the (sorted) fixes. */ export const analyzeShippingAddress = (input: any, opts: AddressFixOptions = {}): AddressFix[] => { const a = normaliseFixableAddress(input) const ctx = buildContext(a, opts, !!opts.includeLow) const fixes: AddressFix[] = [] for (const rule of RULES) { let res: any try { res = rule(a, ctx) } catch { res = null } if (!res) continue const list = Array.isArray(res) ? res : [res] for (const f of list) { if (!f) continue // Drop fixes that don't change anything const effective = Object.entries(f.changes).filter(([k, v]) => str((a as any)[k]) !== str(v)) if (!effective.length) continue f.changes = Object.fromEntries(effective) as Partial fixes.push(f) } } // Stable sort by confidence (rule order preserved inside the same confidence) return fixes .map((f, i) => ({ f, i })) .sort((x, y) => (CONF_RANK[x.f.confidence] - CONF_RANK[y.f.confidence]) || (x.i - y.i)) .map((x) => x.f) } export const applyAddressChanges = (a: FixableAddress, changes: Partial): FixableAddress => { const next: any = { ...a } for (const [k, v] of Object.entries(changes)) next[k] = str(v) if (next.country) next.country = String(next.country).toUpperCase() return next as FixableAddress } const addressKey = (a: FixableAddress) => ADDRESS_FIX_FIELDS.map((f) => str(a[f])).join('\u0001') const diffFields = (a: FixableAddress, b: FixableAddress): AddressFixField[] => ADDRESS_FIX_FIELDS.filter((f) => str(a[f]) !== str(b[f])) interface ChainStep { before: FixableAddress; fix: AddressFix } const runChain = (start: FixableAddress, opts: AddressFixOptions, includeLow: boolean, seed?: { changes: Partial; fix: AppliedAddressFix }) => { let cur = seed ? applyAddressChanges(start, seed.changes) : { ...start } const applied: AppliedAddressFix[] = seed ? [seed.fix] : [] const steps: ChainStep[] = [] const seen = new Set([addressKey(cur)]) for (let i = 0; i < 20; i++) { const fixes = analyzeShippingAddress(cur, { ...opts, includeLow }).filter((f) => includeLow || f.confidence !== 'low') if (!fixes.length) break const f = fixes[0] const next = applyAddressChanges(cur, f.changes) const k = addressKey(next) if (seen.has(k)) break seen.add(k) steps.push({ before: cur, fix: f }) cur = next applied.push({ id: f.id, title: f.title, reason: f.reason, confidence: f.confidence }) } return { address: cur, applied, steps } } /** * Build full-address recommendations for the given form values. * Always safe to call (never throws); returns `hasSuggestions: false` when nothing to propose. */ export const buildAddressRecommendations = (input: any, opts: AddressFixOptions = {}): AddressFixReport => { const original = normaliseFixableAddress(input) const ctx = buildContext(original, opts, true) let warnings: AddressWarning[] = [] let fixes: AddressFix[] = [] try { warnings = collectWarnings(original, ctx) fixes = analyzeShippingAddress(original, { ...opts, includeLow: true }) } catch { return { fixes: [], warnings, recommendations: [], hasSuggestions: false } } const recs: AddressRecommendation[] = [] const seenKeys = new Set([addressKey(original)]) const push = (rec: Omit) => { const k = addressKey(rec.address) if (seenKeys.has(k)) return false const changedFields = diffFields(original, rec.address) if (!changedFields.length) return false seenKeys.add(k) recs.push({ ...rec, changedFields }) return true } try { // 1) Recommended: chain of high/medium fixes const main = runChain(original, opts, false) if (main.applied.length) { push({ id: 'recommended', kind: 'recommended', label: 'Recommended', address: main.address, fixes: main.applied }) } // 2) Recommended + cosmetic (casing, abbreviations, …) const cosmetic = runChain(main.address, opts, true) if (cosmetic.applied.length) { push({ id: 'cosmetic', kind: 'cosmetic', label: main.applied.length ? 'Recommended + cosmetic cleanup' : 'Cosmetic cleanup', address: cosmetic.address, fixes: [...main.applied, ...cosmetic.applied] }) } // 3) Alternatives: for every step in the main chain that had another reading, branch there for (const step of main.steps) { for (const alt of step.fix.alternatives || []) { const seedFix: AppliedAddressFix = { id: `${step.fix.id}:alt`, title: alt.title, reason: alt.reason || step.fix.reason, confidence: 'medium' } const prefix = main.applied.slice(0, main.steps.indexOf(step)) const branch = runChain(step.before, opts, false, { changes: alt.changes, fix: seedFix }) push({ id: `alt-${step.fix.id}-${recs.length}`, kind: 'alternative', label: 'Alternative', address: branch.address, fixes: [...prefix, ...branch.applied] }) } } // 4) Single fixes (only meaningful when more than one fix is on the table) const singles = fixes.filter((f) => f.confidence !== 'low') if (singles.length > 1) { for (const f of singles) { push({ id: `single-${f.id}`, kind: 'single', label: 'Only this fix', address: applyAddressChanges(original, f.changes), fixes: [{ id: f.id, title: f.title, reason: f.reason, confidence: f.confidence }] }) for (const alt of f.alternatives || []) { push({ id: `single-${f.id}-alt-${recs.length}`, kind: 'single', label: 'Only this fix', address: applyAddressChanges(original, alt.changes), fixes: [{ id: `${f.id}:alt`, title: alt.title, reason: alt.reason || f.reason, confidence: 'medium' }] }) } } } } catch { // fall through with whatever was collected } return { fixes, warnings, recommendations: recs.slice(0, 12), hasSuggestions: recs.length > 0 } } /** Human readable one-line address for display. */ export const formatFixableAddress = (a: FixableAddress) => { const lines = [ clean(a.name), clean(a.name2), NUMBER_FIRST_COUNTRIES.has(countryCodeOf(a)) ? clean(`${a.houseNumber} ${a.address}`) : clean(`${a.address} ${a.houseNumber}`), clean(a.address2), clean(`${a.postalCode} ${a.city}${a.state ? ', ' + a.state : ''}`), clean(a.countryName || a.country) ].filter(Boolean) return lines.join(', ') } export const useAddressFixer = () => ({ analyze: analyzeShippingAddress, build: buildAddressRecommendations, apply: applyAddressChanges, format: formatFixableAddress, fieldLabel: addressFieldLabel, fields: ADDRESS_FIX_FIELDS })