# LEDGER — POS settings infrastructure + Drug product type

**Feature slug:** `pos-settings-drug-product`
**Started:** 2026-08-01
**Branch:** `hazemdev2` (BE `/home/moonui2/moon-erp-be` · FE `/home/moonui2/public_html/moon-erp`)
**Source plan (approved):** [`plans/pos-settings-catalogue-and-drug-product.html`](../pos-settings-catalogue-and-drug-product.html)
**Owner approval:** 2026-08-01 — «اعمل كل المرحلة الأولى والثانية كاملة بكل مراحلها بتوصياتك كاملة… أما إعداد إعداد هنشتغل عليه فيما بعد» + «لا تنسَ تحديث البيز نولدج».

## Owner's acceptance test

The owner opens **Settings → POS** and sees a *generated* list of settings grouped by topic, each with a bilingual label **and a short explanation under it**, rendered from its declared type (switch / dropdown / number) — not a hand-written form. He opens **Products → edit a product**, ticks «هذا الصنف دواء», and a fifth **دواء** tab appears carrying active ingredients (multi-select), dosage form, strength, Rx/controlled flags. He opens **المواد الفعّالة** from the menu and can add/edit/delete one. In the product's units table he can now type a **sale price, purchase price and barcode per unit**, and a composition helper writes the base-relative factor for him. Nothing that worked before is broken.

## Scope

**IN:** Phase A (WP1–WP4, settings infrastructure + the two security holes) and Phase B (WP5–WP8, product/drug).
**OUT (explicitly deferred, owner's words):** Phase C — the 46 settings themselves, one at a time, later. **Do NOT seed any pharmacy setting definition in this run** — the plan's own rule: *a definition without its first reader is a dead setting* (`clinic.pharmacy_source` is the live cautionary example).

## Decisions (approved — do not re-litigate)

1. **Drug is a flag + a 1:1 side table**, never a third `type` value. A `ProductType::Drug` would silently remove every drug from POS index, POS search and Inventory Count, all three of which hard-filter `type = 'product'`.
2. **Dosage form is a lookup table**, not a PHP enum — the list is not closed across markets, and a table gives per-company Arabic naming.
3. **Active ingredients are a first-class CRUD module in `Modules/Core`**, with a many-to-many pivot to products carrying per-ingredient strength. In Core — NOT WebStore — so it does not become the third orphaned lookup (`brands` and `manufacturers` both have a BE CRUD and no FE screen).
4. **Pack composition stays flat** (factor relative to the base unit). No `parent_unit_id`, no recursion — `UnitConversionService` is the primitive every stock movement, sale, purchase and valuation depends on. The nesting affordance is a **UI helper** that multiplies for the user.
5. **POS settings become company-scoped by default.** Only decisions taken inside `Modules/POS` controllers may be terminal-scoped, because `PostSalesInvoice` / `StockService` / `LotAllocationService` / `ValidatesMinimumSalePrice` receive `company_id` and can never read a terminal.
6. **Settings the till must obey are delivered on the session-open payload**, not read key-by-key — `GET /core/settings/{key}` 403s for a cashier and the FE swallows it into a silent default.

## Baseline (recorded BEFORE any change)

### 🔴 Pre-existing blocker found and fixed before baselining
The **entire** BE test suite was dead: `Modules/WebStore/tests/Feature/LoyaltyPointsTest.php:54` and `Modules/Production/tests/Feature/ProductionConfirmationsTest.php:157` both declared a top-level `makeOrder()`. Pest loads every test file into one process ⇒ fatal redeclare ⇒ **exit 255, zero output, under every filter**. **Fourth occurrence** of this trap (Clinic, Inventory, POS, now WebStore↔Production).
**Fix applied:** renamed the WebStore helper to `makeLoyaltyOrder` (9 call sites, one file) + a comment recording the trap. Not part of any WP — it was blocking the ability to measure anything.

### Scoped baseline
Command: `pest --filter='ProductApiTest|ProductUnitApiTest|SettingApiTest|SettingPermissionEnforcementTest|BrandApiTest|UnitConversionServiceTest|UnitBaseValidationTest|POSTerminalApiTest'`
(note: `ProductApiTest` also substring-matches `StorefrontProductApiTest`)

| | |
|---|---|
| **Result** | **174 passed / 14 failed (844 assertions)** — 230s |
| Pre-existing failures | see `baseline-failures.md` in this folder — all in `Modules/WebStore/tests/Feature/StorefrontProductApiTest` (404s: storefront routes gated off) |
| Rule | a WP is green when it adds **zero new** failures vs this list |

FE baseline: `ng build` green (to be confirmed before the first FE WP).

⚠️ **Working tree is NOT clean at start:** 55 modified BE files + 54 modified FE files from the POS overhaul, uncommitted, awaiting the owner's `/fullpush`. Do not revert, stash, or commit them as part of this work. **Never run `git checkout`/`restore`/`stash` on `src/assets/i18n/{ar,en}.json`.**

## WP table

| WP | Scope | Repo | Depends | Review | Migration | Status | Commits / notes |
|----|-------|------|---------|--------|-----------|--------|-----------------|
| WP1 | Convert the POS settings tab to the generic definition-driven pattern: `POSSettingController` + `UpdatePOSSettingsRequest` (via `MergesSettingDefinitionRules`) mirroring Sales; FE `loadPosSettings()`/`posGroupMap`/`@switch(value_type)` block replacing the 3 hand-written selects. Existing 6 `pos.*` keys get bilingual labels + descriptions + groups. | BE+FE | — | Codex | no | ✅ **done** 2026-08-01 22:2x | **13 passed / 52 assertions** new; regression **187 passed / 14 failed = baseline unchanged**; `ng build` green. Uncommitted. |
| WP2 | Enforce the 7 terminal settings **server-side** (`allow_discount`, `allow_price_override`, `require_customer`, `default_tax_rate`, `allow_negative_stock` name-collision, receipt header/footer) + formalize the undeclared 8th key `show_unit` + unify the 3 conflicting `require_customer` defaults. **[FIN]** | BE+FE | — | orchestrator-verified | no | ✅ **done** 2026-08-01 23:0x | **red-before-green proved for all 3 guards** (5 tests 201→422). New `POSTerminalPolicyTest` 14 tests + 4 in `POSTerminalApiTest`. Regression **191 passed / 14 failed = baseline unchanged**; whole POS module **212 passed**. `ng build` + `tsc --noEmit` green. Uncommitted. |
| WP3 | Deliver POS-relevant settings on the **session-open / active-session payload** instead of per-key `GET /core/settings/{key}`; FE consumes from the session. Removes the silent-403→silent-default failure for cashiers. **+ folded in from WP4: FE till gate → `pos.sales.create` (3 sites), catalog `pos.refunds.create`.** **+ orchestrator fix: terminal policy blob now rides on the session.** | BE+FE | WP1, WP4 | orchestrator-verified | no | ✅ **done** 2026-08-02 00:4x | red-before-green proved (7 tests null→populated). New `POSSessionSettingsPayloadTest` **8 passed**; whole POS **226 passed / 0 failed**; regression 191/14 = baseline. `ng build` green. |
| WP4 | Separate POS sale permission (`pos.sales.create`) replacing the `sales.invoices.create` gate on `POSSaleController`, **with a bridge migration** (the updater runs `migrate`, not `RolePermissionSeeder` — this is exactly how `pos.refunds.create` nearly shipped dead). | BE | — | orchestrator-verified | **yes** | ✅ **done** 2026-08-01 23:5x | 7 new tests; **whole POS module 219 passed / 0 failed**; regression 191/14 = baseline unchanged. Bridge ran on dev: 6 roles gained it incl. a hand-made `testrole`; re-run → identical grant checksum. Migration `2026_08_01_630000_pos_sales_permission_bridge`. |
| WP5 | `active_ingredients` + `dosage_forms` CRUD modules in **Core**: migration, model, controller, requests, resource, factory, test, routes, permissions; FE list+dialog, service, NgRx slice, route, menu, i18n. | BE+FE | — | orchestrator-verified | **yes** | ✅ **done** 2026-08-02 01:3x | **31 passed / 152 assertions**; regression 191/14 = baseline; `PermissionCatalogTest` 11 passed; migrations ran on dev; 18 dosage forms seeded bilingually × 6 companies (idempotent, verified twice); both screens live on `/app`. |
| WP6 | `products.is_drug` (root migration, guarded) + `product_drug_details` 1:1 + `product_active_ingredient` pivot with per-ingredient strength; wire into Core `ProductResource` + `StoreProductRequest` + `UpdateProductRequest` + `ProductController` **in the same WP** (else it becomes the next `manufacturer_id` orphan). **+ restored `products_count` on both WP5 lookups.** | BE | WP5 | orchestrator-verified | **yes** | ✅ **done** 2026-08-02 02:5x | New `ProductDrugDetailsApiTest` **13 passed / 94 assertions**; `Modules/Core` **686 passed / same 10 pre-existing**; lookups 33 passed; regression 191/14 = baseline. 3 migrations ran on dev, idempotency re-proved. **Criterion 3 proved with live HTTP against POS index, POS search and Inventory Count.** `clinic_drugs.active_ingredient`: **0 distinct values on dev** — nothing to migrate. |
| WP7 | Conditional 5th «دواء» tab in the product dialog, gated on `is_drug`; handles active-tab reset when the flag is unticked (`p-tabs value` is a static binding today). | FE | WP6 | orchestrator-verified | no | ✅ **done** 2026-08-02 03:4x | `tsc --noEmit` 0, `ng build` 0, **deployed to `/app`** (`main-IMTBGD35.js`). Payload diffed programmatically against WP6's real `StoreProductRequest`/`UpdateProductRequest` rules — all 5 shapes survive `validated()`, nothing dropped. Round-trip proved through the live controller. **Manufacturer control NOT delivered — no Core endpoint; folded into WP8.** |
| WP8 | Make the product-units table editable (sale/purchase price, barcode, is_sale, is_purchase) + wire the already-written-but-uncalled `updateProductUnit` PUT + composition helper ("1 box contains ⟨N⟩ ⟨unit⟩" → stores base-relative factor). **+ folded in from WP7: `GET /api/core/manufacturers` lookup + the picker.** | FE+BE | — | orchestrator-verified | no | ✅ **done** 2026-08-02 01:3x | +10 tests → regression **201 passed / 14 failed = baseline**; `Modules/Core` **696 / same 10**; `tsc` clean; `ng build` 0; **deployed `main-3YGPBJ2T.js`**. |

### WP9 — added by owner request 2026-08-02 (after Phase A+B)

> «عايز تضيفهم كلهم بالشكل الواقعي في إعدادات نقاط البيع، ويبقى جنبهم "تحت التشغيل"، وكل واحد نخلّصه نفعّله.»

Seed the whole catalogue as **visible but locked**. This is a *revision* of the plan's original rule ("never define a setting before its reader"): that rule was aimed at the setting that **lies** — one that looks live and does nothing (`clinic.pharmacy_source` is the live example). A definition that openly declares itself not-yet-active is a roadmap, not a lie, and it puts the catalogue inside the product instead of in an HTML file.

**Non-negotiable safety condition:** locked at BOTH layers — the FE control `[disabled]`, AND `SettingsService::set()` returning 422. If it is switchable, a manager enables «منع بيع الدواء المنتهي» and expired medicine still sells — worse than the option not existing.

**Activation contract:** a setting flips `is_implemented => true` in the SAME WP that writes its first reader. Never standalone.

Brief: `tasks/WP9-coming-soon-settings-catalogue.md`. Status: ✅ **done** 2026-08-02.

**Delivered:** `setting_definitions.is_implemented` (boolean, **default `true`** so all ~343 existing rows are untouched) · the lock in `SettingsService::set()` — the single write choke point every module's settings controller passes through · FE badge «تحت التشغيل» + genuinely `[disabled]` on 25 controls + locked keys excluded from the save payload · **42 catalogue settings seeded** in 9 groups with Arabic labels + explanations.
**Verified on dev:** 385 definitions, **42 locked / 343 implemented**, POS tab now serves 48 rows. Live `PUT` on a locked key → **422** bilingual; `PUT` on a live key → **200**. Tests: new suite 9 passed / 266 assertions; regression **210 passed / same 14**; `Modules/Core` **696 / same 10**; `Modules/POS` **236 / 0**. Deployed `main-766262G2.js`.
**Skipped, with reasons:** 7 already-live keys (not re-seeded) · **10 terminal-scoped** (the mechanism has no terminal scope — not forced into company scope) · 1 row that is a permission, not a setting.
**Orchestrator follow-up:** the seeder shipped 11 Arabic descriptions carrying engineering asides (`⚠ ده مش نفس inventory.expired_issue_policy…`) aimed at implementers, not at a pharmacy manager. Stripped at the `⚠` in the seeder and re-seeded — 42 still locked, **0 engineering notes remain**. The asides survive in the analysis HTML where they belong.

### Corrections to the approved plan, found while preparing (recorded, not silently applied)
- The plan said *"neither `StoreProductUnitRequest` nor `UpdateProductUnitRequest` validates `conversion_factor`"*. **Wrong** — both already have `conversion_factor => ['sometimes','numeric','gt:0']` with a translated message, and `StoreProductUnitRequest::withValidator()` already enforces unit-group consistency against the base unit. Both also already accept `barcode`, `purchase_price`, `sale_price`, `is_purchase`, `is_sale`.
  ⇒ **WP8 shrinks to pure FE + wiring.** No BE validation work needed.

## DAG / serialization

- **One writer per repo at a time.** WP1 and WP3 both touch FE settings code — serialize.
- Independent chains: `WP5 → WP6 → WP7` (product) vs `WP1 → WP3` (settings) vs `WP2`, `WP4`, `WP8` (standalone).
- Safe concurrency: at most one BE agent and one FE agent, and only when they touch disjoint directories.
- Execution order chosen: **WP1 → WP2 → WP4 → WP3 → WP5 → WP6 → WP7 → WP8** (settings infra first so WP3 has a converted tab to read from; security WPs early; product chain last and contiguous).

## Additional baseline discovered during WP5

`pest Modules/Core` (no prior baseline) → **671 passed / 10 failed**. All 10 read and confirmed unrelated to this plan:
`SettingPermissionEnforcementTest` (already documented) · `CountryApiTest` ×2 (asserts on `KW` seeded by a LIS data migration that does not land under sqlite — same family) · `ProductCategorySlugTest` (`/api/store/*` 404, the gated-off storefront routes) · `RoleLifecycleTest`/`RoleTenantScopingTest` ×3 (`/api/lis/roles` scoping + `username`/`branch_ids` now required — unrelated WIP in the dirty tree) · `SessionApiTest` (login 403) · `SettingsServiceTest` (exception class) · `AdminApiTest` (COA import id mismatch).
**Use 671/10 as the Core baseline for WP6.**

## Phase C — activating settings one at a time (started 2026-08-02, owner: «ابدأ بمجموعة الصلاحية والتشغيلات»)

**Ordering is a dependency, not a preference.** The pharmacy analysis measured **4 of 6 batch-tracked products on `moonui2_dev_be` violating the invariant the lot migration itself declares**. Activating an expiry refusal on top of a drifting ledger produces a guard that refuses valid sales and passes invalid ones — a *different* lying setting.

| # | Package | Why here | Status |
|---|---|---|---|
| C1 | `inventory.require_batch_on_receipt` + reader | Capture point. If expiry is not recorded here, nothing downstream can recover it. | ✅ **done** 2026-08-02 — guard in `ReceiptLotService::assertBatchAndExpiryCaptured()`, called from `ApproveReceipt` **inside the transaction** (a refusal rolls back the lots just written). 14 new tests; **red-before-green on 3 of 4** (the 4th was already enforced — reported, not faked). `Modules/Inventory` 694→**708 / same 4 failures**; other three suites exact match. **Shipped default OFF** — dev data showed company 4 with **6 of 14** batch-tracked receipt lines already received with no batch row, so an ON default would have 422'd their next receipt. |
| C2 | Lot **shortfall becomes fatal** + a reconciliation report | `StockService` decrements `stock_balances` first, allocates lots after, and discards the shortfall silently. **Cannot ship ungated:** 4 of 6 batch products already drift, so a bare fatal guard would block legitimate sales on day one. Ships behind `pos.enforce_batch_selection` (default OFF) with a read-only reconciliation report so the drift can be seen and cleared first. | ✅ **done** 2026-08-02 — `GET api/inventory/reports/lot-reconciliation` (read-only, reproduces all 5 known deltas + the 9 expired-with-remaining lots) + `assertLotCoverage()` in `StockService`, gated, default OFF. 27 new tests. `Modules/Inventory` 708→**735 / same 4**; POS 236/0; Core 696/10; **Sales baseline established 470/12**, unchanged. |
| C3 | `pos.block_expired_sale` (block/warn/allow) | The real refusal at the POS boundary. | ✅ **done** 2026-08-04 · BE `fe4b34b75` · FE `7a0637215` | **RED proved across all three modes**: expired medicine posted 201 in `block`, `warn` AND `allow` — 20 units off the balance, the expired lot untouched, **zero allocation rows**. New `ExpiredLotPolicy` enum passed as an explicit 4th arg to `PostSalesInvoice` (the `RecognitionIntent` precedent), refusal at the POS boundary beside `assertStockAvailable()`. `warn` rides on `meta.expiry_warnings` — the same envelope the till already reads for `idempotent_replay`, **omitted when empty** so older bundles are unaffected. POS **258/0** · Inventory **742/4** · Core **719/10** · Sales **474/8** (4 date-rot cases flipped green on their own). |
| C4 | `inventory.allow_expired_override` server-side | The permission `inventory.issues.expired-override` is seeded and enforced **client-side only**. | ⬜ — **note for C4:** the locked-setting count now lives in **two** files (`POSComingSoonSettingApiTest`, `RequireBatchOnReceiptTest`) and both must move 39→38 together; and `POS_COMING_SOON_KEY` currently points at `inventory.allow_expired_override`, i.e. C4's own target — it must rotate again. |

### ⚠️ C3 design conflict — resolved by the orchestrator before dispatch

`inventory.expired_issue_policy` (default **block**) already excludes expired lots from FEFO **company-wide**. So a POS value of `warn` or `allow` would have had nothing to warn about or allow — the lot is never selected in the first place. Two settings, one idea, each cancelling the other.

**Decision: `pos.block_expired_sale` is the TILL's authority.** On the POS sale path it governs and overrides the inventory policy for that call; every non-POS movement (issues, transfers, production, WebStore) keeps obeying `inventory.expired_issue_policy`, untouched.

This still honours "one home per concept" because the concepts differ: **the counter's policy** vs **the warehouse's policy**. A pharmacy can refuse to dispense expired stock at the till while the warehouse still needs to move it to quarantine.

Semantics: `block` → 422 naming **expiry** (distinct from C2's generic shortfall) · `warn` → posts, expired lots used, warning returned to the screen · `allow` → posts silently.

**Activation contract (recorded in the seeder header):** a setting flips `is_implemented => true` in the SAME package that writes its first reader. Never standalone.

### 🔴 C2's operational finding — the owner must act on this before switching the guard on

Probing `coverage()` against real dev rows (setting hypothetically ON):

| product | wh | balance | covered by valid lots | verdict |
|---|---|---|---|---|
| 17159 | 13 | 100 | **0** | would refuse |
| 17159 | 16 | 220 | **0** | would refuse |
| 17159 | 15 | 45 | **0** | would refuse |
| 17161 | 10 | 30 | **0** | would refuse |
| 17162 | 10 | 7.74 | 7.74 | would post |
| 17163 | 10 | 687 | 687 | would post |
| 17164 | 10 | 200 | 10 | would refuse |

**5 of 7 would refuse — and mostly not because of drift: those lots EXPIRED between 2026-07-15 and 2026-07-31.** Under the default `expired_issue_policy = block` they are excluded from FEFO, so their quantity arrives at the guard as an ordinary shortfall. Default-OFF was the right call.

Also: **17159 / warehouse 15 looks balanced (45 = 45) but its own-coverage is 0** — all 45 units belong to consignment customer 26. The report ships `lot_remaining_own` / `lot_remaining_consignment` separately for exactly this reason.

### Date-rot in the pre-existing failures — partly not defects

C2 traced several of the known failures to fixtures with hard-coded `expiry_date => '2026-08-01'`, now in the past: both `LotAllocationTest` failures, `LotAllocationDeliveryNoteTest`, and several `SalesDeliveryNoteApiTest` cases. Under `block` the lot is excluded and the assertion fails. **These are stale tests, not broken code.** Repairing them is drift-repair and stayed out of scope. All new C1/C2 tests use dates relative to `now()`.

## D1 — «إضافة دواء» preset entry point (owner-approved 2026-08-03)

Design: [`plans/drug-entry-form-design.html`](../drug-entry-form-design.html), option **ج** — «اتشغل على ج بكل توصياتك، كل المراحل».

A preset over the **existing** product dialog: same entity, same dialog, same `POST/PUT /api/core/products`. Option **ب** (a separate drug form) was rejected because drawing it honestly shows it re-containing ~80% of the product form (units, prices, barcode, supplier, tax) over a single endpoint — and this codebase has already proven parallel forms for one entity diverge (`WebStore` vs Core: `Rule::in` vs `Rule::enum`, `manufacturer_id` in one and not the other).

**Indivisible bundle** — a bare preset would be "option أ with a shortcut" and the request would return within a month:
1. «إضافة دواء» entry button (preset: `is_drug` on, drug tab active, barcode early for the scanner)
2. **«حفظ + دواء تاني»** — save, clear, keep the dialog open
3. **Sticky fields** — category, manufacturer, storage temperature, tax carry between consecutive entries

**Backend: zero change.** Brief: `tasks/D1-drug-entry-preset.md`. Status: ✅ **implemented + verified**, FE only, deployed to moonui2 `/app` (`main-N7MTBNXM.js`, products chunk `chunk-22PNPWUS.js`).

**What it is, in one line:** `openDrugEntry()` = `openNew()` + tick `is_drug` through the CONTROL + `activeTab = DRUG_TAB`. Both footer buttons call the SAME `onSaveEdit()`; the only difference is the flag `drugSaveAndNext`, read by the new single exit `afterProductSaved()` that replaced all four `editDialogVisible.set(false)` calls.

**Two signals, kept apart on purpose:**
- `drugSessionMode` — a CREATION session. Owns «حفظ + دواء تاني», the counter and the sticky fields. `openNew()` and `editProduct()` both call `endDrugSession()`, so editing can never be one.
- `drugPresetLayout()` — the pharmacist LAYOUT (groups 1/2/3 + «دواء» tab pulled to the front). Reads the **form control**, not `editingProduct()?.is_drug`: that is what makes a creation session, a saved drug reopened, and an item just flagged as a drug all agree. Reading the record instead would give ticking «دواء» by hand a layout that changed the moment the same product was reopened.

**The pack & price grid is not forked.** WP8's block was wrapped in `<ng-template #packAndPriceGrid>` and rendered through `*ngTemplateOutlet` — once at its original position in «متغيّرات وأكثر», and once inside the «دواء» tab under the preset. One grid, one set of handlers, one payload builder.

**Sticky is three fields, not four.** ⚠️ **Tax is impossible without a backend change.** `products` has no tax column; neither `StoreProductRequest` nor `UpdateProductRequest` declares `tax_*`; `ProductResource` emits none. The design's fourth sticky field cannot exist on the FE. Shipped: category, manufacturer, storage temperature — plus the base unit is now reachable on the drug tab (the pack grid's `conversion_factor` is base-relative and meaningless without it).

**Verified, not asserted** — `e2e/specs/d1-drug-entry-preset.spec.ts`, 8 scenarios, API-mocked and local (the WP10 pattern), **8 passed / 0 failed**. The mock captures every request, so «the ordinary flow sends exactly what it always sent, and nothing extra» is checked against the wire: an ordinary create emits exactly `['POST /core/products']` and no `drug_details` / `active_ingredients` key. The drug and non-drug payloads differ by exactly `drug_details` + `active_ingredients` — both WP7 keys, both declared.

⚠️ **Trap for the next person:** the «دواء» tab leads the strip by CSS `order: -1`, not by DOM position (WP7's `@if` is untouched). Any test asserting «drug tab first» with a DOM-order `.first()` will read «Basic Info» and look like a bug. Assert by bounding box — `leadingTabText()` in the spec.

**Volume question — answered 2026-08-03.** Bulk import from supplier files **will be a separate topic** («هنعمل موضوع الاستيراد الجماعي»). D1 stays focused on the single-item path.

**Scope revised same day:** the preset must follow the **record**, not the entry path — opening any `is_drug` product for edit gets the same pharmacist layout («تعديل للدواء يكون سهل… علشان الدوا يبقى مهيّأ»). Boundaries: «حفظ + دواء تاني» is creation-only, and sticky fields never leak into an edit (that would silently rewrite a saved product).

### C3's read-only reality probe (no repairs made)

| product | wh | balance | valid | expired | till `block` would… |
|---|---|---|---|---|---|
| 17159 | 13 | 100 | 0 | 89 | **refuse — expiry** |
| 17159 | 15 | 45 | 0 | 0 | post (consignment — C2 territory, C3 silent) |
| 17159 | 16 | 220 | 0 | 20 | **refuse — expiry** |
| 17161 | 10 | 30 | 0 | 30 | **refuse — expiry** |
| 17162 | 10 | 7.74 | 5002.74 | 0 | post |
| 17163 | 10 | 687 | 687 | 0 | post |
| 17164 | 10 | 200 | 10 | 0 | post (drift — C2 territory, C3 silent) |

3 of 7 refused, **all genuinely expiry** — and C3 correctly stays silent on the two that are drift/consignment rather than claiming them. On the shipped `warn`, all seven post and three carry a warning.

### C3 deviations accepted

- **A same-day-expiry inconsistency fixed en route:** `allocate()` judged expiry with `isPast()` (true from midnight) while its own candidate query accepts `expiry_date >= today` — so a lot expiring **today** was stamped `expired_override = true` by the very call that had just accepted it as valid. Two answers to one question inside the file `coverage()` exists to keep consistent. One `isExpired()` now answers both. Only the audit flag changes, only for same-day lots; a test now covers it.
- **Offline replays drop the warning toast** — a queued sale synced later has no cashier at the counter to read it. The sale itself is correct (`warn` semantics intact); only the toast is absent. Reported, not silently accepted.
- **C1's test held a second copy of the locked count** and broke on the unlock — the one genuine regression this package caused, fixed in place.

## Pushed to `main` — 2026-08-04

`/fullpush` completed: BE `2924b1e87`, FE `dacb8fdd2`, both branches == `origin/main`. One conflict, in the BE CHANGELOG, union-resolved (20 `[Unreleased]` bullets, 96 released sections preserved, every bullet bilingual). `Modules/POS` on the **merged** tree: **258 passed / 0 failed**. Deployed `main-RHVQMV6O.js`.

Note for the next `/fullpush`: the command is a sync-and-merge flow and contains **no test step**. I gated the merge on the POS suite this time and it cost the owner ~20 minutes of waiting for a merge that turned out clean. Previous merge did break two things, so the instinct was not wrong — but it must be offered as a choice, not imposed as a silent delay.

## Deferrals

- **`drug_details.notes` is in WP6's contract but has no FE control**, and `syncDrugData` writes `notes => $payload['notes'] ?? null` unconditionally — so a save from the product dialog would null a note set by any other path. Harmless today (nothing can set it), but it is a latent data-loss shape. Phase C.
- 🔴 **Silent write-loss on all cross-module settings tab rows — found in C1, fixed there.** `MergesSettingDefinitionRules` derived a rule name containing an **unescaped dot**, which Laravel reads as a nested path, so `validated()` dropped the key and the PUT returned **2xx having written nothing**. Worse than the documented `pos.`-re-prefix trap, and it would have hit **all 13** cross-module rows. Both halves fixed; both recorded in the seeder's TRAP block.
- ⚠️ **Permission mismatch on cross-module rows — decision needed before C4.** A definition's `permission` resolves from its key prefix (`inventory.settings.manage`), but `POSSettingController::update()` is gated only by `pos.settings.manage`. So a POS manager can change an inventory-prefixed setting from that tab. Pre-existing for all 13 rows; a per-key check would 403 the whole-tab save.
- **C1 accepts the legacy line-column capture:** a batch-tracked line with no batch *rows* but with `batch_number` + `expiry_date` on the line itself still posts — that is how opening balances and the pre-Phase-2 editor record a lot, and the expiry does reach the movement. A line with neither is still refused.
- **`allow_price_override` stays terminal-scoped for now.** The analysis marks it «ماكينة ← شركة». WP9 deliberately did not move it: relocating a setting is a behaviour change and belongs to the WP that implements its server-side reading. Same reasoning for `require_customer` / `allow_negative_stock`, whose real guards already live elsewhere. **Owner decision pending.**
- **⚠ TRAP recorded in the seeder:** `POSSettingController::update()` writes `set("pos.{$key}")`, re-prefixing whatever short key the tab posts. Correct for `pos.*` rows, **wrong** for the 12 `pharmacy.*` / `inventory.*` / `sales.*` rows — a write would land on `pos.pharmacy.dispensing_enabled`. Harmless today (all 42 are locked and refused anyway). **The WP that unlocks any non-`pos.` key must fix the controller's key resolution in the same WP.**
- **`withCount('products')` was dropped from both new lookup controllers/resources** — there is no FK from `products` to either table until WP6, so the relation would be a SQL error. **WP6 must add it back** once the pivot and `dosage_form_id` exist.
- **`pos.refunds.create` was never added to `PermissionCatalog`** — discovered in WP4; it shows as a bare key on the roles screen. **Folded into WP3**, not deferred.
- **`pos.default_receiving_account_type` is a decorative setting.** Verified: its only occurrences outside the seeder are WP1's own docblock and tests — **zero functional readers**. `POSReceivingAccountResolver` never consults it. It is the same class of defect as the terminal settings. Candidate for Phase C: either give it a reader or retire it.
- **Petty-cash-id vs GL-account-id data cleanup (other installs only).** See the WP1 finding below. On `moonui2_dev_be` the stored values are already correct GL ids, so nothing to repair here; a client install that saved through the *old* picker may hold a petty-cash id in `pos.*_receiving_account_id` and will 422 on the next save until re-picked. No migration written — flagged for the owner.

## Findings that came out of execution (not in the approved plan)

### WP3 — a third dead-settings defect, fixed by the orchestrator

`POSSessionResource` built its `terminal` block by hand as `{id, name, code}` and **never emitted `settings`**. But the shell reads `session.terminal.settings` in `publishTerminalPolicy()`, so `PosPolicyService.settings` was **null on every till**.

Consequence: WP2's `require_customer` / `allow_discount` / `allow_price_override` were enforced server-side (422) but the screen could not pre-empt them — the cashier learned the rule **only by being refused after pressing Pay**. `show_unit` and the receipt strings were inert for the same reason.

Fixed by emitting `$this->terminal->setting` (the **singular** accessor, which merges `defaultSettings()` over the stored blob — the raw `settings` cast is unmerged and must never be used here). Pinned by a new test asserting a partial stored blob comes back merged, and that the two keys WP2 retired do **not** reappear. `POSSessionSettingsPayloadTest`: 8 passed / 58 assertions.

### WP1 — a real latent bug, verified independently by the orchestrator

The FE bound `pos.default_receiving_account_id` to `pettyCashService.listAll()`, i.e. it stored a **petty-cash record id**. But:
- `sales_payments.receiving_account_id` is `->constrained('accounts')` (`Modules/Sales/database/migrations/2026_02_24_500001_create_sales_payments_table.php:30`), and
- `PostSalesPayment.php:43` uses it **directly as `account_id` on a journal-entry line**.

So the screen was offering the wrong entity for a field the ledger reads as a GL account. WP1's switch to GL account pickers aligns the UI with the backend.

**Verified against live dev data:** the stored values are `65` → account `110101 خزينه رئيسيه` and `70` → account `1108 مدفوعات الشبكة` — both real GL accounts, and **no `petty_cash` rows with those ids exist**. So the data was already correct and only the picker was wrong. The change is a fix, not a regression, on this install.

## Execution-tooling decision (recorded 2026-08-01)

The standing preference is *"Opus organizes, Codex implements"*. **Suspended for this plan**, for a concrete reason, not a preference:

- WP1 was dispatched to `codex:codex-rescue` at ~21:22. It returned immediately with only *"task started in the background, check `/codex:status <id>`"*.
- **25 minutes later it had written zero files** — no new controller, no modified file anywhere under `Modules/POS/` or the FE settings feature.
- The forwarder subagent is **not permitted to query status**, so there is no completion signal and no failure signal reaching the orchestrator. An 8-WP pipeline cannot be run blind.
- The only live `codex` processes on the host belong to a **different account (`/home/moonui`)** and a different session — none were operating on moonui2.

⇒ WPs are executed by top-tier (opus) implementation sub-agents that report files, test commands and verbatim tallies. Revisit if codex gains an observable status path from a subagent.

## Progress log

- **2026-08-01 ~21:00** — Phase A prep. Fixed the suite-wide `makeOrder()` redeclare (4th occurrence of this trap). Baseline recorded: 174 passed / 14 pre-existing failures, all `StorefrontProductApiTest`. Workspace + WP1/WP2/WP4 briefs written.
- **2026-08-01 ~21:22** — WP1 dispatched to codex. No output.
- **2026-08-01 ~21:48** — codex route abandoned (see above). WP1 re-dispatched to an opus implementer.
