# P6 — POS pharmacy: pending-prescription worklist, the sale marks the prescription dispensed, badge at the doctor

Read `_COMMON.md` first. Two serial WPs: **P6a** (BE: dispense model + action + event + POS worklist + sale link) → **P6b** (FE: POS pharmacy drawer + doctor badge). Migration range **`2026_11_03_250000`–`250099`**.

**Depends on:** P1 ✅ **incl. P1-FE** (P6b edits `prescription.component` for the badge — P1-FE is 🔄 on it), P3 ✅ (done). P2 ✅ recommended (discharge Rx `kind=discharge` should appear in the worklist; P6 works without it). D2a's `170003` (prescriptions.encounter_id nullable) is irrelevant here.

## Spec (read before coding)
- `../design.md` §3 rule 7 («الصرف مش على شاشة الطبيب أبداً … الطبيب بيشوف «اتصرفت» كشارة بس»), §4.1 rows «State machine» (`dispense_status` independent of `status`), «Settings» (`pos.pharmacy.*`), «Events» (`PrescriptionDispensed` fired by POS), the paragraph «**الصرف (hand-off)**» (worklist `GET pos/pharmacy/prescriptions?status=pending` reading `prescriptions` directly; the sale calls `MarkPrescriptionDispensed` updating `dispensed_qty`/`dispense_status`; Nutrition `DispensedItemsService` stays a display reader), §8 row P6 (owner test: «يروشت → يفتح POS يلاقي الروشتة → يبيع → الشارة «اتصرفت» عند الطبيب»), §9 #7.
- `../design-visit-shell.md` §2.6 «الطلب ≠ التنفيذ» (the pharmacy may pull the doctor's lines **without dispensing all of them**).
- `../master-plan.md` row P6.

## Code reality (verified 2026-10-22 — trust this over the docs)
- **POS has no pharmacy/prescription/dispense code at all** (only comments: `POSSettingController.php:112`, `POSSaleController.php:889-890`).
- **The settings are `pharmacy.*`, not `pos.pharmacy.*`** (design.md and the ledger use the wrong prefix). Defined in `Modules/Core/database/seeders/SettingDefinitionSeeder.php` (header note 3495-3530), all `module='pos'`, `is_implemented => false`, group `pos_prescription`: `pharmacy.dispensing_enabled` (bool, false, L3749), `pharmacy.allow_partial_dispense` (bool, true, L3766), `pharmacy.prescription_validity_days` (int, 30, L3783), `pharmacy.require_prescription_rx` (bool, false, L3800), `pharmacy.require_prescription_controlled` (bool, true, L3817), `pharmacy.controlled_offline_blocked` (L3834), `pharmacy.block_refund_controlled` (L3991), `pharmacy.require_pharmacist` (L4027), `pharmacy.eprescription_provider` (L4376), `pharmacy.identity_verification` (L4393). **Nothing reads them.** `SettingsService` (`Modules/Core/app/Services/SettingsService.php` L140-154) **refuses writes to `is_implemented=false` keys** — a key is flipped to `true` in the WP that adds its first reader; `Modules/POS/tests/Feature/POSComingSoonSettingApiTest.php` L64/L90 count them (`POS_COMING_SOON_COUNT = 39`, `POS_COMING_SOON_TAB_COUNT = 39`).
- **Sale flow:** `POST api/pos/sales` → `POSSaleController::store(StorePOSSaleRequest)` (L92) → `DB::transaction` → private `createSale(...)` (L164): `SalesInvoice::create` (L293, `is_pos`, `source='pos'`, status Approved) → `$invoice->items()->create([...])` loop (L296-314: product_id, variant, unit_id, warehouse_id, description, quantity, unit_price, discounts, tax, line_total, sort_order) → `PostSalesInvoice::execute(...)` (L388) → payments → L510 `DB::afterCommit(fn () => event(new PosSaleCompleted($companyId, $invoiceId, $paymentIds)))`. `PosSaleCompleted(int $companyId, int $invoiceId, array $paymentIds = [])` (`Modules/POS/app/Events/PosSaleCompleted.php`), only listener `SyncPosLoyaltyPoints` (`Modules/POS/app/Providers/EventServiceProvider.php` L16).
- `StorePOSSaleRequest` uses `RejectsUnknownKeys` — any new key must be added to its rules. Keys today: pos_session_id, local_id, client_created_at, customer_id (**required**), credit_override_reason, warehouse_id, notes, discount_amount, `items.*` (product_id required, product_variant_id, unit_id required, warehouse_id, description, quantity gt:0, unit_price, catalogue_unit_price, discount_percent, tax_rate_id), `payments.*`.
- `sales_invoice_items` has **no source/reference column** per line (only `sales_order_item_id`); it has `delivered_quantity`, `cancelled_quantity`, `base_quantity`. Returns: `sales_return_items.invoice_item_id`.
- **Clinic prescriptions:** `prescriptions.dispense_status` (pending|partial|dispensed|not_applicable, indexed `prescriptions_dispense_status_idx`), written only by `IssuePrescription.php:87` (pending). `prescription_items.dispensed_qty` decimal(12,3) **never written**. **There is no prescribed quantity column** (`quantity`/pack count) and `PrescriptionRules` accepts none. `PrescriptionStatus::Dispensed` exists but is «reserved; not reachable» — do not use it; dispensing is `dispense_status` only. Item drug: `drug_id` (Core product, products mode) or `clinic_drug_id` (+ `clinic_drugs.product_id` optional link). No `MarkPrescriptionDispensed` action, no `PrescriptionDispensed` event. No route lists prescriptions by patient or number.
- `PrescriptionResource` already exposes `dispense_status`; `PrescriptionItemResource` exposes `dispensed_qty`. FE `features/clinic/encounter/prescription/prescription.component.ts` — `PrescriptionRecord.dispense_status?` exists (model L116) but **nothing renders it**; the status badge uses `rx.status` only (`statusKey()` ~L905).
- **Patient ↔ customer:** POS needs `customer_id` (a `business_partners` id); the patient's customer is `lab_patients.partner_id`. `Modules/LIS/app/Services/LabPatientWriteService::create` creates a partner for NEW patients; older patients may have none. The «link or create» logic exists only inside `Modules/Nutrition/app/Http/Controllers/VisitRecordController::linkCustomer` (L100-145: existing → match ONE unlinked customer by phone → else `CreateBusinessPartner`).
- **Nutrition readers:** `Modules/Nutrition/app/Services/Visit/DispensedItemsService::forPartner()` reads posted `sales_invoice_items` for the partner (net of returns) — stays as is. `PatientMedicationsService` reads the last 10 Rx — does not show dispense status (leave it).
- **Permissions:** no pharmacy/dispense permission exists. POS cashier role gets `pos.sales.create` (RolePermissionSeeder ~L2567).
- **FE POS:** `features/pos/pos-layout/pos-layout.component.ts` (only route, `pos.routes.ts` L6-9); `components/` (cart-panel, product-grid, held-orders-dialog, payment-dialog …); `services/pos-cart.service.ts` `addProduct(product: PosProduct, variant?)` L345 (merges on product/variant/unit, qty +1), `toInvoicePayload()`; `services/pos.service.ts` `processSale(invoiceData, paymentLines, localId?)` (~L72, POST `${posBase}/sales` L184). `CartItem` (`models/pos.model.ts` L181) has no source field. Offline queue exists (`sync-queue-dialog`).

## Decisions already made (do not re-litigate)
1. **Dispense state lives on Clinic rows only** (`prescriptions.dispense_status`, `prescription_items.dispensed_qty`); POS never writes Clinic tables directly — it calls `Modules\Clinic\Actions\MarkPrescriptionDispensed`.
2. **Prescribed quantity:** add `prescription_items.quantity` decimal(12,3) nullable + `quantity_unit_id` nullable (product unit). Optional in `PrescriptionRules` — **but `PrescriptionRules.php` is P1's file**: add ONLY two lines (`'items.*.quantity' => ['nullable','numeric','gt:0','max:9999']`, `'items.*.quantity_unit_id' => ['nullable','integer']`) and the matching snapshot in `PrescriptionItemBuilder::attributes()` — nothing else in those files. Line completeness: `quantity` set ⇒ dispensed when `dispensed_qty ≥ quantity`; `quantity` null ⇒ dispensed when `dispensed_qty > 0`. Header: all non-stopped lines dispensed ⇒ `dispensed`; some `dispensed_qty > 0` ⇒ `partial`; else `pending`. Lines the pharmacist marks «لن يُصرف» (`dispense_skipped` bool, new column) count as done for the header.
3. **Sale ↔ Rx link = a POS table, not a Sales column:** `pos_prescription_dispenses` (id, company_id, prescription_id, prescription_item_id, sales_invoice_id, sales_invoice_item_id nullable, product_id nullable, quantity decimal(12,3), dispensed_by, dispensed_at, reversed_at nullable, reversed_by nullable, timestamps; idx (company_id, prescription_id), (sales_invoice_id); unique (sales_invoice_item_id, prescription_item_id)). The shared `sales_invoice_items` table is not altered.
4. **The sale carries the link:** `StorePOSSaleRequest` + `prescription_id` (nullable, exists in company) and `items.*.prescription_item_id` (nullable, must belong to `prescription_id`). `createSale` stores the mapping rows in the SAME transaction right after the items are created (keyed by the line index → created item id). After commit (in the existing `afterCommit`, or a new POS listener on `PosSaleCompleted` reading `pos_prescription_dispenses` by `sales_invoice_id` — preferred, keeps `createSale` edits to the mapping write) → `MarkPrescriptionDispensed::handle($prescription, [[item_id, qty]], $userId, source: 'pos', ref: invoice id)`. A failure there must NOT fail the sale (log + leave the mapping; a console/command or the next sale reconciles — `MarkPrescriptionDispensed` recomputes from the mapping table, so it is idempotent).
5. **`MarkPrescriptionDispensed`** (Clinic, `Actions/`): recomputes each touched item's `dispensed_qty` = SUM(non-reversed `pos_prescription_dispenses.quantity`) when the POS table exists, plus manual deltas (see 7) — i.e. it is a **recompute, not an increment** (idempotent, retry-safe). Sets header `dispense_status` per Decision 2, fires `PrescriptionDispensed($prescription, string $status, array $itemIds)` after commit. Works on a **signed** encounter (dispensing is not a clinical write — do NOT call `assertEditable()`), refuses a cancelled prescription (422) and `kind=home` / `inpatient` (`not_applicable` — hospital floor stock is P2).
6. **Worklist** = prescriptions with kind ∈ {outpatient, discharge}, status ∈ {issued, printed}, dispense_status ∈ {pending, partial}, `issued_at ≥ now − pharmacy.prescription_validity_days` (default 30), company + (optional) branch scope; search by patient name/phone/MRN or prescription number. Each row: prescription header, patient (+ `partner_id`), doctor, items with drug_name, sentence, quantity, dispensed_qty, remaining, **product mapping** (drug_id, or clinic_drugs.product_id; null ⇒ «غير مربوط بصنف» — can only be skipped or dispensed manually), alert notes.
7. **Partial dispensing** is allowed only when `pharmacy.allow_partial_dispense` (default true); when false the POS must send every non-skipped line with full remaining qty (422 otherwise). Manual (non-POS) dispense, e.g. an outside pharmacy: `POST clinic/prescriptions/{p}/dispense` by a user with `clinic.prescriptions.dispense` — writes `prescription_items.dispensed_qty_manual` (new decimal nullable) which the recompute adds. Skipping a line: same endpoint with `skip: true`.
8. **Returns:** a POS refund of an invoice item mapped to an Rx line marks its mapping `reversed_at` and recomputes (listener on the refund flow — find the refund action/event `POST api/pos/refunds`; if there is no event, add the reversal call where the refund is posted and report it).
9. **Customer:** the worklist returns `partner_id`; if null, the POS calls `POST pos/pharmacy/patients/{patient}/customer` → shared service `Modules/LIS/app/Services/PatientCustomerLinker::link(LabPatient, int $userId): array{partner, action}` extracted from Nutrition's `linkCustomer` (same three steps); `VisitRecordController::linkCustomer` then calls the service (keep its response shape — Nutrition tests must stay green).
10. **Settings flipped to implemented** (orchestrator edits Core `SettingDefinitionSeeder` + lowers the two counts to 35 in `POSComingSoonSettingApiTest.php` in the same commit): `pharmacy.dispensing_enabled` (gates the worklist + sale link: false ⇒ worklist 403 `pos::pharmacy.disabled`, sale ignores `prescription_id`), `pharmacy.allow_partial_dispense`, `pharmacy.prescription_validity_days`, `pharmacy.require_prescription_rx` (when true, a POS sale line whose product has `product_drug_details.is_prescription=true` requires `items.*.prescription_item_id` → 422 `pos::pharmacy.rx_required`). Read them with code defaults via `SettingsService::get`. The other `pharmacy.*` keys stay locked.

## Shared-file rules
- **Orchestrator-owned — REPORT, never edit:** `Modules/POS/routes/api.php`, `Modules/Clinic/routes/api.php`, `RolePermissionSeeder.php`, `StandardRolesSeeder.php`, `Modules/Core/database/seeders/SettingDefinitionSeeder.php`, `ClinicSettingDefinitionSeeder.php`, `config/moonstack.php`, `POSComingSoonSettingApiTest.php` counts, CHANGELOG (2 lines EN + AR).
- **Hot — do NOT edit** (beyond the exact lines Decision 2 allows): `IssuePrescription.php`, `UpdatePrescription.php`, `PrescriptionRules.php`, `PrescriptionItemBuilder.php`, `Models/Encounter.php`, Hospital `NursingService` (P2), `visit-shell/**`, `consultation.component.*`.
- **P6 owns:** `Modules/POS/app/Http/Controllers/POSSaleController.php` (mapping write only), `Http/Requests/StorePOSSaleRequest.php`, new `Modules/POS/app/Http/Controllers/POSPharmacyController.php`, new `Modules/POS/app/Listeners/*`, `Modules/POS/app/Providers/EventServiceProvider.php` (add mappings), POS models/migrations; Clinic new `Actions/MarkPrescriptionDispensed.php`, `Events/PrescriptionDispensed.php`, `Http/Controllers/PrescriptionDispenseController.php`, `Models/PrescriptionItem.php` (fillable/casts for the new columns), `Http/Resources/PrescriptionItemResource.php` (new fields); `Modules/LIS/app/Services/PatientCustomerLinker.php` (new) + the one-method edit in `Modules/Nutrition/app/Http/Controllers/VisitRecordController.php`.

---

## P6a — BE

### Do
1. **Migration `Modules/Clinic/database/migrations/2026_11_03_250000_add_dispense_columns_to_prescription_items.php`** (guarded): `quantity` decimal(12,3) nullable, `quantity_unit_id` nullable, `dispensed_qty_manual` decimal(12,3) nullable, `dispense_skipped` bool default false, `last_dispensed_at` timestamp nullable.
2. **Migration `Modules/POS/database/migrations/2026_11_03_250001_create_pos_prescription_dispenses_table.php`** (Decision 3) + model `Modules/POS/app/Models/POSPrescriptionDispense.php` (TenantAware like the POS neighbours).
3. **`MarkPrescriptionDispensed`** (Decision 5) — signature `handle(Prescription $rx, array $itemIds, ?int $userId, string $source = 'pos', ?int $refId = null): Prescription`; lock the prescription row (`lockForUpdate`); recompute; fire the event. **Event** `Modules/Clinic/app/Events/PrescriptionDispensed.php` (map with no listeners in Clinic `EventServiceProvider`, next to `PrescriptionIssued`).
4. **Manual dispense / skip** (Decision 7): `PrescriptionDispenseController::store(Request, int $prescription)` body `{items: [{id, qty?, skip?}]}` → updates `dispensed_qty_manual`/`dispense_skipped` then `MarkPrescriptionDispensed`. Company-scoped, 404 otherwise.
5. **POS worklist** `POSPharmacyController` (permission check in the controller like `POSSaleController` L85 does — `pos.pharmacy.view` / `pos.pharmacy.dispense`): `index(Request)` (Decision 6; `?q=&status=pending|partial&branch_id=&page=`, paginate 25 — the FE uses `listAll()` style paging), `show(int $prescription)` (one Rx with product mapping, current unit price via the POS catalogue price service the products endpoint uses — read `POSCataloguePrice` — and stock in the session warehouse if cheap to get; else omit), `linkCustomer(int $patient)` (Decision 9). Resource `POSPharmacyPrescriptionResource`.
6. **Sale link** (Decision 4): request rules; in `createSale` after the items loop, for each `items[i].prescription_item_id` insert the mapping with the created item id + quantity (`base_quantity` if the line unit ≠ base unit — read how `base_quantity` is computed and store that); validate the item belongs to `prescription_id`, the Rx is dispensable (Decision 5 kinds/status), and partial rules (Decision 7). Listener `Modules/POS/app/Listeners/MarkPrescriptionsFromSale.php` on `PosSaleCompleted` → `MarkPrescriptionDispensed` per distinct prescription (try/catch + `Log::warning`). Offline-synced sales (`local_id`, `pos_client_created_at`) go through the same path.
7. **Refund reversal** (Decision 8).
8. **`require_prescription_rx`** check (Decision 10) in `createSale` before stock checks; needs `product_drug_details.is_prescription` lookup for the line products (one query).
9. **PatientCustomerLinker** (Decision 9) + Nutrition controller delegates to it.
10. Lang: `Modules/POS/lang/{ar,en}/pharmacy.php` (disabled, rx_required, not_dispensable, partial_not_allowed, item_not_in_prescription), Clinic `rx.php` (`dispense_status` labels exist — add `errors.not_dispensable`).
11. **Report:** route lines — POS file (prefix `api/pos`, names `api.pos.`): `GET pharmacy/prescriptions` → `POSPharmacyController@index` (`pharmacy.prescriptions.index`), `GET pharmacy/prescriptions/{prescription}` → `@show` (`pharmacy.prescriptions.show`), `POST pharmacy/patients/{patient}/customer` → `@linkCustomer` (`pharmacy.patients.customer`); Clinic file next to the prescriptions block (L535-543): `POST prescriptions/{prescription}/dispense` → `PrescriptionDispenseController@store` (`permission:clinic.prescriptions.dispense`, `prescriptions.dispense`). Permissions: `pos.pharmacy.view`, `pos.pharmacy.dispense` (cashier/pharmacist presets — propose a `pharmacist` preset = POS cashier set + both + `clinic.prescriptions.view`), `clinic.prescriptions.dispense` (pharmacist, admin). Grants for existing installs are batched by the orchestrator (PERM-style seeder + bridge) — report the mapping: roles holding `pos.sales.create` get `pos.pharmacy.view` + `pos.pharmacy.dispense` (safe: the feature is still off until `pharmacy.dispensing_enabled` is switched on at runtime); roles holding `clinic.prescriptions.create` get nothing new.
12. Settings rows to flip (Decision 10) — list them verbatim.

### Tests — `Modules/POS/tests/Feature/P6PharmacyDispenseTest.php` (prefix `p6_`) + `Modules/Clinic/tests/Feature/P6MarkDispensedTest.php`
Worklist shows an issued outpatient Rx, hides cancelled / `home` / `inpatient` / older than validity / fully dispensed; disabled setting → 403; sale with `prescription_id` + two lines → mapping rows + item `dispensed_qty` + header `dispensed`; one of two lines sold → `partial`; `allow_partial_dispense=false` + partial → 422; item of another prescription → 422; quantity 2 prescribed, 1 sold → partial, second sale of 1 → dispensed; recompute twice → same numbers (idempotent); refund reverses → back to partial/pending; manual dispense + skip; signed encounter still dispensable; `require_prescription_rx=true` blocks an Rx-flagged product sold without a line; listener failure does not fail the sale; `PrescriptionDispensed` fired once per sale; linkCustomer creates/matches a partner and Nutrition `linkCustomer` still returns the same shape. Run also the POS sale suites (`POSSale*Test.php`, `POSComingSoonSettingApiTest.php` — expect the count change and say so), Nutrition `*Visit*Record*Test.php`/`*LinkCustomer*` if present, `P1PrescriptionV2Test.php`, `IssuePrescriptionTest.php`.

### Out of scope (P6a)
Controlled-drug rules, pharmacist sign-off, e-prescription providers, insurance pharmacy claims, WebStore prescriptions (separate image feature — untouched).

---

## P6b — FE

### Do
1. **POS «روشتات» drawer** (`features/pos/components/pharmacy-worklist/` new): a button in the POS header (visible when `pharmacy.dispensing_enabled` comes back true from the POS settings call the layout already makes — read it — and the user has `pos.pharmacy.view`) opens a side drawer: search (name/phone/number), list of pending/partial Rx (patient, doctor, date, «جزئي» chip), pick one → lines with remaining qty, product mapping, alert note; per line qty stepper (≤ remaining unless partial off), «لن يُصرف» toggle; «إضافة للسلة» puts mapped lines into the cart through `PosCartService` (extend `CartItem` with optional `prescription_item_id` and the cart with `prescriptionId` signal; merging must NOT merge an Rx line with a non-Rx line of the same product), sets the sale customer from `partner_id` (call `linkCustomer` when null). `toInvoicePayload()` sends `prescription_id` + `items[].prescription_item_id`. Clearing the cart clears the link. Held orders: keep the link in the held order `items` json (read the held-order save/restore code).
2. **Doctor badge**: in `app-clinic-prescription` (re-read — P1-FE rewrote it) show a chip next to the status: `dispense_status` pending «لم تُصرف» (grey) / partial «صُرفت جزئياً» (amber) / dispensed «اتصرفت» (green) / not_applicable hidden; per line the dispensed qty when > 0. Display only.
3. **Fable consult (mandatory, ONE):** screenshot the POS layout with the cart, send a Fable agent (`model: "fable"`) one focused question: the pharmacy drawer layout (search → Rx list → lines with remaining qty) and how an Rx-linked cart line is marked in the cart panel. Apply; don't iterate.
4. i18n under `POS.PHARMACY.*` and `CLINIC_RX_V2.DISPENSE.*`, targeted edits; no `I18N_VERSION` bump; `npx ng build --base-href /app/` green.

## Report (both WPs)
Files, test output, route lines, permissions + preset proposal, setting keys to flip + the test-count change, CHANGELOG 2 lines (EN + AR), anything not done.
