Handling Reaggregation After Repackaging
A pallet arrives at a redistribution center, a case is opened for a partial pick, and half the saleable units ship out individually while the rest need to travel onward in a new case. The moment that case is opened, its aggregation hierarchy is no longer true — the original SSCC still claims parentage over units that have physically left it — and every downstream verification query against that hierarchy will return a wrong answer until the record is rebuilt. This guide is a focused solution within Decommission & Reaggregation Rules, the area governing how parent-child aggregation state changes after a container’s contents are decommissioned or repacked, and it sits under the broader Aggregation Hierarchy & Validation Workflows domain. The goal is narrow and unforgiving: disaggregate cleanly, re-commission a replacement container, re-aggregate only what is actually still inside it, and never leave a child unit without a valid parent or attached to two parents at once.
Prerequisites
- Python 3.10+ — snippets use
str | Noneunions andlist[str]generics. - Pydantic v2 — for typed
AggregationEventand child-state models withfield_validatorrules, matching the pattern used for validating SSCC check digits. - A queryable aggregation store — something that can answer “what SSCC is this SGTIN currently a child of” and “has this SGTIN been decommissioned” before you commit a new relationship; without it, validation is guesswork.
- DSCSA data prerequisites — the original case’s SSCC, the full list of child SGTINs (GTIN
(01)+ serial(21), with lot(10)and expiry(17)available for reconciliation), and the set of EPCs that were physically picked out during the repack. - Familiarity with decommissioning — repackaging often runs alongside true decommission events for units removed from the supply chain entirely; see Automating Decommission Events in Python for that adjacent, but distinct, operation.
Disaggregation and decommissioning are not the same action, and conflating them is the single most common bug in repackaging logic. A DELETE AggregationEvent only severs the parent-child link — the child unit’s own commissioning status is untouched, and it remains a fully valid, active serialized unit. A decommission event changes the child’s own lifecycle state. A repacked case needs the former for every child; only units that are being pulled from the supply chain (damaged, expired, recalled) need the latter as well.
Step-by-Step Solution
Step 1 — Model the aggregation contract and child state
Define the AggregationEvent shape and a small ChildState enum so the rest of the routine can reason about eligibility instead of re-deriving it from raw records each time.
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, field_validator
class ChildState(str, Enum):
AGGREGATED = "aggregated"
DECOMMISSIONED = "decommissioned"
IN_TRANSIT = "in_transit"
class ChildUnit(BaseModel):
epc: str # SGTIN URI: urn:epc:id:sgtin:<prefix>.<item>.<serial>
gtin: str # AI (01)
serial: str # AI (21)
lot: str # AI (10)
state: ChildState
class AggregationEvent(BaseModel):
parent_id: str # SSCC URI, AI (00)
child_epcs: list[str]
action: str # "ADD" or "DELETE"
event_time: datetime
@field_validator("action")
@classmethod
def _action(cls, v: str) -> str:
if v not in ("ADD", "DELETE"):
raise ValueError("AggregationEvent action must be ADD or DELETE")
return v
@field_validator("event_time")
@classmethod
def _tz_aware(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("event_time must carry a timezone offset")
return v.astimezone(timezone.utc)
DSCSA/GS1 note: the action field is what the EPCIS Core Business Vocabulary uses to distinguish attaching children (ADD) from detaching them (DELETE) — both are the same AggregationEvent type, so a validator that rejects any other value catches malformed pipeline output early.
Step 2 — Disaggregate the opened case
When a case or pallet is opened for a partial pick, emit a DELETE AggregationEvent against the original SSCC before anything else happens. This is the record that formally states “these children no longer belong to this parent,” and it must be committed even if the container will never be reused.
def disaggregate_case(
parent_sscc: str, children: list[ChildUnit]
) -> AggregationEvent:
"""Detach every child from a case/pallet SSCC being opened for repacking."""
if not children:
raise ValueError(f"{parent_sscc} has no children to disaggregate")
return AggregationEvent(
parent_id=parent_sscc,
child_epcs=[c.epc for c in children],
action="DELETE",
event_time=datetime.now(timezone.utc),
)
DSCSA/GS1 note: the DELETE event must list every child that was aggregated at the time the case was originally sealed under case-to-pallet aggregation logic — omitting even one leaves a phantom parent link that later verification queries will still resolve.
Step 3 — Commission the replacement container
The remainder of the case needs a container identity of its own. Never reuse the opened SSCC — GS1 serialization rules treat it as consumed once its aggregation is torn down, and reissuing it risks colliding with the original container’s own audit history.
def commission_container(sscc: str, event_time: datetime) -> dict:
"""Commission a brand-new SSCC container to receive the repack remainder."""
return {
"epc": sscc, # SSCC URI, AI (00)
"biz_step": "urn:epcglobal:cbv:bizstep:commissioning",
"disposition": "urn:epcglobal:cbv:disp:active",
"event_time": event_time.astimezone(timezone.utc).isoformat(),
}
DSCSA/GS1 note: this is an ObjectEvent in its own right, distinct from the AggregationEvent that will follow — the container must exist and carry an active disposition before any child can be aggregated into it.
Step 4 — Validate remaining children, then emit the ADD event
This is the routine that matters most: before a single AggregationEvent with action="ADD" is built, every candidate child must be checked against its current state. A child that was already decommissioned, or that is mid-transit to a different node, must never be silently swept into a new parent.
def reaggregate_remaining_children(
new_parent_sscc: str,
remaining: list[ChildUnit],
picked_epcs: set[str],
) -> AggregationEvent:
"""Validate remaining children, then emit the AggregationEvent binding
them to a newly commissioned SSCC."""
eligible: list[ChildUnit] = []
rejected: list[tuple[str, str]] = []
for child in remaining:
if child.epc in picked_epcs:
continue # picked units ship individually, never re-aggregated
if child.state == ChildState.DECOMMISSIONED:
rejected.append((child.epc, "already decommissioned"))
continue
if child.state == ChildState.IN_TRANSIT:
rejected.append((child.epc, "in transit to another node"))
continue
eligible.append(child)
if rejected:
raise ValueError(f"cannot re-aggregate: {rejected}")
if not eligible:
raise ValueError(f"no eligible children remain for {new_parent_sscc}")
return AggregationEvent(
parent_id=new_parent_sscc,
child_epcs=[c.epc for c in eligible],
action="ADD",
event_time=datetime.now(timezone.utc),
)
DSCSA/GS1 note: validating state before emission — rather than trusting the caller’s list — is what keeps the aggregation store from ever claiming a decommissioned unit as an active child, a discrepancy that a trading partner’s verification query would surface immediately.
Step 5 — Reconcile every original child against an outcome
After both events are committed, every unit that was in the original case must be accounted for exactly once: either shipped individually or bound to the new SSCC. A missing unit is an orphan; a unit in both sets is a double-aggregation.
def assert_no_orphans(
original_children: list[ChildUnit],
picked_epcs: set[str],
add_event: AggregationEvent,
) -> None:
"""Every original child must be either picked or re-aggregated — never both, never neither."""
reaggregated = set(add_event.child_epcs)
accounted_for = picked_epcs | reaggregated
overlap = picked_epcs & reaggregated
if overlap:
raise ValueError(f"double-aggregated children: {overlap}")
orphans = {c.epc for c in original_children} - accounted_for
if orphans:
raise ValueError(f"orphaned children detected, missing parent: {orphans}")
DSCSA/GS1 note: this reconciliation step is the practical proof of parent-child integrity — the same property that rebuilding aggregation trees from EPCIS events checks retroactively across an entire event log rather than at the moment of a single repack.
Verification
Write a table-driven test that exercises the rejection paths first, since a routine that silently accepts a bad child is far more dangerous than one that raises:
import pytest
from datetime import datetime, timezone
def _child(epc: str, state: ChildState) -> ChildUnit:
return ChildUnit(epc=epc, gtin="00312345011111", serial=epc[-6:],
lot="LOT42", state=state)
def test_rejects_decommissioned_child():
remaining = [
_child("urn:epc:id:sgtin:0312345.011111.SER001", ChildState.AGGREGATED),
_child("urn:epc:id:sgtin:0312345.011111.SER002", ChildState.DECOMMISSIONED),
]
with pytest.raises(ValueError, match="already decommissioned"):
reaggregate_remaining_children("urn:epc:id:sscc:0312345.0000000021",
remaining, picked_epcs=set())
def test_no_orphans_after_full_cycle():
original = [
_child("urn:epc:id:sgtin:0312345.011111.SER001", ChildState.AGGREGATED),
_child("urn:epc:id:sgtin:0312345.011111.SER003", ChildState.AGGREGATED),
]
picked = {"urn:epc:id:sgtin:0312345.011111.SER003"}
add_event = reaggregate_remaining_children(
"urn:epc:id:sscc:0312345.0000000021",
[original[0]], picked_epcs=picked,
)
assert_no_orphans(original, picked, add_event) # raises on failure
Beyond unit tests, confirm correctness against the live aggregation store: after committing the DELETE and ADD events, query every original child’s current parent and assert it resolves to either “none, shipped” or the new SSCC — never the disaggregated one. If drift shows up in that reconciliation, the symptoms and fix match fixing parent-child serial mapping drift post-decommission.
Gotchas & Edge Cases
- Reusing the opened SSCC. Emitting a fresh
ADDevent against the same SSCC theDELETEjust cleared looks convenient but corrupts the audit trail — the container’s own history should show one clean aggregation lifecycle, not two overlapping ones with a gap. - Leading-zero SSCC truncation. The SSCC is an 18-digit AI
(00)value; if it passes through any numeric field or spreadsheet export, a leading zero is silently dropped and the check digit no longer validates. - Event ordering under UTC vs. local time. The
DELETEevent’sevent_timemust sort strictly before theADDevent’s, in UTC. A packaging-floor system that timestamps in local time without an offset can produce anADDthat appears to precede its ownDELETEonce both are normalized. - Idempotency-key collisions on retry. If the pipeline retries a failed
ADDemission, reusing the same idempotency key with a different child list (because one more unit got picked in between) will silently apply the first version. Key on(new_parent_sscc, sorted(child_epcs), attempt_id), not just the parent. - Partial picks discovered late. If a unit is marked “picked” after the
ADDevent has already been emitted, do not delete it from the aggregation retroactively — emit a second, explicitDELETEfor that one child, so the audit trail records the correction rather than rewriting history.
Related
- Up to the parent topic: Decommission & Reaggregation Rules
- Automating Decommission Events in Python — the adjacent operation for units leaving the supply chain entirely
- Rebuilding Aggregation Trees from EPCIS Events — retroactively verifying parent-child integrity across a full event log
- Aggregation Hierarchy & Validation Workflows — the domain this repackaging workflow belongs to