Finance-Accounts/ar-aging-app/backend/app/core/column_map.py

440 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
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
"ı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",
"shipping/fulfillment", "fulfillment/shipping", # ES / TR English-form
"expédition", "traitement", # FR / BE
"versand", # DE
"gestione", # IT
"gestión logística", # ES
"realizacja", # PL
"leverans", # SV
"gönderim", # TR
)),
("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",
"sipariş şehri", # TR
)),
("order_state", "text", (
"order state", "state",
"order state/province", "state/province", # IE/NL/PL/SV/BE, DE
"order region/province", "order province/state", # FR, IT
"order region/autonomous community", # ES
"é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",
"sipariş durumu", # TR (order-state column, per reference workbook)
)),
("order_postal", "text", (
"order postal", "postal", "postal code", "zip",
"order postal code", # FR/IE/IT/NL/PL/ES/SV/BE/TR English-form
"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",
"sipariş postası", # TR
)),
("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",
"ürün satışları", # TR
"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",
"shipping credit", # DE (singular English-form)
"kargo kredileri", # TR
"crédits d'expédition", "crédits dexpé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",
"tax on shipping credits", # FR / IT / ES English-form
"tax on shipping credit", # DE (singular English-form)
"taxe sur les crédits d'expédition", "taxe sur les crédits dexpé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",
"gift wrap credit", # DE (singular English-form)
"crédits d'emballage-cadeau", "crédits demballage-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",
"tax on gift wrap credits", # FR / IT / ES English-form
"tax on gift wrap credit", # DE (singular English-form)
"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",
"promotional discounts", # FR/DE/IE/IT/NL/PL/ES/SV/TR English-form
"total discounts", # BE
"rabais promotionnels", "total des réductions",
"rabatte aus werbeaktionen",
"sconti promozionali",
"devoluciones promocionales",
"promotiekortingen",
"rabaty promocyjne",
"kampanjrabatter",
"promosyon indirimleri", # TR
)),
("promotional_rebates_tax", "amount", (
"promotional rebates tax", "promotional rebate tax",
"tax on promotional discounts", # FR / DE / IT / ES English-form
"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 withheld vat", # IT English-form
"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
"satış ücretleri", # TR
)),
("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",
"amazon lojistik ücretleri", # TR
)),
("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",
"diğer işlem ücretleri", # TR
)),
("other", "amount", (
"other",
"autres", "autre", # FR / BE
"andere", # DE
"altro", # IT
"otro", # ES
"overige", # NL
"inne", # PL
"övrigt", # SV
"diğer", # TR
)),
("transaction_status", "text", (
"transaction status",
"statut de la transaction", # FR / BE CSV
"transaktionsstatus", # DE
"stato della transazione", # IT
"estado de la transacción", # ES
"transactiestatus", # NL
"status transakcji", # PL
"İşlem durumu", # TR
)),
("transaction_release_date", "date", (
"transaction release date",
"date de délivrance de la transaction", # FR / BE CSV
"date de sortie de la transaction", # FR
"transaktionsfreigabedatum", # DE
"freigabedatum der transaktion", # DE
"data di rilascio della transazione", # IT
"fecha de liberación de la transacción", # ES
"publicatiedatum van transactie", # NL
"data zrealizowania transakcji", # PL
"transaktionens utgivningsdatum", # SV
"İşlem çıkış tarihi", # TR
)),
("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