EDI 852 vs 846: Reconciliation Differences

How EDI 852 product activity and EDI 846 inventory inquiry differ in pharmacy reconciliation, and how a pipeline routes each

The 852 and the 846 are the two X12 transactions a pharmacy leans on for distributor reconciliation, and the single most common reconciliation bug is treating them as interchangeable inventory feeds. They answer different questions. The EDI 852 Product Activity Data transaction reports movement — what was received, dispensed, and left on hand over a reporting period — as a stream of signed deltas. The EDI 846 Inventory Inquiry/Advice transaction reports state — how many units are available right now — as a point-in-time snapshot. Feed both into the same accumulator and you double-count; assert the 846’s snapshot as a delta and you corrupt the running balance. Reconciliation done right uses them in complementary roles: accumulate the 852 movement to compute an expected on-hand, then use the 846 snapshot to validate that computation. A gap between the two is a reconciliation exception worth investigating, not a parser error. This how-to explains the distinction and implements a pipeline that classifies and reconciles both, within the parent reference, EDI 852 & 846 Parsing Pipelines.

Getting the roles right matters beyond tidiness. In a controlled-substance environment the computed-versus-reported comparison is a diversion tripwire: when the movement the distributor reports does not reconcile to the availability it reports, one of the two is wrong, and an unexplained gap on a Schedule II product is the kind of variance a compliance officer must be able to see and explain under 21 CFR § 1304.11.

Reconciling EDI 852 movement deltas against an EDI 846 point-in-time snapshot Two flows converge on one reconciliation step. On the left, an EDI 852 product-activity transaction is accumulated as signed deltas into a running computed on-hand. On the right, an EDI 846 inventory-inquiry transaction supplies a point-in-time available quantity. The reconciliation step compares the computed on-hand against the reported available quantity: a match is balanced and needs no action, while a mismatch raises a variance ticket for investigation. EDI 852 · activity movement over a period Accumulate deltas Σ signed → computed on-hand EDI 846 · inquiry point-in-time state Reported available snapshot quantity Reconcile computed == reported? Balanced no action Variance ticket investigate match mismatch

Problem framing

The 852/846 parsing pipeline ingests both transaction sets, but they must not flow into the same reducer. The 852 is a delta source: each activity line moves the running balance up or down. The 846 is a state source: it asserts an absolute quantity that overwrites nothing and accumulates into nothing — it exists to be checked against the computed balance. The reconciliation engine therefore has two inputs with two different algebras, and its job is to hold them apart until the moment of comparison.

The comparison is the whole value. Accumulating 852 movement gives you what on-hand should be; the 846 tells you what the trading partner says it is. When they agree, the perpetual record is corroborated. When they disagree, exactly one of the following is true: a movement was missed or double-counted, the snapshot was taken at a different instant than the period boundary, or product was lost, substituted, or diverted. Surfacing that disagreement as a ticket — rather than silently trusting one feed — is what makes the reconciliation defensible.

EDI 852 vs 846: dimension-by-dimension

Dimension EDI 852 (Product Activity) EDI 846 (Inventory Inquiry / Advice)
Question answered What moved over a period? What is available right now?
Data semantics Signed deltas (received, dispensed, returned, on-hand) Absolute point-in-time quantity
Direction Wholesaler / distributor → pharmacy Either direction between trading partners
Reporting cadence Periodic activity report (daily/weekly) On-demand or scheduled snapshot
Key quantity segment QTY with an activity qualifier (QTY01 = 17/33/38) QTY with an availability qualifier
Reconciliation role Drives the computed perpetual balance Validates the computed balance
Sign convention Positive = received/on-hand; negative = dispensed/returned Positive = available; zero = stockout
Failure signal Missing or duplicated movement warps the delta sum Snapshot timing mismatch warps the assertion

The row that governs the pipeline is reconciliation role: the 852 drives, the 846 validates. Everything else follows from that. Because the 852 carries signed movement, its records are folded into a running total per NDC; because the 846 carries an absolute state, its record is held aside and compared to that total at the period boundary. A pipeline that respects this division reconciles cleanly; one that blurs it produces phantom variance on every run.

Prerequisites & environment

  • Python 3.11+, standard library only — hashlib for the audit hash, logging for the PHI-free reconciliation line, dataclasses and enum for the typed records. Add pytest for the test block.
  • Parsed input from both transaction sets. This recipe assumes the field-level extraction has already run; the per-item extraction of 852 segments is covered in EDI 852 Field Extraction with Python, and 846 items are extracted the same way with an availability qualifier.
  • Normalized NDC keys. Both feeds must key on canonical NDC-11 so the same product from either transaction folds to one balance rather than two.
  • Regulatory context. The perpetual-inventory standard of 21 CFR § 1304.11 is why the computed-versus-reported comparison exists at all: an inventory that cannot be reconstructed and corroborated to a point in time is not compliant.

Implementation: classify, accumulate, reconcile

The engine classifies each interchange by its ST01 transaction-set code, folds 852 activity into a per-NDC running balance, records 846 availability as an assertion, and reconciles the two at the period boundary — emitting a balanced or exception result per product with a SHA-256 audit hash.

python
from __future__ import annotations

import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum

logger = logging.getLogger("pharmacy.edi.recon")

# 852 activity qualifiers and their sign toward on-hand.
_SIGN = {"17": +1.0, "33": +1.0, "38": -1.0, "41": -1.0, "QB": 0.0}


class TxnSet(str, Enum):
    ACTIVITY_852 = "852"
    INQUIRY_846 = "846"


@dataclass(frozen=True)
class EdiItem:
    ndc: str
    qualifier: str      # QTY01
    quantity: float     # QTY02 (unsigned magnitude)


class ReconStatus(str, Enum):
    BALANCED = "balanced"
    EXCEPTION = "exception"


@dataclass(frozen=True)
class ReconResult:
    ndc: str
    computed_on_hand: float     # from accumulated 852 deltas
    reported_available: float   # from the 846 snapshot
    variance: float
    status: ReconStatus
    audit_hash: str


def classify(st01: str) -> TxnSet:
    """Route by transaction-set code; anything else is a routing error."""
    try:
        return TxnSet(st01)
    except ValueError as exc:
        raise ValueError(f"Unroutable transaction set: {st01!r}") from exc


class Reconciler:
    """Accumulate 852 movement, hold 846 state, and reconcile the two.
    Supports 21 CFR § 1304.11 point-in-time inventory corroboration."""

    def __init__(self, tolerance: float = 0.0) -> None:
        self._computed: dict[str, float] = {}      # NDC -> running on-hand
        self._reported: dict[str, float] = {}      # NDC -> 846 available
        self._tolerance = tolerance

    def apply(self, txn: TxnSet, items: list[EdiItem]) -> None:
        if txn is TxnSet.ACTIVITY_852:
            for it in items:
                sign = _SIGN.get(it.qualifier, 0.0)
                # On-hand (33) is an absolute checkpoint, not a delta: it seeds
                # the running balance rather than adding to it.
                if it.qualifier == "33":
                    self._computed[it.ndc] = it.quantity
                else:
                    self._computed[it.ndc] = (
                        self._computed.get(it.ndc, 0.0) + sign * it.quantity
                    )
        elif txn is TxnSet.INQUIRY_846:
            for it in items:
                self._reported[it.ndc] = it.quantity   # absolute state assertion

    def reconcile(self) -> list[ReconResult]:
        results: list[ReconResult] = []
        ndcs = set(self._computed) | set(self._reported)
        ts = datetime.now(timezone.utc).isoformat()
        for ndc in sorted(ndcs):
            computed = self._computed.get(ndc, 0.0)
            reported = self._reported.get(ndc, 0.0)
            variance = round(computed - reported, 4)
            status = (
                ReconStatus.BALANCED
                if abs(variance) <= self._tolerance
                else ReconStatus.EXCEPTION
            )
            digest = hashlib.sha256(
                f"{ndc}|{computed}|{reported}|{variance}|{ts}".encode()
            ).hexdigest()
            result = ReconResult(ndc, computed, reported, variance, status, digest)
            results.append(result)
            # PHI-free audit line: product identity and quantities only.
            logger.info(json.dumps({
                "event": "recon_result", "ndc": ndc,
                "computed": computed, "reported": reported,
                "variance": variance, "status": status.value,
                "audit_hash": digest,
            }, sort_keys=True))
        return results


if __name__ == "__main__":
    r = Reconciler(tolerance=0.0)
    # 852: seed on-hand 144, receive 50, dispense 60  => computed 134
    r.apply(TxnSet.ACTIVITY_852, [
        EdiItem("00093721410", "33", 144.0),
        EdiItem("00093721410", "17", 50.0),
        EdiItem("00093721410", "38", 60.0),
    ])
    # 846: distributor reports 134 available => balanced
    r.apply(TxnSet.INQUIRY_846, [EdiItem("00093721410", "QA", 134.0)])
    for res in r.reconcile():
        print(res.ndc, res.status.value, res.variance)

The two apply branches encode the whole distinction: 852 movement folds into a running total (with the on-hand qualifier 33 treated as an absolute checkpoint that seeds the balance rather than a delta that adds to it), while 846 availability is stored as a flat assertion. Only reconcile brings them together, and it never mutates either side — it just measures the gap and classifies it. A non-zero variance beyond tolerance becomes a ReconStatus.EXCEPTION, the signal that feeds a variance ticket.

Verification & testing

The tests prove that 852 deltas accumulate with correct signs, that 846 availability is compared rather than accumulated, and that a genuine gap raises an exception while an exact match balances.

python
import pytest


def test_852_deltas_accumulate_with_sign():
    r = Reconciler()
    r.apply(TxnSet.ACTIVITY_852, [
        EdiItem("00093721410", "33", 100.0),   # seed on-hand
        EdiItem("00093721410", "17", 20.0),    # +received
        EdiItem("00093721410", "38", 15.0),    # -dispensed
    ])
    r.apply(TxnSet.INQUIRY_846, [EdiItem("00093721410", "QA", 105.0)])
    [res] = r.reconcile()
    assert res.computed_on_hand == 105.0
    assert res.status is ReconStatus.BALANCED


def test_846_is_not_accumulated():
    # Two 846 snapshots must not add up; the latest asserts state.
    r = Reconciler()
    r.apply(TxnSet.INQUIRY_846, [EdiItem("00093721410", "QA", 40.0)])
    r.apply(TxnSet.INQUIRY_846, [EdiItem("00093721410", "QA", 42.0)])
    [res] = r.reconcile()
    assert res.reported_available == 42.0     # overwritten, not summed


def test_gap_raises_exception():
    r = Reconciler(tolerance=0.0)
    r.apply(TxnSet.ACTIVITY_852, [EdiItem("00093721410", "33", 136.0)])
    r.apply(TxnSet.INQUIRY_846, [EdiItem("00093721410", "QA", 134.0)])
    [res] = r.reconcile()
    assert res.variance == 2.0
    assert res.status is ReconStatus.EXCEPTION


def test_unroutable_transaction_set_is_rejected():
    with pytest.raises(ValueError):
        classify("810")     # an invoice is not reconcilable here

A captured reconciliation line records the computed and reported quantities, the variance, and its status — product identity only, no patient data:

text
INFO pharmacy.edi.recon {"audit_hash": "3ac9...771e", "computed": 136.0, \
     "event": "recon_result", "ndc": "00093721410", "reported": 134.0, \
     "status": "exception", "variance": 2.0}

The variance line is the artifact a compliance officer reads: a non-zero gap on a controlled product is the corroboration failure that 21 CFR § 1304.11 requires be visible and explainable, and the audit hash makes any later edit to the result detectable.

Gotchas & compliance pitfalls

  • Never accumulate the 846. The 846 asserts an absolute quantity; summing two snapshots doubles the reported availability and manufactures a variance that is not real. Store it as the latest state, and only compare it. This is the single most common 852/846 reconciliation defect.
  • The 852 on-hand qualifier is a checkpoint, not a delta. QTY01 = 33 reports an absolute on-hand at the period, not an incremental movement. Adding it to the running balance instead of seeding the balance with it inflates the computed total. Treat 33 as a reset point and 17/38/41 as deltas.
  • Snapshot-timing mismatch masquerades as diversion. An 846 taken an hour after the 852 period closed reflects movement the 852 has not yet reported, so a small variance can be pure timing, not loss. Align the snapshot instant to the 852 period boundary, and treat a within-tolerance gap as timing noise rather than an alert — but never widen the tolerance far enough to hide a real controlled-substance shortage.
  • Unroutable transaction sets. A functional group may carry an 810 invoice or an 856 ship-notice alongside the 852/846. Classifying strictly on ST01 and rejecting anything that is not reconcilable keeps a non-inventory transaction from silently entering the balance. Route rejects through the shared machinery in Error Handling & Retry Mechanisms rather than dropping them.
  • NDC identity forks the balance. If the 852 arrives as one NDC layout and the 846 as another, the same product folds into two keys and every product looks like a variance. Normalize both feeds to canonical NDC-11 before reconciliation.

Frequently Asked Questions

When should a pharmacy reconcile with the 852 versus the 846?

Use both, in complementary roles. Accumulate the 852 to compute what on-hand should be from received, dispensed, and returned movement, then use the 846 to validate that computed balance against the point-in-time availability the trading partner reports. Neither alone is a full reconciliation: the 852 gives movement without a corroborating state, and the 846 gives state without the movement that produced it. A mismatch between the two is a reconciliation exception, not a parsing error.

Why can’t I just add the 846 availability into my running inventory?

Because the 846 is an absolute snapshot, not a delta. Two 846 snapshots of 40 and 42 units do not mean 82 units are available — they mean the availability was 40, then 42. Accumulating an absolute-state transaction double-counts and produces phantom variance on every run. Store the latest 846 as the reported state and compare it; only the 852’s signed movement is ever accumulated.

What does a variance between computed and reported actually indicate?

One of three things: a movement was missed or double-counted in the 852 stream, the 846 snapshot was taken at a different instant than the 852 period boundary, or product was genuinely lost, substituted, or diverted. The reconciliation engine cannot tell which on its own — it raises the exception so a human can. On a controlled substance, an unexplained gap is exactly the signal that must remain visible under 21 CFR § 1304.11.

Does reconciliation touch protected health information?

No. Both the 852 and 846 are supply-chain transactions between trading partners; they carry NDCs, quantities, and dates, not patient data. The reconciliation engine emits only product identity, computed and reported quantities, the variance, and a SHA-256 audit hash, keeping the reconciliation log inside the HIPAA audit-control boundary of 45 CFR § 164.312(b).

Related topics