# 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](../investigations/task-0206-size-variant-inventory-investigation.md). The [workflow diagram](../diagrams/task-0206-size-variant-inventory/workflow.html) shows both old and proposed paths. ## Non-negotiable invariants 1. One parent `stock_no` owns product-family data; each active variant belongs to exactly one parent and is never reused for a different option combination. 2. All quantity-changing work for a variant-enabled parent carries a valid variant ID. Parent totals are projections, not fulfillment authority. 3. 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. 4. Every variant-level movement preserves parent stock, variant, location, source document, actor, reason, and a display-label snapshot for audit/history. 5. 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. 6. 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 active `size` attribute; 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 and `UNIQUE (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_positions` summing `inventory_items` by 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 structured `variantSelectionRequired`/`unknownVariantBarcode` errors rather than silently picking a size. - Version OpenAPI schemas/examples and event/export payloads. CSV imports accept `stock_no` + `variant_code`/`size` only 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_no` is 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 1. **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. 2. **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. 3. **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. 4. **Reporting/replenishment:** move demand, EOQ, exception and operational reports for pilot families to variant grain; retain parent total parity dashboards. 5. **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: 1. **Variant attributes for v1:** recommended: Size only, with generic internals ready for a later Color/Width decision. Should Color or Width be included now? 2. **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? 3. **Pricing:** recommended: inherit parent price in v1; defer variant price overrides until a concrete business case. Are size-specific prices required now? 4. **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? 5. **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? 6. **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.