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.

Repackaging flow: disaggregation, commissioning a replacement SSCC, and re-aggregation A case identified by SSCC-A is opened. An AggregationEvent with action DELETE detaches all its children, leaving loose SGTINs with no parent. Picked units branch off and ship individually, never re-aggregated. The remaining children are validated, a new SSCC-B container is commissioned with an ObjectEvent, and an AggregationEvent with action ADD binds the validated remainder to SSCC-B, leaving zero orphaned children. DISAGGREGATION → COMMISSION → REAGGREGATION SSCC-A case (aggregated, n children) Children detached loose SGTINs, no parent New SSCC-B commissioned (ObjectEvent) Re-aggregated to SSCC-B zero orphaned children DELETE AggregationEvent ADD AggregationEvent commission picked units — not re-aggregated Picked units shipped individually

Prerequisites

  • Python 3.10+ — snippets use str | None unions and list[str] generics.
  • Pydantic v2 — for typed AggregationEvent and child-state models with field_validator rules, 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 ADD event against the same SSCC the DELETE just 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 DELETE event’s event_time must sort strictly before the ADD event’s, in UTC. A packaging-floor system that timestamps in local time without an offset can produce an ADD that appears to precede its own DELETE once both are normalized.
  • Idempotency-key collisions on retry. If the pipeline retries a failed ADD emission, 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 ADD event has already been emitted, do not delete it from the aggregation retroactively — emit a second, explicit DELETE for that one child, so the audit trail records the correction rather than rewriting history.