371 lines
14 KiB
Python
371 lines
14 KiB
Python
"""
|
||
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",)),
|
||
("transaction_release_date", "date", ("transaction release date",)),
|
||
("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)
|
||
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 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
|