""" Column-mapping layer for Amazon Custom Unified Transaction reports — all marketplaces. Amazon localizes report headers per marketplace (French, German, Italian, Spanish, Dutch, Polish, Swedish, Turkish) and varies the column set (USA 30 cols, Canada 29, UK 27, AU/IE 23, localized EU 28/24). We never rely on column *position*: each header string is normalized (lowercase, accents stripped, punctuation collapsed) and matched against known aliases. Every alias below was captured from the real Jan-2026 files. Unmatched headers are surfaced (never dropped), admins can add aliases as saved mapping rules (normalized header -> field) without code changes, and missing required fields raise a validation error the UI shows. """ from __future__ import annotations import re import unicodedata from dataclasses import dataclass, field from typing import Iterable # --------------------------------------------------------------------------- # Canonical schema: (internal_field, kind, aliases) # kind: "text" | "amount" | "int" | "id" | "date" # The first alias is the canonical Amazon (US) header. # --------------------------------------------------------------------------- CANONICAL_FIELDS: list[tuple[str, str, tuple[str, ...]]] = [ ("date_time", "date", ( "date/time", "date time", "datetime", "posted date/time", "date/heure", # FR / BE "datum/uhrzeit", # DE "data/ora:", "data/ora", # IT "fecha y hora", # ES "datum/tijd", # NL "data/godzina", # PL "datum/tid", # SV "tarih/saat", # TR )), ("settlement_id", "id", ( "settlement id", "settlementid", "settlement ID", "numéro de versement", "identifiant du paiement", # FR / BE "abrechnungsnummer", # DE "numero pagamento", # IT "identificador de pago", # ES "schikkings-id", # NL "identyfikator rozliczenia", # PL "reglerings-id", # SV "hesap kesim no", # TR )), ("txn_type", "text", ( "type", "transaction type", "typ", # DE / PL / SV "tipo", # IT / ES "tip", # TR )), ("txn_type_en", "text", ( "type 2", "typ2", "type2", # Finance-added English helper column in EU files )), ("order_id", "id", ( "order id", "orderid", "order-id", "amazon order id", "numéro de la commande", # FR / BE "bestellnummer", # DE "numero ordine", # IT "número de pedido", # ES "bestelnummer", # NL "identyfikator zamówienia", # PL "beställnings-id", # SV "sipariş no.", "sipariş no", # TR )), ("sku", "text", ("sku", "seller sku")), ("description", "text", ( "description", "product name", "beschreibung", # DE "descrizione", # IT "descripción", # ES "beschrijving", # NL "opis", # PL "beskrivning", # SV "açıklama", # TR )), ("quantity", "int", ( "quantity", "qty", "quantité", # FR "menge", # DE "quantità", # IT "cantidad", # ES "aantal", # NL "ilość", # PL "antal", # SV "adet", # TR )), ("marketplace", "text", ( "marketplace", "sales channel", "site de vente", # BE (FR) "web de amazon", # ES "rynek", # PL "marknadsplats", # SV "pazar yeri", # TR )), ("account_type", "text", ("account type", "accounttype")), ("fulfillment", "text", ( "fulfillment", "fulfilment", "fulfillment channel", "expédition", "traitement", # FR / BE "versand", # DE "gestione", # IT "gestión logística", # ES "realizacja", # PL "leverans", # SV )), ("order_city", "text", ( "order city", "city", "ville de la commande", "ville d'où provient la commande", "ort der bestellung", "città di provenienza dell'ordine", "ciudad de procedencia del pedido", "bestelling stad", "miejscowość zamówienia", "stad för beställning", )), ("order_state", "text", ( "order state", "state", "état de la commande", "région d'où provient la commande", "bundesland", "provincia di provenienza dell'ordine", "comunidad autónoma de procedencia del pedido", "status bestelling", "stan zamówienia", "delstat för beställning", )), ("order_postal", "text", ( "order postal", "postal", "postal code", "zip", "commande postale", "code postal de la commande", "postleitzahl", "cap dell'ordine", "código postal de procedencia del pedido", "bestelling per post", "przekaz pocztowy", "postadress för beställning", )), ("tax_collection_model", "text", ( "tax collection model", "tax collection responsible party", "modèle de perception des taxes", "steuererhebungsmodell", "modello di riscossione delle imposte", "formulario de recaudación de impuestos", )), ("product_sales", "amount", ( "product sales", "sales", "ventes de produits", # FR / BE "umsätze", # DE "vendite", # IT "ventas de productos", # ES "verkoop van producten", # NL "sprzedaż produktów", # PL "försäljning av produkter", # SV )), ("product_sales_tax", "amount", ( "product sales tax", "product sale tax", "taxes sur la vente des produits", "produktumsatzsteuer", "imposta sulle vendite dei prodotti", "impuesto de ventas de productos", )), ("shipping_credits", "amount", ( "shipping credits", "shipping", "postage credits", "crédits d'expédition", "crédits d’expédition", "gutschrift für versandkosten", "accrediti per le spedizioni", "abonos de envío", "verzendtegoeden", "noty kredytowe za wysyłkę", "fraktkrediter", )), ("shipping_credits_tax", "amount", ( "shipping credits tax", "taxe sur les crédits d'expédition", "taxe sur les crédits d’expédition", "steuer auf versandgutschrift", "imposta accrediti per le spedizioni", "impuestos por abonos de envío", )), ("gift_wrap_credits", "amount", ( "gift wrap credits", "gift wrap", "giftwrap credits", "crédits d'emballage-cadeau", "crédits d’emballage-cadeau", "crédits sur l'emballage cadeau", "gutschrift für geschenkverpackung", "accrediti per confezioni regalo", "abonos de envoltorio para regalo", "kredietpunten cadeauverpakking", "środki na pokrycie pakowania na prezent", "krediter för presentinslagning", )), ("giftwrap_credits_tax", "amount", ( "giftwrap credits tax", "gift wrap credits tax", "taxes sur les crédits cadeaux", "steuer auf geschenkverpackungsgutschriften", "imposta sui crediti confezione regalo", "impuestos por abonos de envoltorio para regalo", )), ("regulatory_fee", "amount", ("regulatory fee",)), ("tax_on_regulatory_fee", "amount", ("tax on regulatory fee",)), ("promotional_rebates", "amount", ( "promotional rebates", "promotional rebate", "rabais promotionnels", "total des réductions", "rabatte aus werbeaktionen", "sconti promozionali", "devoluciones promocionales", "promotiekortingen", "rabaty promocyjne", "kampanjrabatter", )), ("promotional_rebates_tax", "amount", ( "promotional rebates tax", "promotional rebate tax", "taxes sur les remises promotionnelles", "steuer auf aktionsrabatte", "imposta sugli sconti promozionali", "impuestos de descuentos por promociones", )), # Single collected-sales-tax column in the compact schemas (AU/IE/NL/PL/SV/BE). ("sales_tax_collected", "amount", ( "sales tax collected", "low value goods", "taxe de ventes prélevée", # BE (FR) "geïnde omzetbelasting", # NL "pobrany podatek od sprzedaży", # PL "inkasserad moms", # SV )), ("marketplace_withheld_tax", "amount", ( "marketplace withheld tax", "marketplace facilitator tax", "marketplace facilitator tax", "taxe marketplace facilitator", # BE (FR) "taxes retenues sur le site de vente", # FR "einbehaltene steuer auf marketplace", # DE "trattenuta iva del marketplace", # IT "impuesto retenido en el sitio web", # ES "belasting voor marketplace-facilitator", # NL "podatek od transakcji marketplace facilitator", # PL "skatt för marknadsplatsförmedlare", # SV )), ("selling_fees", "amount", ( "selling fees", "selling fee", "frais de vente", # FR / BE "verkaufsgebühren", # DE "commissioni di vendita", # IT "tarifas de venta", # ES "verkoopkosten", # NL "opłaty za sprzedaż", # PL "försäljningsavgifter", # SV )), ("fba_fees", "amount", ( "fba fees", "fba fee", "fulfillment fees", "fulfilment by amazon fees", "fulfillment by amazon fees", "frais expédié par amazon", "frais pour le service expédié par amazon", "gebühren zu versand durch amazon", "costi del servizio logistica di amazon", "tarifas de logística de amazon", "fba-vergoedingen", "opłaty za fba", "fba-avgifter", )), ("other_transaction_fees", "amount", ( "other transaction fees", "other transaction fee", "autres frais de transaction", "andere transaktionsgebühren", "altri costi relativi alle transazioni", "tarifas de otras transacciones", "overige transactiekosten", "inne opłaty transakcyjne", "övriga transaktionsavgifter", )), ("other", "amount", ( "other", "autres", "autre", # FR / BE "andere", # DE "altro", # IT "otro", # ES "overige", # NL "inne", # PL "övrigt", # SV )), ("transaction_status", "text", ( "transaction status", "statut de la transaction", # FR / BE CSV "transaktionsstatus", # DE "stato della transazione", # IT "estado de la transacción", # ES )), ("transaction_release_date", "date", ( "transaction release date", "date de délivrance de la transaction", # FR / BE CSV "transaktionsfreigabedatum", # DE )), ("total", "amount", ( "total", "total amount", "amount", "gesamt", # DE "totale", # IT "totaal", # NL "suma", # PL "totalt", # SV "toplam", # TR )), ] FIELD_KIND: dict[str, str] = {f: k for f, k, _ in CANONICAL_FIELDS} FIELD_ORDER: list[str] = [f for f, _, _ in CANONICAL_FIELDS] # Fields the engine cannot run without. (`account_type` intentionally NOT required — # only the USA report has it; other marketplaces run a single receivable stream.) REQUIRED_FIELDS: tuple[str, ...] = ("date_time", "settlement_id", "txn_type", "total") AMOUNT_FIELDS: tuple[str, ...] = tuple(f for f, k, _ in CANONICAL_FIELDS if k == "amount") # Build a normalized-alias -> field lookup once. _ALIAS_TO_FIELD: dict[str, str] = {} def normalize_header(text: str | None) -> str: """Lowercase, strip accents, strip punctuation, collapse whitespace.""" if text is None: return "" s = str(text).strip().lower() s = unicodedata.normalize("NFD", s) s = "".join(ch for ch in s if not unicodedata.combining(ch)) s = s.replace("&", " and ") s = re.sub(r"[_\-/’']+", " ", s) s = re.sub(r"[^a-z0-9 ]+", "", s) s = re.sub(r"\s+", " ", s).strip() return s for _field, _kind, _aliases in CANONICAL_FIELDS: for _a in _aliases: _ALIAS_TO_FIELD.setdefault(normalize_header(_a), _field) @dataclass class ColumnMapping: """Result of matching a report's header row to canonical fields.""" # column-letter (e.g. "A", "AD") -> canonical field col_to_field: dict[str, str] = field(default_factory=dict) # canonical field -> column-letter field_to_col: dict[str, str] = field(default_factory=dict) # headers we could not confidently map: column-letter -> raw header text unmapped: dict[str, str] = field(default_factory=dict) # required fields absent from the header row missing_required: list[str] = field(default_factory=list) # Two headers resolved to the SAME canonical field: field -> [(col, header), ...]. # Only the first is used, so the second column's amounts would vanish from the journal. # Surfaced as an error rather than silently demoted to `unmapped`. duplicate_fields: dict[str, list[tuple[str, str]]] = field(default_factory=dict) # Extra AMOUNT columns folded into an already-mapped field: field -> [(col, header)]. # Amazon splits one concept across columns in some schemas (AU: "sales tax collected" # + "low value goods", both inside the row `total`), so readers SUM these instead of # dropping them. sum_cols: dict[str, list[tuple[str, str]]] = field(default_factory=dict) header_row: int = 0 @property def is_valid(self) -> bool: return not self.missing_required def resolve_field(header_text: str, saved: dict[str, str] | None = None) -> str | None: """Map a single header string to a canonical field, honoring saved admin rules first.""" norm = normalize_header(header_text) if not norm: return None if saved and norm in saved: return saved[norm] return _ALIAS_TO_FIELD.get(norm) def build_mapping( header_cells: Iterable[tuple[str, str]], header_row: int, saved_overrides: dict[str, str] | None = None, ) -> ColumnMapping: """ header_cells: iterable of (column_letter, header_text). saved_overrides: normalized-header -> field, from previously confirmed mapping rules. """ m = ColumnMapping(header_row=header_row) for col, text in header_cells: fld = resolve_field(text, saved_overrides) if fld and fld not in m.field_to_col: m.col_to_field[col] = fld m.field_to_col[fld] = col elif fld: if FIELD_KIND.get(fld) == "amount": # A second amount column for the same concept (AU "low value goods" next # to "sales tax collected") — readers ADD it into the field, because the # row `total` includes both and dropping it fails control C2. m.sum_cols.setdefault(fld, []).append((col, str(text))) else: # Collision on a non-amount field: first column wins and this one is # dropped. Record both so the close can raise, instead of quietly # excluding a whole column. first_col = m.field_to_col[fld] m.duplicate_fields.setdefault(fld, [(first_col, "")]).append((col, str(text))) m.unmapped[col] = str(text) elif text and str(text).strip(): m.unmapped[col] = str(text) m.missing_required = [f for f in REQUIRED_FIELDS if f not in m.field_to_col] return m