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