# Scout B — Clinic Pricing Matrix (م1 surface map)

READ-ONLY scout. No code changed. All line numbers verified against current file contents.

Repos:
- FE: `/home/moonui/public_html/moon-erp/src/app/features/clinic/pricing/`
- BE: `/home/moonui/moon-erp-be/Modules/Clinic/`

---

## 1. EXISTS (file:line)

### FE — pricing matrix component
- `pricing-matrix.component.ts`
  - `buildPlaceholderSplit()` — **ts:62-73** — hardcoded fake split generator (see §2a).
  - `columns`/`rows`/`doctors`/`contracts` signals — ts:108-111.
  - `loadAll()` — ts:144-179 — `forkJoin({services, doctors, contracts})`, builds columns, seeds `row.prices` with only `base` (from `svc.base_price`), then calls `loadPriceAxes()`.
  - `buildColumns()` — ts:181-199 — column order: `base`, `dept`, one per active doctor (`doctor_${id}`), one per active contract (`contract_${id}`).
  - `loadPriceAxes()` — ts:201-224 — per service, `GET /clinic/services/{id}/prices`, merges via `axisToColKey()`. **Never fetches contract prices** — no call to any contract/coverage endpoint here.
  - `axisToColKey()` — **ts:236-241** — maps `base`→`'base'`, `department`→`'dept'`, `doctor`(+doctor_id)→`doctor_${id}`. **Returns `null` for anything else — there is no `contract` case, and the BE axis scope enum doesn't even have `contract`** (see §3, `clinic_service_prices.scope` is `base|department|doctor` only). So `getPrice(row, 'contract_X')` is **always `null`** → template always renders the `—` dash (pricing-matrix.component.html:90-94).
  - `onCellInput` / `setupSavePipeline` / `persistCell` — ts:263-350 — debounced (600 ms) save pipeline, per-cell `saving` Set, PrimeNG toast on success/error.
  - `persistCell()` — **ts:298-299** — explicitly `if (colKey.startsWith('contract_')) return;` → contract columns are **hard-coded read-only client-side**, no save path exists at all for them.
  - `colKeyToUpsertRow()` — ts:352-363 — `base`→`{scope:'base', price}`, `dept`→`{scope:'department', price}` **(⚠ never sets `department_id` — see §3 gap)**, `doctor_*`→`{scope:'doctor', doctor_id, price}`.
  - `selectRow()` / `selectCoverage()` — ts:369-394 — click a row → natural-price rail; click a contract cell → `GET /clinic/payer-contracts/{contractId}/coverage?service_id=` (single cell, on-demand, not persisted into `row.prices`).
  - `updateSplitRail()` — **ts:396-402** — calls `buildPlaceholderSplit(price)` unconditionally. This is the only caller of the fake split; both `selectRow` and `selectCoverage` funnel through it.
- `pricing-matrix.component.html`
  - Contract column cell — html:82-95 — read-only `<td>`, click → `selectCoverage()`, renders dash when `getPrice()` is null (always, per above).
  - Editable cell (base/dept/doctor) — html:96-114 — `<input type="number">` bound to `onCellInput`.
  - Split rail — html:147-229 — "P1 notice" banner (html:171-175, i18n key `CLINIC_PRICING.SPLIT_P1_NOTE`) explicitly tells the user the split shown is provisional; party rows + parties-total footer with a fake `✓` checkmark when `partiesTotal() === appliedPrice()` (html:205-210) — this always ties out because `buildPlaceholderSplit`'s `center` party is defined as `max(0, price - primary - referrer - assistant)`, i.e. **the check mark is mathematically guaranteed, not a real validation**.
- `clinic-pricing.service.ts`
  - `ServicePriceAxis` / `UpsertPriceRow` — ts:49-64 — `scope: PriceScope` where `PriceScope = 'base'|'department'|'doctor'` (ts:13) — **no `'contract'` variant in the FE type either**.
  - `getPrices(serviceId)` / `upsertPrices(serviceId, rows)` — ts:199-210 — wraps `GET/PUT /clinic/services/{id}/prices`.
  - `coverage(contractId, serviceId, doctorId?)` — ts:232-243 — wraps `GET /clinic/payer-contracts/{id}/coverage`, flat response, single service+doctor at a time (no bulk/grid fetch).
  - **No FE method exists at all for** `GET/PUT /clinic/payer-contracts/{id}/service-prices` (the actual contract-price bulk CRUD endpoint — see §3) — service.ts never calls it.
  - `doctors()` — ts:160-162 — `buildListAll()` auto-paginates through **every** doctor in the company (100/page, loops all pages). No department filter, no search param sent (see §4).

### BE — pricing axes (base/dept/doctor)
- `database/migrations/2026_06_22_020001_create_clinic_service_prices_table.php` — table `clinic_service_prices`: `service_id, scope('base'|'department'|'doctor' — VARCHAR(16), not an enum), department_id?, doctor_id?, price, is_active`, unique `(service_id, scope, department_id, doctor_id)`.
- `app/Models/ServicePrice.php` — Eloquent model over that table.
- `app/Services/PricingResolver.php` — `resolveNatural()` / `resolveWithAxis()` — **ts→php:21-133** — most-specific-wins: doctor → department → base, each query scoped by `company_id` when given. Pure read/query service, stateless.
- `app/Http/Controllers/ClinicServicePriceController.php`
  - `index()` — php:21-31 — `GET /clinic/services/{service}/prices`, returns all axis rows for that service (no scope filter).
  - `upsert()` — php:37-84 — `PUT /clinic/services/{service}/prices`, bulk upsert keyed by `(service_id, scope, department_id, doctor_id)`, restores soft-deleted rows first, dispatches `ServicePriceChanged` event.
  - **Gap confirmed**: validation allows `department_id` per-row (php:44) but the FE's `colKeyToUpsertRow()` for `dept` never sends it (ts:357) — every dept-scope save lands with `department_id: null`, which collides with the `base` row's unique key `(service_id,'department',null,null)` semantics — i.e. the "Dept. Override" column can never actually key off a specific department; it silently behaves as a second base-ish row. See §3 for the fix needed.
- `app/Listeners/SeedBaseServicePrice.php` (on `ClinicServiceCreated`) / `SyncBaseServicePrice.php` (on `ClinicServiceUpdated`) — one-way sync **`clinic_services.base_price` → `clinic_service_prices` scope=base row**. There is **no listener on `ServicePriceChanged`** (dispatched at `ClinicServicePriceController.php:81` but not registered in `app/Providers/EventServiceProvider.php`) — so editing the "Base" cell in the matrix updates `clinic_service_prices` but does **not** write back to `clinic_services.base_price`. Any other screen/report reading `service.base_price` directly (not through `PricingResolver`) will drift out of sync after a matrix edit.
- `routes/api.php:102-105` — `GET/PUT services/{service}/prices` (perm `clinic.pricing.view` / `clinic.pricing.manage`), `GET pricing/resolve` (perm `clinic.pricing.view`, → `PricingController::resolve`, flat `{price, axis}` response, not currently called by the matrix — matrix reads axes directly instead of using resolve()).

### BE — contract prices (the missing axis)
- `database/migrations/2026_06_22_020003_create_clinic_contract_service_prices_table.php` — table `clinic_contract_service_prices`: `payer_contract_id, service_id, doctor_id?, approved_price, patient_share, insurance_share, requires_approval, is_active`, unique `(payer_contract_id, service_id, doctor_id)`.
- `app/Models/ContractServicePrice.php`.
- `app/Http/Controllers/PayerContractController.php`
  - `servicePrices()` — **php:92-102** — `GET /clinic/payer-contracts/{payerContract}/service-prices` — returns **all** `ContractServicePrice` rows for one contract in **one request**. This is the bulk-fetch endpoint the matrix needs (one call per contract column, not per service-cell) — currently unused by the FE.
  - `upsertServicePrices()` — **php:108-157** — `PUT /clinic/payer-contracts/{payerContract}/service-prices` — bulk upsert by `(payer_contract_id, service_id, doctor_id)`, validates `service_id`/`doctor_id` exist, dispatches `ContractServicePriceChanged` (also unregistered — no listener, same dangling-event pattern as `ServicePriceChanged`). **This is the write path the matrix's contract columns would need to become editable** — completely unwired from the FE today.
  - `coverage()` — php:163-183 — `GET /clinic/payer-contracts/{payerContract}/coverage?service_id=&doctor_id=` — the only contract-pricing call the FE currently makes, and only on-click per cell (not bulk, not for grid population).
- `app/Services/CoverageService.php`
  - `resolveContractPrice()` — php:26-83 — priority: per-doctor contract row → service-default contract row (doctor_id null) → fallback to `PricingResolver::resolveNatural()` + `applyCoverage()` (percentage/fixed-copay split). This is the authoritative "what does this contract charge for this service+doctor" resolver — matches what `servicePrices()` bulk data represents when doctor_id is populated per row.
- `routes/api.php:107-115` — full contract-price route surface: `GET/PUT payer-contracts/{id}/service-prices` (perm `clinic.payer.view`/`clinic.payer.manage`), `GET payer-contracts/{id}/coverage` (perm `clinic.payer.view`).

### BE — revenue split (the REAL engine the FE bypasses)
A complete, already-shipped revenue-split engine exists server-side and is **entirely disconnected** from the pricing-matrix rail:
- `database/migrations/2026_06_22_090001_create_revenue_split_schemes_table.php` / `..._090002_create_revenue_split_lines_table.php`.
- `app/Models/RevenueSplitScheme.php` (axes: `clinic_service_id` + optional `doctor_id` / `doctor_grade_code` / `payer_contract_id`, `specificityScore()` for most-specific-wins) / `app/Models/RevenueSplitLine.php` (`party_role`, `basis: percent|fixed|remainder`, `value`, `is_internal_referrer`, `payable_account_id`).
- `app/Enums/PartyRole.php`, `app/Enums/SplitBasis.php` — real typed enums (vs. the FE's ad-hoc `RevenueSplitParty['role']` union in `clinic-pricing.service.ts:88`).
- `app/Services/RevenueSplitService.php`
  - `resolveScheme(serviceId, companyId, doctorId?, gradeCode?, contractId?)` — **php:33-71** — public, most-specific-scheme lookup (doctor(4) + grade(2) + contract(1) scoring, wildcards via null columns). Pure lookup, no side effects — directly reusable for a rail preview.
  - `computePartyAmounts(linePrice, schemeLines)` — **php:220-280** — `private`, but pure arithmetic (percent/fixed/remainder, remainder absorbs residue so Σ == linePrice, throws if explicit lines exceed price). This is the REAL version of the FE's `buildPlaceholderSplit` — same shape of output (role, basis, value, calculated amount) but driven by actually-configured scheme lines instead of a hardcoded 62.5/20/10/remainder split.
  - `snapshot(ServiceOrderLine $line)` — php:85-133 — writes `EncounterServiceParty` rows once a real service-order line exists (post-order persistence, not a preview).
- `app/Http/Controllers/RevenueSplitSchemeController.php` — full CRUD (`index` filterable by `clinic_service_id`/`is_active`, `store`, `show`, `update`, `destroy`) at `routes/api.php:148-152` (perm `clinic.split.view`/`clinic.split.manage`) — admin screen to configure real split schemes; **no FE feature currently consumes this** (grep found no `revenue-split-schemes` caller anywhere under `features/clinic`).
- `app/Http/Controllers/RevenueSplitController.php` — `lineParties()` (`GET service-order-lines/{line}/parties`, requires an existing `ServiceOrderLine`) and `revenueByDoctor()` report — both post-hoc (operate on a real order line), **no "preview split for hypothetical price" endpoint exists yet**.

### BE — doctors endpoint (owner's "1000 doctors" rule)
- `app/Http/Controllers/DoctorController.php::index()` — **php:19-28** — `LabDoctor::query()->where('company_id', ...)->with(['grade','employee','department'])->orderBy('name')->paginate(25)`. **No `search` param, no `department_id` filter, no scope-limiting of any kind.** Confirmed via `grep -n "search\|department_id"` on the file — zero matches.
- `routes/api.php:67-69` — single `GET doctors` index route (perm `clinic.doctors.view`), no query-string-aware variant elsewhere in the module.
- FE `doctors()` (`clinic-pricing.service.ts:160-162`) calls `buildListAll()` which loops every page (100/page) until `last_page` is exhausted — i.e. **the matrix always fetches every doctor in the company**, then renders one column per doctor client-side. This is exactly the anti-pattern the owner flagged ("لا listAll لـ1000 دكتور") — confirmed as a real, currently-shipped gap, not hypothetical.

---

## 2. Fabricated-split removal + real-contract-columns work needed

### 2a. Remove `buildPlaceholderSplit` (ts:62-73)
- Delete the function and its sole call site `updateSplitRail()` (ts:396-402, specifically the `this.splitParties.set(buildPlaceholderSplit(price))` line).
- Replace with a real call into the BE revenue-split engine. Two sub-options, both requiring **new BE surface** (nothing exposes a price-preview today):
  1. **New lightweight preview endpoint** — e.g. `GET /clinic/revenue-split/preview?service_id=&doctor_id=&price=&contract_id=` that internally calls `RevenueSplitService::resolveScheme()` (already public) then a **newly-public** (or duplicated) `computePartyAmounts()`. Cheapest to add: promote `computePartyAmounts` from `private` to `public` (or add a thin public wrapper), then a new controller action composes `resolveScheme()->lines` + `computePartyAmounts($price, $lines)` and returns the same `{role,basis,value,calculated}` shape the FE already expects (`RevenueSplitParty` — `clinic-pricing.service.ts:87-93`) so the FE model barely changes.
  2. Alternatively client-side compose: FE first calls the (unused) `revenue-split-schemes?clinic_service_id=` list (already exists, `RevenueSplitSchemeController::index`), picks the matching scheme, and computes percent/fixed/remainder locally — duplicates BE arithmetic in FE, more drift risk, not recommended.
- Handle the "no scheme configured" case explicitly (`resolveScheme()` returns `null` when nothing matches — php:63-65) — the rail must show a real empty/"no split configured" state instead of always drawing 4 fake party rows. This also means the "✓ always ties out" checkmark behavior (html:205-210) becomes a genuine correctness check once real amounts are used — worth confirming the `partiesTotal()===appliedPrice()` floating-point-equality comparison (ts:124) still holds with real decimal(12,3) amounts (may need an epsilon comparison, since money is `decimal(12,3)` not always exactly representable in JS floats after arithmetic).
- The i18n "P1 notice" (`CLINIC_PRICING.SPLIT_P1_NOTE`, en.json:10378, html:171-175) should be removed or repurposed once the real engine is wired — it currently exists specifically to disclose that the split is provisional.
- Also worth surfacing (out of scope for removal, but relevant): a real admin path to **configure** `RevenueSplitScheme`/`RevenueSplitLine` exists (`RevenueSplitSchemeController`) but has no FE screen at all under `features/clinic` — if the matrix rail is wired to real split data with no scheme configured, users will see empty/no-split for every service until a scheme-management UI (or seed data) exists somewhere.

### 2b. Make contract columns real (currently always "—")
Two independent gaps must both close:
- **Read side**: `loadPriceAxes()` (ts:201-224) only calls `getPrices()` (base/dept/doctor). Need a parallel bulk load per active contract: call `GET /clinic/payer-contracts/{contractId}/service-prices` (`PayerContractController::servicePrices`, php:92-102 — already returns all rows for the contract in one shot, keyed by `service_id`/`doctor_id`) once per contract (not per service — this is a `contracts.length` fan-out, much cheaper than the current `services.length` fan-out for axes), then merge into `row.prices` under `contract_${contractId}` keyed by `service_id` (and by `doctor_id` if the matrix is later extended to a doctor×contract cross — today the column is contract-only, not contract+doctor).
  - `axisToColKey()` (ts:236-241) needs a new branch, or — since `ServicePriceAxis`'s BE-shape doesn't carry `contract_id`/`payer_contract_id` at all (`ServicePriceAxis` interface, `clinic-pricing.service.ts:49-57`, mirrors only `clinic_service_prices` scope enum) — the merge for contract columns should bypass `axisToColKey` entirely and be driven by the new bulk contract-price response shape (`ContractServicePriceResource`: `id, payer_contract_id, service_id, doctor_id, approved_price, patient_share, insurance_share, requires_approval, is_active` — `ContractServicePriceResource.php:15-27`), mapping `approved_price` → the cell value.
  - Rows with no explicit `ContractServicePrice` row should still be able to show the **resolved** fallback price (natural price passed through `CoverageService::applyCoverage()`), not just a dash — i.e. either the bulk endpoint needs a "resolved" variant, or the FE computes per-cell fallback lazily (defeats the "bulk" win) — worth a m1 design decision: show only *explicit* contract overrides in the grid (dash = no override, click for resolved coverage in the rail — closer to current UX) vs. show *resolved* prices everywhere (more useful at a glance, needs a new bulk-resolve BE endpoint since `coverage()` is per-cell only).
- **Write side**: `persistCell()` (ts:298-299) explicitly no-ops for `contract_*` columns; `colKeyToUpsertRow()` (ts:352-363) has no contract branch. To make contract cells editable, need:
  - A new upsert path in the FE service (`clinic-pricing.service.ts` has no method at all for `PUT /clinic/payer-contracts/{id}/service-prices` today) — wraps `PayerContractController::upsertServicePrices` (php:108-157), body shape `{prices: [{service_id, doctor_id?, approved_price, patient_share?, insurance_share?, requires_approval?, is_active?}]}` — note the field name is `approved_price`, **not** `price` (different from the base/dept/doctor axis upsert body) — the matrix's single generic `UpsertPriceRow` type (`clinic-pricing.service.ts:59-64`) does not fit this shape and will need either a union type or a separate contract-specific upsert row type.
  - Decide UX: is a bare number edit in the contract cell enough (approved_price only, patient_share/insurance_share defaulted), or does editing a contract cell need a richer control (percentage vs fixed copay awareness, since `PayerContract.coverage_type` governs the natural-price-fallback split but an explicit `ContractServicePrice` row overrides it wholesale)?

---

## 3. 3-axis edit/save flow + gaps

**Axes**: `base` (floor) → `department` → `doctor`, most-specific wins, resolved server-side by `PricingResolver::resolveNatural()`/`resolveWithAxis()` (`PricingResolver.php:21-133`). Contract price is a **separate, fourth axis** entirely (`clinic_contract_service_prices`, resolved by `CoverageService::resolveContractPrice()`), not part of `PricingResolver` at all, and not unioned into the `clinic_service_prices.scope` enum.

**Edit/save flow (base/dept/doctor only — what works today)**:
1. User types in an `<input type="number">` cell (`onCellInput`, ts:263-279) → optimistic local update to `row.prices` Map + pushed onto `savePipe$` Subject.
2. `savePipe$` is debounced 600 ms and deduped by `(serviceId, colKey, value)` (ts:281-295).
3. `persistCell()` (ts:297-350) marks the cell `saving`, converts `colKey` → `UpsertPriceRow` via `colKeyToUpsertRow()`, calls `pricingService.upsertPrices(serviceId, [row])` → `PUT /clinic/services/{id}/prices` (single-row array, always **one row per keystroke-settle**, no batching across cells/services).
4. BE `ClinicServicePriceController::upsert()` (php:37-84) validates, restores any soft-deleted matching row, `updateOrCreate`s keyed by `(service_id, scope, department_id, doctor_id)`, dispatches `ServicePriceChanged` (currently a no-op event — no registered listener).
5. On success/error, `saving` Set is cleared and a PrimeNG toast fires.

**Confirmed gaps in this flow**:
- **`dept` column never sends `department_id`** (`colKeyToUpsertRow`, ts:357: `{ scope: 'department', price: value }`) — the natural fix is `department_id: row.service.department_id` (each `ClinicService` already carries its own `department_id` — `ClinicService` interface, `clinic-pricing.service.ts:17-28`, and the row object is available in `persistCell`'s caller chain via `onCellInput(row, ...)` but `persistCell` itself only receives `serviceId`/`colKey`/`value`, not the row/service — needs a plumbing change to carry `department_id` through the debounce pipeline, e.g. add it to the `savePipe$` payload).
- **No back-sync from `clinic_service_prices` (scope=base) to `clinic_services.base_price`** — `ServicePriceChanged` is dispatched but has zero registered listeners (`EventServiceProvider.php:68-181` has entries for `ClinicServiceCreated`→`SeedBaseServicePrice` and `ClinicServiceUpdated`→`SyncBaseServicePrice`, i.e. the sync only runs **one direction**, service→price-table, never price-table→service). Any other screen reading `clinic_services.base_price` directly will silently drift after a matrix "Base" cell edit.
- **`ContractServicePriceChanged` is likewise dispatched with no listener** (`PayerContractController.php:154`) — same dangling-event pattern, in case م1 wires up cache-invalidation or resolve-cache logic later.
- **`resolve` endpoint (`GET /clinic/pricing/resolve`) is unused by the matrix** — the matrix reads raw axis rows and resolves precedence *client-side implicitly* by which `colKey` the user is looking at, rather than ever calling the BE's own most-specific resolver. Not currently a bug (columns are explicit, not "give me the resolved price for doctor X"), but worth noting if م1 adds a "what price would actually apply" preview column.
- **Debounce pipeline sends 1 HTTP request per cell-settle**, no cross-cell batching even if a user edits multiple cells quickly for the same service — `upsertPrices()` accepts an array (`prices: rows`) but the FE only ever sends a 1-element array (`persistCell`, ts:314: `this.pricingService.upsertPrices(serviceId, [upsertRow])`). Not wrong, just an opportunity if م1 wants fewer round-trips.

**Doctor picker (item 4) — restated**: confirmed `listAll` with **no department filter and no server-side search** (§1 "BE — doctors endpoint"). Matches the owner's explicit "1000 doctors" concern in MEMORY — this is a real, currently-shipped violation of that rule, not a hypothetical risk.

---

## Summary of what م1 needs to decide/build (not prescriptive, just surfacing the decision points found)
1. New BE endpoint(s): a revenue-split **preview** (price-in → parties-out, no order line needed) and, separately, either bulk-resolve or explicit-only semantics for contract columns.
2. New FE service methods: contract-price upsert (`PUT payer-contracts/{id}/service-prices`, different body shape — `approved_price` not `price`), bulk contract-price fetch (`GET payer-contracts/{id}/service-prices`), revenue-split preview call.
3. Fix `dept` upsert to actually send `department_id`.
4. Either wire `ServicePriceChanged`/`ContractServicePriceChanged` to something, or accept the one-way sync and document that `base_price` on `clinic_services` should be treated as legacy/seed-only once the matrix ships.
5. `DoctorController::index` needs `search` + `department_id` params before the doctor-column picker can be considered compliant with the owner's scale rule; FE `doctors()` needs to stop full `listAll()`-ing once that lands.
