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

371 lines
14 KiB
Python
Raw Permalink 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",
"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 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",
"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",
"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",
"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