## Context

Today `Product.unit` is a free-text string (not even a foreign key — it stores the `Unit.name` value directly, sourced from `Unit::all()->pluck('name','name')`). `Unit` itself is a flat, admin-managed lookup table (`name`, `description`) shared across all products — not an enum, so no unit name (e.g. "Viên") can be assumed to always exist or mean the same thing across stores.

Pricing today is two-tier: `Product.sale_price` (retail) and `Product.wholesale_prices` (a JSON map keyed by `Customer` type, e.g. `sale_si`/etc.), read through `Product::getPriceByCustomerType($type)`.

Order pricing is entirely server-computed and already distrusts the client: `OrderController::store`/`update` loop over submitted `items[code]`, look up the `Product` fresh, and call `getPriceByCustomerType($customerType)` — the client only ever sends `qty`, never a price, and the server ignores any price the client displays. This is a load-bearing security/integrity property that this change must preserve, not just replicate in a new code path.

The POS cart is server-rendered jQuery + jsrender (`#scannerItem` template in `resources/views/vendor/admin/index.blade.php`), driven by inline PHP-generated JS in `PosController@index`. There is no SPA/build step for this part of the UI — new interactivity must fit the existing jQuery + jsrender + `$.templates` pattern already in use.

`order_product` currently stores only `qty`, `price`, `discount` per line — no product/unit snapshot beyond price. Stock is never decremented anywhere in the order flow today, so unit conversion has no inventory-adjustment concern to design around.

## Goals / Non-Goals

**Goals:**
- Let an admin configure, per product, any number of additional sellable units, each with a conversion quantity relative to the product's existing base unit.
- Derive retail and wholesale prices for every configured unit purely from the existing base-unit prices (`sale_price`, `wholesale_prices[type]`) divided by that unit's conversion quantity — never store a separately-editable price per unit, so a later edit to the base price automatically re-derives every unit's price.
- Let a cashier pick any configured unit (base or conversion) on a cart line in POS, with the line's price/subtotal recalculated accordingly, respecting the active customer's wholesale type.
- Keep the server as the sole source of truth for the price actually charged, exactly as today — extend `OrderController` to compute price from `(product, customer_type, unit)` rather than just `(product, customer_type)`.
- Record which unit (and at what conversion ratio) each order line was actually sold at, snapshotted at order time, so historical orders remain accurate if the product's conversion configuration changes later.

**Non-Goals:**
- No inventory/stock deduction logic of any kind (matches current system behavior — stock is not tracked through the order flow today).
- No per-unit barcode scanning or lookup; a unit is only selectable via dropdown after the product is already a cart line (added at its default unit).
- No hard-coded unit-name semantics (e.g. no special-casing "Viên"); `is_default` is an explicit admin-set flag on a `product_units` row, never inferred from unit name or conversion ratio.
- No change to how `Product.unit` (the base unit) itself is stored or selected — it remains the existing free-text-backed select.

## Decisions

**1. `product_units` stores only the conversion ratio, never a price.**
Columns: `product_id`, `unit_id`, `conversion_qty` (decimal), `is_default` (boolean). Price for any unit is always computed on read as `base_price / conversion_qty`, where `base_price` is whichever of `sale_price` or `wholesale_prices[type]` applies. Alternative considered: snapshot/cache a computed price per unit at save time (like `wholesale_prices` does per type). Rejected because the user explicitly wants unit prices to "tự khớp lại" (auto-stay-in-sync) when the base price changes — a cached value would drift and require an extra invalidation step on every base-price edit.

**2. Wholesale price per unit reuses the exact same division formula — no per-unit wholesale input fields.**
`getPriceByCustomerType($type, $unitId = null)` on `Product` becomes the single place that: resolves the base price for `$type` (existing logic), then if `$unitId` is set and not the base unit, divides by that unit's `conversion_qty`. This keeps one formula for both retail and wholesale instead of two parallel code paths, and matches the user's explicit confirmation that wholesale must follow the same per-unit ratio.

**3. `is_default` is a plain boolean per `product_units` row, exactly one enforced true at a time (or none).**
Considered auto-selecting the unit with the largest `conversion_qty` (i.e., "smallest" physical unit) as a heuristic default. Rejected: `units` is an open, admin-defined vocabulary (confirmed while exploring — no enum, no reserved names), so any name- or ratio-based heuristic will silently misfire for stores whose smallest configured unit isn't the one they want defaulted (e.g. non-pharmacy inventories). An explicit flag keeps the default correct and self-documenting per product; enforcement (at most one default per product) is handled at the form-submission layer (uncheck others when one is checked), not a DB constraint, to keep the migration simple — see Risks.

**4. `order_product` gains `unit_id` (nullable FK) + `conversion_qty` (nullable decimal), both snapshotted at order-create/update time, not re-derived from `product_units` on read.**
This mirrors the existing `price` column, which is already a frozen snapshot rather than a live join. Alternative considered: store only `unit_id` and join to `product_units` for the ratio at display time. Rejected because `product_units` rows can be edited or deleted after the sale, which would silently corrupt historical order display (e.g. receipts, past-order views) — the same failure mode the existing `price` snapshot already protects against. `unit_id = NULL` means "sold at the base unit," preserving compatibility with every existing order row without a backfill.

**5. `OrderController::store`/`update` accept `items[code][unit_id]` (optional) and validate it belongs to that product before pricing.**
The server re-fetches the `Product`, looks up `unit_id` in that product's `product_units` (ignoring/rejecting a `unit_id` that doesn't belong to the product or doesn't exist), and calls the extended `getPriceByCustomerType($type, $unitId)`. This preserves the existing trust boundary: the client can influence *which unit*, never *what it costs*.

**6. `/pos/scan` response is extended with a `units` array per product** (each entry: `unit_id`, `label`, `conversion_qty`, `is_default`, plus the base unit itself as an implicit entry with `unit_id: null`), so the cart-line dropdown and default-selection logic have everything they need without an extra round-trip per product.

**7. Cart-line unit switching stays purely client-side (jQuery/jsrender), reusing the existing `getPriceByCustomerType` AJAX endpoint (`/products/get-price`) extended with an optional `unit_id` query param**, rather than duplicating the price formula in JS. This avoids a second, potentially-drifting implementation of the retail/wholesale/unit pricing formula in JavaScript — the display price and the eventually-charged price come from the exact same PHP method.

## Risks / Trade-offs

- **[Risk]** No DB constraint enforces "at most one `is_default = true` row per product" → a race or a bug could set two defaults, leaving default-selection ambiguous. **Mitigation**: enforce in `ProductController::store`/`update` by unsetting other rows' `is_default` when persisting the submitted set (single source of truth: the submitted form state, not incremental toggles); document the invariant in the `ProductUnit` model.
- **[Risk]** Snapshotting `unit_id`/`conversion_qty` on `order_product` means a unit's *label* (name) shown on old receipts still depends on `units.name` at read time — if an admin renames a `Unit` row, old orders will show the new name against the old ratio. **Mitigation**: acceptable — this matches existing behavior for product names on orders (also joined live, not snapshotted), so it's consistent with the codebase's existing precedent rather than a new inconsistency.
- **[Risk]** Extending `/products/get-price` and `/pos/scan` response shapes could break any other consumer of those endpoints. **Mitigation**: both changes are additive (new optional param, new response key) — existing behavior for callers that don't pass `unit_id` / don't read `units` is unchanged.
- **[Trade-off]** Always deriving unit price live (Decision 1) means one more division at read time on every cart render/price lookup instead of a stored value. Given per-request product counts in this POS (a handful of cart lines), this is negligible and was explicitly preferred by the user over the staleness risk of a cached price.

## Migration Plan

1. Migration: create `product_units` table; add nullable `unit_id`, `conversion_qty` to `order_product`. Both are additive, no backfill needed (existing products/orders behave as "base unit only").
2. Ship `Product`/`ProductUnit` model changes and the extended `getPriceByCustomerType` behind the existing method signature (new param defaults to `null`, so all current call sites keep working unchanged).
3. Ship `ProductController` create/edit/store/update changes (new box, persistence) — deployable independently; with no `product_units` rows yet, POS behavior is unchanged.
4. Ship `/pos/scan`, `/products/get-price`, and cart JS/template changes — only then does the new dropdown appear, and only for products with configured conversion units.
5. Ship `OrderController` `unit_id`-aware pricing last, once the client can actually send it.
6. Rollback: each migration is additive/reversible independently (drop columns/table); no data transformation to undo.

## Note on the bundled `pos-receipt-customer-points` capability

This change also carries `pos-receipt-customer-points` (showing a customer's live reward-point balance on the printed receipt), folded in from a separate, previously proposal-only change for delivery convenience. It is a single read-only line added to `pos-print.blade.php`, reading the existing `Customer->points` field with no new model, controller, or data flow — it shares no code path or design decision with the multi-unit pricing work above, so none of the Decisions/Risks/Migration Plan sections apply to it.

## Open Questions

- None outstanding — all open questions from exploration (wholesale-per-unit, order-line snapshotting, stock scope, default-unit selection strategy) were resolved with the user before writing this design.
