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_line — zero columns dropped, 5 net-new columns added (3 on the header, 2 on the line — see §12), 1 column renamed in place (inventory_movement_id → stock_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 cappedreceived_qtyincrement; 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.stock—InventoryService.receive()owns the write; Receiving only originates the linkage columns pointing at the movement it caused). - The 3-way match (
purchasing.vendor_invoice_matchstays 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_return—vendor_return_linecarries a nullable link back intogoods_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_numberstay plain text;shipment_photo_refstays 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_definitionown 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:
platform—tenant.id(everytenant_idcolumn).multi_loc—site.id, now via a real composite FK (goods_receipt.site_id) — structurally impossible before this session's ownmulti_locreopen gavesiteitsUNIQUE(id, tenant_id)(PROJECT_DECISIONS #54).shared—currency.iso_code(both tables'currency_code).purchasing—vendor/vendor_address/purchase_order/purchase_order_line, all composite, all upgraded from bare by this same build (each needed its own new prerequisiteUNIQUE(id, tenant_id), confirmed missing).inventory—item_variant/lot/stock_movement/stock_movement_line, all composite, all upgraded from bare by this same build (same prerequisite pattern).identity—actor.id(every*_actor_idcolumn — bare, matching the codebase-wide convention thatidentity.actoritself carries noUNIQUE(id, tenant_id)and no FK into it is composite anywhere).admin—tenant_setting/setting_definition, read-only, no FK (the tolerance value/action lookup — see §6 DR-3).
Depended on by: purchasing — vendor_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_rollupand fix #11's tolerance trigger as independent. They are not: a naive write-back of the fullaccepted_qtydeterministically 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 theabsorbed_qtycap (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 leftover_short_qty's derivation and the write-back itself as an external, undocumented-in-DB convention for the not-yet-builtReceivingServiceto 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 derivesover_short_qtyand performs the write-back atomically (DR-2). Live-reproduced multiple ways across both build passes: a 120-vs-100 over-tolerance receipt self-computesover_short_qty=20and writes back exactly 100 (not 120) automatically, no manualUPDATEanywhere 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_idas the FK this extraction needed to retarget — the one already disclosed inOPEN_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, movingpurchase_receipt_lineout would either fail the migration outright or silently strip referential integrity from a live column. Resolved: bothvendor_invoice_match.purchase_receipt_line_idandvendor_return_line.purchase_receipt_line_idwere renamedgoods_receipt_line_idand 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_modulestays'purchasing'. A receiving-originated movement is still taggedsource_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_idwas renamedstock_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 newstock_movement_line_id(fix #7) supersedes it as the real line-grain linkage —stock_movement_idstays 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_idis 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_refscoping) 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
authenticatedGRANT-closed by default on every schema,receivingneeded the identical bootstrap Approvals' own migration used:GRANT USAGE ON SCHEMA, table GRANTs, andALTER 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_receiptwas missing its ownUNIQUE(id, tenant_id)— required becausegoods_receipt_line.goods_receipt_idreferences it via composite FK; fixed in both the migration and the Drizzle source in the same pass. (2) theadmin.custom_field_definitionentity-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::numericcast 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 theBEGIN/EXCEPTIONwrap in DR-3. - DR-J — additive, disclosed schema closures bundled into the move.
goods_receiptgained void-attribution (voided_at/voided_by_actor_id/void_reason+chk_goods_receipt_voided_requires_actor_at) — closing a real pre-existing gap wherestatus='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
UPDATEscope is deliberately narrower than the design's literal wording, and was itself corrected once during this build. The design text specifiedBEFORE INSERT OR UPDATEunscoped. The first built version installedBEFORE INSERT (unconditionally) OR UPDATE OF over_short_qty— but onceover_short_qtybecame a DERIVED value rather than caller-supplied (DR-B/DR-2), the column that actually needs watching isaccepted_qty(the trigger's real input), notover_short_qty(its output). Corrected toBEFORE INSERT OR UPDATE OF accepted_qty. An unscopedUPDATEtrigger would still re-run on every unrelated later edit to an already-reviewed line (e.g. fixingnote/defect_codeafter a human already setgoods_receipt.review_statusback to'approved') and silently re-flipreview_statusto'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; anaccepted_qtyedit 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 ongoods_receipt_line(→goods_receipt/purchase_order_line/variant/lot/stock_movement/stock_movement_line/itself-via-reversal), and 2 on Purchasing's ownvendor_invoice_match/vendor_return_line(→goods_receipt_line) — all upgraded from either bare or (for the 5 that were intra-purchasingbefore 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) isNOT NULL.chk_goods_receipt_voided_requires_actor_atandchk_setting_definition_receiving_tolerance_actionboth use thecondition_not_met OR requirement_holdsimplication shape rather than bare equality against a nullable column — the same NULL-bypass class this codebase already learned frompurchasing.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_sourceis guarded at the header level by a plain CHECK (chk_goods_receipt_source_po_consistency:receipt_source='purchase_order'iffpurchase_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_idmust 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'sreceipt_source, only its own row. This is enforced instead by a newBEFORE INSERT OR UPDATE OF purchase_order_line_id, goods_receipt_idtrigger,trg_goods_receipt_line_validate_po_reference(receiving.validate_goods_receipt_line_po_reference()), which looks up the parentgoods_receipt.receipt_sourceand rejects any line whosepurchase_order_line_iddisagrees with it — mirroringpricing.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-levelpurchase_order_id/receipt_sourcechange; relaxinggoods_receipt_line.purchase_order_line_idfromNOT NULLto 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-existingreceiving.check_goods_receipt_line_over_receipt_tolerance()tolerance trigger (fix #11) already tolerated a NULLpurchase_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 crm — no 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_id → inventory.*(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_atongoods_receipt. - Put-away / bin structuring — converging the existing free-text
destination_zoneinto a structuredinventory_location_id(Inventory already has the structured version elsewhere). - Serial capture — a future
goods_receipt_line_serialchild table. - Fuller inspection/QC workflow — the new
'quarantine'inspection_statusvalue 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
- All 5 scale extension points in §10 — none built.
vendor_return_line.inventory_movement_id(the header-grain link on vendor-return's own side, as opposed togoods_receipt_line's side) is not upgraded to line-grain by this build — a separate, still-deferred Purchasing item.stock_movement.source_modulestays'purchasing'for receiving-originated movements — disclosed naming asymmetry (DR-E), not revisited.- 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. - No
ReceivingServiceexists yet (§5) — but unlike a typical schema-only module, theabsorbed_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. - 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_lineeach gainUNIQUE(id, tenant_id);vendor_invoice_match.purchase_receipt_line_idandvendor_return_line.purchase_receipt_line_id(2, not 1 — DR-C) both renamedgoods_receipt_line_idand upgraded bare → composite.Inventory side. Unchanged at 25 tables / 351 cols — pure constraint-only:
item_variant,lot,stock_movement,stock_movement_lineeach gainUNIQUE(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_receipt30 goods_receipt33 MOVED + RENAMED + extended: +voided_at,+voided_by_actor_id,+void_reasonpurchase_receipt_line27 goods_receipt_line29 MOVED + RENAMED + extended: +stock_movement_line_id,+reversal_of_goods_receipt_line_id; 1 column renamed in place (inventory_movement_id→stock_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.ts— 42 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 originalstock_movement_lineis 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.ts60 → 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 forvendor_invoice_match.goods_receipt_line_idplus 4 new tests for the 4 new Purchasing-sideUNIQUE(id, tenant_id)constraints), all 59 passing;inventory-schema.spec.ts55 → 59 tests (+4, one per newUNIQUE(id, tenant_id)constraint), all 59 passing.