# 10 — New Requirements (Phase 4.5): Periods, Pricing & Follow-up

Source: `new-requierment.md` (three requirements). All three land **inside the admin dashboard**
(`routes/admin.php` + `resources/views/dashboard/admin/clinic/...`), with one deliberate exception
noted in Track C (the doctor's follow-up flag).

This is an **additive Phase 4.5**, inserted after the shipped Phase 0–4 code and before the
reserved Phase 5 (mobile API + Agora). It does not renumber anything in `05-roadmap.md` or
`09-roadmap-phase6-13.md`.

## Why

| # | Requirement | Today |
|---|---|---|
| 1 | Doctor schedule on a periods system | `clinic_doctor_schedules` holds exactly one `start_time`/`end_time` per (`doctor_branch_id`, `day_of_week`) — enforced by `DoctorScheduleService::save()`'s `updateOrCreate` keyed on the day and read back by `BookingService::availableSlots()`'s `->first()`. A morning + evening shift cannot be expressed. |
| 2 | New pricing screen | Price is two flat pivot columns (`clinic_doctor_branch.visit_price` / `consultation_price`) edited inside the doctor form, with no doctor/center split and no third follow-up price. |
| 3 | Follow-up (إعادة الكشف) | Does not exist. `clinic_bookings.type` accepts `visit` / `online` only, and nothing records whether a patient needs a re-visit or when. |

## Decisions taken with the user before building

| Decision | Choice |
|---|---|
| Share validation | **Mandatory**: doctor share + center share must equal the total (±0.01); percentages must sum to 100 |
| Old pivot price columns | **Dropped outright** — the pricing screen is the single source of truth and every reader is converted |
| Who records the follow-up | **The doctor flags it** when finishing the visit; **the admin sets the date and books** |
| Pricing permissions | Super Admin **and** Clinic Admin get full CRUD; Receptionist gets nothing |

## Build order (one commit per track)

**A (periods)** → **B (pricing)** → **C (follow-up — depends on B for the `followup` price)**.

---

## Track A — Doctor schedule on a periods system

### Migration

`..._add_periods_to_clinic_doctor_schedules_table.php` — the table carries no unique index on
(`doctor_branch_id`, `day_of_week`), so nothing is dropped; only added:

- `label` — `string` nullable (period name: صباحي / مسائي, cosmetic only)
- `slot_duration_minutes` — `unsignedSmallInteger` nullable (per-period override, falls back to `clinic_doctor_branch.slot_duration_minutes`)
- `sort_order` — `unsignedTinyInteger` default 0

No backfill: every existing row is already a valid single period.

### Checklist

- [ ] `DoctorSchedule` — three columns in `$fillable`/`$casts`, plus `scopeActive()` and `effectiveSlotDuration()` resolving the pivot fallback
- [ ] `DoctorScheduleService::getForDoctorBranch()` returns `0..6 => Collection<DoctorSchedule>` (empty collection for a closed day) instead of `0..6 => ?DoctorSchedule`
- [ ] `DoctorScheduleService::save()` — replace `updateOrCreate` with a per-day reconcile inside `DB::transaction`: delete rows whose `id` was not submitted, update submitted ones, insert new ones
- [ ] New `UpdateDoctorScheduleRequest` — moves the inline `$request->validate()` out of `DoctorScheduleController::update()` into the module's FormRequest convention; validates `days.*.periods.*.{id,start_time,end_time,label,slot_duration_minutes}` and rejects **overlapping periods within a day** plus `end_time <= start_time` via `withValidator`
- [ ] `BookingService::availableSlots()` — the behavioural core: `->first()` becomes `->orderBy('start_time')->get()`, looping every active period for that weekday at its effective duration, then `->unique()`. Booked-slot filtering and the `> now()` guard are unchanged
- [ ] `doctors/schedule.blade.php` — each weekday becomes a repeater: day toggle + N period rows (start, end, optional label) with "+ إضافة فترة" and a per-row remove button. A hidden `days[d][periods][i][id]` carries existing ids so the reconcile can tell an edit from an insert. The `?clinic=` selector and Metronic markup stay
- [ ] Sweep the other readers for one-row-per-day assumptions: `Site\Clinic\BookingController`, `Site\Clinic\MyBookingController`, `web_site/clinic/doctor-profile.blade.php`, doctor portal
- [ ] Lang: `clinic.doctors.periods` / `.add_period` / `.remove_period` / `.period_label` / `.overlap_error` (ar + en)

**Definition of done**: a doctor at one clinic has two non-overlapping Sunday periods
(09:00–13:00, 17:00–21:00); the admin booking form's slot list for that Sunday shows slots from
both and none from the gap; re-saving round-trips both periods with no duplicate rows; submitting
overlapping periods is rejected with a validation message.

---

## Track B — Pricing screen (شاشة الأسعار)

### Migrations

**`..._create_clinic_service_prices_table.php`** — one row **per service type** (not three column
groups on one row) so a fourth priced service is a data row, not a migration:

| Column | Type |
|---|---|
| `clinic_profile_id` | FK `clinic_profiles` cascadeOnDelete |
| `doctor_id` | FK `clinic_doctors` cascadeOnDelete |
| `branch_id` | FK `branches` restrictOnDelete — denormalized from the clinic so branch scoping and finance reports need no join |
| `service_type` | `string` index — `visit` \| `online` \| `followup` (same vocabulary as `clinic_bookings.type`) |
| `total_price` | `decimal(10,2)` |
| `doctor_share_type` / `doctor_share_value` | `string` (`amount`\|`percentage`) / `decimal(10,2)` |
| `center_share_type` / `center_share_value` | `string` / `decimal(10,2)` |
| `is_active` | `boolean` default true |
| `created_by` / `updated_by` | nullable FK `admins` nullOnDelete (module convention) |
| | `unique(['doctor_id','clinic_profile_id','service_type'])` |

**`..._add_share_snapshot_to_clinic_bookings_table.php`** — `doctor_share_amount` and
`center_share_amount`, `decimal(10,2)` nullable after `price`. A booking already snapshots `price`
at creation and never re-reads it (`models/Booking.md`); the split must be snapshotted the same way
or a later price edit silently rewrites the history of every past visit.

**`..._drop_prices_from_clinic_doctor_branch_table.php`** — drops `visit_price` and
`consultation_price`. **Runs last in the track**, after every reader below is converted; `down()`
restores both columns from `clinic_service_prices`.

### Checklist — new code

- [ ] `App\Models\Clinic\ServicePrice` — `BelongsToClinicBranch`, `doctor()`/`clinicProfile()`/`branch()`, and computed `doctorShareAmount()` / `centerShareAmount()` resolving a `percentage` type against `total_price` (2dp)
- [ ] `App\Services\Clinic\ServicePriceService` — `saveForPair(DoctorClinic, array): void` (upserts the three services in one transaction), `resolve(DoctorClinic, string): ?ServicePrice`, `breakdown(ServicePrice): array{total, doctor, center}`
- [ ] `Admin\Clinic\ServicePriceController` — `index` (branch/clinic/doctor filters), `create`, `store`, `edit`, `update`, `destroy`, and a `clinic-doctors` JSON endpoint copying the select2 cascade in `BookingController::doctorsForClinic()` / `DoctorScheduleController::doctorsForClinic()`. Branch scoping via the same `when($admin->branch_id !== null, ...)` as the sibling controllers
- [ ] `StoreServicePriceRequest` / `UpdateServicePriceRequest` — per-service rules plus a `withValidator` that rejects a doctor+center total ≠ `total_price` (±0.01), and percentages not summing to 100 when both types are `percentage`

### Checklist — converting existing readers (all before the drop migration)

| File | Change |
|---|---|
| `Services/Clinic/BookingService.php` | pivot lookup → `ServicePriceService::resolve()`; 422 "doctor is not priced for this service" when absent; write `price` + `doctor_share_amount` + `center_share_amount` |
| `Models/Clinic/DoctorClinic.php` | drop both from `$fillable`/`$casts`; add `servicePrices(): HasMany` |
| `Models/Clinic/Doctor.php`, `ClinicProfile.php` | drop both from `withPivot([...])` |
| `Services/Clinic/DoctorService.php` | stop writing both on attach/sync |
| `StoreDoctorRequest`, `UpdateDoctorRequest` | drop both `required` rules |
| `doctors/partials/form.blade.php` | remove both inputs; link to the pricing screen instead |
| `web_site/clinic/doctor-profile.blade.php` | read `visit`/`online` from `clinic_service_prices`; hide the line when unpriced |
| `factories/Clinic/DoctorClinicFactory.php` | drop both keys; add a `ServicePriceFactory` |
| `seeders/ClinicDemoSeeder.php` | seed `clinic_service_prices` rows (default 60/40 split) and read the booking price from them |

### Checklist — permissions, routes, UI

- [ ] `RolePermissionSeeder` — `clinic.pricing.view` / `.create` / `.edit` / `.delete`; **Super Admin and Clinic Admin get all four**, Receptionist none
- [ ] `routes/admin.php` — `clinic/pricing` group named `admin.dashboard.clinic.pricing.*` after the `clinic/schedules` group, each route `->middleware('permission:clinic.pricing.*')`
- [ ] Sidebar entry after Doctors guarded by `@can('clinic.pricing.view')` + `sidebar.clinic_pricing` (ar + en)
- [ ] Lang block `clinic.pricing.*` (title, branch, clinic, doctor, service, total_price, doctor_share, center_share, amount, percentage, share_mismatch, saved…) in ar + en
- [ ] `pricing/index.blade.php` — doctor↔clinic pairs with the three services as columns, branch/clinic/doctor filters
- [ ] `pricing/create.blade.php` + `edit.blade.php` sharing `partials/form.blade.php`: cascading select2 (branch → clinic → doctor) then three identical service blocks (كشف / استشارة أونلاين / إعادة الكشف), each with a total plus doctor-share and center-share inputs each paired with an amount/percentage toggle, and a live JS readout of the computed split — the server still decides

**Definition of done**: an admin prices one doctor at one clinic — visit 500 split 60/40, online 300
split 200/100 as fixed amounts, follow-up 200; a 300/300 split on a 500 total is rejected; a booking
created afterwards stores `price = 500`, `doctor_share_amount = 300`, `center_share_amount = 200`;
the doctor's public profile still shows the same visit price; `migrate` + `migrate:rollback` are
clean and `ClinicDemoSeeder` runs on a fresh database.

---

## Track C — Follow-up (إعادة الكشف)

### Migration

`..._create_clinic_followups_table.php`:

| Column | Type | Note |
|---|---|---|
| `booking_id` | FK `clinic_bookings` cascadeOnDelete, **unique** | The completed visit the decision belongs to — one decision per visit |
| `patient_id` / `doctor_id` / `clinic_profile_id` / `branch_id` | FKs restrictOnDelete | Denormalized so the worklist and its filters need no joins |
| `is_required` | `boolean` default true | "no follow-up needed" is recorded explicitly (false), not as a missing row — otherwise the worklist cannot tell "decided: no" from "not decided yet" |
| `due_date` | `date` nullable index | متى مطلوب الإعادة — **filled by the admin** |
| `doctor_note` / `admin_note` | `text` nullable | The doctor's note when flagging, the admin's when scheduling |
| `status` | `string` default `pending` index | `pending` \| `scheduled` \| `cancelled` |
| `followup_booking_id` | nullable FK `clinic_bookings` nullOnDelete | The resulting booking |
| `created_by` | nullable FK `admins` nullOnDelete | |

`clinic_bookings.type` gains a third value, `followup`, priced from `clinic_service_prices`
(`service_type = 'followup'`) — this is Track C's dependency on Track B.

### The doctor's flag ("بعد انتهاء الكشف")

The completion moment today is `MedicalRecordService::create()`, which flips the booking to
`completed` when the doctor publishes the medical record — so the flag rides on that same form:

- [ ] `doctor/visits/show.blade.php` — a "needs follow-up" checkbox + optional note inside the existing `doctor.visits.store` form. **No date field and no booking button in the doctor portal** — the date and the booking belong to the admin
- [ ] `StoreMedicalRecordRequest` — `needs_followup` bool, `followup_note` nullable string max:1000
- [ ] `MedicalRecordService::create()` — inside the same transaction, create a `pending` `Followup` with `due_date = null` when flagged

### Checklist — rest of the code

- [ ] `App\Models\Clinic\Followup` + `Booking::followup()` (hasOne on `booking_id`) and `Booking::followupSource()` (hasOne on `followup_booking_id`)
- [ ] `App\Services\Clinic\FollowupService` — `record()` (guarded by `abort_unless($booking->status === 'completed')`, upsert on `booking_id`), `markScheduled()`, `cancel()`, `pendingQuery(?Admin)` as the branch-scoped worklist source
- [ ] `Admin\Clinic\FollowupController` — `index` (pending worklist, clinic/doctor/due-window filters, overdue first), `store` (from the booking show page), `cancel`
- [ ] `StoreFollowupRequest` — `is_required` bool, `due_date` with `required_if:is_required,1` + `after_or_equal:today`, `admin_note` nullable
- [ ] `Admin\Clinic\BookingController::create()` — accept `?followup=` and prefill patient, clinic, doctor, `type=followup`, and the date from the linked `Followup`
- [ ] `StoreAdminBookingRequest` — `'type' => ['required','in:visit,online,followup']` + an optional `followup_id` validated as belonging to the same patient/doctor and still `pending`
- [ ] `BookingController::store()` — call `FollowupService::markScheduled()` inside the booking-create transaction when `followup_id` is present

### Admin-only enforcement

- [ ] `Site\Clinic\BookingController` already coerces any type outside `['visit','online']` to `visit` — **keep it**, with a comment stating `followup` is deliberately unbookable from the patient site
- [ ] No follow-up route in `routes/site.php` and none in `routes/doctor.php` (the doctor portal gets the flag only, never the booking)
- [ ] Record the constraint in `mobile_api_requier1.md` so the Phase 5 mobile API does not expose `type=followup` either

### Checklist — permissions, routes, UI

- [ ] `RolePermissionSeeder` — `clinic.followups.view`, `clinic.followups.manage` (the booking itself reuses `clinic.bookings.create`)
- [ ] `routes/admin.php` — `clinic/followups` group + `POST clinic/bookings/{booking}/followup`
- [ ] Sidebar entry guarded by `@can('clinic.followups.view')` with a pending-count badge from `FollowupService::pendingQuery()`
- [ ] `bookings/show.blade.php` — for a `completed` booking: a card showing the doctor's flag and note, the admin's fields (required yes/no + due date + note) and save; afterwards a "حجز الإعادة" button to `bookings/create?followup={id}`
- [ ] `followups/index.blade.php` — the worklist: patient, doctor, clinic, last visit date, due date (overdue highlighted), status, and a "حجز الآن" action per row
- [ ] `bookings/create.blade.php` — third `followup` type option, prefill support, and a read-only banner naming the source visit when arriving via `?followup=`
- [ ] Lang: `clinic.followups.*`, `clinic.bookings.followup`, `doctor.visits.needs_followup` (ar + en)

**Definition of done**: a doctor publishes a record flagged "needs follow-up" with a note → the
booking completes and the case appears in the admin worklist with no date; the admin sets it due in
14 days; "حجز الآن" opens the booking form pre-filled for that patient/doctor/clinic at
`type = followup` priced from the follow-up price; saving creates the booking, links it back, and
flips the request to `scheduled` so it leaves the worklist. The same patient sees no way to book a
`followup` from the site (a hand-crafted `type=followup` is coerced to `visit`), and a Receptionist
never sees the pricing screen.
