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
Decimaleverywhere. Mass values, unit factors, and tolerances are allDecimal. A singlefloatkilogram silently reintroduces binary rounding error, which defeats the whole point of a tolerance test measured in grams. The code usesdataclass(frozen=True),Optional, andEnum. - 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 breaksDecimalparsing 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
loggingwith the%(asctime)s | %(levelname)s | %(name)s | %(message)sformat 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.
Verification steps
Run these checks against a representative batch before trusting the reconciler in production:
- Unit-mismatch parity. Reconcile a net of
100 LBagainst a gross of50 KG. Confirm both convert to grams (45359.237 gnet,50000 ggross) and the pair reconciles with a plausible tare, rather than reporting a false discrepancy from comparing100against50. - Exact pound factor. Assert
to_grams(WeightReading(Decimal("1"), "LB"))equalsDecimal("453.592")after the quantize step. A drift here means a truncated orfloat-derived factor slipped in; the international pound is exactly453.59237 g. - Implausible ordering. Feed a net of
60 KGand a gross of55 KG. Assert the status isIMPLAUSIBLE, notOUT_OF_TOLERANCE— a negative implied tare must never be absorbed by a tolerance band. - Tare inference. Supply only a gross of
52 KGwith anexpected_tare_gof2000. Confirm net is inferred as50000 gand the line reconciles. Then remove the tare policy and confirm the same input returnsUNRESOLVABLE. - 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 assertOUT_OF_TOLERANCE. - No-expectation path. Reconcile a valid ordered pair with no
expected_tare_g. Confirm it reconciles on ordering and non-negativity alone, and thatdelta_gis zero — you have no expectation to measure against, so the band does not apply. - Quarantine count. Compare the count of non-
RECONCILEDresults 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
ValueErrorfromto_grams— which is correct, fail loudly — but add the factors explicitly rather than approximating; an ounce is28.349523125 gexactly, not28.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 + tarerelationship and will quarantine legitimately consistent shipments. - Fullwidth or thousands-separated magnitudes. A weight rendered as
1,250or1.250,00in a European decimal convention breaksDecimalparsing. Normalize the numeric form before constructing aWeightReading, 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 == 0is 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.
Related
- Packing List Data Normalization
- Normalizing CJK invoice fields with Unicode NFC
- Syncing packing lists to shipment records via API
- Document Ingestion & Parsing Workflows
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).