# WP0 — UnitConversionService (pure primitive) [FIN-core]

## Goal
Create the ONE pure, unit-tested function that converts a quantity in any unit to the product's BASE unit. No wiring into stock/transactions yet — just the math + its tests. WP1 will call it from StockService.

## Exact files
- NEW: `/home/moonui2/moon-erp-be/Modules/Core/app/Services/UnitConversionService.php`
- READ for semantics: `Modules/Core/app/Models/Product.php` (`base_unit_id`, `baseUnit()`, `productUnits()`), `ProductUnit.php` (`product_id, unit_id, conversion_factor`), `Unit.php` (`unit_group_id, conversion_factor, is_base`).
- NEW test: `Modules/Core/tests/Unit/UnitConversionServiceTest.php` (or Feature if it needs the DB — it does, to read product_units/units; use a Feature test with factories).

## Semantics (get this exactly right)
`conversion_factor` = "how many of the reference base does 1 of THIS unit equal."
- `units.conversion_factor` is relative to the unit's GROUP base (the group's `is_base` unit has factor 1). e.g. Weight group: gram=1 (base), kg=1000, ton=1,000,000.
- `product_units.conversion_factor` is relative to the PRODUCT's base unit. e.g. product base=kg, carton=12 (1 carton = 12 kg).

## Public API
```php
class UnitConversionService {
    // qty in $unitId → qty in the product's base unit.
    public function toBase(int $productId, ?int $unitId, float $qty): float;
    // the multiplicative factor (base units per 1 of $unitId). toBase = qty * factorToBase.
    public function factorToBase(int $productId, ?int $unitId): float;
}
```

## Resolution algorithm (factorToBase)
1. `$unitId === null` → return **1.0** (identity).
2. Load the product; `$base = product.base_unit_id`.
3. `$unitId === $base` → **1.0** (identity).
4. Look up `product_units` row for `(product_id=$productId, unit_id=$unitId)`. If found and its `conversion_factor > 0` → return that factor.
5. Fallback via `units` (same group as base): if the product has a base unit and both `$unitId` and `$base` are `units` rows **in the same unit_group** → return `unitFactor / baseFactor` (both from `units.conversion_factor`). e.g. base=kg(1000), unit=ton(1,000,000) → 1000.0.
6. Any other case (no base unit, different groups, missing row, factor ≤ 0) → return **1.0** (identity) — defensive: invalid/unconfigured data must never break a stock movement or divide by zero. (WP5 adds validation to prevent factor ≤ 0 at entry.)

`toBase` = `$qty * factorToBase(...)`.

## Acceptance criteria
- Identity: `toBase(p, null, 5) == 5`; `toBase(p, base_unit_id, 5) == 5`; product with no product_units and unit==base → 5.
- product_units: product base=kg, carton factor 12 → `toBase(p, cartonUnitId, 2) == 24.0`; `factorToBase == 12.0`.
- units fallback: product base=kg (units kg=1000), issue in ton (units ton=1,000,000), NO product_units row → `toBase(p, tonUnitId, 2) == 2000.0`.
- Defensive: unit in a different group than base, or product_units factor 0/negative → identity (returns qty unchanged), no exception, no div-by-zero.
- Pure/deterministic; company scoping respected (reads go through tenant-scoped models like the rest of Core).

## Tests
`UnitConversionServiceTest` (Pest/sqlite) covering every acceptance bullet. Use existing Core factories (UnitFactory, UnitGroupFactory, ProductUnitFactory, ProductFactory) — check they exist under `Modules/Core/database/factories/`.

## Flags
- [FIN-core] (the math everything else depends on) — Codex + architect review. NO migration (read-only service). NO wiring (WP1 does that).

## Out of scope
- Do NOT touch StockService, any Action, any transaction, or the FE. Do NOT add columns. Only the service + its tests.
