Plan: task-0206 — size variants with inventory under one stock number
Recommendation
Add an opt-in variant set beneath an existing parent stock_no. The first
configured axis is Size. Inventory, allocations, receiving, selling, counts, and
replenishment operate on an immutable stock_variant_id; the parent stock remains
the catalog identity and rolls up its variants for legacy screens and parent-level
reporting. Do not create a stock number per size, overload serial number/UOM,
or allow a variant-enabled product to transact against an unspecified size.
The detailed findings are in the investigation. The workflow diagram shows both old and proposed paths.
Non-negotiable invariants
- One parent
stock_noowns product-family data; each active variant belongs to exactly one parent and is never reused for a different option combination. - All quantity-changing work for a variant-enabled parent carries a valid variant ID. Parent totals are projections, not fulfillment authority.
- A variant cannot be selected when it is inactive, does not belong to the parent, or lacks a required option value. Database enforcement must prevent mismatches.
- Every variant-level movement preserves parent stock, variant, location, source document, actor, reason, and a display-label snapshot for audit/history.
- Existing single-size stock stays fully supported without data conversion. A null variant has one meaning only: a product that has never entered variant mode.
- Parent barcode scans may open a picker but cannot infer a size. Variant barcode scans identify one selectable variant directly.
Proposed workflows
| Workflow | Today | Proposed |
|---|---|---|
| Product setup | Create/edit one stock record and enter product identifiers/packaging dimensions. | In Stock File, enable “Tracks variants”, define the Size values and order, create active variants, assign optional supplier SKU and barcode, and preview a size matrix. Parent description/price/supplier defaults are inherited. Publishing is blocked until every currently active/reserved/on-order quantity is assigned. |
| Purchase / PO | Search one stock number; create one stock-number line. | Search/select parent then choose Size. The editor stores variant ID per operational line and groups rows visually under the parent. Purchasing may split a planned quantity across sizes. Supplier SKU/barcode is shown for the chosen size. |
| Receiving | Receive a stock-number line into cost/location inventory blocks. | Receive the PO-selected size into variant inventory blocks. A mismatch opens a permission-gated “receive as different size” confirmation that preserves requested/received variant, reason, and source PO detail. Mixed-size cartons are entered as split receipts. |
| Adjust / count / transfer | Pick stock/location; serial applies where applicable. | Scan/search parent then choose/resolve Size before entering a quantity. Count import/API accepts a variant barcode or explicit variantId; parent scans with several sizes enter a pending resolution queue. Transfer preserves the same variant. A variance can never reduce a different size to satisfy a parent total. |
| Sales / order / fulfillment | Search or scan resolves stock number, then available stock is parent total. | Stock search returns parent family; sales must pick Size before add. Variant barcode skips the picker. Availability, allocation, substitute/partial fulfillment, picking, returns, and backorder status are variant-specific; parent line is only a presentation grouping. |
| Replenishment | Demand and EOQ/reorder values are stock/location based. | Calculate demand, open PO, safety/reorder, exception and recommended buy at variant/location. Show a parent rollup for planning; do not net stock of size 8 against size 11. |
| Reports | Sales, margin, inventory, aging, stock history, and valuation group by parent stock. | Default parent rollups remain familiar; an explicit “break down by size” dimension exposes variant facts. Drilldown labels include parent stock + immutable variant label. Pre-rollout history remains parent-only and is clearly labeled. |
Data and schema plan
New tables
stock_variant_sets(stock_no unique, status, enabled_at, created/updated audit).stock_variant_attributes(stock_variant_set_id, key, display_name, required, sort_order). First release permits exactly one activesizeattribute; this shape avoids a second redesign for color/width later.stock_variants(id UUID/bigint immutable, stock_variant_set_id, parent_stock_no, display_code, active, sort_order, retired_at, audit), with a unique parent/value combination andUNIQUE (id, parent_stock_no)to support composite references.stock_variant_values(stock_variant_id, stock_variant_attribute_id, value, normalized_value, sort_order)with a unique value per variant/attribute.stock_variant_identifiers(stock_variant_id, identifier_type, normalized_value, raw_value, source, audit)with uniqueness appropriate to barcode versus supplier SKU policy. Store normalization rules beside identifier type, not in UI code.stock_variant_replenishment_settings(stock_variant_id, location_cd, ...source backed reorder/safety/buy policy...)only for deliberate overrides; absence means the displayed parent setting is inherited, not hard-coded.- A database view/service projection
stock_variant_positionssumminginventory_itemsby warehouse, inventory location, parent stock, variant, and physical cost/receipt block. It returns on-hand, available, allocated, and value at variant grain; parent rollups sum that projection.
Existing data extensions
Add nullable stock_variant_id (and a snapshot label where a historical document
needs it) to the current inventory blocks and transaction/document lines: inventory
items/details, purchase-order and PO detail, receipt and receipt detail, order and
pick detail, invoice/history writing path, stock moves, counts, variances,
requisitions, EOQ/reorder facts and exports. The final technical design must map the
exact legacy/foreign per-location tables before migration generation.
Use a validated foreign key plus a composite parent/variant consistency constraint
or trigger. A simple FK alone is insufficient because it permits a variant for the
wrong parent stock. Lock the relevant variant inventory rows in the same transaction
currently used for InventoryItem detail updates; predicates must include
stock_variant_id before any quantity is read or reduced.
Keep stockslc/other warehouse on_hand, allocated, and related parent fields as
compatibility totals until all dependent consumers have moved. Derive them from the
variant ledger (and legacy null-variant rows) atomically. Do not make two ledgers.
UI, API, import, and integration contract
- Extend stock-detail/search responses with
variantMode, parent totals, available option matrix, and permission-filtered actions. Existing callers retain the parent shape until they opt into variant fields. - New endpoints should use an opaque
variantId, never a display size as the identifier: list/create/retire variants, resolve identifier, get positions, and set size-specific replenishment settings. Mutations require an idempotency key and return parent stock plus variant context. - Add
variantId(nullable while compatibility mode exists) to PO, receive, order, pick, transfer, count, variance, and inventory APIs. Reject a variant-mode write without it; return structuredvariantSelectionRequired/unknownVariantBarcodeerrors rather than silently picking a size. - Version OpenAPI schemas/examples and event/export payloads. CSV imports accept
stock_no+variant_code/sizeonly when the combination uniquely resolves; direct barcode input is supported where the scanner can supply it. Ambiguous parent imports become error rows with no partial posting. - Review integrations that currently assume
stock_nois a globally unique sellable key: external catalog/AQ/Magento links, RFID/barcode payloads, batch stock updates, pricing/reorder AI tools, reports/BI extracts, labels, and warehouse/mobile count clients. Add an explicit capability/version flag before emitting variant data. - Preserve existing stock-number URLs and API inputs for legacy items. For a variant parent, stock-only requests may read a parent rollup but may not perform a quantity mutation without variant context.
Pricing, cost, accounting, and reporting policy
Parent price and discounts are inherited by default. Variant price overrides are optional future-ready records with effective dates, permission and invoice snapshot; they are not required for Size v1. Receipt-lot inventory cost and existing accounting posting remain authoritative, but the selected variant flows through the inventory detail so COGS, variance, receipt and financial-journal provenance can be filtered by size. Parent average/last cost stays a derived display rollup.
Reports must expose two deliberate grains: parent (default, stable totals) and variant (size quantities/margin/demand). Parent rollups include all variants and legacy null-variant inventory. A report cannot label pre-rollout parent-only history as a size split. Replenishment must use the variant grain and derive its targets from database-backed configuration for the selected location/date context.
Permissions and audit
Reuse existing view/create/modify inventory and purchasing permissions for routine transactions, and introduce narrowly named permissions for:
- configuring a variant set and option values;
- retiring/reactivating a size;
- changing a variant identifier or variant-specific price/replenishment setting;
- resolving a parent scan/count/receipt mismatch; and
- viewing cost/margin at variant grain where parent cost is already restricted.
All conversion, mismatch, override, and retirement actions require reason text and
the current actor. PaperTrail/PosVersion should receive variant key and label in
tracked attributes or audit metadata; document/fact snapshots make old history
readable after labels change. Permission checks belong in controllers/services and
the API contract—not just disabled UI buttons.
Migration and rollout
- Foundation, dark read: deploy schema, models, composite integrity, positions projection, permissions, and telemetry with all product UI behind a feature flag. Null variants remain legacy and no stock enters variant mode.
- Read-only pilot: enable a small selected-location cohort to configure/display size matrices and compare projected parent totals against existing parent totals. Alert on any difference; do not permit transactions yet.
- Transaction pilot: choose a small, pre-cleaned set of parent stocks. Reconcile every active/reserved/on-order/on-PO quantity to explicit variants, run dry-run count and receive flows, then enable PO/receive/order/count/transfer for that cohort. Block conversion if any quantity cannot be classified.
- Reporting/replenishment: move demand, EOQ, exception and operational reports for pilot families to variant grain; retain parent total parity dashboards.
- Controlled expansion: batch enable only after reconciliation, staff training, integration capability confirmation, and rollback rehearsal. Keep a kill switch that disables new variant creation/selection while preserving already posted variant transactions; never “roll back” by erasing variant history.
Conversion must be resumable/idempotent, record source rows and decision actor, and use an exception queue. It must not mass-update closed historical invoices merely to create appealing reports. Backfill known mappings only with evidence; leave unknown history parent-only.
Performance and reliability
- Index every hot position path by
(location_cd, stock_no, stock_variant_id, active), plus inventory location and serial where applicable. Index identifier resolution by(identifier_type, normalized_value)and values by variant/set. - Aggregate from the existing detail ledger under the same locking/transaction boundary; avoid recomputing all parent inventory synchronously for each matrix render. Use a bounded, cacheable/read model only after parity checks.
- Search should return a small parent row plus an on-demand size availability matrix, not join every size in broad stock-list queries. Barcode lookup must be exact and indexed.
- Reconciliation jobs need pagination, advisory/cohort locking, restart checkpoints, metrics for ambiguous rows, and no long parent-table locks. Test concurrent allocation, variance, receive, and transfer for the same variant and different variants of the same parent.
Phased implementation slices
| Slice | Deliverable | Exit evidence |
|---|---|---|
| A — Domain foundation | Schema/migrations, models, constraints, audit metadata, flags, permissions, positions API/read model. | Database constraints and parent-total parity tests pass; no legacy flow behavior changes. |
| B — Stock setup and identifiers | Stock File variant configuration, size matrix, barcode/SKU resolution, label/scan behavior. | Accessibility/browser tests; ambiguous parent barcode requires choice; identifier uniqueness tests. |
| C — Purchase and receiving | Variant PO lines, split/mismatch receiving, inventory block creation and accounting provenance. | Receive/unreceive/cost tests and audited mismatch workflow pass. |
| D — Sales, allocation, transfers and counts | Size selection/search, allocation/pick/return/backorder, moves, adjustments, cycle/full counts and mobile/API payloads. | Concurrency and negative-availability tests; count/transfer cannot cross sizes. |
| E — Replenishment, reports and integrations | Variant demand/reorder/EOQ, reports/exports, OpenAPI/imports/events, integration versioning. | Parent rollup parity and variant drilldown verification on pilot data. |
| F — Pilot and rollout | Conversion service, reconciliation dashboard, training/manuals, observability, staged enablement. | Dry run, production-read-only parity, pilot acceptance, rollback/kill-switch rehearsal. |
This is intentionally a multi-slice story; no branch, worktree, product-code build, CI, push, or PR is authorized until the user approves a build after planning.
Test and verification plan
- Database/model: parent/variant ownership, unique normalized options and identifiers, inactive/reused variant rejection, composite FK/trigger rejection, legacy null compatibility, auditable label snapshots.
- Inventory ledger: receive, receive correction, cost layers, allocation, pick/ship, return, transfer, adjustment, variance and count preserve variant; parent totals equal sum of variants + legacy rows; a size cannot go negative because another size has stock.
- Documents/accounting: PO/order/receipt/invoice/financial transaction retain both parent and variant provenance; parent-price inheritance and approved override snapshot behavior; closed history immutability.
- Replenishment/reporting: demand/on-order/allocated/available at variant grain; parent rollups and existing legacy reports retain correct totals; no source-code KPI fallback values.
- API/import/integration: old stock-only reads remain valid; variant writes
require
variantId; barcode resolution, idempotency, malformed/ambiguous CSV, and OpenAPI contract tests cover error behavior. - Security/UI: permission matrix, non-JS/controller enforcement, audit reasons, stock setup, scan, sales, receiving, count and reporting browser tests. Rails tests must run through the project Docker convention only after implementation.
- Operational pilot: read-only parent-total comparison; controlled dry-run; concurrent-user load test; metrics/error-log review; a user-led receive-sale-count walkthrough using non-sensitive pilot data.
Risks and mitigations
| Risk | Mitigation |
|---|---|
| Parent total lets the wrong size sell | Every mutating flow validates variant availability; parent totals are display-only for variant parents. |
| Partial conversion creates “unknown size” inventory | Hard conversion gate and exception queue; no variant mode until all active/reserved/on-order quantities are resolved. |
| New schema slows stock search and reports | Indexed exact identifier lookup, lazy matrix fetch, query plans/load tests, and parent aggregate compatibility view. |
| Legacy integrations drop size | Capability/version flag, additive reads, reject unsafe writes, staged partner validation and monitoring. |
| Barcode duplicates or parent ambiguity | Normalized unique identifier registry and required chooser/error path. |
| Cost/financial audit loses traceability | Carry variant through inventory detail and document snapshots; parity/reconciliation before rollout. |
| Staff selects the wrong size | Size-first UI, scan confirmation, mismatch reason/permission, labels, guided count and receiving workflows. |
| Scope expands into color/style matrix immediately | Ship only Size in v1 atop a generic model; require a separate decision before enabling extra axes. |
Product decisions required
These decisions are intentionally not assumed:
- Variant attributes for v1: recommended: Size only, with generic internals ready for a later Color/Width decision. Should Color or Width be included now?
- Barcode policy: recommended: every active sellable size gets a unique variant barcode where supplier data permits; parent barcode opens a required size chooser. Is a default-size parent scan ever acceptable?
- Pricing: recommended: inherit parent price in v1; defer variant price overrides until a concrete business case. Are size-specific prices required now?
- Replenishment: recommended: variant-level reorder/EOQ for enabled families, beginning with pilot locations. Do buyers require per-size target quantities from the first pilot or a parent target split manually?
- Migration policy: recommended: pilot only products with a clean physical size allocation; unresolved quantities block activation. Is an explicit temporary “unclassified inventory” quarantine acceptable, or must every pilot item be manually assigned before release?
- Customer document presentation: recommended: show parent stock number plus size on one sales/packing/invoice line, grouping parent lines only in planning views. Is that the desired customer-facing document behavior?
Planning state
Keep task-0206 in planning pending these product decisions and a user review of
the migration scope. The work must transition to ready only after decisions are
recorded and before explicit build approval.