# 08 — The Shared Data Spine: Patient / Encounter / Billing across ERP + LIS + HIS + OBGY

Technical companion to the Arabic management fragment `sections/08-data-spine.html`.
Everything below is traced to real files; design proposals are explicitly marked **(proposal)** or **(inference)**.

---

## 1. Patient identity — where the MPI lives

### 1.1 The three patient-shaped entities that exist today

| Entity | Location | Nature | Key columns |
|---|---|---|---|
| `lab_patients` | `/home/moonui/moon-erp-be/Modules/LIS/database/migrations/2026_03_01_000006_create_lab_patients_table.php` | Clinical person registry (production, ~11,769 internal + 15 B2B rows per `/home/moonui/hms-phase0-spec.md` line 25) | `company_id`, `mrn` (unique), `name`/`name_ar`, `date_of_birth`, `gender`, `phone`, `national_id`, `insurance_info` JSON, `medical_history`, `partner_id` → `business_partners` (nullOnDelete), `blood_group` |
| `business_partners` | `/home/moonui/moon-erp-be/Modules/Core/database/migrations/2026_02_16_200001_create_business_partners_table.php` | Commercial counterpart (customer/supplier): `is_customer`, `is_supplier`, `credit_limit`, `payment_terms_days`, `tax_number`, unique `(company_id, code)` | No clinical fields at all |
| CRM customer | `/home/moonui/moon-erp-be/Modules/CRM/database/migrations/2026_03_31_100001_create_crm_tables.php` | `crm_customer_ext` / `crm_customer_tags` / `crm_customer_interactions` — an *extension* of `business_partners`, not an independent person registry | `customer_type` only |

Plus the legacy OBGY `patients` table (`/home/amrtechogate/public_html/obgy-erp-analysis.md`, section "Module: Patients & Registration"):
- One row = the **couple**: wife demographics + embedded husband columns (`husdandname` [sic], `husbandnationalid`, `husbandbl`, …).
- File number `statusno` generated by `MAX(statusno)+1` (race-prone), Egyptian 14-digit NID parsing, **zero declared FKs anywhere** in the DB.
- Already syncs outward today: synchronous cURL dual-DB write creating `client.obygyPatientId -> patients.id` rows in an external ERP (`patients.php::erpClient/curlAddClient`). The analysis' own migration note: *"in the target ERP the patient IS the client (patient_id on the client/account record)"*.

### 1.2 How `lab_patients` already behaves as an MPI

Accreted columns show it has outgrown "lab patient":

- **B2B multi-tenancy**: `external_lab_id` owner column (NULL = internal) — `2026_05_23_000002_add_external_lab_and_tax_status_to_lab_patients_table.php`, FK → `lab_external_labs`. This is the FHIR `Patient.managingOrganization` pattern (settled decision, spec line 25, scored 37/40 vs 19/40 for splitting).
- **Identity uniqueness**: `UNIQUE (company_id, external_lab_id, national_id)` — `2026_06_03_010000_scope_lab_patient_uniqueness_by_external_lab.php` (with documented MariaDB NULL-semantics gap closed at app layer; Phase-0 W2-1 item 6 adds the `ext_scope_key` partition guard).
- **KSA payer identity**: `national_id_type`, `passport_country` added by the **NPHIES module** (`Modules/NPHIES/database/migrations/2026_04_02_200001_add_saudi_id_fields_to_lab_patients.php`) and `tax_status` (saudi/resident VAT branch) — i.e., the national-claims layer already treats `lab_patients` as *the* patient source.
- **Finance bridge**: `partner_id` → `business_partners`; `Modules/LIS/app/Actions/PostLabInvoice.php` resolves per-partner AR accounts through `Modules\Accounting\Models\AccBpExt.ar_account_id` (lazily created by `Modules\Accounting\Listeners\CreatePartnerAccounts`). Patient↔client linkage that OBGY hacks with cURL already exists natively here.
- **Patient portal**: portal token issuance (`2026_03_02_300001_add_portal_token_to_lab_patients_table.php`, `LabPatient.php:60-65` booted hook).

### 1.3 Verdict on MPI placement

**The future MPI is `lab_patients`, promoted to a shared (Core/HIS-consumable) namespace without a table rename** — exactly what Phase-0 W2-1 already schedules: *"Promotion to shared entity: namespace/ownership decision only (shared model location + scopes) — NO table rename, NO global scope"* (spec line 75), with named scopes `LabPatient::scopeInternal()` / `scopeForLab($labId)` (W2-1 item 7) and the governing constraint *"NO blanket Global Scope on lab_patients"* (spec line 19). HIS consumes `internal()`; B2B rows stay (settled, do not reopen).

OBGY migration consequence **(proposal, consistent with the analysis' own notes)**:
- OBGY `patients` ETLs into `lab_patients` as internal rows (`external_lab_id = NULL`): `statusno` → `mrn`, wife demographics → patient columns, NID → `national_id` (+ `national_id_type='egyptian_nid'` value reuse of the NPHIES column).
- Husband/couple data does **not** belong in the MPI: a specialty-owned `his_patient_spouses` (1:1) table per the analysis' `Spouse`/`PatientHusband` recommendation; same for `ObstetricBaseline`.
- The cURL dual-DB sync dies: `partner_id` + `AccBpExt` is the in-process replacement for `client.obygyPatientId`.
- `business_partners`/CRM remains the *financial* identity; never a clinical registry. The only join point is `lab_patients.partner_id`.

---

## 2. Encounter spine — Encounter + Folio that every clinical module FKs into

### 2.1 What exists today

- **LIS**: `lab_requests.encounter_id` — nullable `unsignedBigInteger`, **no FK, read nowhere** (inert by design): `2026_06_10_160000_add_encounter_id_to_lab_requests.php`, comment: *"HIS encounters table does not exist yet — FK + consumers arrive at HIS kickoff (Phase-0 W1-3 inert column)"*. Also `lab_visits` (`2026_03_01_000010`) — a lightweight reception visit (`visit_number`, `patient_id`, `lab_request_id`, `visit_type='walk_in'`, `status`), not a hospital encounter.
- **OBGY**: the `visits` table is *"appointment + encounter + payment ledger in one table"* (analysis, visits module): queue columns (`visitorder`, `enterordered`, `view`), clinical routing via `lastvisit.control`, AND money columns (`totaldetectionvalue`, `detectionvalue_cash`, `detectionvalue_visa`, `restdetectionvalue`) with **magic `detectionid` values −99 = installment, 999 = pay-rest, 9999 = refund** and a self-FK `visitid` for payment rows. This conflation is the single strongest argument for a separated Encounter/Folio spine.
- **HIS**: Encounter/Folio tables are *"DEFERRED to HIS kickoff (design approved, creation later)"* (spec line 27). Module rule (spec line 26): HIS is ONE module `Modules/HIS`; **HIS imports LIS/Accounting/HRM forward; LIS never imports HIS** — backward flow only via existing events `LabResultReleased` / `LabRequestCompleted` (`Modules/LIS/app/Events/`).

### 2.2 Proposed schema (proposal — follows existing LIS column conventions: `company_id` first, `decimal(12,3)`, `created_by/updated_by`, softDeletes)

```php
// his_encounters — the clinical timeline anchor (FHIR Encounter)
Schema::create('his_encounters', function (Blueprint $t) {
    $t->id();
    $t->foreignId('company_id')->constrained('companies');
    $t->string('encounter_number')->unique();          // like lab_requests.request_number / lab_visits.visit_number
    $t->foreignId('patient_id')->constrained('lab_patients'); // MPI; HIS writes only internal() rows
    $t->string('type');            // opd | ipd | er | daycase
    $t->string('status')->default('planned'); // planned|arrived|in_progress|discharged|cancelled (FHIR Encounter.status subset)
    $t->unsignedBigInteger('department_id')->nullable();   // clinic/ward
    $t->foreignId('attending_doctor_id')->nullable()->constrained('lab_doctors'); // chains to HRM via lab_doctors.employee_id (W1-2)
    $t->foreignId('parent_encounter_id')->nullable()->constrained('his_encounters'); // episode linkage
    $t->unsignedInteger('queue_position')->nullable(); // replaces OBGY visitorder/enterordered/view triple
    $t->boolean('urgent')->default(false);
    $t->string('source')->default('reception');        // reception|portal|mobile|referral
    $t->timestamp('started_at')->nullable();
    $t->timestamp('ended_at')->nullable();
    $t->foreignId('branch_id')->nullable()->constrained('branches')->nullOnDelete();
    $t->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
    $t->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
    $t->timestamps(); $t->softDeletes();
    $t->index(['company_id', 'status']); $t->index(['patient_id']); $t->index(['started_at']);
});

// his_folios — the patient account per admission/visit (financial container)
Schema::create('his_folios', function (Blueprint $t) {
    $t->id();
    $t->foreignId('company_id')->constrained('companies');
    $t->string('folio_number')->unique();
    $t->foreignId('encounter_id')->constrained('his_encounters');
    $t->foreignId('patient_id')->constrained('lab_patients');  // denormalized, mirrors lab_invoices.patient_id
    $t->string('payer_type')->default('self_pay');  // self_pay | insurance | b2b_account
    $t->unsignedBigInteger('insurance_contract_id')->nullable(); // generalized lab_insurance_contracts
    $t->string('status')->default('open');          // open | closed | invoiced | cancelled
    $t->decimal('total_charges', 12, 3)->default(0);
    $t->decimal('total_payments', 12, 3)->default(0);
    $t->decimal('balance', 12, 3)->default(0);
    $t->timestamp('opened_at')->nullable();
    $t->timestamp('closed_at')->nullable();
    // + branch_id / created_by / updated_by / timestamps / softDeletes as above
    $t->index(['company_id', 'status']); $t->index(['encounter_id']);
});

// his_folio_charges — every module posts billable lines here
Schema::create('his_folio_charges', function (Blueprint $t) {
    $t->id();
    $t->foreignId('company_id')->constrained('companies');
    $t->foreignId('folio_id')->constrained('his_folios');
    $t->string('source_type');                      // morph: lab_request_investigation | obgy_service | pharmacy_dispense | bed_day ...
    $t->unsignedBigInteger('source_id');
    $t->string('service_code')->nullable();         // SBS/NPHIES code — pattern exists: lab_investigations.sbscs_code (NPHIES 2026_04_02_200002) + nafis fields (2026_05_31_140000)
    $t->string('description'); $t->string('description_ar');
    $t->decimal('quantity', 8, 2)->default(1);
    $t->decimal('unit_price', 12, 3)->default(0);
    $t->decimal('discount_amount', 12, 3)->default(0);
    $t->decimal('tax_amount', 12, 3)->default(0);
    $t->decimal('total', 12, 3)->default(0);
    $t->decimal('unit_cost', 12, 4)->default(0);    // COGS pattern from lab_invoice_items (2026_03_07_211313_add_cogs_fields)
    $t->unsignedBigInteger('cost_center_id')->nullable(); // Accounting cost centers, as in PostLabInvoice::createCogsJournalEntry
    $t->string('status')->default('pending');       // pending | invoiced | voided
    $t->unsignedBigInteger('invoice_id')->nullable();
    $t->timestamp('charged_at');
    $t->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
    $t->timestamps();
    $t->unique(['source_type', 'source_id']);       // a clinical act bills exactly once
    $t->index(['folio_id', 'status']);
});
```

### 2.3 FK contracts per module

| Module | FK into spine | Mechanism |
|---|---|---|
| LIS | `lab_requests.encounter_id` → `his_encounters.id` (FK added at HIS kickoff, column already live) | HIS calls `Modules/LIS/app/Actions/CreateLabRequest` (extracted in W1-3 from `LabRequestController::store` lines ~172-471) passing `encounter_id` — forward import, allowed direction |
| LIS → spine results | `LabResultReleased` / `LabRequestCompleted` events (existing: `Modules/LIS/app/Events/`, dispatched at `LabResultController.php:516,1058`, `LabWorkflowService.php:75,120`) | HIS listener attaches results to the encounter; LIS code untouched |
| OBGY (future specialty module) | `obgy visits` row → `his_encounters` (queue/booking half) + payment rows → folio payments; clinical sheets (`ancsheet`, `gynasheet`, `infertilitysheet`) FK `encounter_id` | ETL: rows with `detectionid IN (−99, 999, 9999)` are NOT encounters — split to payment transactions (analysis, visits ERP-migration notes) |
| Pharmacy/Radiology (later) | same `his_folio_charges` morph | `FolioChargePosted` event (proposal) |

`lab_visits` stays as-is for standalone-lab operation; when a lab order originates from an encounter, `lab_requests.encounter_id` is the link (no change to walk-in flow — governing constraint "LIS must NOT be affected", spec line 16).

---

## 3. Billing flow — today's LIS posting, generalized to Folio → Invoice → NPHIES claim

### 3.1 As-built LIS flow (all real code)

1. `lab_invoices` (`2026_03_02_000006`): `lab_request_id`, `patient_id`, `insurance_contract_id`, `insurance_amount`/`patient_amount` split, `journal_entry_id`, `cancel_journal_entry_id`, `cogs_journal_entry_id`, `posted_by/posted_at`.
2. **Posting** — `Modules/LIS/app/Actions/PostLabInvoice.php`:
   - Resolves AR account: partner-specific `AccBpExt.ar_account_id` (lazily created via `CreatePartnerAccounts::ensurePartnerAccounts`) → fallback `SettingsService` key `lis.receivable_account_id`.
   - Builds JE lines: **DR** AR (total) / **CR** revenue (`lis.revenue_account_id`) / **CR** VAT (`lis.tax_payable_account_id`); insurance split re-debits `patient_amount` to patient AR + `insurance_amount` to insurance receivable (contract → partner → settings fallback chain).
   - Calls `Modules\Accounting\Actions\CreateJournalEntry::execute()` with `source_type='lab_invoice'`, `source_id`, `entry_type` per invoice type (`lab_invoice`, `lab_insurance_invoice`, `external_lab_outbound`, `external_lab_inbound`).
   - Separate **COGS JE** (`DR lis.cogs_account_id / CR lis.reagent_expense_account_id`) grouped by `investigation.section.cost_center_id`.
   - Triggers `DoctorCommissionService::calculateForInvoice` (skipped for insurance/external-lab types).
3. **Payment** — `Modules/LIS/app/Actions/PostLabPayment.php`: DR `receiving_account_id` (treasury) / CR the *same* receivable resolved by the same fallback chain; `entry_type='lab_payment'`; then `invoice->recalculatePaymentStatus()`.
4. **NPHIES** — `Modules/NPHIES`: claim fields ON `lab_invoices` (`nphies_claim_status`, `nphies_preauth_ref`, `nphies_covered_amount`, `nphies_copay_amount` — `2026_04_03_000001`); `NphiesClaimService::submit()` logs to `nphies_transactions` (`lab_invoice_id`, `patient_id`, `type`) and writes the outcome back to `nphies_claim_status`. FHIR builders: `FhirPatientBuilder`, `FhirServiceItemBuilder`.

### 3.2 Generalization (proposal — this IS the "unified charge-posting service" the spec defers, line 96)

```
clinical act (any module)
  └─ event FolioChargePosted ──▶ his_folio_charges (+ folio running totals)
folio close (discharge / visit end)
  └─ event FolioClosed ──▶ generate invoice(s) per payer split:
        patient share  → invoice payer_type=self_pay
        insurance share → invoice payer_type=insurance (preauth ref from nphies_preauths)
invoice post
  └─ Core PostInvoice (generalized PostLabInvoice):
        DR AccBpExt.ar_account_id | his.receivable_account_id
        CR his.revenue_account_id (+ CR tax payable)
        COGS JE by cost_center_id
        CreateJournalEntry(source_type='his_invoice')
payment
  └─ generalized PostLabPayment (treasury DR / AR CR)
claim
  └─ NphiesClaimService::submit(invoice) → nphies_transactions → nphies_claim_status
```

Key engineering point: `CreateJournalEntry` is already module-agnostic (Accounting action). The lab-specific part of `PostLabInvoice` is only the **settings-key prefix and account fallback chains** — extract to a Core `ChargePostingService` parameterized by module prefix (`lis.*` → `his.*`), exactly what the Phase-0 spec's Generalization Register anticipates (*"payment-methods/treasuries hoist, generalized price-lists/insurance, unified charge-posting service"*, spec line 96). OBGY's `erpSellbill` cURL hack is replaced by the same in-process flow.

### 3.3 Event contract summary

| Event | Direction | Status | File / proposal |
|---|---|---|---|
| `LabResultReleased` | LIS → HIS (backward) | EXISTS | `Modules/LIS/app/Events/LabResultReleased.php` |
| `LabRequestCompleted` | LIS → HIS (backward) | EXISTS | `Modules/LIS/app/Events/LabRequestCompleted.php` |
| `CriticalResultDetected` | LIS → HIS | EXISTS | `Modules/LIS/app/Events/CriticalResultDetected.php` |
| `EncounterOpened` / `EncounterClosed` | HIS internal + consumers | proposal | `Modules/HIS/app/Events/` at kickoff |
| `FolioChargePosted` | any clinical module → HIS billing | proposal | morph payload `(folio_id, source_type, source_id, total)` |
| `FolioClosed` | HIS → invoicing | proposal | triggers payer-split invoice generation |
| `InvoicePosted` | HIS → Accounting (via action, not event) | pattern exists | generalized `PostLabInvoice` |
| Claim lifecycle | invoice → NPHIES | pattern exists | `NphiesClaimService::submit`, `nphies_transactions` |

---

## 4. Inputs to the board verdict (OBGY-as-HIS-core vs specialty module)

1. OBGY `patients` cannot be the MPI: couple-in-one-row, no FKs, varchar lookup ids, `MAX+1` file numbers, mojibake latin1 lookups — vs `lab_patients` with NPHIES Saudi-ID fields, B2B governance, partner/AR bridge, and 11.7k production rows.
2. OBGY `visits` conflates appointment + encounter + payments with magic ids — the spine must be *designed*, and OBGY data ETL-ed into it; porting OBGY's model would bake the conflation into HIS.
3. OBGY's own cURL ERP sync proves the demand for patient↔client and visit↔bill integration — Moon ERP already solves both natively (`partner_id`+`AccBpExt`, `PostLabInvoice`→`CreateJournalEntry`).
4. The settled module rule (one `Modules/HIS`, forward imports only, LIS events backward) leaves OBGY exactly one clean seat: a women's-health specialty package *inside/alongside* HIS that FKs into `his_encounters`/`his_folios`, contributing its clinical sheets (ANC/gyna/infertility/IUI) as encounter documentation.
