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, 'روز کلینیک باید در محیط کلینیک نوبت بدهد'); + } + } +}