receiving — Module Spec

1. Purpose

receiving owns the physical act of receiving goods against a purchase order: the receipt document itself, line-level accept/reject/damage/quarantine disposition, receiving-time over-receipt tolerance, the lot a receipt creates, and the stock-movement linkage a receipt originates. It is module #22 in the v2 build order — the next module built after Approvals (#21) — and the 2nd instance in this codebase of moving tables out of an already-locked module into a brand-new one (the 1st was approvals out of admin, PROJECT_DECISIONS #44). This build MOVES purchasing.purchase_receipt/purchase_receipt_line into a new receiving schema, renames them goods_receipt/goods_receipt_line, and folds in 2 header/line-remediation fixes that were explicitly deferred at Purchasing's own 2026-07-10 reopen (PROJECT_DECISIONS #47) pending exactly this extraction: fix #7 (real line-grain movement linkage to Inventory) and fix #11 (over-receipt tolerance).

receiving is deliberately bounded: Inventory executes the actual stock mutation — Receiving never writes inventory.stock directly (the v1 guard on the pre-move purchase_receipt_line, preserved verbatim through this move). Receiving calls the existing InventoryService.receive() seam, which writes an inventory.stock_movement/stock_movement_line pair and updates item_variant.avg_cost_cents (weighted-average) — the same seam purchasing's own lock (PROJECT_DECISIONS #30) already described, just now owned by a dedicated module rather than a Purchasing sub-concern. Receiving also does not own the purchase order itself, the vendor master, the 3-way match (it is a leg of that match, not its owner — vendor_invoice_match stays in Purchasing), or payment.

See PROJECT_DECISIONS #55 for the full build record and the retained delta-accounting blocks (§12 below).

2. Ownership

Owns — 2 tables, 63 columns:

Table Cols Autonomy tier Role
goods_receipt 34 FULL The receipt header — vendor/PO/site reference, freight, currency, idempotency key, the existing review seam, (new) void attribution, and (new, 2026-07-20, Gap-Fill Batch #74) receipt_source distinguishing PO-sourced from direct (non-PO) receipts
goods_receipt_line 29 LIGHT Per-line accept/reject/damage/quarantine disposition, cost snapshot, the PO-line write-back input, and (new) the line-grain movement linkage + reversal self-link
Total 63

Both tables are a straight MOVE + RENAME of purchasing.purchase_receipt/purchase_receipt_linezero columns dropped, 5 net-new columns added (3 on the header, 2 on the line — see §12), 1 column renamed in place (inventory_movement_idstock_movement_id, deprecated-not-dropped per this codebase's standing convention), and every previously-bare cross-tenant FK on both tables upgraded to composite (child_col, tenant_id) → parent(id, tenant_id).

Does NOT own:

  • The purchase order itself (purchasing.purchase_order/purchase_order_line — Receiving references them, line-grain, and writes back a capped received_qty increment; it never owns the PO's own lifecycle/approval).
  • The vendor master (purchasing.vendor/vendor_address — referenced, never duplicated).
  • Stock truth / the actual mutation (inventory.stockInventoryService.receive() owns the write; Receiving only originates the linkage columns pointing at the movement it caused).
  • The 3-way match (purchasing.vendor_invoice_match stays in Purchasing — Receiving is one of the two legs it joins against, goods_receipt_line, not the match's owner).
  • Vendor credit/return (purchasing.vendor_credit/vendor_returnvendor_return_line carries a nullable link back into goods_receipt_line, but the return itself is Purchasing's).
  • Payment (Billing, via Purchasing's vendor_invoice — unchanged, Receiving has no payment-shaped column at all).
  • Shipping execution / document storage (carrier/tracking_number stay plain text; shipment_photo_ref stays a deferred forward-ref to the not-yet-built Files module — both carried over unchanged from the pre-move table).
  • The tolerance policy value itself (admin.tenant_setting/setting_definition own the configured percent/action — Receiving only reads it, read-only, no FK; see §6 DR-3).

3. Layer & Dependencies

Tenant-scoped operational layer sitting between Purchasing (the PO it receives against) and Inventory (the stock movement it originates) — structurally the same "step in a flow that reaches materially into a different domain" realization that justified Approvals' own extraction from Admin (PROJECT_DECISIONS #44).

Depends on:

  • platformtenant.id (every tenant_id column).
  • multi_locsite.id, now via a real composite FK (goods_receipt.site_id) — structurally impossible before this session's own multi_loc reopen gave site its UNIQUE(id, tenant_id) (PROJECT_DECISIONS #54).
  • sharedcurrency.iso_code (both tables' currency_code).
  • purchasingvendor/vendor_address/purchase_order/purchase_order_line, all composite, all upgraded from bare by this same build (each needed its own new prerequisite UNIQUE(id, tenant_id), confirmed missing).
  • inventoryitem_variant/lot/stock_movement/stock_movement_line, all composite, all upgraded from bare by this same build (same prerequisite pattern).
  • identityactor.id (every *_actor_id column — bare, matching the codebase-wide convention that identity.actor itself carries no UNIQUE(id, tenant_id) and no FK into it is composite anywhere).
  • admintenant_setting/setting_definition, read-only, no FK (the tolerance value/action lookup — see §6 DR-3).

Depended on by: purchasingvendor_invoice_match.goods_receipt_line_id and vendor_return_line.goods_receipt_line_id (both renamed from purchase_receipt_line_id, both composite) are genuine, live FKs from Purchasing into Receiving's own table. This is the same "the core data model doesn't depend on it, but real cross-references do" property Approvals' own extraction had (PROJECT_DECISIONS #44) — Purchasing's PO/PO-line model is independent of how received_qty gets written, but 2 of its own tables structurally reference what is now Receiving's data.

4. Capabilities — a deliberately low-agency module

Unlike Purchasing (which has the richest legitimate agent surface of any module so far — reorder drafting, invoice OCR, cost-trend flags), receiving a physical shipment is fundamentally a human/system dock event, not an agent decision — the same honest framing Purchasing's own DR-E already established for the pre-move purchase_receipt_line ("the accept/reject inspection is a human physical-dock decision"). This module defines no draft_only agent-authored capability of its own in this pass. The automation_source CHECK on both tables ('human','agent','system','seed') carries the uniform codebase-wide vocabulary (mirroring purchasing's own DR-E reasoning — provenance is worth knowing even where it's rarely non-human), not an active designed agent surface.

The one place mechanical automation genuinely acts here is a deterministic DB guardrail, not an agent: the over-receipt tolerance trigger (fix #11, §6 DR-3) fires on every insert/relevant update and either raises (block) or flags the header's existing review seam (flag, the default) — the same class of fail-closed/fail-open mechanical gate this codebase already uses elsewhere (e.g. the gift-card/store-credit tender CHECKs), not an agent_duty_grant-gated decision.

B3 — non-PO (direct) receiving, added 2026-07-20 (Gap-Fill Batch, PROJECT_DECISIONS #74)

Not every physical delivery has a purchase order behind it. A supplier drop-off of an unplanned substitute item, or a nursery grower delivering plant stock ahead of — or entirely without — a formal PO, are real dock events this module must still be able to record. goods_receipt.purchase_order_id is now nullable, and the new goods_receipt.receipt_source column ('purchase_order' | 'direct', default 'purchase_order') makes the distinction explicit rather than leaving it to be inferred from a NULL. A direct receipt still requires a known vendor — goods_receipt.vendor_id was already NOT NULL at the table level, so no change was needed there; a walk-in/drop-off receipt already structurally requires knowing who delivered it — and it still writes a real inventory.stock_movement/stock_movement_line pair per accepted line, exactly like a PO-sourced receipt (live-reproduced end-to-end as a full stock-effect walk). goods_receipt_line.purchase_order_line_id is now also nullable, the necessary line-level counterpart — without it, a direct receipt's header would be creatable but completely line-less (see DR-N for why, and for the trigger that keeps the two tables coherent).

D7 — Autonomy boundary, per action

Action Authority Rationale
Record a receipt / accept-reject-damage a line human/system (physical event) Not an agent decision — mirrors purchasing's own pre-move rationale verbatim.
Record a direct (non-PO) receipt, receipt_source='direct' human/system (physical event) Same authority as a PO-sourced receipt — still not an agent decision; a direct receipt simply has no PO line to reconcile against.
Over-tolerance line, action='flag' (default) mechanical DB trigger, not agent Flags goods_receipt.review_status='pending' on the existing FULL-pack seam; a human resolves.
Over-tolerance line, action='block' mechanical DB trigger, not agent RAISE EXCEPTION — rejects the whole line outright, tenant-configurable.
Void a receipt human decision chk_goods_receipt_voided_requires_actor_at requires a real actor + timestamp — no autonomous void path exists.
Reversal / correction line human/service-initiated Mirrors inventory.stock_adjustment_request's own correction framing — not an agent proposal surface in this pass.

5. Service Contract — ReceivingService

Does not exist yet. Schema + migration + tests only this pass — matching every product module's own established first-pass precedent (crm, inventory, pricing, pos, orders, purchasing, tax, billing, payments, admin, approvals all shipped schema-only before their service layer). Downstream code queries receiving.* directly via Drizzle until one is built. Notably, unlike most modules' service contracts, DR-2/DR-3 below are not waiting on this service layer to be correct — trg_goods_receipt_line_check_over_receipt_tolerance enforces both directly and atomically, regardless of what any future ReceivingService does or doesn't do. §6 states the remaining binding contract a future ReceivingService (and the PurchasingService/InventoryService it calls into) must never violate.

6. Build Requirements (binding)

DR-1 — never write inventory.stock directly; always go through InventoryService.receive()

Accepting a goods_receipt_line MUST call the existing InventoryService.receive() seam — never a direct write to inventory.stock. That call creates one stock_movement row (movement_type='received', source_module='purchasing' — see DR-E for the disclosed naming asymmetry) and one stock_movement_line row per accepted line (quantity_delta = accepted_qty, never received_qty), updates item_variant.avg_cost_cents, and sets both goods_receipt_line.stock_movement_id and .stock_movement_line_id. This is the v1 guard on the pre-move table, preserved verbatim.

DR-2 — the PO-line write-back is capped at absorbed_qty, never the raw accepted_qty — now DB-ENFORCED, not a service-layer convention

absorbed_qty = LEAST(accepted_qty, ordered_qty − received_qty − invoiced_qty − cancelled_qty) — the PO line's own remaining capacity under Purchasing's existing, unmodified, zero-slack chk_purchase_order_line_quantity_rollup CHECK (fix #10). Strengthened post-independent-verification: the trigger itself (trg_goods_receipt_line_check_over_receipt_tolerance) now computes absorbed_qty and performs UPDATE purchase_order_line SET received_qty = received_qty + absorbed_qty atomically, in the same invocation as the goods_receipt_line insert/accepted_qty update — no caller (service or otherwise) can get this wrong, since the DB owns both the derivation and the write. The portion that could not be absorbed is recorded as the trigger-derived over_short_qty = accepted_qty − absorbed_qty (> 0 only when genuinely over-tolerance). This is the resolved form of BLOCKER 1 — see DR-B, updated.

DR-3 — the over-receipt tolerance trigger's lookup precedence and fail-safe default

trg_goods_receipt_line_check_over_receipt_tolerance (receiving.check_goods_receipt_line_over_receipt_tolerance()) fires on every INSERT and every UPDATE OF accepted_qty, first deriving over_short_qty (DR-2) and then evaluating tolerance only when that derived value is > 0. It resolves both the tolerance percent and the block/flag action via site-scoped admin.tenant_setting row > tenant-wide admin.tenant_setting row > admin.setting_definition catalog default — the same precedence every other tenant_setting consumer in this codebase follows. If every level is absent it falls back to 0% / 'flag' (flag at zero tolerance — never silently accept, never crash). The 3-level percent cascade is wrapped in its own BEGIN/EXCEPTION WHEN invalid_text_representation so a corrupted, non-numeric catalog/tenant value degrades to the safe 0 default instead of throwing and blocking every future receipt for that tenant (a live crash vector found and closed during this build's own verification pass — see DR-I).

DR-4 — reversal never touches the original append-only movement rows

A correction is a compensating goods_receipt_line (reversal_of_goods_receipt_line_id, a self-referencing composite FK) plus a compensating stock_movement_line carrying a negative quantity_delta (same stock_movement.correlation_id as the original) — the original stock_movement/stock_movement_line rows are never edited or deleted (both are append-only, DB-enforced). The compensating goods_receipt_line's own received_qty/accepted_qty columns stay positive magnitude (e.g. 10, never -10) — chk_goods_receipt_line_qty_nonneg has no reversal carve-out. The sign of a reversal lives entirely in the linked stock_movement_line.quantity_delta and in the reversal_of_goods_receipt_line_id link itself, never in goods_receipt_line's own quantity columns. (This precise rule was not stated explicitly in the design doc — it is a genuine build-time clarification, confirmed by live-reproduction.)

DR-5 — the whole receive-transaction and idempotency dedup

The (tenant_id, idempotency_key) unique pairs on goods_receipt and stock_movement are sound only if the entire write set executes as one DB transaction. The PO-line write-back piece of that write set is now trigger-guaranteed atomic with the goods_receipt_line insert itself (DR-2) — the remaining precondition a future ReceivingService must guarantee is that the stock_movement/stock_movement_line inserts (via InventoryService.receive(), DR-1) share that same transaction, not that it separately gets the PO-line arithmetic right.

7. Design Rationale (DR-A…N)

  • DR-A — the extraction itself, and why now. Purchasing's own lock (PROJECT_DECISIONS #30) already described receiving as "a step in the flow" (vendor → PO → receiving → inventory), not an intrinsic Purchasing-only concern — the same realization that justified Approvals leaving Admin. Receiving is the one Purchasing sub-concern reaching materially into a different domain (Inventory) more than any other. This build amends Purchasing's lock and Purchasing's own 2026-07-10 header/line-remediation reopen (PROJECT_DECISIONS #47), which explicitly deferred fixes #7 and #11 "to the upcoming Receiving extraction."
  • DR-B — BLOCKER 1 (design-phase, independently caught), then STRENGTHENED a second time by 2 separate post-build independent verification passes: the capped write-back. The design's first draft treated fix #10's zero-slack chk_purchase_order_line_quantity_rollup and fix #11's tolerance trigger as independent. They are not: a naive write-back of the full accepted_qty deterministically violates fix #10's CHECK the instant an over-tolerance receipt (fix #11's entire reason to exist) occurs, making the trigger's "flag, don't block" branch dead code — every receipt that actually needs it would fail one layer down regardless of the configured tolerance. Resolved at design time by the absorbed_qty cap (DR-2/§6). A second, independent gap surfaced after the first build landed: both the Section-4 re-audit and the adversarial lock-gate verification (2 separate agents, pasted and attributed in PROJECT_DECISIONS #55) independently found that this first built version left over_short_qty's derivation and the write-back itself as an external, undocumented-in-DB convention for the not-yet-built ReceivingService to get right — the CHECK backstop protected against silent corruption either way, but neither the formula nor the cap was actually DB-enforced. Fixed: the trigger itself now derives over_short_qty and performs the write-back atomically (DR-2). Live-reproduced multiple ways across both build passes: a 120-vs-100 over-tolerance receipt self-computes over_short_qty=20 and writes back exactly 100 (not 120) automatically, no manual UPDATE anywhere in the test; the rollup CHECK holds; the review-flag path fires; a deliberate uncapped write-back attempt on top of the same row was separately proved to violate the rollup CHECK — confirming the cap is load-bearing, not coincidental; a blocked/rejected line's write-back correctly does not leak.
  • DR-C — BLOCKER 2 (design-phase, independently caught): the second, initially-missed dependency. The first design draft named only vendor_invoice_match.purchase_receipt_line_id as the FK this extraction needed to retarget — the one already disclosed in OPEN_ITEMS.md. Independent verification found a second, live, bare, nullable FK into the same table: purchasing.vendor_return_line.purchase_receipt_line_id — never tracked in any prior OPEN_ITEMS/CROSS_MODULE_CONTRACTS row. Left unaddressed, moving purchase_receipt_line out would either fail the migration outright or silently strip referential integrity from a live column. Resolved: both vendor_invoice_match.purchase_receipt_line_id and vendor_return_line.purchase_receipt_line_id were renamed goods_receipt_line_id and upgraded to identical composite-FK treatment, in the same migration.
  • DR-D — entitlement bundling: Receiving rides on Purchasing's toggle, no independent one. Receiving has exactly one consumer (Purchasing) and one execution dependency (Inventory), both already required together for Receiving to mean anything — unlike Approvals, which multiple modules could plausibly consume independently and which therefore might eventually warrant its own toggle. Documented as the rule for the future entitlement system; no per-module entitlement mechanism exists anywhere in this codebase yet, so nothing is built for this today.
  • DR-E — disclosed naming asymmetry: stock_movement.source_module stays 'purchasing'. A receiving-originated movement is still tagged source_module='purchasing', not renamed to 'receiving'inventory.stock_movement's CHECK already includes 'purchasing' and needed no widening. A deliberate, disclosed choice, not an oversight; logged in §9.
  • DR-F — deprecate-in-place, not drop; and the fix #7 line-grain upgrade. inventory_movement_id was renamed stock_movement_id (v1's own name retired, per this codebase's standing "deprecate in place, don't drop" convention) and its own bare FK upgraded to composite. The new stock_movement_line_id (fix #7) supersedes it as the real line-grain linkage — stock_movement_id stays header-grain and is kept for continuity, not because it's still the primary reference.
  • DR-G — the reversal self-FK is a real FK, not a bare-column-plus-trigger. reversal_of_goods_receipt_line_id is a genuine self-referencing composite FK (goods_receipt_line_reversal_tenant_fkey). This codebase's one prior same-shape case, tax.tax_calculation.supersedes_calculation_id, instead used a bare column + trigger — but only because it needed an extra business rule (same-source_ref scoping) a plain FK can't express. No such extra rule applies here, so a real FK is the more direct fit; self-referencing FKs (composite or not) are ordinary, valid Postgres.
  • DR-H — schema-bootstrap GRANTs, reused verbatim from Approvals. Since Remediation Phase 1 made authenticated GRANT-closed by default on every schema, receiving needed the identical bootstrap Approvals' own migration used: GRANT USAGE ON SCHEMA, table GRANTs, and ALTER DEFAULT PRIVILEGES — missed in the design's first draft, added before build, confirmed live.
  • DR-I — 3 real bugs found and fixed during this build's own live-reproduction pass (distinct from, and in addition to, the 2 pre-build BLOCKERs in DR-B/DR-C, which were caught by design-phase independent verification before any schema existed): (1) the migration's first live-apply attempt failed because goods_receipt was missing its own UNIQUE(id, tenant_id) — required because goods_receipt_line.goods_receipt_id references it via composite FK; fixed in both the migration and the Drizzle source in the same pass. (2) the admin.custom_field_definition entity-type backfill hit a genuine constraint-migration chicken-and-egg: widening the CHECK first and backfilling after fails just as hard as backfilling before widening (the widened CHECK validates all existing rows immediately, and they still hold the old value at that instant) — fixed by dropping the CHECK, backfilling while unconstrained, then adding the final CHECK once every row already conforms. (3) the tolerance trigger's ::numeric cast had no defensive handling — a corrupted, non-numeric catalog/tenant value would crash the trigger outright (blocking all future over-tolerance receiving for that tenant), rather than failing safe; found via the verification script's own deliberate corruption test and fixed via the BEGIN/EXCEPTION wrap in DR-3.
  • DR-J — additive, disclosed schema closures bundled into the move. goods_receipt gained void-attribution (voided_at/voided_by_actor_id/void_reason + chk_goods_receipt_voided_requires_actor_at) — closing a real pre-existing gap where status='void' already existed with zero attribution. goods_receipt_line.inspection_status's CHECK widened to add 'quarantine' — the minimal disposition/quality-hold seed (see §10). destination_zone (pre-existing, free text) stays the named-not-built seed of a future structured put-away/bin system.
  • DR-K — the tolerance trigger's UPDATE scope is deliberately narrower than the design's literal wording, and was itself corrected once during this build. The design text specified BEFORE INSERT OR UPDATE unscoped. The first built version installed BEFORE INSERT (unconditionally) OR UPDATE OF over_short_qty — but once over_short_qty became a DERIVED value rather than caller-supplied (DR-B/DR-2), the column that actually needs watching is accepted_qty (the trigger's real input), not over_short_qty (its output). Corrected to BEFORE INSERT OR UPDATE OF accepted_qty. An unscoped UPDATE trigger would still re-run on every unrelated later edit to an already-reviewed line (e.g. fixing note/defect_code after a human already set goods_receipt.review_status back to 'approved') and silently re-flip review_status to 'pending' even though nothing about the received quantity changed — clobbering a human review decision. Live-reproduced: an unrelated-column edit does not refire and does not disturb an 'approved' status; an accepted_qty edit does refire, correctly re-derives against the PO line's current (not stale) capacity, and correctly re-flags if now over-tolerance.
  • DR-L — the 13-composite-FK sweep and its test methodology. 4 FKs on goods_receipt (→ purchase_order/vendor/site/ship_from_vendor_address), 7 on goods_receipt_line (→ goods_receipt/purchase_order_line/variant/lot/stock_movement/stock_movement_line/itself-via-reversal), and 2 on Purchasing's own vendor_invoice_match/vendor_return_line (→ goods_receipt_line) — all upgraded from either bare or (for the 5 that were intra-purchasing before the move) invisible to composite. Every one of the 13 was individually live-reproduced for cross-tenant rejection using a 2-tenant fixture that isolates exactly one mismatched FK column per test, all other FK columns on the same row left valid — not "at least one FK on the table rejects."
  • DR-M — Section 4 self-audit / NULL-in-CHECK note. Every CHECK-guarded enum/status column on both tables (status, automation_source, review_status, inspection_status) is NOT NULL. chk_goods_receipt_voided_requires_actor_at and chk_setting_definition_receiving_tolerance_action both use the condition_not_met OR requirement_holds implication shape rather than bare equality against a nullable column — the same NULL-bypass class this codebase already learned from purchasing.chk_purchase_order_sent_requires_approval's own historical bug — confirmed NULL-safe by construction.
  • DR-N — B3 direct receiving (2026-07-20, Gap-Fill Batch, PROJECT_DECISIONS #74): trigger-enforced cross-table coherence, and the mid-build scope correction to purchase_order_line_id. goods_receipt.receipt_source is guarded at the header level by a plain CHECK (chk_goods_receipt_source_po_consistency: receipt_source='purchase_order' iff purchase_order_id IS NOT NULL) — a single-row, single-table rule a CHECK can express directly. But the equivalent line-level rule — goods_receipt_line.purchase_order_line_id must be present when the parent receipt is PO-sourced, and NULL when the parent is direct — reaches across two tables, and a plain CHECK cannot see the parent row's receipt_source, only its own row. This is enforced instead by a new BEFORE INSERT OR UPDATE OF purchase_order_line_id, goods_receipt_id trigger, trg_goods_receipt_line_validate_po_reference (receiving.validate_goods_receipt_line_po_reference()), which looks up the parent goods_receipt.receipt_source and rejects any line whose purchase_order_line_id disagrees with it — mirroring pricing.validate_price_rule_supersession's established pattern in this codebase for the same class of problem (a coherence rule spanning a parent/child pair, not just one row). The task as originally scoped named only the header-level purchase_order_id/receipt_source change; relaxing goods_receipt_line.purchase_order_line_id from NOT NULL to nullable was a scope correction found mid-build and architect-approved beyond that original column list — without it, a direct receipt's header would be creatable but structurally line-less, since every line would still demand a PO-line reference a direct receipt, by definition, doesn't have. The pre-existing receiving.check_goods_receipt_line_over_receipt_tolerance() tolerance trigger (fix #11) already tolerated a NULL purchase_order_line_id — a defensive guard added back on 2026-07-10 for an unrelated reason — confirmed live, so no change was needed there.

8. Agent Authority Mapping

No new authority mechanism, and — unlike every product module built since crmno active agent_duty_grant-gated permission code at all in this pass. §4 explains why: receiving is a physical dock event, not an agent decision, and the module defines no draft_only agent-authored capability of its own. The only automated behavior (the over-receipt tolerance trigger) is a deterministic DB guardrail, not an agent action, so it has no permission code to gate. automation_source/created_by_actor_id follow the codebase-wide uniform vocabulary (able to record 'agent' if some future caller is agent-driven) but this is provenance capture, not a designed capability — matching purchasing's own DR-E precedent for the pre-move table.

9. Cross-Module Seams

Cataloged in CROSS_MODULE_CONTRACTS.md. Key:

Direction Contract
Receiving → Purchasing goods_receipt.purchase_order_id, goods_receipt_line.purchase_order_line_id (composite, line-grain). Writes back purchase_order_line.received_qty (capped, DR-2).
Receiving → Inventory goods_receipt_line.stock_movement_id/.stock_movement_line_id/.lot_id/.variant_idinventory.*(id, tenant_id) (all composite). Never writes inventory.stock directly (DR-1).
Receiving → Multi-Location goods_receipt.site_id → multi_loc.site(id, tenant_id) (composite).
Receiving → Shared currency_code → shared.currency.iso_code (both tables).
Receiving → Identity every *_actor_id → identity.actor.id (bare, codebase-wide convention).
Receiving → Admin reads admin.tenant_setting/setting_definition for the tolerance value/action — read-only, no FK (DR-3).
Purchasing ← Receiving (reverse dependency) vendor_invoice_match.goods_receipt_line_id, vendor_return_line.goods_receipt_line_id (both renamed from purchase_receipt_line_id, both composite) — the one place Purchasing structurally depends on Receiving's own table (DR-C).

10. Scale Extension Points (named, not built)

Named during design, none stubbed, all logged to OPEN_ITEMS:

  • ASN (advance ship notice) — header-level asn_reference/asn_expected_at on goods_receipt.
  • Put-away / bin structuring — converging the existing free-text destination_zone into a structured inventory_location_id (Inventory already has the structured version elsewhere).
  • Serial capture — a future goods_receipt_line_serial child table.
  • Fuller inspection/QC workflow — the new 'quarantine' inspection_status value is the minimal seed, not a full quality-hold workflow.
  • Blind receiving — a future tenant setting (received quantity hidden until confirmed); zero schema impact when built.

11. Deferred / Future Items

  1. All 5 scale extension points in §10 — none built.
  2. vendor_return_line.inventory_movement_id (the header-grain link on vendor-return's own side, as opposed to goods_receipt_line's side) is not upgraded to line-grain by this build — a separate, still-deferred Purchasing item.
  3. stock_movement.source_module stays 'purchasing' for receiving-originated movements — disclosed naming asymmetry (DR-E), not revisited.
  4. The 3-way match's own separate tolerance/variance mechanism (vendor_invoice_match.match_status) is untouched by fix #11 — a different, separately-deferred Purchasing item.
  5. No ReceivingService exists yet (§5) — but unlike a typical schema-only module, the absorbed_qty/capped-write-back contract (DR-2) is already DB-enforced regardless of when or how that service is built; only the stock-movement-insert half of the one-transaction requirement (DR-5) remains binding on it.
  6. This build CLOSES, rather than defers, the 2 header/line-remediation items Purchasing's own 2026-07-10 reopen (PROJECT_DECISIONS #47) explicitly named as pending this extraction: fix #7 (movement-line linkage) and fix #11 (over-receipt tolerance) — both fully built, not merely re-deferred.

12. Purchasing/Inventory Reopen — Accounting

Full retained record lives in this build's PROJECT_DECISIONS entry (#55, cited here, not duplicated word-for-word).

  • Purchasing side. 17 tables / 410 cols → 15 tables / 353 cols (−2 tables, −57 cols: purchase_receipt −30, purchase_receipt_line −27; the remaining 15 tables are unchanged in their own column counts). Constraint-only additions: vendor, vendor_address, purchase_order, purchase_order_line each gain UNIQUE(id, tenant_id); vendor_invoice_match.purchase_receipt_line_id and vendor_return_line.purchase_receipt_line_id (2, not 1 — DR-C) both renamed goods_receipt_line_id and upgraded bare → composite.

  • Inventory side. Unchanged at 25 tables / 351 cols — pure constraint-only: item_variant, lot, stock_movement, stock_movement_line each gain UNIQUE(id, tenant_id), the prerequisite for Receiving's own composite FKs into them. This makes 3 modules touched by one design (Receiving new, Purchasing reopened, Inventory reopened) — disclosed prominently, not minimized, matching this codebase's own standing disclosure norm.

  • Receiving side — table-by-table fate.

    Table (Purchasing, before) Cols Table (Receiving, after) Cols Fate
    purchase_receipt 30 goods_receipt 33 MOVED + RENAMED + extended: +voided_at, +voided_by_actor_id, +void_reason
    purchase_receipt_line 27 goods_receipt_line 29 MOVED + RENAMED + extended: +stock_movement_line_id, +reversal_of_goods_receipt_line_id; 1 column renamed in place (inventory_movement_idstock_movement_id, no net column-count effect)

    Reconciles exactly: 30+27 = 57 cols leave Purchasing; 33+29 = 62 cols arrive in Receiving (net +5, matching the 3+2 additions above). Zero columns dropped without a named successor.

  • Migration: packages/db/migrations/20260710090000_receiving_extraction.sql. Drizzle schema: packages/db/src/schema/receiving/{_schema,receipt,index}.ts (new); packages/db/src/schema/purchasing/{vendor,order,invoice,credit_return,index}.ts, packages/db/src/schema/inventory/{catalog,lot,stock}.ts, packages/db/src/schema/admin/config.ts, and the top-level schema barrel (packages/db/src/schema/index.ts) all updated in the same pass.

  • Tests. apps/api/src/receiving/__tests__/receiving-schema.spec.ts42 tests, all passing (40 original + 2 added post-independent-verification for the strengthened trigger), covering table/RLS existence (A/B), live column counts (C), all 13 composite FKs with isolated cross-tenant rejection per column (D/E/K), the void-attribution and quarantine CHECKs (F/G), idempotency dedup incl. NULL-distinctness (H), the fix #11 tolerance trigger's full precedence chain, BLOCKER-1's now-DB-enforced capped-write-back proof incl. the uncapped-write-back counter-proof, and (I4, new) that an UPDATE OF accepted_qty correctly re-fires/re-derives while an unrelated column edit does not (I), the reversal design's 6 assertions incl. a live proof that mutating the original stock_movement_line is DB-rejected (J), and the 3-way match resolving across the new schema boundary (K). Sibling suites updated in the same pass: purchasing-schema.spec.ts 60 → 59 tests (removed the 2 tests each for the moved-out receipt tables' movement-FK and idempotency coverage, now owned by Receiving's own suite; added 1 new isolated-cross-tenant test for vendor_invoice_match.goods_receipt_line_id plus 4 new tests for the 4 new Purchasing-side UNIQUE(id, tenant_id) constraints), all 59 passing; inventory-schema.spec.ts 55 → 59 tests (+4, one per new UNIQUE(id, tenant_id) constraint), all 59 passing.

Last modified: Jul 14, 2026, 1:27 PM PT
On this page
Esc