Normalizing SKU Hierarchies Across Retailers
Trade promotion reconciliation collapses when trading partners operate on divergent item masters: a single vendor-manufactured unit is logged as a GTIN-14 at the distributor level, a retailer-specific identifier (DPCI, TCIN, or PLU) at point-of-sale, and a temporary promotional variant during scan-based trading events — and unless those are resolved into one canonical hierarchy before trade terms are applied, off-invoice deductions, rebate accruals, and promotional lift drift into unreconciled variance. This page documents the exact procedure for normalizing those hierarchies into a single, auditable SKU graph so that a case-level retailer deduction and an each-level vendor ERP record match instead of flagging as a discrepancy. It is the implementation-level companion to the field mapping strategies cluster, which frames the canonical schema this resolver writes into, inside the broader data ingestion normalization pipelines discipline.
Prerequisites
Before resolving retailer identifiers into a canonical hierarchy, confirm the following are in place:
- Parsed, schema-tagged source records. Raw vendor files must already be flattened and validated by the CSV & EDI parsing workflows, with every incoming row carrying
source_system_id,raw_sku, andunit_of_measurebefore it reaches the resolver. This page consumes parsed records, never raw X12 segments. - A loaded GTIN cross-reference. A pre-compiled mapping of standardized identifiers (UPC-A, EAN-13, GTIN-14) to the canonical vendor ERP item number, plus a per-vendor pack-size matrix for unit-of-measure conversion.
- A pack-hierarchy contract. Each canonical item declares its packaging levels (each → inner → case → pallet) and the integer multipliers between them, version-pinned so a mid-cycle pack change does not silently reprice history.
- Python packages:
pydantic>=2.6for the canonical model and validators,polars>=0.20(orpandas) for vectorized joins, and the standard-librarydecimalmodule. Every monetary and rate field usesdecimal.Decimal; neverfloat. - Access role: read access to vendor and retailer master data and write access to the canonical SKU graph (typically the
reconciliation_etlservice role).
For authoritative guidance on standardizing product identifiers and packaging hierarchies across trading partners, refer to the GS1 GDSN data standards.
Step-by-step implementation
Step 1 — Model the canonical SKU node
Encode the canonical node as a Pydantic v2 model so an invalid GTIN checksum, a missing pack level, or a float multiplier is rejected at load time rather than discovered during accrual. The node captures resolved identity; the raw source identifiers are retained for audit but never used for arithmetic.
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel, field_validator
class PackLevel(str, Enum):
each = "each"
inner = "inner"
case = "case"
pallet = "pallet"
class CanonicalSku(BaseModel):
canonical_item: str # vendor ERP item number, write-once
gtin14: str # GS1 GTIN-14, check-digit valid
base_pack_level: PackLevel # the level this node represents
units_per_parent: int # integer multiplier to the next level up
promo_variant_id: str | None = None
agreement_version: int # pinned, never "latest"
@field_validator("gtin14")
@classmethod
def gtin_checksum(cls, v: str) -> str:
if len(v) != 14 or not v.isdigit():
raise ValueError("GTIN-14 must be 14 digits")
digits = [int(c) for c in v]
check = (10 - (sum(d * (3 if i % 2 == 0 else 1)
for i, d in enumerate(digits[:13])) % 10)) % 10
if check != digits[13]:
raise ValueError("invalid GTIN-14 check digit")
return v
Validation check: load a fixture with a transposed-digit GTIN and assert CanonicalSku(**fixture) raises ValidationError. An identifier that fails its own check digit must never anchor a match.
Step 2 — Anchor matching to standardized identifiers first
Resolve in strict precedence: exact GTIN match before any retailer-assigned code. A deterministic primary join against the pre-compiled cross-reference is both faster and audit-defensible than fuzzy logic, so it must run first and short-circuit on success.
def resolve_primary(raw: dict, gtin_xref: dict[str, str]) -> str | None:
# gtin_xref maps any standardized identifier -> canonical_item
for field in ("gtin14", "ean13", "upca"):
code = raw.get(field)
if code and code in gtin_xref:
return gtin_xref[code]
return None # fall through to UOM + probabilistic resolution
Validation check: feed a record whose gtin14 is present in the cross-reference and assert it resolves without touching the fallback path. Standardized identifiers must always win over retailer codes.
Step 3 — Normalize the unit of measure before comparison
The dominant failure is a case-level retailer deduction matched against an each-level ERP record. Convert every quantity to the canonical base level using the integer pack matrix so quantities are commensurable before any financial line is built.
from decimal import Decimal
PACK_MATRIX = { # canonical_item -> units per level
"ERP-00471": {"each": 1, "inner": 6, "case": 24, "pallet": 1344},
}
def to_base_units(canonical_item: str, qty: Decimal, level: str) -> Decimal:
multiplier = PACK_MATRIX[canonical_item][level]
return (qty * Decimal(multiplier)) # exact; no float division
Validation check: convert 2 cases of ERP-00471 and assert the result is exactly Decimal("48") eaches. Use Decimal end to end — a float multiplier reintroduces the drift this step exists to remove.
Step 4 — Preserve promotional lineage
When a retailer introduces a temporary promotional SKU, the resolver must link it to the base item while tagging the temporary attributes, so accruals are not double-counted and a deduction dispute can be traced to the exact promotional window.
def attach_promo_lineage(node: CanonicalSku, raw: dict) -> CanonicalSku:
if raw.get("is_promo"):
node = node.model_copy(update={
"promo_variant_id": raw["promo_variant_id"],
})
return node # base canonical_item is preserved; variant is additive
Validation check: resolve a promotional record and assert canonical_item still points at the base ERP item while promo_variant_id is populated. Lineage must be additive, never a replacement of base identity.
Step 5 — Apply probabilistic fallback, then quarantine the rest
Records that survive without a GTIN match enter a bounded fuzzy pass on brand + pack_size + net_weight. Anything below the confidence threshold is not guessed — it is quarantined for manual review so a weak match never silently mis-attributes a rebate.
def resolve_fuzzy(raw: dict, candidates: list[dict],
threshold: Decimal = Decimal("0.90")) -> str | None:
best, score = None, Decimal("0")
for cand in candidates:
s = score_candidate(raw, cand) # returns Decimal in [0, 1]
if s > score:
best, score = cand["canonical_item"], s
return best if score >= threshold else None # else -> quarantine
Validation check: feed an ambiguous record whose best score is 0.84 and assert it returns None and is routed to the review queue, not auto-matched. Below threshold must mean quarantine, not a coin-flip.
Step 6 — Resolve at scale with vectorized joins
Drive the whole batch through a single vectorized join against the pre-compiled cross-reference rather than per-row Python loops. polars lazy evaluation (or pandas categorical dtypes) keeps millions of line items inside one pass while preserving referential integrity per vendor-retailer pair.
import polars as pl
def resolve_batch(records: pl.LazyFrame, xref: pl.LazyFrame) -> pl.LazyFrame:
return (
records
.join(xref, on="gtin14", how="left") # primary anchor
.with_columns(
pl.col("canonical_item").is_null().alias("needs_fallback")
)
)
Validation check: run the join on a fixture where 5 of 100 rows lack a GTIN and assert exactly 5 rows carry needs_fallback = true. The vectorized pass must isolate, not silently drop, the unresolved tail.
Common failure modes and fixes
- Unit-of-measure mismatch (
UOM_MISMATCH). A retailer deducts at the case level while the vendor ERP records eaches, so a legitimate transaction flags as a discrepancy. Convert both sides to canonical base units with the integer pack matrix (Step 3) before comparison — never compare raw quantities across levels. - Floating-point drift in attribution. Computing rebate amounts or pack conversions with
floatlets rounding error compound across millions of lines and breaks ledger ties. Keep every quantity and rate indecimal.Decimal, use integer pack multipliers, and quantize monetary results explicitly withROUND_HALF_UP. - Missing promotional variant (
MISSING_PROMO_VARIANT). A scan-based promo SKU with no link to its base item double-counts during accrual. Attach lineage additively (Step 4) so the basecanonical_itemis preserved and the variant is a tag, then escalate orphaned variants to vendor managers before the accrual cycle closes. - Invalid GTIN checksum (
INVALID_GTIN_CHECKSUM). A transposed or truncated identifier matches the wrong canonical item. Validate the check digit at the model boundary (Step 1) so a malformed GTIN is rejected at ingest rather than anchoring a false match. - Hierarchy drift (
HIERARCHY_DRIFT). A retailer reorganizes its pack configuration mid-cycle and the resolver silently reprices history. Pinagreement_versionand the pack matrix per record, classify the mismatch asHIERARCHY_DRIFT, and route it to master-data correction through fallback routing logic rather than auto-resolving it.
Operational checklist
Frequently asked questions
Why anchor to GTIN before retailer codes instead of the reverse? Standardized identifiers are stable across trading partners and carry a self-validating check digit, so an exact GTIN match is both deterministic and audit-defensible. Retailer-assigned codes (DPCI, TCIN, PLU) are namespaced per retailer and can collide or be reused, so they are only a fallback once the standardized anchor misses.
What happens to a record the resolver cannot confidently match? It is quarantined, not guessed. Any record whose best fuzzy score falls below the confidence threshold routes to a manual-review queue and is held out of accrual until a vendor manager corrects master data — a weak match that mis-attributes a rebate is more expensive than a held line.
How are temporary promotional SKUs kept from double-counting? Lineage is additive: the promo variant links back to the base canonical_item and carries its own promo_variant_id, effective dates, and rebate tier as tags. Accruals roll up against the base item while the variant attributes preserve traceability to the exact promotional window.
Where do resolved SKUs go next? The canonical graph feeds the rest of field mapping strategies, and resolved line items synchronize to the general ledger through POS & ERP sync patterns on the retailer’s fiscal cutoff, carrying the canonical item number so each entry traces back to its originating sales line.
Related
- Parent cluster: Field Mapping Strategies
- CSV & EDI Parsing Workflows — flattening and schema-tagging raw vendor files before resolution
- Implementing Async Batch Queues for Sales Data — feeding canonical records into idempotent reconciliation queues
- POS & ERP Sync Patterns — posting resolved line items to the general ledger on fiscal cutoff