receiving — module #22, the physical goods-receipt layer (extracted from purchasing)
2 tables, 63 columns — schema-locked 2026-07-10 (PROJECT_DECISIONS #55), reopened 2026-07-20 (Gap-Fill Batch, PROJECT_DECISIONS #74) to add B3 non-PO (direct) receiving. receiving owns the physical act of receiving goods against a purchase order: the receipt document, line-level accept/reject/damage/quarantine disposition, receiving-time over-receipt tolerance, the lot a receipt creates, and the stock-movement linkage receiving originates (Inventory executes the actual stock mutation). Its 2 tables — goods_receipt and goods_receipt_line — are MOVED and RENAMED out of purchasing.purchase_receipt/purchase_receipt_line via ALTER TABLE ... SET SCHEMA, 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). Both tables had zero rows at migration time — no data-safety risk in any of the accompanying column/FK changes.
This build folds in 2 header/line-remediation fixes that were explicitly deferred to it when purchasing's own Header/Line Remediation reopen landed 2026-07-10 (PROJECT_DECISIONS #47): fix #7 (the real line-grain stock_movement_line linkage, superseding the header-grain-only inventory_movement_id) and fix #11 (over-receipt tolerance, a new trigger enforcing a tenant-configurable percent threshold with flag-or-block behavior). It also upgrades every previously-bare FK on these 2 tables to a composite (col, tenant_id) → parent(id, tenant_id) FK — found by this extraction's own Adversarial Pre-Mortem, not left bare because the tables happened to be moving anyway — and adds 3 void-attribution columns closing a real pre-existing gap (status already had a 'void' value with zero recorded attribution).
Preceded by a design proposal whose own independent verification pass found and required fixing 2 BLOCKERs before this build started (see "The two pre-build BLOCKERs" below), and a further 3 real bugs were found and fixed live during the build's own live-reproduction pass (distinct from, and in addition to, the 2 pre-build BLOCKERs — see "Design Patterns Summary" below). 32 guard assertions across 6 categories were live-reproduced against the local Supabase Postgres DB (127.0.0.1:54322) before the regression suite was written, and 42 regression tests, all passing (40 original + 2 added post-independent-verification for the strengthened trigger), now cover the same ground (apps/api/src/receiving/__tests__/receiving-schema.spec.ts).
Depends on platform (tenant), identity (actor), multi_loc (site), shared (currency), purchasing (vendor/vendor_address/purchase_order/purchase_order_line — all 4 gained a new prerequisite UNIQUE(id, tenant_id) in this same migration), and inventory (item_variant/lot/stock_movement/stock_movement_line — same prerequisite, added via this migration's own Inventory-reopen step). Its one trigger also reads (never FKs) admin.setting_definition/admin.tenant_setting for the over-receipt tolerance config. Uniquely among this module's dependencies, the relationship with purchasing is genuinely bidirectional: 2 purchasing tables (vendor_invoice_match, vendor_return_line) hold composite FKs pointing back INTO receiving.goods_receipt_line, retargeted in this same migration — see "The seams" below.
PROJECT_DECISIONS entry: #55.
Groups: Receipt Header (goods_receipt) / Receipt Line (goods_receipt_line) — no further internal grouping needed; this is the smallest schema-locked module in the codebase besides tax (also 2 tables).
Global rules for this schema:
- Uniform tenant-scoping — both tables carry
tenant_id NOT NULLFK →platform.tenant, no mixed-scope case. Both have RLS enabled with a permissive<table>_tenant_isolationpolicy (FOR ALL TO authenticated,USING/WITH CHECKbothtenant_id = current_setting('app.current_tenant_id')::uuid) — persisted across the schema move, confirmed live rather than re-created. Plaintenant_idindex on both. updated_attrigger-maintained viaplatform.set_updated_at()(shared, reused — persisted across the move, confirmed live) on both tables — no write-once exception here (unlikepurchasing.vendor_credit_lineorapprovals.approval_event), since agoods_receipt/goods_receipt_linerow is genuinely revisable across a receipt's lifecycle (inspection status changes, review resolution, void).- Soft delete (
deleted_at) on both tables; every UNIQUE (receipt_number, and the(goods_receipt_id, line_number)pair) isWHERE deleted_at IS NULL. - Agent-as-actor — every
*_actor_idtargetsidentity.actor. - Autonomy tiers, unchanged by the move. FULL (
goods_receipt) =created_by_actor_id+automation_source+ full review seam (review_status/review_reason/reviewed_by_actor_id/reviewed_at) +decision_provenance. LIGHT (goods_receipt_line) =created_by_actor_id+automation_sourceonly. Identical split to the pre-movepurchase_receipt(FULL) /purchase_receipt_line(LIGHT) — the extraction did not change either table's autonomy tier. - Composite
(col, tenant_id)FK convention, now total on this module's own 2 tables. Every FK ongoods_receipt/goods_receipt_linetargeting a tenant-scoped table is composite — 11 such FKs (4 on the header, 7 on the line, including the line's own self-referencing reversal FK) — zero bare cross-tenant FKs remain anywhere in this module. The usual codebase-wide exceptions still apply and stay bare, single-column:currency_code → shared.currency.iso_code(a global natural-key reference) and every*_actor_id → identity.actor. - Schema-bootstrap GRANTs. Since Remediation Phase 1 made
authenticatedGRANT-closed by default on every schema, a brand-new schema needs its own bootstrap —GRANT USAGE ON SCHEMA receiving,GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES, andALTER DEFAULT PRIVILEGES— confirmed live (SELECT/INSERT/UPDATE/DELETEall present forauthenticatedon both tables), the same 3-statement template theapprovalsextraction's own migration used. - Table-move mechanics.
ALTER TABLE purchasing.purchase_receipt(_line) SET SCHEMA receiving;preserves the table object, RLS enablement, theset_updated_attrigger, and existing GRANTs (none are schema-scoped in Postgres). Every index/constraint/policy name was then explicitly renamed to match the Drizzle source's own naming (purchase_receipt_* → goods_receipt_*), avoiding the exact source/DB naming-drift class this build's own pre-migration review separately found and fixed onvendor_invoice_match.vendor_invoice_line_id's composite FK.
Cross-Phase / Cross-Module Foreign Keys (receiving)
| Column | Target | Notes |
|---|---|---|
*.tenant_id (both tables) |
platform.tenant |
NOT NULL |
goods_receipt.purchase_order_id |
purchasing.purchase_order (id, tenant_id) |
composite FK goods_receipt_purchase_order_tenant_fkey — upgraded from bare on the move; relaxed to nullable 2026-07-20 (Gap-Fill Batch #74) for B3 direct receiving (NULL iff receipt_source='direct', CHECK-enforced) |
goods_receipt.vendor_id |
purchasing.vendor (id, tenant_id) |
composite FK goods_receipt_vendor_tenant_fkey |
goods_receipt.site_id |
multi_loc.site (id, tenant_id) |
composite FK goods_receipt_site_tenant_fkey |
goods_receipt.ship_from_vendor_address_id |
purchasing.vendor_address (id, tenant_id) |
nullable, composite FK goods_receipt_ship_from_vendor_address_tenant_fkey |
goods_receipt.currency_code, goods_receipt_line.currency_code |
shared.currency.iso_code |
char(3), NOT NULL, bare natural-key FK (codebase-wide convention) |
goods_receipt.received_by_actor_id/.verified_by_actor_id/.created_by_actor_id/.reviewed_by_actor_id/.voided_by_actor_id |
identity.actor |
all nullable, bare (codebase-wide convention for actor FKs) |
goods_receipt_line.goods_receipt_id |
receiving.goods_receipt (id, tenant_id) |
composite FK goods_receipt_line_goods_receipt_tenant_fkey — RENAMED from purchase_receipt_id; same-schema, composite for consistency |
goods_receipt_line.purchase_order_line_id |
purchasing.purchase_order_line (id, tenant_id) |
composite FK goods_receipt_line_purchase_order_line_tenant_fkey — THE #1 ERP correctness point: line grain, not header, so partial receipts reconcile per-line; relaxed to nullable 2026-07-20 (Gap-Fill Batch #74) for B3 direct receiving — a plain CHECK can't reference the parent goods_receipt row, so coherence with receipt_source is trigger-enforced (trg_goods_receipt_line_validate_po_reference), not CHECK-enforced |
goods_receipt_line.variant_id |
inventory.item_variant (id, tenant_id) |
composite FK goods_receipt_line_variant_tenant_fkey |
goods_receipt_line.lot_id |
inventory.lot (id, tenant_id) |
nullable, composite FK goods_receipt_line_lot_tenant_fkey |
goods_receipt_line.stock_movement_id |
inventory.stock_movement (id, tenant_id) |
nullable, composite FK goods_receipt_line_stock_movement_tenant_fkey — RENAMED from inventory_movement_id (v1's name); deprecated-in-place header-grain link, superseded in practice by the line-grain FK below |
goods_receipt_line.stock_movement_line_id |
inventory.stock_movement_line (id, tenant_id) |
NEW (fix #7), nullable, composite FK goods_receipt_line_stock_movement_line_tenant_fkey — the real line-grain movement linkage |
goods_receipt_line.reversal_of_goods_receipt_line_id |
receiving.goods_receipt_line (id, tenant_id) |
NEW, nullable, self-referencing composite FK goods_receipt_line_reversal_tenant_fkey |
goods_receipt_line.created_by_actor_id |
identity.actor |
nullable, bare |
(reciprocal, owned by purchasing) purchasing.vendor_invoice_match.goods_receipt_line_id |
receiving.goods_receipt_line (id, tenant_id) |
composite FK vendor_invoice_match_goods_receipt_line_tenant_fkey — RENAMED from purchase_receipt_line_id, upgraded from bare |
(reciprocal, owned by purchasing) purchasing.vendor_return_line.goods_receipt_line_id |
receiving.goods_receipt_line (id, tenant_id) |
composite FK vendor_return_line_goods_receipt_line_tenant_fkey — RENAMED from purchase_receipt_line_id, upgraded from bare |
receiving.goods_receipt (34 cols, FULL autonomy) — MOVED + RENAMED from purchasing.purchase_receipt (+3 cols at the 2026-07-10 move, +1 more at the 2026-07-20 reopen)
The receipt header — one row per physical delivery event against a PO, or (as of 2026-07-20, Gap-Fill Batch #74) a direct/non-PO delivery. All 30 pre-existing columns preserved verbatim; +3 NEW at the original move (voided_at/voided_by_actor_id/void_reason) closing a real gap where status already had a 'void' value with zero recorded attribution. 4 previously-bare FKs (purchase_order_id, vendor_id, site_id, ship_from_vendor_address_id) upgraded to composite — all 4 became genuinely cross-module the moment this table left purchasing. +1 more NEW at the 2026-07-20 reopen (receipt_source), added alongside relaxing purchase_order_id to nullable — see "B3 — direct receiving" below.
Tenant-scoped. RLS enabled —
goods_receipt_tenant_isolation(persisted across the move, confirmed live). Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at()(persisted across the move, confirmed live).
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
receipt_number |
text | NOT NULL | — | UNIQUE per (tenant_id) WHERE not deleted |
purchase_order_id |
UUID | nullable | — | composite FK → purchasing.purchase_order — relaxed from NOT NULL 2026-07-20 (Gap-Fill Batch #74); NULL iff receipt_source='direct' |
receipt_source |
text | NOT NULL | 'purchase_order' |
NEW (2026-07-20, Gap-Fill Batch #74) — CHECK IN (purchase_order,direct) — B3 non-PO (direct) receiving |
vendor_id |
UUID | NOT NULL | — | composite FK → purchasing.vendor — unchanged; already structurally required for a direct receipt |
site_id |
UUID | NOT NULL | — | composite FK → multi_loc.site |
status |
text | NOT NULL | 'pending' |
CHECK IN (pending,complete,void) |
received_at |
timestamptz | NOT NULL | now() |
|
ship_from_vendor_address_id |
UUID | nullable | — | composite FK → purchasing.vendor_address |
carrier |
text | nullable | — | |
tracking_number |
text | nullable | — | |
packing_slip_ref |
text | nullable | — | |
shipment_photo_ref |
text | nullable | — | Deferred Files forward-ref — plain text, no FK (Files not built) |
freight_cents |
bigint | NOT NULL | 0 |
|
currency_code |
char(3) | NOT NULL | — | FK → shared.currency.iso_code |
idempotency_key |
text | nullable | — | NULL-safe UNIQUE per tenant_id — InventoryService.receive() dedup |
received_by_actor_id |
UUID | nullable | — | FK → identity.actor |
verified_by_actor_id |
UUID | nullable | — | FK → identity.actor — the double-count verifier |
note |
text | nullable | — | |
attributes |
jsonb | nullable | '{}' |
Example shape undefined — logged to OPEN_ITEMS |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor — FULL-pack |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
review_status |
text | NOT NULL | 'not_required' |
CHECK IN (not_required,pending,approved,rejected) — flipped to 'pending' by fix #11's tolerance trigger on an over-tolerance line |
review_reason |
text | nullable | — | Auto-populated by the tolerance trigger on flag |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — | |
decision_provenance |
jsonb | nullable | — | Project-wide reason/evidence/confidence/memory_refs convention |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
voided_at |
timestamptz | nullable | — | NEW — void attribution |
voided_by_actor_id |
UUID | nullable | — | NEW — FK → identity.actor |
void_reason |
text | nullable | — | NEW |
CHECK constraints (6): chk_goods_receipt_status; chk_goods_receipt_automation_source; chk_goods_receipt_review_status; chk_goods_receipt_reviewer_not_creator (reviewed_by_actor_id IS NULL OR created_by_actor_id IS NULL OR reviewed_by_actor_id != created_by_actor_id); chk_goods_receipt_voided_requires_actor_at (NEW at the 2026-07-10 move — status != 'void' OR (voided_by_actor_id IS NOT NULL AND voided_at IS NOT NULL), NULL-safe by construction: the status != 'void' clause short-circuits true for every non-void row regardless of the 2 nullable columns' state; mirrors the reviewer/approver presence-CHECK pattern used everywhere else in this codebase, e.g. chk_purchase_order_approved_requires_actor_at); chk_goods_receipt_source_po_consistency (NEW, 2026-07-20, Gap-Fill Batch #74 — (receipt_source = 'purchase_order' AND purchase_order_id IS NOT NULL) OR (receipt_source = 'direct' AND purchase_order_id IS NULL) — the header-level half of B3 direct receiving; the line-level half can't be a plain CHECK since it must see the parent row, so it's trigger-enforced instead, see goods_receipt_line below).
Indexes (7): PK on id; goods_receipt_id_tenant_id_unique UNIQUE(id,tenant_id) (prerequisite for goods_receipt_line's own composite FK back to its header — added after the migration's first live-apply attempt failed without it); goods_receipt_tenant_id_idx; goods_receipt_tenant_id_receipt_number_unique UNIQUE(tenant_id,receipt_number) WHERE not deleted; goods_receipt_tenant_id_purchase_order_id_idx on (tenant_id,purchase_order_id); goods_receipt_site_id_status_idx on (site_id,status); goods_receipt_tenant_id_idempotency_key_unique UNIQUE(tenant_id,idempotency_key) WHERE idempotency_key IS NOT NULL AND deleted_at IS NULL (NULL-safe — matches inventory.stock_movement's own idempotency pattern).
Triggers (1): set_updated_at (persisted from purchasing).
Grants: schema-bootstrap only (SELECT/INSERT/UPDATE/DELETE to authenticated) — no column-level restriction on this table (unlike approvals.approval_workflow.blocks_agent_approver), since there is no equivalently security-critical single column here.
Autonomy: FULL — created_by_actor_id + automation_source + the full review seam (review_status/review_reason/reviewed_by_actor_id/reviewed_at) + decision_provenance. This is where fix #11's over-receipt tolerance trigger flips review_status to 'pending' on an over-tolerance line (see goods_receipt_line's own trigger below) — zero new columns were needed on this table for that, the seam already existed.
receiving.goods_receipt_line (29 cols, LIGHT) — MOVED + RENAMED from purchasing.purchase_receipt_line (+2 cols)
Each line writes an inventory.stock_movement/stock_movement_line. All 27 pre-existing columns preserved (1 renamed: inventory_movement_id → stock_movement_id, v1's old name retired in favor of the header-grain link's new name). +2 NEW: stock_movement_line_id (fix #7 — the real line-grain movement linkage) and reversal_of_goods_receipt_line_id (the correction/reversal path). 4 previously-bare FKs upgraded to composite (purchase_order_line_id — THE #1 ERP correctness point, line grain not header, so partial receipts reconcile per-line — plus variant_id, lot_id, stock_movement_id). Gains its own UNIQUE(id, tenant_id) — needed by purchasing.vendor_invoice_match's and purchasing.vendor_return_line's retargeted composite FKs (the 2nd of those two dependencies, vendor_return_line's, was missed in this design's own first draft and only caught by independent verification — BLOCKER 2 below) and by this table's own new self-referencing reversal FK.
over_short_qty (a pre-existing column) now has a precise computation rule for the first time: accepted_qty − absorbed_qty, where absorbed_qty = LEAST(accepted_qty, ordered_qty − received_qty − invoiced_qty − cancelled_qty) on the linked purchase_order_line — the portion of an accepted quantity that could NOT be absorbed by the PO line's own remaining capacity under its existing chk_purchase_order_line_quantity_rollup CHECK (fix #10, unmodified). This is fix #11's actual trigger input — see "The seams" below for the full precedence chain and the BLOCKER 1 write-back rule.
Tenant-scoped. RLS enabled —
goods_receipt_line_tenant_isolation(persisted across the move, confirmed live). Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at()(persisted across the move, confirmed live).
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
goods_receipt_id |
UUID | NOT NULL | — | composite FK → receiving.goods_receipt — RENAMED from purchase_receipt_id |
purchase_order_line_id |
UUID | nullable | — | composite FK → purchasing.purchase_order_line — THE #1 ERP correctness point; relaxed from NOT NULL 2026-07-20 (Gap-Fill Batch #74) for B3 direct receiving — NULL required when the parent goods_receipt.receipt_source='direct', present when 'purchase_order', enforced by trg_goods_receipt_line_validate_po_reference (a plain CHECK can't reference the parent row) |
variant_id |
UUID | NOT NULL | — | composite FK → inventory.item_variant |
line_number |
integer | NOT NULL | — | UNIQUE per (goods_receipt_id) WHERE not deleted |
expected_qty |
numeric | NOT NULL | — | |
received_qty |
numeric | NOT NULL | — | |
accepted_qty |
numeric | NOT NULL | '0' |
|
rejected_qty |
numeric | NOT NULL | '0' |
|
damaged_qty |
numeric | NOT NULL | '0' |
|
over_short_qty |
numeric | NOT NULL | '0' |
Computed as accepted_qty − absorbed_qty — fix #11's tolerance-trigger input (see "The seams") |
stock_unit_cost_cents |
bigint | NOT NULL | — | |
currency_code |
char(3) | NOT NULL | — | FK → shared.currency.iso_code |
inspection_status |
text | NOT NULL | 'pending' |
CHECK widened to add 'quarantine' (disposition/quality-hold, additive) |
defect_code |
text | nullable | — | |
substitution_note |
text | nullable | — | |
destination_zone |
text | nullable | — | Plain-text seed of a future structured put-away/bin system (inventory.inventory_location_id is the structured version elsewhere in Inventory) — named extension point, not built |
lot_id |
UUID | nullable | — | composite FK → inventory.lot |
stock_movement_id |
UUID | nullable | — | composite FK → inventory.stock_movement — RENAMED from inventory_movement_id (v1's name); deprecated-in-place header-grain link |
inspection_note |
text | nullable | — | |
note |
text | nullable | — | |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor — LIGHT-pack |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
stock_movement_line_id |
UUID | nullable | — | NEW (fix #7) — composite FK → inventory.stock_movement_line, the real line-grain movement linkage |
reversal_of_goods_receipt_line_id |
UUID | nullable | — | NEW — self-referencing composite FK → receiving.goods_receipt_line |
CHECK constraints (3): chk_goods_receipt_line_inspection_status (widened — pending/accepted/rejected/partial/quarantine, 'quarantine' new); chk_goods_receipt_line_qty_nonneg (received_qty >= 0 AND accepted_qty >= 0 AND rejected_qty >= 0 AND damaged_qty >= 0 — no reversal carve-out, see "The seams" for why the reversal design deliberately never needs one); chk_goods_receipt_line_automation_source.
Indexes (8): PK on id; goods_receipt_line_id_tenant_id_unique UNIQUE(id,tenant_id) (prerequisite for vendor_invoice_match's/vendor_return_line's retargeted composite FKs and this table's own reversal self-FK); goods_receipt_line_tenant_id_idx; goods_receipt_line_goods_receipt_id_idx; goods_receipt_line_purchase_order_line_id_idx; goods_receipt_line_variant_id_idx; goods_receipt_line_inspection_status_idx (partial, WHERE inspection_status != 'accepted'); goods_receipt_line_receipt_line_number_unique UNIQUE(goods_receipt_id,line_number) WHERE not deleted (Header/Line Remediation fix #12, carried forward unchanged through the move). No dedicated index exists on lot_id, stock_movement_id, stock_movement_line_id, or reversal_of_goods_receipt_line_id — a disclosed gap, not asserted otherwise.
Triggers (3, platform.set_updated_at() / receiving.check_goods_receipt_line_over_receipt_tolerance() / receiving.validate_goods_receipt_line_po_reference()):
set_updated_at— standard, persisted frompurchasing.trg_goods_receipt_line_check_over_receipt_tolerance(BEFORE INSERT OR UPDATE OF accepted_qty, corrected post-independent-verification from an initialOF over_short_qtyscoping) — fix #11, the over-receipt tolerance guard. Full precedence chain and timing-scoping rationale documented under "The seams" below.trg_goods_receipt_line_validate_po_reference(BEFORE INSERT OR UPDATE OF purchase_order_line_id, goods_receipt_id) — NEW (2026-07-20, Gap-Fill Batch #74), functionreceiving.validate_goods_receipt_line_po_reference(). Looks up the parentgoods_receipt.receipt_sourceand enforces line-level coherence with it: apurchase_order-sourced receipt requires every line'spurchase_order_line_idto be non-NULL; adirect-sourced receipt requires it to be NULL. A plain CHECK can't reference a parent row, so this cross-table rule is trigger-enforced — mirrorspricing.validate_price_rule_supersession's established pattern in this codebase. Note: the pre-existingcheck_goods_receipt_line_over_receipt_tolerance()trigger (fix #11, above) already tolerated a NULLpurchase_order_line_id— a defensive guard added back on 2026-07-10 for an unrelated reason — confirmed live, so it needed no change for B3.
Grants: schema-bootstrap only, same as goods_receipt.
Autonomy: LIGHT — created_by_actor_id + automation_source only. The accept/reject/quarantine decision is a human physical-dock decision; the review seam lives on the FULL header (matches orders.order_fulfillment[FULL]/order_fulfillment_line[ZERO]'s own split).
The seams
The inventory seam (this module's reason for existing). Accepting a line writes an inventory.stock_movement (movement_type='received', source_module='purchasing' — a disclosed, deliberate naming asymmetry: the movement's own source_module value stays 'purchasing' even though the table writing it is now receiving, since inventory.stock_movement.source_module's CHECK vocabulary was untouched by this extraction) via InventoryService.receive(), and updates inventory.item_variant.avg_cost_cents (weighted-average) from goods_receipt_line.stock_unit_cost_cents. Receiving NEVER writes inventory.stock directly — the v1 guard, preserved verbatim through this move. Two linkage grains now coexist on goods_receipt_line: stock_movement_id (header-grain, v1's original inventory_movement_id, renamed but deprecated-in-place per this codebase's "deprecate in place, don't drop" convention) and the NEW stock_movement_line_id (line-grain, fix #7 — the real receiving-to-inventory linkage a per-line receipt actually needs). Both are composite (col, tenant_id) FKs.
The line-grain reconciliation seam (THE #1 ERP correctness point). goods_receipt_line.purchase_order_line_id is line-grain, not header-grain — this is what lets partial receipts reconcile correctly against purchase_order_line.received_qty's own rollup counter (chk_purchase_order_line_quantity_rollup, fix #10, unmodified by this build) rather than double- or under-counting at the PO header level.
BLOCKER 1 (design-phase, fixed before this build started) — the capped write-back, STRENGTHENED to be genuinely DB-enforced post-independent-verification. The first built version left the PO-line write-back as an external, undocumented-in-DB convention for the not-yet-built ReceivingService to get right on its own — the CHECK backstop (chk_purchase_order_line_quantity_rollup) protected against silent corruption either way, but neither the absorbed_qty derivation nor the cap itself was DB-enforced. 2 separate lock-gate verification passes independently converged on this exact gap (pasted and attributed in PROJECT_DECISIONS #55). Fixed: trg_goods_receipt_line_check_over_receipt_tolerance now DERIVES over_short_qty itself (accepted_qty - absorbed_qty, overriding whatever the caller passed) and performs UPDATE purchase_order_line SET received_qty = received_qty + absorbed_qty atomically, in the same trigger invocation — the cap is now structurally guaranteed by the trigger's own arithmetic (absorbed_qty is always in [0, accepted_qty] given chk_purchase_order_line_quantity_rollup's own pre-existing invariant), not a convention a future service must independently honor. Live-reproduced (tests I1a/I4b): an over-tolerance receipt (accepted_qty=120 vs ordered_qty=100, zero prior received_qty) self-computes over_short_qty=20 and writes back exactly 100 automatically, with no manual UPDATE anywhere in the test; a further, uncapped +20 write-back attempt on top of that correctly VIOLATES the rollup CHECK — confirming the cap is load-bearing, not coincidental; a blocked/rejected line's write-back correctly does NOT leak (the whole statement aborts, received_qty unchanged).
Fix #11 — the over-receipt tolerance trigger, full precedence chain. trg_goods_receipt_line_check_over_receipt_tolerance fires whenever the self-computed over_short_qty > 0 (i.e. only when acceptance genuinely exceeded the linked PO line's remaining capacity) and resolves the tenant's configured tolerance via a 3-level precedence, matching every other admin.tenant_setting consumer in this codebase: a site-scoped tenant_setting row (matching goods_receipt.site_id) beats a tenant-wide tenant_setting row (site_id IS NULL), which beats the admin.setting_definition catalog default (category='receiving', key='over_receipt_tolerance_percent' default 0 / key='over_receipt_tolerance_action' default 'flag', both seeded by this migration). If the over-receipt percentage exceeds the resolved tolerance: action='block' raises an exception and rejects the write outright (write-back included — nothing commits); action='flag' (the default) lets the write proceed but flips the parent goods_receipt.review_status to 'pending' with a generated review_reason, reusing the existing FULL-pack review seam — zero new columns needed for that. Live-reproduced for all 3 precedence levels: I1a (catalog-default fallback, flag path), I2a (a tenant-wide percent override genuinely read — within tolerance, no flag), I3a (a site-scoped 'block' override winning over a tenant-wide 'flag' override for the same tenant).
Trigger-timing scoping — a disclosed, deliberate deviation from the design's literal wording, itself corrected once during this build. The design text specified an unscoped BEFORE INSERT OR UPDATE; the first built version installed BEFORE INSERT OR UPDATE OF over_short_qty, then — once over_short_qty became a DERIVED value rather than a caller-supplied one (see BLOCKER 1 above) — was corrected to BEFORE INSERT OR UPDATE OF accepted_qty, since accepted_qty is the actual input the derivation depends on, not over_short_qty (its output). Every INSERT still fires unconditionally regardless of this column list (Postgres semantics: UPDATE OF col only restricts the UPDATE arm, never the INSERT arm) — but an unscoped UPDATE trigger would re-run on every unrelated later edit to an already-reviewed line (e.g. fixing note/defect_code after a human reviewer already set goods_receipt.review_status back to 'approved'), silently re-flipping review_status to 'pending' even though nothing about the received quantity changed — clobbering a human review decision. Scoping to accepted_qty closes that re-trigger hole while correctly re-deriving and re-evaluating whenever the actual input changes. Live-reproduced (tests I4a/I4b): an UPDATE of note alone does not refire and does not disturb an 'approved' review_status; an UPDATE OF accepted_qty does refire, correctly recomputes over_short_qty against the PO line's current (not stale) remaining capacity, and correctly re-evaluates tolerance.
Defensive fix — found live during this build's own verification, not by the pre-build design-phase pass. The trigger's 3-level percent-resolution cascade is wrapped in its own BEGIN … EXCEPTION WHEN invalid_text_representation, falling back to the existing COALESCE(...,0) safe path. admin.setting_definition/tenant_setting.value are JSONB with no enforcement that a 'number'-typed setting's value is actually JSON-numeric (a disclosed, pre-existing gap on that shared table) — without this guard, a single malformed catalog or tenant-setting value would make the ::numeric cast throw and crash the whole INSERT, not just fail through to the safe default, blocking all future over-tolerance receiving for that tenant until manually corrected. Found by the verification pass's own test proving the CHECK ignores unrelated setting_definition rows — which legitimately writes an arbitrary non-numeric value to a sibling catalog row under the same category to prove that point — and this exposed the real crash vector. Confirmed fixed via a dedicated follow-up test proving the trigger no longer crashes against a corrupted catalog default (falls back to 0% tolerance instead).
The reversal seam. A correction is represented as a compensating goods_receipt_line (linked via the new self-referencing reversal_of_goods_receipt_line_id) paired with a compensating inventory.stock_movement_line carrying a NEGATIVE quantity_delta (same stock_movement.correlation_id as the original movement) — the original append-only stock_movement/stock_movement_line rows are never edited or deleted (live-reproduced: J1/J3 prove the original line and movement are untouched; J6 proves a direct mutation attempt against the original stock_movement_line is DB-rejected, even on this admin connection). Qty-sign clarification — a genuine build-time finding, not stated explicitly in the original design doc: the compensating goods_receipt_line's OWN received_qty/accepted_qty columns stay POSITIVE — a magnitude (e.g. 10, never -10) — because chk_goods_receipt_line_qty_nonneg has no reversal carve-out and would reject a negative value there (live-reproduced, J5). The reversal's sign lives entirely in the linked stock_movement_line.quantity_delta and in the reversal_of_goods_receipt_line_id link itself — goods_receipt_line's own qty columns record magnitude only, never direction.
Why a real FK here rather than a bare column + trigger (unlike tax.tax_calculation.supersedes_calculation_id's precedent): that precedent needed an extra business rule (same-source_ref scoping) a plain FK can't express. No equivalent extra rule applies to reversal_of_goods_receipt_line_id, so a direct composite self-referencing FK is the more direct fit — fully valid Postgres (self-referencing FKs, composite or not, are a completely normal pattern).
The 3-way-match / purchasing reciprocal seam. Two purchasing tables point INTO receiving.goods_receipt_line, both RENAMED from purchase_receipt_line_id and upgraded from a bare to a composite FK in this same migration: purchasing.vendor_invoice_match.goods_receipt_line_id (vendor_invoice_match_goods_receipt_line_tenant_fkey) and purchasing.vendor_return_line.goods_receipt_line_id (vendor_return_line_goods_receipt_line_tenant_fkey). This makes the dependency between the two modules genuinely bidirectional — receiving's own tables depend on purchasing (purchase_order, purchase_order_line, vendor, vendor_address), while these 2 purchasing tables depend back on receiving.goods_receipt_line — mirroring the link-don't-convert shape orders/pos and purchasing/orders already established elsewhere in this codebase. Live-reproduced: K1 proves the full chain purchase_order_line ↔ goods_receipt_line ↔ vendor_invoice_line resolves through vendor_invoice_match; K2 proves vendor_invoice_match.goods_receipt_line_id's cross-tenant rejection; K3/K4 prove the same same-tenant-resolves / cross-tenant-rejects pair for vendor_return_line.goods_receipt_line_id.
The admin.custom_field_definition vocabulary side effect. chk_custom_field_definition_entity_type had its 'purchasing.purchase_receipt' value REPLACED with 'receiving.goods_receipt' — a vocabulary swap, not an additive widen (the old value is not kept alongside the new one). 11 live rows using the old value were found, all confirmed test-fixture debris from repeated admin-schema.spec.ts runs (every one of the 11 tenants is literally named "Admin Test cfd-entity-ok", that test's own fixture literal), and were BACKFILLED in the same migration rather than left dangling on a retired CHECK value. Live-verified: all 11 custom_field_definition rows now read entity_type='receiving.goods_receipt'. This does not change admin's own table/column counts — a data backfill + CHECK-definition swap only, no new column.
Entitlement bundling (documented only). Receiving is bundled to Purchasing's own entitlement toggle — it has no independent entitlement toggle of its own. This is a documentation-only decision: no per-module entitlement-gating mechanism exists anywhere in this codebase yet, so there is nothing to build for either module.
receiving — Design Patterns Summary
The two pre-build BLOCKERs (design-phase, fixed before this build started)
- BLOCKER 1 (fix #10 × fix #11 interaction) — the capped write-back, detailed under "The seams" above. Without the cap, an over-tolerance "flag, don't block" receipt would silently violate
chk_purchase_order_line_quantity_rollupthe moment its write-back ran. - BLOCKER 2 (a 2nd, initially-missed dependency) — the first design draft named only
vendor_invoice_match.purchase_receipt_line_idfor retargeting to the new schema;vendor_return_line.purchase_receipt_line_id's identical dependency was missed in that first draft and only caught by the design's own independent verification pass. Both are retargeted in this same migration (STEP 12).
Three real bugs found and fixed during this build's own live-reproduction pass (distinct from the 2 pre-build BLOCKERs)
receiving.goods_receiptwas missing its ownUNIQUE(id, tenant_id)— the migration's first live-apply attempt failed with"no unique constraint matching given keys for referenced table 'goods_receipt'"(needed becausegoods_receipt_line.goods_receipt_idreferences its header via a composite FK, and Postgres requires the referenced columns to carry a matching unique constraint). Fixed in both the migration file and the Drizzle source (receiving/receipt.ts) in the same pass — a real gap in the original design/build, not merely a migration-ordering issue.- The
admin.custom_field_definitionbackfill hit a genuine constraint-migration chicken-and-egg case — 2 failed attempts before the correct order was found. Attempt 1 (backfill, then widen the CHECK) failed: the OLD CHECK didn't yet allow the new value, so the UPDATE itself was rejected. Attempt 2 (widen the CHECK, then backfill) ALSO failed:ADD CONSTRAINTvalidates all existing rows immediately, and the 11 rows still held the OLD value at that exact point, which the NEW CHECK no longer allowed. Fixed by DROPPING the CHECK entirely, running the backfill UPDATE while unconstrained (safe — the whole migration is one transaction holding an ACCESS EXCLUSIVE lock on the table for its duration, so no concurrent writer can observe or exploit the gap), THEN adding the final CHECK once every row already conformed. - The tolerance trigger's
::numericcast had no defensive handling against a malformed catalog/tenant-setting value — detailed as the "Defensive fix" under "The seams" above.
NULL-in-CHECK sweep
All 8 CHECK constraints across both tables were examined for NULL-bypass risk. chk_goods_receipt_status, chk_goods_receipt_automation_source, chk_goods_receipt_review_status, chk_goods_receipt_line_inspection_status, and chk_goods_receipt_line_automation_source all guard columns that are themselves NOT NULL — no bypass surface exists on any of them. chk_goods_receipt_reviewer_not_creator uses a deliberate NULL-safe IS NULL OR pattern on 2 legitimately-nullable actor columns (matching intended semantics, not a bypass). chk_goods_receipt_voided_requires_actor_at — the one NEW presence CHECK — is NULL-safe by construction (status != 'void' short-circuits true regardless of the 2 nullable columns' state); live-reproduced F1 (rejects void with both NULL)/F2 (accepts void with both set)/F3 (accepts non-void with both NULL). chk_goods_receipt_line_qty_nonneg's 4 guarded columns (received_qty/accepted_qty/rejected_qty/damaged_qty) are all NOT NULL — no bypass surface.
Column-count reconciliation
| Table | Pre-move (in purchasing) |
New cols | Post-move (in receiving) |
|---|---|---|---|
goods_receipt (was purchase_receipt) |
30 | +3 (voided_at, voided_by_actor_id, void_reason) |
33 |
goods_receipt_line (was purchase_receipt_line) |
27 | +2 (stock_movement_line_id, reversal_of_goods_receipt_line_id) |
29 |
| Total | 57 | +5 | 62 |
Verified live via information_schema.columns, schema receiving: 2 tables, 62 columns (33 + 29). Matches purchasing's own reconciliation exactly: 410 (pre-extraction) − 57 (the 2 moved tables' pre-move column count) = 353, independently confirmed live via information_schema.columns, schema purchasing (summed across all 15 remaining tables: vendor 38, vendor_contact 16, vendor_address 17, vendor_item 29, purchase_order 46, purchase_order_line 30, purchase_order_template 15, purchase_order_template_line 10, vendor_invoice 40, vendor_invoice_line 20, vendor_invoice_match 18, vendor_credit 24, vendor_credit_line 8, vendor_return 23, vendor_return_line 19). The 2 in-place renames (purchase_receipt_id → goods_receipt_id, inventory_movement_id → stock_movement_id) are column renames, not additions/removals — net zero column-count impact from either.
inventory and admin are both unaffected in table/column count by this build: inventory gained 4 constraint-only UNIQUE(id, tenant_id) additions (item_variant/lot/stock_movement/stock_movement_line), confirmed live still 25 tables / 351 cols; admin gained 2 new setting_definition catalog rows (not columns) and a CHECK-vocabulary swap + backfill on custom_field_definition (not a new column), confirmed live still 10 tables / 122 cols.
2026-07-20 addendum (Gap-Fill Batch, PROJECT_DECISIONS #74). receiving was reopened to add B3 non-PO (direct) receiving: goods_receipt gained +1 column (receipt_source, 33 → 34); goods_receipt_line gained no new column (its own change was purchase_order_line_id relaxed NOT NULL → nullable, a nullability change with zero column-count impact, still 29). receiving is now 2 tables, 63 columns (34 + 29), live-verified — up from the 2026-07-10 figure of 62 stated above, which this addendum does not otherwise revise.
Service layer
No ReceivingService yet — schema-only this pass, same as every module's deferred service layer at build time. The PO-line write-back described under BLOCKER 1, however, is NOT waiting on that service layer to be correct: trg_goods_receipt_line_check_over_receipt_tolerance performs it directly and atomically on every goods_receipt_line insert/accepted_qty update, regardless of whether any service ever calls it via raw SQL or through a future ReceivingService method.
JSONB columns
goods_receipt.attributes(default{}) — example shape not yet defined, logged to OPEN_ITEMS (matchespurchasing.purchase_receipt's own pre-move disclosed gap, carried through unchanged by the move).goods_receipt.decision_provenance— the project-wide reason/evidence/confidence/memory_refs convention, same shape as every other FULL-tier table'sdecision_provenancecolumn codebase-wide.
Triggers, full list (3 functions, 4 trigger objects)
| Trigger | Table | Fires | Function |
|---|---|---|---|
set_updated_at |
goods_receipt, goods_receipt_line |
BEFORE UPDATE |
platform.set_updated_at() (shared, reused) |
trg_goods_receipt_line_check_over_receipt_tolerance |
goods_receipt_line |
BEFORE INSERT OR UPDATE OF accepted_qty (corrected from an initial OF over_short_qty scoping once over_short_qty became a derived, not caller-supplied, value) |
receiving.check_goods_receipt_line_over_receipt_tolerance() (new; derives over_short_qty and performs the PO-line write-back atomically) |
trg_goods_receipt_line_validate_po_reference |
goods_receipt_line |
BEFORE INSERT OR UPDATE OF purchase_order_line_id, goods_receipt_id |
receiving.validate_goods_receipt_line_po_reference() (new, 2026-07-20, Gap-Fill Batch #74 — B3 direct receiving; enforces purchase_order_line_id presence/absence against the parent goods_receipt.receipt_source) |
Regression tests
apps/api/src/receiving/__tests__/receiving-schema.spec.ts — 48 tests, all passing (40 original + 2 added post-independent-verification, I4a/I4b, covering the strengthened self-computing trigger; + 6 more added 2026-07-20, Gap-Fill Batch #74, covering B3 non-PO direct receiving). Run with:
cd apps/api && npx jest src/receiving/__tests__/receiving-schema.spec.ts --forceExit
| Section | Covers | Tests |
|---|---|---|
| A | Both tables exist, RLS enabled on both | 3 |
| B | RLS <table>_tenant_isolation policy correctness spot-check |
2 |
| C | Column counts match the build record (34 / 29 — corrected from 33 / 29 on 2026-07-20, Gap-Fill Batch #74, since receipt_source made the old assertion stale), queried live from information_schema.columns |
2 |
| D | goods_receipt's 4 composite FKs — 1 all-resolve success + 4 isolated cross-tenant rejections (one mismatched column per test, all others valid) |
5 |
| E | goods_receipt_line's 7 composite FKs (incl. the self-referencing reversal FK) — same isolated-rejection pattern |
8 |
| F | chk_goods_receipt_voided_requires_actor_at (reject / accept / accept) |
3 |
| G | chk_goods_receipt_line_inspection_status quarantine widening (accept valid, reject invalid) |
2 |
| H | Idempotency dedup on (tenant_id,idempotency_key), incl. NULL-distinctness |
2 |
| I | Fix #11 tolerance trigger — catalog-default fallback + BLOCKER 1's capped write-back proof (incl. the uncapped-write-back rollup-CHECK violation); a tenant-wide percent override genuinely read; a site-scoped 'block' override winning over a tenant-wide 'flag' override |
3 |
| J | Reversal design — original line/movement untouched, compensating line's negative delta, reversal link resolves, compensating line's own qty columns stay positive-magnitude, direct-mutation DB-rejection on the original | 6 |
| K | The 3-way match (vendor_invoice_match) resolving PO line ↔ goods_receipt_line ↔ vendor invoice line, plus the remaining 2 of the 13 composite FKs (vendor_invoice_match/vendor_return_line → goods_receipt_line) |
4 |
| L | NEW (2026-07-20, Gap-Fill Batch #74) — B3 direct receiving: a direct (receipt_source='direct') goods_receipt insert with NULL purchase_order_id; a direct goods_receipt_line insert with NULL purchase_order_line_id; the full stock-effect walk for a direct line (real inventory.stock_movement/stock_movement_line pair); both directions of trg_goods_receipt_line_validate_po_reference rejection (a PO-sourced receipt's line with NULL purchase_order_line_id, and a direct receipt's line with a non-NULL one); header-level chk_goods_receipt_source_po_consistency incoherence rejection |
6 |
| Total | 46 (+2 for I4a/I4b not reflected in this per-section table's own running total, per the file's pre-existing 42-vs-40 discrepancy — see the 48-test prose count above) |
Every one of the 13 composite (col, tenant_id) → parent(id, tenant_id) FKs the whole 3-module extraction created or upgraded got real, individually-isolated cross-tenant-rejection coverage (not a representative subset) — 11 owned by receiving's own 2 tables (sections D/E), plus the 2 reciprocal ones owned by purchasing that point back into receiving.goods_receipt_line (section K).
Uses a full 2-tenant parallel fixture (tenant a / tenant b, every row tagged with a per-run slug prefix), one top-level afterAll teardown. inventory.stock_movement/stock_movement_line rows are deliberately left in place (append-only — REVOKE + trigger-enforced even against this admin connection, proven directly by test J6), matching purchasing-schema.spec.ts's own established tolerance for that exact residue; cleanup of item_variant/item/multi_loc.site/platform.tenant loops per-tenant (not one blanket statement across all fixtures) so the 3 tenants that legitimately built a seed line (a, b, j) don't drag the other 6 down into the same all-or-nothing rollback.
Open items carried forward
No ReceivingService yet — the accept/reject/quarantine workflow and any future AI OCR/put-away agent surfaces are still to be designed at the service layer (the PO-line write-back itself is no longer one of these — see BLOCKER 1, now DB-enforced by the trigger directly). goods_receipt.attributes's example JSONB shape is undefined (matches purchasing's own pre-existing carried-forward gap). goods_receipt.shipment_photo_ref remains a deferred Files forward-ref (plain text, no FK — Files not built, matches purchasing's own precedent). No dedicated index exists yet on goods_receipt_line.lot_id/.stock_movement_id/.stock_movement_line_id/.reversal_of_goods_receipt_line_id — add if a future query pattern needs it. admin.setting_definition/tenant_setting.value's lack of type enforcement (a 'number'-typed setting can still hold non-numeric JSON) is a pre-existing, disclosed gap on that shared table, not fixed by this build — only defended against inside this one trigger via the EXCEPTION WHEN invalid_text_representation fallback. Purchasing's own still-deferred fix #7/#11 prerequisites (named in PROJECT_DECISIONS #47 as pending "the upcoming Receiving extraction") are now CLOSED by this build.