From a0b4a969fc17ae4171920190bb2bdf9c18d45c4f Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 20 Aug 2026 17:20:53 +0330 Subject: [PATCH] fix(appointment): one weekly schedule per doctor, place chosen per shift A doctor working both at their own practice and at a clinic had to write two independent schedules and neither panel could see the other, so the clinic showed an empty form even though the doctor had configured their practice. The schedule is now a single record owned by the doctor. What varies between days is the place: the context of a shift is read from its location_id, not from the record it lives in. Booking in a context therefore sees only that context's days, so a personal-practice secretary still cannot book a clinic day. The caller's own context decides which addresses they may assign: the doctor gets every place of theirs, a clinic manager only its own, and shifts outside their reach are returned for display but preserved verbatim on save. Existing per-clinic rows are merged by migration; location_id was already stored on every shift, so no context information is lost. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/schedule/ScheduleSection.tsx | 65 ++++++- docs/api/appointment-settings.md | 44 ++++- docs/api/appointment.md | 4 + migrations/Version20260820120000.php | 113 ++++++++++++ .../Controller/AppointmentController.php | 16 +- .../AppointmentSettingsController.php | 121 +++++++++++-- src/Appointment/Entity/WeeklySchedule.php | 14 +- .../Repository/WeeklyScheduleRepository.php | 58 +++--- .../Schedule/ScheduleLocationScope.php | 143 +++++++++++++++ .../Service/SlotCalculatorService.php | 34 +++- .../BookingLocationValidityTest.php | 14 +- .../Appointment/BookingLocationsScanTest.php | 17 +- .../ClinicOwnerScheduleAccessTest.php | 38 ++-- tests/Appointment/ServiceModeContextTest.php | 22 ++- .../Appointment/UnifiedDoctorScheduleTest.php | 170 ++++++++++++++++++ 15 files changed, 789 insertions(+), 84 deletions(-) create mode 100644 migrations/Version20260820120000.php create mode 100644 src/Appointment/Schedule/ScheduleLocationScope.php create mode 100644 tests/Appointment/UnifiedDoctorScheduleTest.php diff --git a/assets/admin/components/schedule/ScheduleSection.tsx b/assets/admin/components/schedule/ScheduleSection.tsx index 63a69568..34f69d36 100644 --- a/assets/admin/components/schedule/ScheduleSection.tsx +++ b/assets/admin/components/schedule/ScheduleSection.tsx @@ -102,7 +102,17 @@ const DEFAULT_BOOKING_META: BookingMeta = { booking_mode: 'slot', buffer_minutes: 0, }; -interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; meta?: BookingMeta; booking_mode_locked?: boolean; } +interface WeeklyScheduleData { + uuid: string; + doctor_uuid: string; + schedule: NewScheduleMap; + meta?: BookingMeta; + booking_mode_locked?: boolean; + /** همهٔ مکان‌های پزشک — مطب شخصی و هر کلینیکی که عضوش است. فقط برای برچسب‌زدن. */ + locations?: AddressData[]; + /** مکان‌هایی که همین کاربر حق دارد روی برنامه بنشاند. */ + selectable_location_ids?: number[]; +} // ── Persian (Jalali) date utilities ─────────────────────────────────────── @@ -561,8 +571,35 @@ function NoLocationsNotice({ clinicUuid }: { clinicUuid?: string | null }) { ); } +/** + * شیفتی که مکانش خارج از دسترس این کاربر است. + * + * حذفش از فهرست، برنامه را ناقص نشان می‌دهد و کاربر روی همان ساعت شیفت تازه می‌گذارد؛ + * ویرایش‌پذیر کردنش هم یعنی مدیر کلینیک می‌تواند برنامهٔ مطب شخصی پزشک را عوض کند. + * پس دیده می‌شود و دست نمی‌خورد. + */ +function ForeignSessionRow({ session, placeName }: { session: SessionConfig; placeName: string }) { + return ( +
+ + + {session.start_time} تا {session.end_time} + + {placeName} + خارج از دسترس شما +
+ ); +} + export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) { const qc = useQueryClient(); + // برنامه یکی است و شیفت‌های همهٔ محیط‌های پزشک را دارد. مدیر کلینیک شیفت مطب شخصی + // را می‌بیند ولی نباید بتواند عوضش کند، پس این دو از هم جدا نگه داشته می‌شوند. + const [allLocations, setAllLocations] = useState([]); + const [selectableIds, setSelectableIds] = useState(null); const [scheduleMap, setScheduleMap] = useState(EMPTY_NEW_SCHEDULE); const [scheduleUuid, setScheduleUuid] = useState(null); const [expandedDay, setExpandedDay] = useState(null); @@ -592,12 +629,26 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly setScheduleUuid(d.uuid); } if (d?.meta) setMeta({ ...DEFAULT_BOOKING_META, ...d.meta }); + if (d?.locations) setAllLocations(d.locations); + if (d?.selectable_location_ids) setSelectableIds(d.selectable_location_ids.map(Number)); setModeLocked(!!d?.booking_mode_locked); } else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) { setScheduleMap(EMPTY_NEW_SCHEDULE); setScheduleUuid(null); } }, [scheduleQ.data, scheduleQ.error]); + // شیفت بدون مکان هنوز در حال ساخت است، پس دست کاربر باز می‌ماند. + const canEditSession = (session: SessionConfig) => + selectableIds === null || session.location_id === null || selectableIds.includes(session.location_id); + + const locationLabel = (locationId: number | null) => { + const found = (allLocations.length ? allLocations : addresses).find(a => Number(a.id) === locationId); + if (!found) return 'مکان نامشخص'; + return found.type === 'clinic' + ? (found.clinic_name ?? found.name ?? 'کلینیک') + : (found.name ?? 'مطب شخصی'); + }; + const overlapDays = useMemo(() => Object.fromEntries(SCHEDULE_DAYS.map(d => [d.key, hasOverlap(scheduleMap[d.key]?.sessions ?? [])])) , [scheduleMap]); @@ -891,10 +942,14 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly ) : sessions.map((session, idx) => ( - updateSession(day.key, idx, s)} - onRemove={() => removeSession(day.key, idx)} /> + canEditSession(session) ? ( + updateSession(day.key, idx, s)} + onRemove={() => removeSession(day.key, idx)} /> + ) : ( + + ) ))} )} diff --git a/docs/api/appointment-settings.md b/docs/api/appointment-settings.md index 8c1361e5..57ec88ba 100644 --- a/docs/api/appointment-settings.md +++ b/docs/api/appointment-settings.md @@ -15,8 +15,20 @@ Every endpoint in this file operates inside **one booking context**, selected by | omitted / `null` | the doctor's **personal practice** | `entity_type='doctor'` | the doctor's own `personal` addresses | | a clinic uuid | that **doctor inside that clinic** | `entity_type='clinic'` | that clinic's addresses | -A doctor holds **one schedule per context** — a personal one plus one per clinic — and they are -fully independent: separate sessions, separate `booking_mode` lock, separate date overrides. +**Breaking change (2026-08): a doctor holds exactly ONE weekly schedule**, no matter how many clinics +they work at. What varies from day to day is the *place*: Saturday at the personal practice, Monday at +the clinic. The context of a shift is read from the `location_id` on that shift, not from the record it +lives in. Consequences: + +- `clinic_uuid` no longer selects *which record* is read or written — every context reads the same one. +- It still selects **which addresses the caller may assign**, and **which days a booking context sees**: + a personal-practice secretary never sees the clinic days and cannot book on them, and vice versa. +- A shift whose address belongs to another context is returned to the caller for display but is + preserved verbatim on save — a clinic manager can neither edit nor delete the doctor's personal shifts. +- `booking_mode` and the rest of `meta` are now doctor-wide, because there is one record. The existing + "mode is locked after the first save" rule therefore applies across contexts. +- Date overrides and holidays are unchanged and remain per-context. + Services never cross the boundary (they are polymorphic on `service_sections.entity_type`). If the doctor is not a member of the given clinic → `422 ERR_VALIDATION_001` @@ -43,7 +55,10 @@ Anything else → `403 ERR_AUTH_006`. ## Weekly Schedule -Each doctor has **one weekly schedule per context** (upsert keyed by `doctor_id` + `clinic_id`). +Each doctor has **exactly one weekly schedule** (upsert keyed by `doctor_id`; the legacy `clinic_id` +column stays `NULL` on new rows). Rows created before 2026-08 were merged by +`migrations/Version20260820120000.php`, which appended each clinic record's sessions into the doctor's +single record — the `location_id` already on every shift carries the context. The schedule is keyed by **day index** (0=Saturday ... 6=Friday), each day containing a `sessions` array. @@ -69,7 +84,9 @@ Create or update the weekly schedule for a doctor (upsert). > **الزام آدرس:** هر session با `active=true` باید `location_id` (آدرس مطب/کلینیک) داشته باشد. در غیر این صورت `422 ERR_VALIDATION_001` («برای هر شیفت فعال باید آدرس انتخاب شود»). این آدرس هنگام رزرو خودکار روی نوبت ذخیره می‌شود. > -> **الزام محیط:** آدرس انتخاب‌شده باید به همان context تعلق داشته باشد. آدرس کلینیک در محیط شخصی (و برعکس) → `422 ERR_VALIDATION_001` («آدرس انتخاب‌شده متعلق به این کلینیک نیست»). +> **الزام مکان:** آدرس هر شیفت باید در فهرست `available-locations` همان درخواست‌کننده باشد. پزشک هر دو محیط خودش را دارد؛ کلینیک فقط آدرس خودش. آدرس بیرون از این فهرست → `422 ERR_VALIDATION_001` («آدرس انتخاب‌شده متعلق به این کلینیک نیست»). +> +> **ادغام هنگام ذخیره:** شیفت‌هایی که آدرسشان بیرون از دسترس درخواست‌کننده است، از نسخهٔ ذخیره‌شده دست‌نخورده برمی‌گردند؛ ورودی نه می‌تواند حذفشان کند نه عوضشان. > > **نوبت‌دهی سرویسی:** با `meta.booking_mode = "service"` صاحبِ همان context باید حداقل یک سرویس با `bookable = true` داشته باشد؛ وگرنه `422 ERR_VALIDATION_001` روی فیلد `booking_mode`. پیام در محیط کلینیک به کلینیک اشاره می‌کند. @@ -694,8 +711,10 @@ Returns all locations a doctor can assign as `location_id` in their schedule ses | Field | Type | Meaning | |---|---|---| -| `clinic_uuid` | `string\|null` | the clinic this schedule belongs to; `null` = personal practice | -| `context` | `"personal" \| "clinic"` | convenience mirror of the above | +| `clinic_uuid` | `string\|null` | **legacy**, always `null` on a weekly schedule — the record is no longer owned by one context | +| `context` | `"personal" \| "clinic"` | **legacy**, always `"personal"` for the same reason | +| `locations` | `array` | every address of this doctor (personal + each clinic they belong to), for labelling shifts the caller may not edit — `GET` only | +| `selectable_location_ids` | `int[]` | the subset of those the **caller** may assign — `GET` only | `DateOverride.toArray()` returns the same two fields. `Holiday.toArray()` returns `clinic_uuid` plus `scope` (`"global" | "clinic"`), and the list endpoint adds `editable` (see below). @@ -726,8 +745,17 @@ union, because an override changes working hours and working hours are themselve ### `GET /available-locations/{doctorUuid}` -Now takes `?clinic_uuid=`. Without it, only the doctor's `personal` addresses are returned; with it, -only that clinic's addresses. The two sets are never merged (they used to be). +Returns the addresses the **caller** may assign, which is not the same as the addresses of the current +context: + +| Caller | Returned | +|---|---| +| the doctor themselves, or `ROLE_ADMIN` | every address of theirs — personal **and** each clinic they belong to | +| anyone else (clinic manager, secretary) | only the addresses of the context in `?clinic_uuid=` | + +The doctor gets the full set because their schedule is a single one and they move between places from +day to day; a clinic manager gets only its own so it cannot move a shift into the doctor's private +practice. --- diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 9556068c..deb2fb2c 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -1327,6 +1327,10 @@ the JWT firewall, so a valid bearer + `management=1` enables management mode (`n Lists every place the doctor can be booked at. The site should show **all** of them, grouped by location — picking one and hiding the rest removes real capacity from the doctor. +Since 2026-08 the doctor has a **single** weekly schedule, so this endpoint iterates over the doctor's +*places* (personal practice + each clinic they belong to) rather than over schedule records, and keeps +for each place only the shifts whose `location_id` belongs to it. The response shape is unchanged. + ```json { "success": true, diff --git a/migrations/Version20260820120000.php b/migrations/Version20260820120000.php new file mode 100644 index 00000000..6c18fa13 --- /dev/null +++ b/migrations/Version20260820120000.php @@ -0,0 +1,113 @@ +connection->fetchAllAssociative( + 'SELECT id, doctor_id, clinic_id, setting FROM weekly_schedules ORDER BY doctor_id ASC, clinic_id IS NULL DESC, id ASC' + ); + + /** @var array>> $byDoctor */ + $byDoctor = []; + foreach ($rows as $row) { + $byDoctor[(int) $row['doctor_id']][] = $row; + } + + foreach ($byDoctor as $doctorId => $doctorRows) { + $base = array_shift($doctorRows); + $setting = $this->decode($base['setting']); + $deleteIds = []; + + foreach ($doctorRows as $row) { + $setting = $this->mergeDays($setting, $this->decode($row['setting'])); + $deleteIds[] = (int) $row['id']; + } + + $this->connection->executeStatement( + 'UPDATE weekly_schedules SET setting = :setting, clinic_id = NULL, entity_type = :type, entity_id = :entityId, updated_at = :now WHERE id = :id', + [ + 'setting' => json_encode($setting, JSON_UNESCAPED_UNICODE), + 'type' => 'doctor', + 'entityId' => $doctorId, + 'now' => time(), + 'id' => (int) $base['id'], + ] + ); + + if ($deleteIds !== []) { + $this->connection->executeStatement( + 'DELETE FROM weekly_schedules WHERE id IN (' . implode(',', $deleteIds) . ')' + ); + } + } + } + + /** + * برگشت‌ناپذیر است: بعد از ادغام معلوم نیست کدام شیفت از کدام رکورد آمده بود. + * خودِ شیفت‌ها از دست نمی‌روند و `location_id` هرکدام سر جایش است. + */ + public function down(Schema $schema): void + { + $this->throwIrreversibleMigration('Merged weekly schedules cannot be split back into per-clinic rows.'); + } + + /** @return array */ + private function decode(mixed $raw): array + { + $decoded = json_decode((string) $raw, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $base + * @param array $extra + * + * @return array + */ + private function mergeDays(array $base, array $extra): array + { + foreach ($extra as $dayKey => $day) { + if ($dayKey === self::META_KEY || !is_array($day)) { + continue; + } + + $sessions = $day['sessions'] ?? []; + if (!is_array($sessions) || $sessions === []) { + continue; + } + + $existing = $base[$dayKey]['sessions'] ?? []; + $base[$dayKey] = ['sessions' => array_merge(is_array($existing) ? $existing : [], $sessions)]; + } + + return $base; + } +} diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index fc9e92e8..bb5be3b7 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -313,9 +313,21 @@ class AppointmentController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date'); } + // برنامهٔ پزشک یکی است و شیفت‌های همهٔ محیط‌هایش را دارد، پس حلقه روی محیط‌ها + // زده می‌شود نه روی برنامه‌ها: مطب شخصی، و هر کلینیکی که پزشک عضوش است. + $schedule = $this->scheduleRepo->findUnified($doctor); + if ($schedule === null) { + return $this->success([ + 'doctor_uuid' => $doctorUuid, + 'date' => $date !== '' ? $date : null, + 'locations' => [], + ]); + } + + $contexts = array_merge([null], $this->clinicRepo->findByDoctor($doctor)); + $locations = []; - foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) { - $clinic = $schedule->getClinic(); + foreach ($contexts as $clinic) { $addresses = $this->addressRepo->findForContext($doctor, $clinic?->getId()); // محلی که آدرسی ندارد، محل نیست — چیزی برای مراجعهٔ بیمار وجود ندارد. diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php index 93c44ed3..524dac27 100644 --- a/src/Appointment/Controller/AppointmentSettingsController.php +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -8,6 +8,7 @@ use App\Appointment\Entity\WeeklySchedule; use App\Appointment\Repository\DateOverrideRepository; use App\Appointment\Repository\HolidayRepository; use App\Appointment\Repository\WeeklyScheduleRepository; +use App\Appointment\Schedule\ScheduleLocationScope; use App\Auth\Entity\User; use App\Clinic\Entity\Clinic; use App\Clinic\Repository\ClinicRepository; @@ -35,13 +36,13 @@ class AppointmentSettingsController extends BaseController private readonly DateOverrideRepository $overrideRepo, private readonly HolidayRepository $holidayRepo, private readonly DoctorRepository $doctorRepo, - private readonly DoctorAddressRepository $addressRepo, private readonly ClinicRepository $clinicRepo, private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo, private readonly \App\Resource\Repository\ClinicResourceRepository $resourceRepo, private readonly \App\Appointment\Availability\Picker\ResourcePickerRegistry $pickers, private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker, private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, + private readonly \App\Appointment\Schedule\ScheduleLocationScope $locationScope, ) {} /** @@ -207,18 +208,21 @@ class AppointmentSettingsController extends BaseController return $err; } - if (($err = $this->validateSessions($data['schedule'] ?? [], $doctor, $clinic)) !== null) { + $selectable = ScheduleLocationScope::idMap($this->locationScope->selectableFor($doctor, $user, $clinic)); + + if (($err = $this->validateSessions($data['schedule'] ?? [], $selectable, $clinic)) !== null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, $err, 422); } - // یک برنامه به ازای هر context — upsert - $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); + // یک برنامه برای هر پزشک، نه یکی به ازای هر محیط — upsert روی همان یکی. + $schedule = $this->scheduleRepo->findUnified($doctor); $prevMode = $schedule?->getStoredBookingMode(); + $merged = $this->mergeSchedule($data['schedule'] ?? [], $schedule?->getDaySchedule() ?? [], $selectable); if ($schedule !== null) { - $schedule->setSetting($data['schedule'] ?? []); + $schedule->setSetting($merged); } else { - $schedule = new WeeklySchedule($doctor, $data['schedule'] ?? [], $clinic); - $schedule->assignTenant(EntityContext::forBooking($doctor, $clinic)); + $schedule = new WeeklySchedule($doctor, $merged, null); + $schedule->assignTenant(EntityContext::forBooking($doctor, null)); } if (isset($data['meta']) && is_array($data['meta'])) { @@ -265,7 +269,7 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404); } $clinic = $this->contextClinic($data['clinic_uuid'] ?? $request->query->get('clinic_uuid'), $doctor); - $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); + $schedule = $this->scheduleRepo->findUnified($doctor); } else { $clinic = $schedule->getClinic(); } @@ -280,10 +284,15 @@ class AppointmentSettingsController extends BaseController $prevMode = $schedule->getStoredBookingMode(); if (isset($data['schedule'])) { - if (($err = $this->validateSessions($data['schedule'], $schedule->getDoctor(), $clinic)) !== null) { + $selectable = ScheduleLocationScope::idMap( + $this->locationScope->selectableFor($schedule->getDoctor(), $user, $clinic) + ); + + if (($err = $this->validateSessions($data['schedule'], $selectable, $clinic)) !== null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, $err, 422); } - $schedule->setSetting($data['schedule']); + + $schedule->setSetting($this->mergeSchedule($data['schedule'], $schedule->getDaySchedule(), $selectable)); } if (isset($data['meta']) && is_array($data['meta'])) { $schedule->setMeta($data['meta']); @@ -317,7 +326,7 @@ class AppointmentSettingsController extends BaseController $doctor = $this->doctorRepo->findByUuid($uuid); if ($doctor !== null) { $clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor); - $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); + $schedule = $this->scheduleRepo->findUnified($doctor); } else { $schedule = $this->scheduleRepo->findByUuid($uuid); $clinic = $schedule?->getClinic(); @@ -331,7 +340,22 @@ class AppointmentSettingsController extends BaseController return $err; } - return $this->success(['data' => $schedule->toArray()]); + // برنامه یکی است و شیفت‌های همهٔ محیط‌ها را دارد، پس پاسخ باید دو چیز را از هم + // جدا کند: چه چیزی دیده می‌شود و چه چیزی همین کاربر می‌تواند عوض کند. بدون + // دومی، پنل مجبور بود از روی نقش حدس بزند. + $doctor = $schedule->getDoctor(); + $selectable = $this->locationScope->selectableFor($doctor, $user, $clinic); + + return $this->success(['data' => array_merge($schedule->toArray(), [ + 'locations' => array_map( + fn(DoctorAddress $a): array => $a->toArray($this->clinicNameOf($a)), + $this->locationScope->allForDoctor($doctor), + ), + 'selectable_location_ids' => array_map( + static fn(DoctorAddress $a): int => (int) $a->getId(), + $selectable, + ), + ])]); } #[Route('/api/v1/booking-setting/{uuid}', methods: ['DELETE'])] @@ -637,14 +661,77 @@ class AppointmentSettingsController extends BaseController return $err; } + // خودِ پزشک همهٔ محیط‌هایش را می‌بیند، چون برنامه‌اش یکی است و روز به روز جا + // عوض می‌کند. مدیر کلینیک کل برنامه را می‌بیند ولی فقط آدرس کلینیک خودش را + // می‌تواند روی آن بنشاند. $result = array_map( - fn(DoctorAddress $a): array => $a->toArray($clinic?->getName()), - $this->addressRepo->findForContext($doctor, $clinic?->getId()) + fn(DoctorAddress $a): array => $a->toArray($this->clinicNameOf($a)), + $this->locationScope->selectableFor($doctor, $user, $clinic) ); return $this->success(['data' => $result]); } + /** نام کلینیکِ صاحبِ آدرس؛ برای آدرس مطب شخصی null. */ + private function clinicNameOf(DoctorAddress $address): ?string + { + $clinicId = $address->getClinicId(); + + return $clinicId === null ? null : $this->clinicRepo->find($clinicId)?->getName(); + } + + /** + * برنامهٔ ارسالی را با برنامهٔ ذخیره‌شده ادغام می‌کند. + * + * برنامه یکی است و چند نفر ویرایشش می‌کنند، پس ذخیرهٔ خامِ ورودی یعنی مدیر کلینیک + * با یک ذخیره، شیفت‌های مطب شخصی پزشک را پاک می‌کند. قاعده: هر شیفتی که آدرسش + * خارج از دسترس این کاربر است، از نسخهٔ ذخیره‌شده دست‌نخورده برمی‌گردد و ورودی + * دربارهٔ آن حرفی ندارد. + * + * @param array $incoming + * @param array $stored + * @param array $selectableIds + * + * @return array + */ + private function mergeSchedule(array $incoming, array $stored, array $selectableIds): array + { + $dayKeys = array_unique(array_merge(array_keys($incoming), array_keys($stored))); + $merged = []; + + foreach ($dayKeys as $dayKey) { + if ($dayKey === WeeklySchedule::META_KEY) { + continue; + } + + $foreign = []; + foreach (($stored[$dayKey]['sessions'] ?? []) as $session) { + $locationId = (int) ($session['location_id'] ?? 0); + if ($locationId !== 0 && !isset($selectableIds[$locationId])) { + $foreign[] = $session; + } + } + + $own = []; + foreach (($incoming[$dayKey]['sessions'] ?? []) as $session) { + $locationId = (int) ($session['location_id'] ?? 0); + // شیفت با آدرسِ محیط دیگر از ورودی پذیرفته نمی‌شود؛ نسخهٔ ذخیره‌شده‌اش + // بالا در $foreign آمده، پس نه پاک می‌شود نه دو بار می‌آید. + if ($locationId !== 0 && !isset($selectableIds[$locationId])) { + continue; + } + $own[] = $session; + } + + $sessions = array_merge($own, $foreign); + if ($sessions !== [] || isset($incoming[$dayKey])) { + $merged[$dayKey] = ['sessions' => $sessions]; + } + } + + return $merged; + } + /** * تنها نقطهٔ تصمیم‌گیری دربارهٔ «چه کسی تنظیمات نوبت‌دهی این پزشک را می‌بیند/می‌نویسد». * @@ -679,11 +766,11 @@ class AppointmentSettingsController extends BaseController * هر شیفت فعال باید آدرسی داشته باشد که به همین context تعلق دارد. بدون بررسی * دوم، کلینیک می‌توانست شیفت را روی آدرس مطب شخصی پزشک بنشاند (و برعکس). */ - private function validateSessions(array $schedule, Doctor $doctor, ?Clinic $clinic): ?string + private function validateSessions(array $schedule, array $allowedIds, ?Clinic $clinic): ?string { $allowed = []; - foreach ($this->addressRepo->findForContext($doctor, $clinic?->getId()) as $address) { - $allowed[(string) $address->getId()] = true; + foreach (array_keys($allowedIds) as $addressId) { + $allowed[(string) $addressId] = true; } foreach ($schedule as $day) { diff --git a/src/Appointment/Entity/WeeklySchedule.php b/src/Appointment/Entity/WeeklySchedule.php index 41f89764..c8b4eb94 100644 --- a/src/Appointment/Entity/WeeklySchedule.php +++ b/src/Appointment/Entity/WeeklySchedule.php @@ -11,11 +11,15 @@ use App\Appointment\Repository\WeeklyScheduleRepository; use Symfony\Component\Uid\Uuid; /** - * برنامهٔ هفتگی نوبت‌دهی یک پزشک در یک context مشخص. + * برنامهٔ هفتگی نوبت‌دهی یک پزشک — یکی، برای همهٔ جاهایی که کار می‌کند. * - * context با ستون clinic_id بیان می‌شود: NULL یعنی مطب شخصی پزشک، و مقدار غیر-NULL - * یعنی همان پزشک در آن کلینیک. یک پزشک می‌تواند هم‌زمان چند برنامه داشته باشد - * (شخصی + یکی به ازای هر کلینیک) و این برنامه‌ها کاملاً مستقل‌اند. + * آنچه بین روزها فرق می‌کند مکان است: شنبه مطب شخصی، دوشنبه کلینیک. محیطِ هر شیفت از + * `location_id` همان شیفت خوانده می‌شود ({@see \App\Appointment\Schedule\ScheduleLocationScope})، + * پس رزرو در هر محیط فقط روزهای همان محیط را می‌بیند و پزشک یک برنامه بیشتر ندارد. + * + * ستون `clinic_id` بازمانده است و روی رکوردهای تازه همیشه NULL می‌ماند. تا پیش از + * migration ادغام، هر محیط رکورد خودش را داشت و پزشکِ دو-محیطی مجبور بود دو جا برنامه + * بنویسد بی‌آنکه هیچ‌کدام دیگری را ببیند. */ #[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)] #[ORM\Table(name: 'weekly_schedules')] @@ -172,6 +176,8 @@ class WeeklySchedule return [ 'uuid' => $this->uuid, 'doctor_uuid' => $this->doctor->getUuid(), + // بازمانده‌ها: برنامه دیگر مالِ یک محیط نیست. حذفشان قرارداد پاسخ را + // می‌شکند، پس می‌مانند و همیشه مقدار مطب شخصی را می‌دهند. 'clinic_uuid' => $this->clinic?->getUuid(), 'context' => $this->clinic === null ? 'personal' : 'clinic', 'schedule' => $this->getDaySchedule(), diff --git a/src/Appointment/Repository/WeeklyScheduleRepository.php b/src/Appointment/Repository/WeeklyScheduleRepository.php index 4d3fa2e9..d707c968 100644 --- a/src/Appointment/Repository/WeeklyScheduleRepository.php +++ b/src/Appointment/Repository/WeeklyScheduleRepository.php @@ -6,45 +6,63 @@ use App\Appointment\Entity\WeeklySchedule; use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use App\Shared\Tenant\TenantFilterScope; use Doctrine\Persistence\ManagerRegistry; class WeeklyScheduleRepository extends ServiceEntityRepository { - public function __construct(ManagerRegistry $registry) - { + public function __construct( + ManagerRegistry $registry, + private readonly TenantFilterScope $tenantScope, + ) { parent::__construct($registry, WeeklySchedule::class); } /** - * برنامهٔ یک context مشخص. $clinic === null یعنی مطب شخصی. + * برنامهٔ هفتگی این پزشک. یکی است و به محیط وابسته نیست. * - * چون MySQL در unique index مقادیر NULL را متمایز می‌شمارد، یکتایی رکورد شخصی - * را همین متد تضمین می‌کند: قبل از ساخت برنامهٔ جدید همیشه صدا زده می‌شود. + * پارامتر $clinic فقط برای سازگاری امضای فراخوان‌های قدیمی مانده و عمداً نادیده + * گرفته می‌شود: محیطِ هر شیفت از آدرسِ همان شیفت خوانده می‌شود + * ({@see \App\Appointment\Schedule\ScheduleLocationScope}). حذف پارامتر یعنی + * دست‌بردن در بیست فراخوان، بدون اینکه چیزی روشن‌تر شود. + * + * @param ?Clinic $clinic نادیده گرفته می‌شود */ - public function findByDoctorAndClinic(Doctor $doctor, ?Clinic $clinic): ?WeeklySchedule + public function findByDoctorAndClinic(Doctor $doctor, ?Clinic $clinic = null): ?WeeklySchedule { - $qb = $this->createQueryBuilder('ws') + return $this->findUnified($doctor); + } + + /** + * تنها برنامهٔ پزشک. + * + * رکوردهای قدیمیِ هر کلینیک با migration در همین رکورد ادغام شده‌اند؛ اگر داده‌ای + * از قلم افتاده باشد، رکورد شخصی (clinic_id NULL) برنده است تا رفتار قطعی بماند. + */ + public function findUnified(Doctor $doctor): ?WeeklySchedule + { + // رکورد برنامه به محیطِ خودِ پزشک تعلق دارد، ولی کلینیک هم باید ببیندش و + // ویرایشش کند — همان چیزی که کاربر می‌خواهد. پس فیلتر محیط اینجا کنار می‌رود و + // مجوز جای دیگری سنجیده می‌شود: چه کسی حق دیدن دارد در denyDoctorAccess، و چه + // آدرسی را حق دارد بنشاند در ScheduleLocationScope. + return $this->tenantScope->withoutFilter(fn(): ?WeeklySchedule => $this->createQueryBuilder('ws') ->where('ws.doctor = :doctor') - ->setParameter('doctor', $doctor); - - if ($clinic === null) { - $qb->andWhere('ws.clinic IS NULL'); - } else { - $qb->andWhere('ws.clinic = :clinic')->setParameter('clinic', $clinic); - } - - return $qb->setMaxResults(1)->getQuery()->getOneOrNullResult(); + ->setParameter('doctor', $doctor) + ->orderBy('ws.clinic', 'ASC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult()); } /** همهٔ برنامه‌های پزشک در همهٔ contextها (شخصی + هر کلینیک). @return WeeklySchedule[] */ public function findAllByDoctor(Doctor $doctor): array { - return $this->createQueryBuilder('ws') + return $this->tenantScope->withoutFilter(fn(): array => $this->createQueryBuilder('ws') ->where('ws.doctor = :doctor') ->setParameter('doctor', $doctor) ->orderBy('ws.clinic', 'ASC') ->getQuery() - ->getResult(); + ->getResult()); } /** @param Doctor[] $doctors @return WeeklySchedule[] */ @@ -53,11 +71,11 @@ class WeeklyScheduleRepository extends ServiceEntityRepository if (empty($doctors)) { return []; } - return $this->createQueryBuilder('ws') + return $this->tenantScope->withoutFilter(fn(): array => $this->createQueryBuilder('ws') ->where('ws.doctor IN (:doctors)') ->setParameter('doctors', $doctors) ->getQuery() - ->getResult(); + ->getResult()); } public function findByUuid(string $uuid): ?WeeklySchedule diff --git a/src/Appointment/Schedule/ScheduleLocationScope.php b/src/Appointment/Schedule/ScheduleLocationScope.php new file mode 100644 index 00000000..efa3ad50 --- /dev/null +++ b/src/Appointment/Schedule/ScheduleLocationScope.php @@ -0,0 +1,143 @@ + + */ + private array $allCache = []; + + /** @var array> */ + private array $contextCache = []; + + public function __construct( + private readonly DoctorAddressRepository $addresses, + private readonly ClinicRepository $clinics, + ) {} + + /** + * همهٔ آدرس‌های قابل استفادهٔ پزشک — مطب شخصی + آدرس هر کلینیکی که عضو آن است. + * + * @return DoctorAddress[] + */ + public function allForDoctor(Doctor $doctor): array + { + $doctorId = (int) $doctor->getId(); + if (isset($this->allCache[$doctorId])) { + return $this->allCache[$doctorId]; + } + + $result = $this->addresses->findForContext($doctor, null); + + foreach ($this->clinics->findByDoctor($doctor) as $clinic) { + foreach ($this->addresses->findForContext($doctor, $clinic->getId()) as $address) { + $result[] = $address; + } + } + + return $this->allCache[$doctorId] = $result; + } + + /** + * آدرس‌هایی که این کاربر مجاز است در برنامهٔ این پزشک انتخاب کند. + * + * خودِ پزشک (و ادمین) همهٔ محیط‌هایش را دارد؛ بقیه فقط محیطی که در آن ایستاده‌اند. + * + * @return DoctorAddress[] + */ + public function selectableFor(Doctor $doctor, User $actor, ?Clinic $clinic): array + { + if ($actor->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $actor->getId()) { + return $this->allForDoctor($doctor); + } + + return $this->addresses->findForContext($doctor, $clinic?->getId()); + } + + /** + * شناسهٔ آدرس‌های یک محیط، به‌صورت نقشهٔ id => true برای isset(). + * + * @return array + */ + public function contextAddressIds(Doctor $doctor, ?Clinic $clinic): array + { + $key = $doctor->getId() . ':' . ($clinic?->getId() ?? 0); + if (isset($this->contextCache[$key])) { + return $this->contextCache[$key]; + } + + $map = []; + foreach ($this->addresses->findForContext($doctor, $clinic?->getId()) as $address) { + $map[(int) $address->getId()] = true; + } + + return $this->contextCache[$key] = $map; + } + + /** + * آدرس‌هایی که به محیط‌های *دیگرِ* همین پزشک تعلق دارند. + * + * فیلترِ رزرو بر همین اساس است و نه بر اساس «آدرس این محیط»: شیفتی که آدرسش هیچ + * جای این پزشک نیست، مثل دادهٔ قدیمی یا آدرس حذف‌شده، رفتار قبلی‌اش را نگه می‌دارد + * و ناپدید نمی‌شود. چیزی که باید پنهان شود شیفتِ محیطِ دیگر است، نه شیفتِ مبهم. + * + * @return array + */ + public function foreignAddressIds(Doctor $doctor, ?Clinic $clinic): array + { + $context = $this->contextAddressIds($doctor, $clinic); + $foreign = []; + + foreach ($this->allForDoctor($doctor) as $address) { + $id = (int) $address->getId(); + if (!isset($context[$id])) { + $foreign[$id] = true; + } + } + + return $foreign; + } + + /** + * @param DoctorAddress[] $addresses + * + * @return array + */ + public static function idMap(array $addresses): array + { + $map = []; + foreach ($addresses as $address) { + $map[(int) $address->getId()] = true; + } + + return $map; + } +} diff --git a/src/Appointment/Service/SlotCalculatorService.php b/src/Appointment/Service/SlotCalculatorService.php index 00498b2c..b2348f51 100644 --- a/src/Appointment/Service/SlotCalculatorService.php +++ b/src/Appointment/Service/SlotCalculatorService.php @@ -6,6 +6,7 @@ use App\Appointment\Repository\AppointmentRepository; use App\Appointment\Repository\DateOverrideRepository; use App\Appointment\Repository\HolidayRepository; use App\Appointment\Repository\WeeklyScheduleRepository; +use App\Appointment\Schedule\ScheduleLocationScope; use App\Appointment\Entity\WeeklySchedule; use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; @@ -24,8 +25,31 @@ class SlotCalculatorService private readonly DateOverrideRepository $overrideRepo, private readonly HolidayRepository $holidayRepo, private readonly AppointmentRepository $appointmentRepo, + private readonly ScheduleLocationScope $locationScope, ) {} + /** + * شیفت‌های همین محیط از دلِ برنامهٔ واحد پزشک. + * + * برنامه یکی است و شیفت‌های همهٔ محیط‌های پزشک را دارد، پس بدون این فیلتر منشیِ + * مطب شخصی روزهای کلینیک را هم می‌دید و رویشان نوبت می‌داد. + * + * فیلتر بر اساس «آدرسِ محیطِ دیگر» است و نه «آدرسِ این محیط»: شیفتی که آدرسش هیچ + * جای این پزشک نیست، مثل دادهٔ قدیمی یا آدرس حذف‌شده، همان رفتار قبلی‌اش را نگه + * می‌دارد. پنهان‌کردنش یعنی برنامه‌ای که تا دیروز کار می‌کرد بی‌صدا خالی شود. + * + * @param array> $sessions + * @param array $foreignAddressIds + * + * @return array> + */ + private function sessionsInContext(array $sessions, array $foreignAddressIds): array + { + return array_values(array_filter($sessions, static function (array $session) use ($foreignAddressIds): bool { + return !isset($foreignAddressIds[(int) ($session['location_id'] ?? 0)]); + })); + } + /** * Returns available slots (flat array) for booking conflict checks. * Day index convention: 0=Saturday(شنبه), 1=Sunday, ..., 6=Friday(جمعه) @@ -213,7 +237,8 @@ class SlotCalculatorService $overrides[date('Y-m-d', $override->getDate())] = $override; } - $daySchedule = $schedule->getSetting(); + $daySchedule = $schedule->getSetting(); + $foreignAddressIds = $this->locationScope->foreignAddressIds($doctor, $clinic); for ($dayStart = $todayStart; $dayStart <= $scanEnd; $dayStart += 86400) { if ($this->isHoliday($holidays, $dayStart)) { @@ -235,7 +260,7 @@ class SlotCalculatorService continue; } $sessions = []; - foreach (($dayConf['sessions'] ?? []) as $session) { + foreach ($this->sessionsInContext($dayConf['sessions'] ?? [], $foreignAddressIds) as $session) { if ($session['active'] ?? false) { $sessions[] = ['slots' => $this->buildSessionSlots($session, $dayStart)]; } @@ -365,7 +390,10 @@ class SlotCalculatorService $dayConf = $schedule->getSetting()[$dayKey] ?? null; if ($dayConf === null) return []; - $sessionConfigs = $dayConf['sessions'] ?? []; + $sessionConfigs = $this->sessionsInContext( + $dayConf['sessions'] ?? [], + $this->locationScope->foreignAddressIds($doctor, $clinic), + ); $activeSessions = array_filter($sessionConfigs, fn($s) => $s['active'] ?? false); usort($activeSessions, fn($a, $b) => $this->parseTime($a['start_time'] ?? '00:00') <=> $this->parseTime($b['start_time'] ?? '00:00') diff --git a/tests/Appointment/BookingLocationValidityTest.php b/tests/Appointment/BookingLocationValidityTest.php index a5e00e8a..caf4ba64 100644 --- a/tests/Appointment/BookingLocationValidityTest.php +++ b/tests/Appointment/BookingLocationValidityTest.php @@ -80,7 +80,12 @@ class BookingLocationValidityTest extends ApiTestCase self::assertSame([], $this->locations($doctor), 'محلی که آدرس ندارد نباید محل به حساب بیاید'); } - public function testLocationWhoseShiftsPointOutsideItsContextIsNotReturned(): void + /** + * برنامه یکی است و محیطِ هر شیفت از آدرسش خوانده می‌شود، پس شیفتی که روی آدرس + * کلینیک نشسته، محلِ کلینیک را می‌سازد و نه مطب شخصی را — حتی وقتی پزشک آدرس + * شخصی هم دارد. مطب شخصی که هیچ شیفتی رویش نیست، محل به حساب نمی‌آید. + */ + public function testShiftDecidesItsPlaceByItsAddressNotByTheRecord(): void { $doctor = $this->makeDoctor(); $clinic = $this->makeClinicWith($doctor); @@ -91,10 +96,13 @@ class BookingLocationValidityTest extends ApiTestCase $this->em->persist($personal); $this->em->flush(); - // برنامهٔ شخصی که شیفتش روی آدرس کلینیک نشسته — دقیقاً حالتی که در dev دیده شد. $this->scheduleFor($doctor, null, $clinicAddress->getId()); - self::assertSame([], $this->locations($doctor)); + $locations = $this->locations($doctor); + + self::assertCount(1, $locations); + self::assertSame('clinic', $locations[0]['type']); + self::assertSame($clinicAddress->getUuid(), $locations[0]['location_uuid']); } public function testValidLocationIsReturnedWithItsOpeningHours(): void diff --git a/tests/Appointment/BookingLocationsScanTest.php b/tests/Appointment/BookingLocationsScanTest.php index 242ec685..d0f33b89 100644 --- a/tests/Appointment/BookingLocationsScanTest.php +++ b/tests/Appointment/BookingLocationsScanTest.php @@ -43,10 +43,9 @@ class BookingLocationsScanTest extends ApiTestCase $this->em->persist($personalAddress); $this->em->flush(); - $this->em->persist($this->newWeeklySchedule( - $doctor, - $this->weekOfSessions($personalAddress->getId(), '09:00', '13:00') - )); + // برنامه یکی است و شیفتِ همهٔ محل‌ها داخل همان می‌نشیند؛ محیطِ هر شیفت از + // آدرسش خوانده می‌شود، نه از رکورد جدا. + $setting = $this->weekOfSessions($personalAddress->getId(), '09:00', '13:00'); for ($i = 0; $i < $clinicCount; $i++) { $clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC'])); @@ -61,13 +60,13 @@ class BookingLocationsScanTest extends ApiTestCase $this->em->persist($address); $this->em->flush(); - $this->em->persist($this->newWeeklySchedule( - $doctor, - $this->weekOfSessions($address->getId(), '16:00', '20:00'), - $clinic - )); + $clinicWeek = $this->weekOfSessions($address->getId(), '16:00', '20:00'); + foreach ($clinicWeek as $dayKey => $day) { + $setting[$dayKey]['sessions'] = array_merge($setting[$dayKey]['sessions'], $day['sessions']); + } } + $this->em->persist($this->newWeeklySchedule($doctor, $setting)); $this->em->flush(); return $doctor; diff --git a/tests/Appointment/ClinicOwnerScheduleAccessTest.php b/tests/Appointment/ClinicOwnerScheduleAccessTest.php index cc4d17ab..5b341ddd 100644 --- a/tests/Appointment/ClinicOwnerScheduleAccessTest.php +++ b/tests/Appointment/ClinicOwnerScheduleAccessTest.php @@ -10,9 +10,11 @@ use App\Doctor\Entity\DoctorAddress; use App\Tests\ApiTestCase; /** - * تنظیمات نوبت‌دهی per-context است: مطب شخصی پزشک (بدون clinic_uuid) فقط برای خود - * پزشک و ادمین باز است، و کلینیک با clinic_uuid فقط برنامهٔ همان کلینیک را - * می‌بیند/می‌نویسد. این دو برنامهٔ جدا هستند و روی هم اثر نمی‌گذارند. + * برنامهٔ هفتگی یکی است و مالِ خودِ پزشک؛ آنچه بین روزها فرق می‌کند مکان است. + * + * دسترسی همچنان per-context است، ولی روی *مکان* اعمال می‌شود نه روی *رکورد*: کلینیک + * فقط آدرس خودش را روی برنامه می‌نشاند و شیفت‌های مطب شخصی را نه می‌بیندشان که پاک + * کند نه اجازهٔ ویرایششان دارد؛ خودِ پزشک هر دو را دارد. */ class ClinicOwnerScheduleAccessTest extends ApiTestCase { @@ -117,14 +119,27 @@ class ClinicOwnerScheduleAccessTest extends ApiTestCase self::assertSame(422, $this->responseCode()); } - public function testPersonalContextRejectsClinicAddress(): void + public function testDoctorMayPlaceTheirClinicAddressOnTheirOwnSchedule(): void { $doctor = $this->makeDoctor('دکتر عضو'); [, $clinic] = $this->makeClinicWith($doctor); $clinicAddress = $this->clinicAddress($clinic); + // پزشک روز به روز جا عوض می‌کند؛ برنامه‌اش یکی است، پس هر دو آدرسش انتخاب‌شدنی‌اند. $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $clinicAddress->getId(), '09:00')); + self::assertSame(201, $this->responseCode()); + } + + public function testDoctorCannotUseAnAddressOfAClinicTheyDoNotBelongTo(): void + { + $doctor = $this->makeDoctor('دکتر مستقل'); + $outsider = $this->makeDoctor('دکتر دیگر'); + [, $clinic] = $this->makeClinicWith($outsider); + $foreign = $this->clinicAddress($clinic); + + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $foreign->getId(), '09:00')); + self::assertSame(422, $this->responseCode()); } @@ -140,7 +155,7 @@ class ClinicOwnerScheduleAccessTest extends ApiTestCase self::assertSame(422, $this->responseCode(), 'پزشک عضو این کلینیک نیست'); } - public function testPersonalAndClinicSchedulesCoexistIndependently(): void + public function testOneScheduleHoldsShiftsOfBothPlaces(): void { $doctor = $this->makeDoctor('دکتر دو-محیطی'); [$owner, $clinic] = $this->makeClinicWith($doctor); @@ -157,15 +172,16 @@ class ClinicOwnerScheduleAccessTest extends ApiTestCase $reloadedDoctor = $this->em->getRepository(Doctor::class)->find($doctor->getId()); $schedules = $this->em->getRepository(WeeklySchedule::class)->findAllByDoctor($reloadedDoctor); - self::assertCount(2, $schedules, 'یک برنامه به ازای هر محیط'); + self::assertCount(1, $schedules, 'یک برنامه برای هر پزشک، نه یکی به ازای هر محیط'); - $byContext = []; - foreach ($schedules as $schedule) { - $byContext[$schedule->getClinic() === null ? 'personal' : 'clinic'] = $schedule->getSetting()[0]['sessions'][0]['start']; + $byLocation = []; + foreach ($schedules[0]->getSetting()[0]['sessions'] as $session) { + $byLocation[(int) $session['location_id']] = $session['start']; } - self::assertSame('08:00', $byContext['personal']); - self::assertSame('16:00', $byContext['clinic']); + // نوشتنِ کلینیک، شیفت مطب شخصی را پاک نکرده — همان چیزی که ادغام باید تضمین کند. + self::assertSame('08:00', $byLocation[$personal->getId()] ?? null); + self::assertSame('16:00', $byLocation[$inClinic->getId()] ?? null); } public function testDoctorKeepsFullAccessToOwnSchedule(): void diff --git a/tests/Appointment/ServiceModeContextTest.php b/tests/Appointment/ServiceModeContextTest.php index 8af7b6c5..c7f5c2cb 100644 --- a/tests/Appointment/ServiceModeContextTest.php +++ b/tests/Appointment/ServiceModeContextTest.php @@ -102,7 +102,14 @@ class ServiceModeContextTest extends ApiTestCase self::assertSame(201, $this->responseCode()); } - public function testBookingModeLocksPerContextNotGlobally(): void + /** + * برنامه یکی است، پس نوع نوبت‌دهی هم یکی است. + * + * پیش‌تر هر محیط رکورد خودش را داشت و می‌شد مطب را اسلاتی و کلینیک را سرویسی کرد. + * حالا که یک برنامه بیشتر نیست، تغییر نوع بعد از اولین ثبت رد می‌شود — همان قفلی + * که از قبل داخل یک محیط وجود داشت، فقط دامنه‌اش پزشک شده. + */ + public function testBookingModeIsLockedOnceForTheWholeDoctor(): void { [$doctor, $clinic, $owner] = $this->makeDoctorInClinic(); $this->bookableServiceFor('clinic', $clinic->getId()); @@ -123,8 +130,19 @@ class ServiceModeContextTest extends ApiTestCase ]); self::assertSame(201, $this->responseCode()); - // همان پزشک در کلینیک: سرویسی — قفلِ محیط دیگر نباید مانع شود + // همان پزشک در کلینیک، این بار سرویسی — نوع قفل است و عوض نمی‌شود. $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->servicePayload($doctor, $inClinic->getId(), $clinic)); + self::assertSame(422, $this->responseCode()); + + // ولی افزودن شیفت کلینیک با همان نوع اسلاتی مشکلی ندارد. + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'clinic_uuid' => $clinic->getUuid(), + 'schedule' => [['day' => 'monday', 'sessions' => [ + ['active' => true, 'location_id' => $inClinic->getId(), 'start' => '16:00', 'end' => '20:00'], + ]]], + 'meta' => ['booking_mode' => 'slot'], + ]); self::assertSame(201, $this->responseCode()); } } diff --git a/tests/Appointment/UnifiedDoctorScheduleTest.php b/tests/Appointment/UnifiedDoctorScheduleTest.php new file mode 100644 index 00000000..4a35b823 --- /dev/null +++ b/tests/Appointment/UnifiedDoctorScheduleTest.php @@ -0,0 +1,170 @@ +createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'دکتر دو-محیطی'); + $doctor->setMobileNumber($doctorUser->getMobileNumber()); + $this->em->persist($doctor); + + $ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($ownerUser); + $clinic->setName('کلینیک تست'); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + $personal = DoctorAddress::forDoctor($doctor); + $inClinic = DoctorAddress::forClinic($clinic->getId()); + $this->em->persist($personal); + $this->em->persist($inClinic); + $this->em->flush(); + + return [$doctor, $clinic, $ownerUser, $personal, $inClinic]; + } + + /** @param array $days */ + private function settingOf(array $days): array + { + $setting = []; + foreach ($days as $dayKey => $spec) { + $setting[(string) $dayKey] = ['sessions' => [[ + 'active' => true, + 'location_id' => $spec['location'], + 'start_time' => $spec['start'], + 'end_time' => $spec['end'], + 'duration_per_patient' => 30, + 'has_rest' => false, + 'patient_limit' => null, + ]]]; + } + + return $setting; + } + + public function testDoctorSeesEveryPlaceOfTheirsAsSelectable(): void + { + [$doctor, , , $personal, $inClinic] = $this->makeDoctorWithBothPlaces(); + + $body = $this->authJson('GET', "/api/v1/appointment-settings/available-locations/{$doctor->getUuid()}", $doctor->getUser()); + + self::assertSame(200, $this->responseCode()); + $ids = array_map(static fn(array $a): int => (int) $a['id'], $body['data']['data'] ?? []); + + self::assertContains($personal->getId(), $ids); + self::assertContains($inClinic->getId(), $ids, 'آدرس کلینیک هم باید برای خود پزشک انتخاب‌شدنی باشد'); + } + + public function testClinicOwnerOnlySeesClinicAddressesAsSelectable(): void + { + [$doctor, $clinic, $owner, $personal, $inClinic] = $this->makeDoctorWithBothPlaces(); + + $body = $this->authJson('GET', "/api/v1/appointment-settings/available-locations/{$doctor->getUuid()}?clinic_uuid={$clinic->getUuid()}", $owner); + + self::assertSame(200, $this->responseCode()); + $ids = array_map(static fn(array $a): int => (int) $a['id'], $body['data']['data'] ?? []); + + self::assertSame([$inClinic->getId()], $ids); + self::assertNotContains($personal->getId(), $ids); + } + + public function testBothContextsReadTheSameRecord(): void + { + [$doctor, $clinic, , $personal] = $this->makeDoctorWithBothPlaces(); + + $this->em->persist($this->newWeeklySchedule($doctor, $this->settingOf([ + 0 => ['location' => $personal->getId(), 'start' => '09:00', 'end' => '12:00'], + ]))); + $this->em->flush(); + + $personalView = $this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $doctor->getUser())['data']['data']; + self::assertSame(200, $this->responseCode()); + + $clinicView = $this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}?clinic_uuid={$clinic->getUuid()}", $doctor->getUser())['data']['data']; + self::assertSame(200, $this->responseCode()); + + self::assertSame($personalView['uuid'], $clinicView['uuid'], 'برنامه یکی است، از هر دو پنل همان دیده می‌شود'); + self::assertNotEmpty($personalView['locations'], 'پاسخ باید همهٔ مکان‌های پزشک را برای برچسب‌زدن بدهد'); + } + + public function testClinicOwnerCannotWipeThePersonalShift(): void + { + [$doctor, $clinic, $owner, $personal, $inClinic] = $this->makeDoctorWithBothPlaces(); + + $this->em->persist($this->newWeeklySchedule($doctor, $this->settingOf([ + 0 => ['location' => $personal->getId(), 'start' => '09:00', 'end' => '12:00'], + ]))); + $this->em->flush(); + + // مدیر کلینیک شنبه را با شیفت کلینیک می‌فرستد و شیفت مطب را از ورودی حذف کرده. + $this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $owner, [ + 'clinic_uuid' => $clinic->getUuid(), + 'schedule' => $this->settingOf([ + 0 => ['location' => $inClinic->getId(), 'start' => '16:00', 'end' => '20:00'], + ]), + ]); + + self::assertSame(200, $this->responseCode()); + + $this->em->clear(); + $reloaded = $this->em->getRepository(Doctor::class)->find($doctor->getId()); + $schedule = $this->em->getRepository(WeeklySchedule::class)->findUnified($reloaded); + $ids = array_map( + static fn(array $s): int => (int) $s['location_id'], + $schedule->getSetting()['0']['sessions'] + ); + + self::assertContains($personal->getId(), $ids, 'شیفت مطب شخصی باید دست‌نخورده بماند'); + self::assertContains($inClinic->getId(), $ids); + } + + public function testEachContextOnlySeesItsOwnDays(): void + { + [$doctor, $clinic, , $personal, $inClinic] = $this->makeDoctorWithBothPlaces(); + + // همهٔ روزها را می‌سازیم تا نتیجه به روزِ هفتهٔ اجرای تست وابسته نباشد: + // روزهای زوج مطب، روزهای فرد کلینیک. + $days = []; + foreach (range(0, 6) as $dayKey) { + $days[$dayKey] = $dayKey % 2 === 0 + ? ['location' => $personal->getId(), 'start' => '09:00', 'end' => '12:00'] + : ['location' => $inClinic->getId(), 'start' => '16:00', 'end' => '20:00']; + } + + $this->em->persist($this->newWeeklySchedule($doctor, $this->settingOf($days))); + $this->em->flush(); + + $date = date('Y-m-d', strtotime('+3 days')); + // شاخص روز در برنامه: ۰ شنبه است و date('w') یکشنبه را صفر می‌گیرد. + $dayKey = ((int) date('w', strtotime($date)) + 1) % 7; + $isOwn = $dayKey % 2 === 0; + + $personalSessions = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$date}", $doctor->getUser())['data']['sessions']; + $clinicSessions = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$date}&clinic_uuid={$clinic->getUuid()}", $doctor->getUser())['data']['sessions']; + + if ($isOwn) { + self::assertNotEmpty($personalSessions, 'روز مطب باید در محیط شخصی نوبت بدهد'); + self::assertSame([], $clinicSessions, 'روز مطب نباید در کلینیک نوبت بدهد'); + } else { + self::assertSame([], $personalSessions, 'روز کلینیک نباید در مطب شخصی نوبت بدهد'); + self::assertNotEmpty($clinicSessions, 'روز کلینیک باید در محیط کلینیک نوبت بدهد'); + } + } +}