# C3 — `pos.block_expired_sale`: the till's own expiry policy  **[FIN]**

**Phase C, group «الصلاحية والتشغيلات», package 3 of 4**
**Repo:** BE (+FE only for the warn channel) · **Branch:** `hazemdev2` · **Migration:** no

## The defect

Expired medicine sells. `inventory.expired_issue_policy = block` (the default) **excludes** expired lots from FEFO selection — it does not refuse the movement. The quantity it declines to allocate falls through as a shortfall, and before C2 that shortfall was discarded silently. C2 made the shortfall fatal, but **only when `pos.enforce_batch_selection` is on**, and it refuses with a **generic** message: it deliberately cannot say *why* coverage failed.

So today the cashier either sells expired stock, or (with the batch guard on) is refused with a message that does not mention expiry.

## ⚠️ The design conflict — resolved, implement this, do not re-open

Two settings currently describe one idea:

| Setting | Default | Governs |
|---|---|---|
| `inventory.expired_issue_policy` | `block` | whether an expired lot may be **selected** by FEFO — company-wide, every module |
| `pos.block_expired_sale` | `warn` | *(this WP)* |

With the inventory policy at `block`, an expired lot is never selected — so a POS value of `warn` or `allow` would have **nothing to warn about or allow**. The two would cancel each other.

**Decision: `pos.block_expired_sale` is the till's authority.** At the POS sale path it **governs**, overriding `inventory.expired_issue_policy` for that call. Everywhere else — inventory issues, transfers, production, WebStore — the inventory policy continues to rule, untouched.

This keeps "one home per concept" because the concepts are genuinely different: **the counter's policy** vs **the warehouse's policy**. A pharmacy may refuse to dispense expired stock at the till while the warehouse still needs to move it to a quarantine location.

Semantics at the till:
- **`block`** → the sale is **refused (422)** when coverage requires expired lots, with a message naming **expiry** and the product — not a generic shortfall.
- **`warn`** → the sale **posts**, expired lots may be used, and the response carries a warning the screen shows.
- **`allow`** → posts silently, as today.

## What to build

1. **Make the POS allocation obey the till's policy.** `LotAllocationService` reads `inventory.expired_issue_policy` at ~line 249 to build its candidate-lot query. The POS call site must be able to pass an override. Find the seam — `PostSalesInvoice` already receives `allocate_lots` from `POSSaleController.php:269`; adding an explicit policy alongside it is the likely shape. **Read it and decide; state which seam you chose and why.**
2. **Distinguish the two causes.** When coverage fails, the caller must know whether it failed because *no lots exist* or because *the only lots are expired*. C2's `LotAllocationService::coverage()` already runs the real selection — extend what it reports rather than adding a second query. C2's tests already pin both causes (`C3 HANDOFF: …`) — **read them first; they were written for you.**
3. **The `warn` channel.** A warning must reach the cashier. The sale response is the natural carrier (the payment dialog already prints the server's sentence verbatim on 422 — see the POS `CLAUDE.md`). Do not invent a new endpoint. If the FE needs a small change to surface it, that is in scope; keep it minimal.
4. **Unlock the setting** — flip `is_implemented => true` for `pos.block_expired_sale` in `SettingDefinitionSeeder`, keep `default_value` **`warn`**, and re-seed. **39 settings must remain locked** afterwards (C1 took 42→41, C2 took 41→40).

## The reality this ships into — read before choosing behaviour

C2 measured, on `moonui2_dev_be`: **5 of 7 product/warehouse combinations would be refused today**, and mostly **not** because of drift — those lots **expired between 2026-07-15 and 2026-07-31**. Example: product 17159 / warehouse 13 holds 100 units on the balance and **0** in valid lots.

So on this install, `block` would stop a lot of selling immediately — correctly. That is why the default is `warn` and why `pos.enforce_batch_selection` is off. **Do not "helpfully" tighten either default.**

## Acceptance criteria

1. `block` + coverage requires expired lots → **422 naming expiry and the product**, and `stock_balances` is unchanged. Distinct from C2's generic shortfall message — assert the wording differs.
2. `block` + fresh lots cover the quantity → posts normally.
3. `warn` → the sale **posts**, expired lots are used, the response carries a warning identifying the product, and stock moves.
4. `allow` → posts with no warning; behaviour identical to today.
5. **Non-POS paths are untouched**: an inventory issue / transfer with `inventory.expired_issue_policy = block` behaves exactly as before, whatever `pos.block_expired_sale` says. Prove it with a test — this is the blast-radius guard.
6. A product with `tracking_type != batch` is unaffected in every mode.
7. **Red-before-green on criteria 1 and 3** — show the expired sale posting silently first. Paste both runs.
8. 39 settings remain locked; `pos.block_expired_sale` saves from the settings screen and lands on the correct key (C1 fixed the `pos.`-prefix trap — verify it still holds for this key).

## Baselines — green = zero NEW failures
- `pest Modules/POS` → **243 passed / 0 failed**
- `pest Modules/Inventory` → **735 passed / 4 failed** (the 4 are documented date-rot + pre-existing)
- `pest Modules/Core` → **696 / 10** · `pest Modules/Sales` → **470 / 12**

## Environment
- BE `/home/moonui2/moon-erp-be`, FE `/home/moonui2/public_html/moon-erp`, branch `hazemdev2`. **moonui2 ONLY — never `/home/moonui`.**
- Tests: `/opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest --filter='…'` (default `php` is php-cgi → "Undefined constant STDOUT").
- Re-seed: `artisan db:seed --class="Modules\\Core\\Database\\Seeders\\SettingDefinitionSeeder" --force`, then `bash local-deploy.sh`. ⛔ Never `migrate:fresh`/`refresh`/`db:wipe` on `moonui2_dev_be`.
- If FE changes: `npx ng build --base-href /app/` + `npx tsc --noEmit`. **Do not deploy** — the orchestrator deploys.
- Commit on `hazemdev2`, conventional. **Do not push, do not merge.** Several unpushed commits exist from earlier packages — do not touch them.
- `pint` on touched files only; `chown moonui2:moonui2` after every edit.
- **Pest loads every test file into ONE process** — prefix every top-level helper with its file's subject. Fatal redeclare = exit 255, zero output. **Five occurrences in this project.**
- ⛔ **No repair of existing drift or expired stock.** The reconciliation report shows it; the owner decides.

## Out of scope
C4 (`inventory.allow_expired_override` server-side enforcement) · re-lotting returns · adjustments/cancel-receipt lot handling · any other locked setting.
