Reconciling net vs gross weight discrepancies

Packing lists and commercial invoices routinely disagree about how much a shipment weighs, and a customs pipeline that accepts either number blindly will either overstate dutiable quantity, understate it, or transmit a net weight that exceeds the declared gross — an arithmetic impossibility that draws an immediate CBP query. The reconciliation this page solves is precise: given a net weight from one document and a gross weight from another, possibly in different units, decide whether the pair is internally consistent within a defined tolerance, infer tare when it is missing, and quarantine the line when it is not. The implementation uses Decimal for every mass value, converts kilograms and pounds through exact factors, and applies per-lane tolerance thresholds before any weight reaches entry assembly. It is a companion to the field-level discipline in Packing List Data Normalization.

Prerequisites

This reconciler assumes a specific data state and toolchain. Confirm each before applying it:

  • Python 3.10+ and Decimal everywhere. Mass values, unit factors, and tolerances are all Decimal. A single float kilogram silently reintroduces binary rounding error, which defeats the whole point of a tolerance test measured in grams. The code uses dataclass(frozen=True), Optional, and Enum.
  • Both weights parsed to a (value, unit) pair. Upstream extraction must already have split each weight into a numeric magnitude and an explicit unit token (kg, KGM, lb, LBR). If units arrive fullwidth or embedded in free text, normalize them first — a fullwidth digit in a weight field breaks Decimal parsing exactly as described for CJK numeric fields.
  • A tare policy per commodity or lane. Tare inference is only safe where you have an expected packaging mass or a historical net-to-gross ratio. Without one, a missing tare cannot be inferred and the line must route to review, not to a guessed value.
  • Defined tolerance thresholds. You need an absolute floor (for very light shipments) and a relative percentage (for heavy ones). A single fixed gram tolerance over-quarantines heavy pallets and under-catches discrepancies on light parcels.
  • A quarantine table and structured logging already provisioned. This page routes out-of-tolerance lines to quarantine; it does not create the table. Configure stdlib logging with the %(asctime)s | %(levelname)s | %(name)s | %(message)s format so every reconciliation decision is auditable.

The reconciliation contract

Net weight is the mass of the goods alone. Gross weight is the goods plus packaging (tare). The identity that must hold for every line is gross = net + tare, with all three in one unit and tare >= 0. Three failure shapes break it:

  • Unit mismatch. The invoice states net in pounds, the packing list states gross in kilograms. Comparing the raw magnitudes reports a false discrepancy. Convert both to a canonical unit — grams, held as an integer count of milligrams-free Decimal — before any comparison.
  • Implausible ordering. A net that exceeds its paired gross is physically impossible and always indicates a swapped field, a transcription error, or a unit error. This is a hard quarantine, never a tolerance question.
  • Within-tolerance drift. Real shipments show small, legitimate differences from scale calibration, moisture, or rounding on the source document. A tolerance band absorbs these; anything outside it is a genuine discrepancy.

The reconciler encodes all three as an explicit status, and only a RECONCILED line is allowed to carry a weight forward into entry assembly.

Implementation

A single reconciler converts units, checks ordering, infers tare when policy allows, and applies the tolerance band. It returns a typed result carrying the canonical net and gross and the decision.

import logging
from dataclasses import dataclass
from enum import Enum
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
    handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("weight_reconciler")

# Exact conversion factors to grams. 1 lb = 453.59237 g is exact by definition.
_TO_GRAMS = {
    "KG": Decimal("1000"), "KGM": Decimal("1000"),
    "G": Decimal("1"), "GRM": Decimal("1"),
    "LB": Decimal("453.59237"), "LBR": Decimal("453.59237"),
}


class WeightStatus(Enum):
    RECONCILED = "RECONCILED"          # within tolerance, ordering valid
    IMPLAUSIBLE = "IMPLAUSIBLE"        # net > gross — hard quarantine
    OUT_OF_TOLERANCE = "OUT_OF_TOLERANCE"
    UNRESOLVABLE = "UNRESOLVABLE"      # missing input, no tare policy


@dataclass(frozen=True)
class WeightReading:
    value: Decimal
    unit: str


@dataclass(frozen=True)
class ReconResult:
    net_g: Optional[Decimal]
    gross_g: Optional[Decimal]
    tare_g: Optional[Decimal]
    delta_g: Optional[Decimal]
    status: WeightStatus


def to_grams(r: WeightReading) -> Decimal:
    factor = _TO_GRAMS.get(r.unit.strip().upper())
    if factor is None:
        raise ValueError(f"Unknown mass unit: {r.unit!r}")
    return (r.value * factor).quantize(Decimal("0.001"), rounding=ROUND_HALF_UP)


def reconcile(
    net: Optional[WeightReading],
    gross: Optional[WeightReading],
    expected_tare_g: Optional[Decimal] = None,
    abs_floor_g: Decimal = Decimal("50"),
    rel_pct: Decimal = Decimal("0.005"),   # 0.5% of gross
) -> ReconResult:
    # Infer a missing net from gross and an expected tare, if policy provides one.
    if net is None and gross is not None and expected_tare_g is not None:
        g = to_grams(gross)
        inferred_net = g - expected_tare_g
        logger.info("Inferred net %.3f g from gross and expected tare.", inferred_net)
        net = WeightReading(inferred_net, "G")

    if net is None or gross is None:
        logger.warning("Missing weight with no tare policy; unresolvable.")
        return ReconResult(None, None, None, None, WeightStatus.UNRESOLVABLE)

    net_g, gross_g = to_grams(net), to_grams(gross)
    tare_g = gross_g - net_g

    # Ordering check first: net must never exceed gross.
    if tare_g < 0:
        logger.warning("net %.3f g exceeds gross %.3f g; implausible.", net_g, gross_g)
        return ReconResult(net_g, gross_g, tare_g, tare_g, WeightStatus.IMPLAUSIBLE)

    # Tolerance band: max of an absolute floor and a relative fraction of gross.
    tolerance = max(abs_floor_g, (gross_g * rel_pct))
    # Delta measures how far the implied tare sits from the expected tare, if known.
    if expected_tare_g is not None:
        delta = abs(tare_g - expected_tare_g)
    else:
        delta = Decimal("0")  # no expectation: only ordering + non-negativity gate

    if delta <= tolerance:
        logger.info("Reconciled: net=%.3f gross=%.3f tare=%.3f", net_g, gross_g, tare_g)
        return ReconResult(net_g, gross_g, tare_g, delta, WeightStatus.RECONCILED)

    logger.warning("Tare delta %.3f g exceeds tolerance %.3f g.", delta, tolerance)
    return ReconResult(net_g, gross_g, tare_g, delta, WeightStatus.OUT_OF_TOLERANCE)

The ordering check runs before the tolerance check by design: a negative implied tare is never a tolerance question, it is a swapped or mis-united field, and conflating the two would let an impossible pair slip through on a wide band.

The net-versus-gross reconciliation and tolerance decision Net and gross weight readings enter the reconciler. Both are converted to grams through exact unit factors. If net is missing but an expected tare exists, net is inferred as gross minus tare; if net is still missing the line is unresolvable and quarantined. The first gate checks ordering: if implied tare, gross minus net, is negative the pair is implausible and hard-quarantined. Otherwise the implied tare is compared against the expected tare within a tolerance band equal to the larger of an absolute gram floor and a relative fraction of gross. Within tolerance the line is reconciled and its canonical weights flow to entry assembly; outside tolerance it is quarantined as out of tolerance. net reading gross reading to grams exact factors gross − net ≥ 0 ? IMPLAUSIBLE net > gross · quarantine ok |tare − exp| ≤ band ? max(floor, %·gross) OUT_OF_TOLERANCE quarantine within RECONCILED net · gross · tare to entry UNRESOLVABLE missing net · no tare policy quarantine ordering is checked before tolerance — impossible pairs never ride a wide band
Both readings convert to grams; an ordering gate hard-quarantines any net exceeding its gross before the tolerance band — the larger of an absolute floor and a relative fraction of gross — decides the rest.

Verification steps

Run these checks against a representative batch before trusting the reconciler in production:

  1. Unit-mismatch parity. Reconcile a net of 100 LB against a gross of 50 KG. Confirm both convert to grams (45359.237 g net, 50000 g gross) and the pair reconciles with a plausible tare, rather than reporting a false discrepancy from comparing 100 against 50.
  2. Exact pound factor. Assert to_grams(WeightReading(Decimal("1"), "LB")) equals Decimal("453.592") after the quantize step. A drift here means a truncated or float-derived factor slipped in; the international pound is exactly 453.59237 g.
  3. Implausible ordering. Feed a net of 60 KG and a gross of 55 KG. Assert the status is IMPLAUSIBLE, not OUT_OF_TOLERANCE — a negative implied tare must never be absorbed by a tolerance band.
  4. Tare inference. Supply only a gross of 52 KG with an expected_tare_g of 2000. Confirm net is inferred as 50000 g and the line reconciles. Then remove the tare policy and confirm the same input returns UNRESOLVABLE.
  5. Tolerance boundary. Construct a pair whose implied tare sits exactly at max(abs_floor_g, gross_g * rel_pct) from the expected tare. Assert it reconciles (the band is inclusive), then nudge it one gram past and assert OUT_OF_TOLERANCE.
  6. No-expectation path. Reconcile a valid ordered pair with no expected_tare_g. Confirm it reconciles on ordering and non-negativity alone, and that delta_g is zero — you have no expectation to measure against, so the band does not apply.
  7. Quarantine count. Compare the count of non-RECONCILED results against rows landed in quarantine. They must match; a shortfall means an exception path is dropping records.

Edge cases & gotchas

  • Ounces, tonnes, and mixed imperial. The factor table covers kg/g/lb. A packing list quoting net in ounces or gross in metric tonnes will raise ValueError from to_grams — which is correct, fail loudly — but add the factors explicitly rather than approximating; an ounce is 28.349523125 g exactly, not 28.35.
  • Per-carton versus shipment totals. A packing list often lists per-carton net and gross while the invoice states a shipment total. Sum the packing-list cartons before reconciling against the invoice total; comparing a single carton against the whole shipment reports a spurious discrepancy every time.
  • Volumetric and chargeable weight. Air freight documents may carry a chargeable (dimensional) weight that is neither net nor gross. Never feed chargeable weight into this reconciler; it has no gross = net + tare relationship and will quarantine legitimately consistent shipments.
  • Fullwidth or thousands-separated magnitudes. A weight rendered as 1,250 or 1.250,00 in a European decimal convention breaks Decimal parsing. Normalize the numeric form before constructing a WeightReading, following the same discipline used for normalizing CJK invoice fields.
  • Zero tare is legitimate. Bulk liquids or unpackaged commodities can have net equal to gross. tare_g == 0 is valid and must reconcile; do not treat a zero tare as a missing value or the reconciler will over-quarantine bulk lines.
  • Scale-rounding asymmetry. A source that rounds net down and gross up inflates the implied tare by up to the sum of both rounding units. Size the absolute floor to at least that combined rounding granularity, or legitimate shipments will trip the band.

Up: Packing List Data Normalization

Authoritative references: International avoirdupois pound (1959) — 1 lb = 453.59237 g exactly; WCO HS 2022; 19 CFR 141.86 (invoice contents including quantities and weights).