# LEDGER — Unit-of-Measure Conversion Engine (ISS-2026-9104)

**Date:** 2026-07-16 · **Branch:** hazemdev2 (both repos) · **Dev DB:** moonui2_dev_be (forward-only)
**Source (approved + scope-frozen):** knowledge-base/plans/uom-units-of-measure-analysis.html (§7 WP1-5, §8 decisions) · Portal ISS-2026-9104 scope_freeze step 3634.

**Owner/client acceptance test:** A product with base unit "kg" and an alternate unit "carton" (factor 12) — buying/selling/issuing 2 cartons moves **24 kg** of stock, values cost on 24 kg, prices from the carton price (or base×12), and availability/FIFO/reservations all compute in kg. A product with only its base unit behaves **byte-for-byte as today**. Conversion is available by default (client: almost all clients use units). Stock balances/movements/reports show the base-unit symbol.

## Frozen decisions (do NOT re-litigate)
- **D1 — available by default** (client Q2 "رأي آخر"): conversion active by default, NOT opt-in OFF. Achieved safely because conversion is **identity** for single-unit products / line unit == base (factor 1) → zero behavioural change for products without alternate units.
- **D2 — identity-safe invariant (SACRED):** single-unit product OR line unit == base_unit ⇒ path byte-for-byte unchanged; existing Inventory/Sales/Purchases/Production tests stay green.
- **D3 — pricing (Q3):** non-base unit price = product_units.sale_price/purchase_price if set, else base price × conversion_factor.
- **D4 — no backfill (Q4):** prevent new wrong data only; no retroactive data repair (moonui2 = test data). Client repair command is a later option if needed.
- **D5 — document vs stock (from architect gate):** customer/vendor-facing document totals stay in the ENTERED unit (qty × unit_price); ONLY the stock/cost side converts to base. Conversion happens exactly ONCE at the stock boundary.
- **Out of scope:** LIS lab_units unification; per-lot × unit balances.

## Phases (WP table) — refined after architect gate
| WP | Scope (one dense line) | Repo | Depends | Review | Migration? | Status | Commits |
|----|------------------------|------|---------|--------|-----------|--------|---------|
| WP0 | Pure `Core/Services/UnitConversionService::toBase/factorToBase` (product_units → units-in-group fallback → identity). [FIN-core] | BE | — | Codex+arch | no | ✅ done | a0d0992 · 11 unit tests green · review APPROVE (math correct: 1 ton=1000kg, all factor branches guarded vs 0/neg, identity exact 1.0) · 2 Low→deferrals |
| WP1 | 5 StockService methods accept unit_id → toBase (dormant); inverse unit_cost on increase; base_quantity col on 10 line tables; availableQuantity returns base. [FIN] | BE | WP0 | Codex+arch | yes | ✅ done | 3ae98beba · migration on dev · 7 new tests green · identity held (434/1-pre-existing, 0 new) · [FIN] review APPROVE (0 crit/high; 1 MEDIUM→WP2: FIFO layer decimal(15,3) unit_cost can drift sub-cent on non-integer factors) |
| WP2 | 🔄 started 2026-07-16 · Thread entered unit_id from each line into StockService `$data` at the 5 risk-ordered site-groups; FIX ApproveIssue:200 direct-check FIRST; invariant + factor≠1 round-trip tests. [FIN] | BE | WP1 | Codex+arch | no | ⬜ | |
| WP3 | FE: line unit dropdown scoped to product units (ProductUnitScopeService + shared-grid scopeToProductUnits flag, 12 screens) + unlock unit column. | FE | WP1 | Codex | no | ✅ done | a3716a7de · ng build green · self-reviewed (presentation-only; edit/barcode-add rows fall back to full list until re-pick — safe) |
| WP4 | Per-unit pricing: FE reprices on unit change (product_units price else base×factor) + BE min-price floor scaled by factor + POS matches product_units.barcode (matched_unit block). [FIN] | BE+FE | WP1 | Codex | no | 🔄 impl done BE 34d073315 / FE e6f2ae173; 28 tests green; [FIN] review WARNING: **1 HIGH pending fix** (POS per-unit barcode ignores conversion_factor → null-sale_price carton sold at base price = undercharge; fix in pos-cart: sale_price ?? base×factor + null-price/cross-company tests) → **HIGH FIXED** (FE 04ebc6fa6 resolveUnitPrice base×factor fallback + BE 1281db67e null-price/cross-company tests; 14 POS tests green). WP4 ✅. 2 LOW + offline-barcode deferral | |
| WP5 | Base-unit symbol on balances/movements/reports + DefaultUnitSeeder (Count/Weight/Volume/Length, idempotent, registered in updater+installer) + validation (factor gt:0, one-base-per-group atomic demote, group consistency) + ProductUnit destroy. | BE+FE | WP1 | Codex | no | ✅ done | BE f171c239f / FE c91daf977 · 12 new tests green · 0 new failures · ran seeder on dev · ng build green · review APPROVE (0 crit/high/med; 4 non-blocking Low) |

(WP0 split out of the analysis's WP1 so the pure converter is unit-tested before any wiring — architect gate may re-merge; will reconcile.)

## DAG / serialization
- **One writer per repo at a time.** WP0→WP1→WP2 are BE-serial (all touch StockService/costing). WP3 is FE-only (after WP1's API contract). WP4 BE+FE, WP5 BE+FE. Execute BE-serial: WP0→WP1→WP2→WP4(BE)→WP5(BE); FE: WP3→WP4(FE)→WP5(FE). Serialize any two WPs touching the same repo.

## Baseline (2026-07-16, sqlite)
**CORRECTED baseline (full run, batched): Inventory feature suite = 434 passed / 1 PRE-EXISTING fail** (`OpeningBalanceApiTest › creates movement type opening` — stale expectation movement_type receipt vs opening; NOT caused by our work — double-confirmed via git-stash on clean tree). Earlier '139/0' was an incomplete/cut-off run. SACRED invariant: no NEW failures beyond this 1. Whole-suite one-shot gets resource-killed (exit 143/144) → always run BATCHED (4×8 files sequential in background). Production guard suites green from ISS-9105.

## Phase C — FINISH (2026-07-17)
- **Whole-feature cross-WP review: APPROVE** (0 crit/high/med; 1 Low = corrected magnitude of the known reverse-leg residual). Single-conversion everywhere, no double-decrement (autoCreateGdn guard), document/stock planes separated, identity byte-for-byte, post/cancel symmetric across all 8 cancels.
- **Full consolidated batched suite (Inventory/Sales/Purchases/Core/POS): ZERO new failures vs baseline.** POS 0 fail. Every failure surfaced (Sales CogsAtDeliveryCancel×6 + SalesReturn×1, Purchases PurchaseReturn/StandaloneReceiptGate/PurchasesSetting[KB-documented double-seed], Core UserApi/RoleTenantScoping/SessionApi×4, Inventory OpeningBalance) is **PRE-EXISTING** — Core cluster CONFIRMED by reverting Modules/Core to pre-9104 (57f297342) → identical 4 failures; others confirmed pre-existing by per-WP stash + KB baseline notes. Codebase has no CI so these long-standing failures were simply never run before this sweep.
- **CHANGELOG: 3 well-formed bilingual [Unreleased] bullets** (stock conversion · pricing+POS · display+seed+validation), all {{ar}}-delimited, both halves; production deferral documented in-bullet.

## Architect design gate (WP1 placement) — VERDICT: GO-WITH-CHANGES
**Placement = option (a)-extended:** StockService is the SINGLE conversion boundary, backed by a pure `Modules/Core/app/Services/UnitConversionService::toBase(productId, ?unitId, qty)` primitive (the math). ALL SIX qty-taking StockService methods accept `unit_id` and convert internally: `increaseStock`, `decreaseStock`, **`getIssueCost`**, `reserve`, `release`, `availableQuantity`. (NOT just increase/decrease — getIssueCost reads cost separately; converting only the write methods corrupts cost.)
**Convention (freeze):** lines store ENTERED unit_id + entered qty; ledger (movements/cost_layers/balances) is ALWAYS base, NO unit column; toBase() called in exactly ONE place (inside StockService); no Action/controller/event pre-converts; a base ledger value is never re-fed to a converting method.
**Cost invariant (critical):** converting qty to base REQUIRES inverse unit_cost conversion: `base_unit_cost = total_cost / base_qty` (= unit_cost / factor). Else 1 carton@ $12 (factor 12) books $144 not $12. Store base_qty + base_unit_cost in layers/movements (choke point already computes total_cost once).
**One REQUIRED schema addition:** `base_quantity DECIMAL(15,3)` (or `applied_conversion_factor DECIMAL(15,6)`) on transaction line-item tables, stamped at post — for reversal factor-STABILITY (if factor edited between post and cancel, reverse must == forward). Ledger tables unchanged (always base).
**Highest-risk site (fix FIRST in WP2):** `ApproveIssue.php:183-208` does a direct `StockBalance::lockForUpdate()` compare of line-unit `$qty` vs base on-hand — bypasses availableQuantity; must be explicitly converted before the `$available < $qty` check (:200).
**WP2 risk-ordered queue:** (1) ApproveIssue direct check; (2) getIssueCost cost read (ApproveIssue:212, PostSalesInvoice:226); (3) PostPurchaseBill:630→ApproveReceipt:185 (qty AND unit_cost together); (4) production reserve/release (ReleaseProductionOrder:80, IssueMaterials:320, CancelMaterialIssue:161, StageMaterialsBulk:187, ProductionOrderController:469/553); (5) symmetric reversal/backflush (IssueMaterials:243, ConfirmDeliveryNote:84, PostSalesReturn:197-210).
**Pricing (WP4) cleanly separable:** document totals in ENTERED unit (qty×unit_price, never through StockService); stock/cost in BASE; they reconcile only via total_cost (invariant under conversion).

## Deferrals
- **WP5 Low (non-blocking):** one-base invariant is "not two bases" not "at least one" (a user can unset the sole base → zero-base group; pre-existing shape) · 2 minor test gaps (non-base-create leaves base alone; update-path factor gt:0) · StoreProductUnitRequest product lookup not explicitly company-scoped (pre-existing, worth a separate pass).
- **WP2 MEDIUM (reverse-leg value residual) — deferred follow-up [FIN]:** forward issue draws value EXACT from remaining_value, but reversal/return re-stock (increaseStock in PostSalesReturn:224, CancelSalesInvoice:122, CancelDeliveryNote:74, CancelIssue:48) recreates a layer at round(base_qty×unit_cost) → residual on factor≠1 non-integer-per-base-cost lines. **Magnitude ≈0.0005×base_quantity PER REVERSE LEG (scales with factor — ~0.004 for factor-12/cost-10 carton, accumulates), NOT flat 0.001** (corrected by final review). Non-regressive for single-unit, within system's endemic 3-dec rounding. Fix = stamp the issued VALUE (base total_cost) on the line + restore it value-exact on reversal (thread through the 4 paths) + a non-integer-cost return/cancel test. Reviewer confirmed forward leg + all else correct. Surface in ticket.
- **WP2 → follow-up WP (Production multi-unit reservations):** production reserve/release/staging/backflush left byte-for-byte identical (factor 1). Material CONSUMPTION already converts (issues stock via wired ApproveIssue), but multi-unit reservation/staging holds are NOT wired — an all-or-nothing cascade across 8+ sites (ReleaseProductionOrder, IssueMaterials, CancelMaterialIssue, StageMaterialsBulk, BackflushFromStaging, ProductionOrderController×2) with no existing multi-unit production integration coverage. Clean follow-up WP. Surface to owner in the ticket update.
- **WP2 MUST-DO (from WP1 [FIN] review):** FIFO cost layer stores unit_cost at decimal(15,3); base_cost=cost/factor for non-integer factors rounds → layer value (qty×3-dec) diverges sub-cent from value-preserved WAC total. On WP2 activation, consume/persist the FIFO layer from the value-preserved TOTAL (or store base total value), not qty×3-decimal unit_cost. Inert now (factor always 1.0).
- **WP0 Low:** UnitConversionService uses unscoped Product::find/Unit::find — relies on caller (StockService) passing already-tenant-scoped IDs + unit_group company-uniqueness (architect-confirmed acceptable). Add a defensive company guard if ever called from an unscoped context.
- **WP0 Low:** untested branch transition (invalid product_units factor 0/neg → valid units fallback); logic correct by inspection.
