Warehouse Hierarchy & Custom Inventory Dimensions
A design reference for nested storage locations and extensible stock-tracking attributes in a single-codebase ERP system
1. Requirements
1.1 Functional requirements
- Represent locations as a tree: group nodes are organizational rollups; only leaf nodes take a stock movement.
- Every location belongs to one Legal Entity and, under perpetual inventory, resolves to a Ledger Account.
- Support a “type” tag on leaf locations, used chiefly to resolve a default in-transit location during transfers.
- Allow converting a leaf to a group and back, or deleting a location, only while empty of history.
- Let an admin declare an arbitrary tracking attribute (a shelf, rack, cold-storage unit) that appears on stock transaction lines with no code change.
- An attribute scopes narrowly (one line type, condition-gated) or broadly (every stock-affecting line type).
- The captured value must reach the movement row for that line, so downstream queries can filter or group by it.
- Support attribute-aware negative-stock protection, opt-in, additive to the item-and-location check.
1.2 Non-functional requirements
- Idempotency: re-declaring or re-saving an attribute must not duplicate its generated inputs.
- Extensibility: a new attribute must not require touching the shared code that builds or posts a movement row.
- Data integrity over flexibility: once movement rows reference an attribute, most of its configuration freezes.
- Clean teardown: deleting an attribute removes the inputs it generated.
1.3 Constraints
- The location tree is a nested-set model (left/right position pairs); structural changes recompute bounds for the affected span.
- An attribute cannot target a child-table record type or re-declare the four identities already tracked natively (item, location, batch, serial).
- This composes with, but does not replace, the shared posting funnel and Stock Position Record cache described in the inventory valuation design.
2. High-Level Design
2.1 Component diagram
2.2 Data flow — declaring a tracking attribute
- Admin picks an Attribute Register — the lookup the value is drawn from; not a child table, not a built-in identity.
- Admin picks a scope: one explicit line type, or “apply to all” — a live query for every line type already carrying a batch/serial-style link, plus one hand-listed exception.
- The Field Generation Step creates a Dimension Field Cluster per applicable line type: a primary field, plus a rejection-leg and/or transfer-leg field where relevant.
- A mirrored field is added to the movement-row and closing-snapshot records only if none exists yet — the idempotency guarantee.
3. Deep Dive
3.1 Data model
Warehouse — self-referencing tree via a parent-location field, maintained as a nested set. Carries a group/leaf flag, an optional Ledger Account, an optional Warehouse Type, a default in-transit location, and a “rejected-stock location” flag used by subcontracting-inward flows.
Warehouse Type — deliberately thin: a name plus free text. The only behavior-affecting value in source is the literal “Transit” type, used when resolving a default in-transit location.
Inventory Dimension — the attribute declaration: dimension_name seeds fieldnames; reference_document is the Attribute Register; apply_to_all/document_type set blanket-vs-explicit scope; condition gates eligibility (non-blanket only); type_of_transaction is the Inward/Outward/Both filter; reqd/mandatory_depends_on_backend drive a mandatory rule decoupled from the field’s own required flag; validate_negative_stock is the opt-in per-attribute check; source_fieldname/target_fieldname name the generated fields on the line and on the Stock Movement Entry.
Attribute Register (coined) — any ordinary, non-child, non-built-in record type a dimension points at.
Dimension Field Cluster (coined) — the one-to-three inputs one dimension generates on one line type: a primary field always; a rejection-leg field on purchase lines with a rejected quantity; a transfer-leg field on lines with an internal source/target split, shown only when that condition is active.
3.2 Tree maintenance and group-versus-leaf posting
A changed parent triggers the shared tree-position recompute, rewriting left/right bounds across the affected span so “is this a descendant” reduces to a bounds comparison. A group’s balance is the sum of every leaf inside its own bounds — a group node never carries a position of its own. This is enforced: the validation every Stock Movement Entry runs through rejects a group-flagged location outright. Converting a leaf to a group (or back), and deletion, are both blocked while a child, a non-cancelled movement, or (for deletion) a nonzero Stock Position Record measure exists.
3.3 The dimension injection mechanism
A declared attribute rewrites the schema of every line type in its scope (§2.2 — explicit, or the self-extending batch/serial-usage query), then rewires the shared posting path to feed it.
What it creates. Per applicable line type: a link field to the Attribute Register, created only if it doesn’t already exist — the idempotency guarantee. Purchase-side lines also get a rejection-leg sibling (accepted vs. rejected quantity land in different locations); transfer-capable lines get a transfer-leg sibling, shown only when that condition is met. A mirrored field is likewise added to the Stock Movement Entry and the closing-snapshot record. A short exclusion list (serial/batch-bundle internals, pick-list rows, a maintenance-visit-purpose child) is skipped even under blanket scope.
Where it hooks in. The shared base controller used by every stock-affecting document type builds one funnel-row dictionary per line before handing it to the posting funnel. That same step narrows dimensions to this line type, applies the direction filter against the line’s quantity sign and submit/cancel status, evaluates any condition, then copies the value onto the funnel row’s mirrored field — with dedicated branches for the rejection leg (matched against the row’s rejected location) and the transfer leg (matched against its source/target fields).
Mandatory enforcement is decoupled from the field’s own required flag, since field-level “required” would block a cancellation-only submission and could not skip a service (non-stock) line. A separate step instead runs once per document before submission, checking whether the relevant field — primary, rejection-leg, or transfer-leg — was filled in wherever its own applicability condition holds.
Locking after first use. Once a non-cancelled movement carries a value in a dimension’s field, a before-save guard freezes nearly every configuration field (register, scope, generated fieldnames) by diffing the save field-by-field; only the parent-fetch mapping, direction filter, condition, and negative-stock flag stay editable. Deletion removes the generated fields.
Negative-stock protection per attribute is opt-in and additive: an outward row sums prior quantity for the same item, location, company, and matching dimension value, rejecting the posting with a dedicated error if that would go negative.
3.4 Cross-reference to accounting dimensions
The accounting dimensions framework described separately follows the same shape — declare a lookup, retrofit a field onto transaction types — but is simpler: one plain field per type, a fixed applicable-type list, and background-job enable/disable rather than a configuration freeze. Inventory dimensions are richer because one stock movement can split across accepted/rejected and source/target legs, which a ledger posting never does; both still funnel generated fields through the same underlying field-creation primitive.
3.5 Error handling
Dedicated errors cover an invalid register (child table or reserved identity) at declaration time, a post-transaction configuration change, and attribute-scoped negative stock (naming item, location, value, and document). Location deletion/conversion guards raise ordinary validation errors naming the blocking condition; a Ledger Account change on a location with history is only warned about, not blocked.
4. Scale and Reliability
- Tree recompute scales with the affected span, not just the moved node — deep or wide hierarchies make reparenting costlier, though warehouse trees are typically shallow.
- Dimension configuration is cached per request, not re-read per line, which matters on documents with many rows.
- Range-based descendant lookups keep rollups cheap, scaling with an index rather than tree depth.
- Blanket-scope dimensions multiply schema writes: enabling one is a batch of field-creation calls across every qualifying line type at once — one-time, not per-transaction.
- Per-dimension negative-stock checks are additive per outward line: each enabled attribute adds its own targeted query.
5. Trade-off Analysis
| Decision | Trade-off |
|---|---|
| Nested-set tree over adjacency-list-only | Cheap rollup queries via bounds comparison, at the cost of recomputing bounds on structural change. |
| Group locations never post, only aggregate leaves | Every movement anchors to one leaf — but conversion/deletion are blocked once history exists, so a bad hierarchy can’t be reshaped in place. |
| One declaration generates a field cluster per line type, not one generic column | Attributes are self-service, no schema migration — but the explosion is what forces the freeze-after-first-use. |
| Mandatory enforcement moved off the field’s own required flag onto a late check | Skips service lines, never blocks cancellation — but the rule no longer lives visibly on the field. |
| Blanket scope from a live batch/serial-usage query, not an allow-list | Self-extends to new line types — but is an indirect heuristic that can mis-scope one. |
| Attribute-scoped negative-stock check, additive to the base check | Enforces a stricter constraint (no overselling one rack) — at the cost of an extra query per attribute per line. |
6. What to Revisit as the System Grows
This is a Low-priority document; the length here runs somewhat past the calibration band because the injection mechanism (§3.3) needed the space to stay grounded rather than hand-wavy — trimming further would mean dropping verified detail rather than padding.
- Configuration-freeze granularity: the lock blocks nearly every field rather than only the register and fieldnames that would corrupt history; a narrower freeze, closer to the accounting-dimensions document-type guard, would ease fixing a scoping mistake.
- Blanket-scope heuristic: inferring “stock-affecting” from live batch/serial usage is indirect; an explicit registry would make inclusion less surprising as line types multiply.
- Per-dimension negative-stock query cost: many enabled attributes on high-volume documents would benefit from one combined check.
- No merge path for restructured locations: nothing in the reviewed source consolidates one location’s history into another ahead of a reorganization.