fix(appointment): one weekly schedule per doctor, place chosen per shift

A doctor working both at their own practice and at a clinic had to write two
independent schedules and neither panel could see the other, so the clinic
showed an empty form even though the doctor had configured their practice.

The schedule is now a single record owned by the doctor. What varies between
days is the place: the context of a shift is read from its location_id, not
from the record it lives in. Booking in a context therefore sees only that
context's days, so a personal-practice secretary still cannot book a clinic
day. The caller's own context decides which addresses they may assign: the
doctor gets every place of theirs, a clinic manager only its own, and shifts
outside their reach are returned for display but preserved verbatim on save.

Existing per-clinic rows are merged by migration; location_id was already
stored on every shift, so no context information is lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-20 17:20:53 +03:30
co-authored by Claude Opus 5
parent 2f030edef1
commit a0b4a969fc
15 changed files with 789 additions and 84 deletions
@@ -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 (
<div style={{
border: '1px dashed var(--border-2)', borderRadius: 'var(--r)', background: 'var(--surface-2)',
padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
}}>
<LockClosedIcon style={{ width: 15, height: 15, color: 'var(--text-3)', flexShrink: 0 }} />
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
{session.start_time} تا {session.end_time}
</span>
<span className="badge" style={{ flexShrink: 0 }}>{placeName}</span>
<span className="muted" style={{ fontSize: 12 }}>خارج از دسترس شما</span>
</div>
);
}
export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) {
const qc = useQueryClient();
// برنامه یکی است و شیفت‌های همهٔ محیط‌های پزشک را دارد. مدیر کلینیک شیفت مطب شخصی
// را می‌بیند ولی نباید بتواند عوضش کند، پس این دو از هم جدا نگه داشته می‌شوند.
const [allLocations, setAllLocations] = useState<AddressData[]>([]);
const [selectableIds, setSelectableIds] = useState<number[] | null>(null);
const [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE);
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
const [expandedDay, setExpandedDay] = useState<string | null>(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
</button>
</div>
) : sessions.map((session, idx) => (
<SessionEditor key={idx} session={session} addresses={addresses}
serviceMode={meta.booking_mode === 'service'}
onChange={s => updateSession(day.key, idx, s)}
onRemove={() => removeSession(day.key, idx)} />
canEditSession(session) ? (
<SessionEditor key={idx} session={session} addresses={addresses}
serviceMode={meta.booking_mode === 'service'}
onChange={s => updateSession(day.key, idx, s)}
onRemove={() => removeSession(day.key, idx)} />
) : (
<ForeignSessionRow key={idx} session={session} placeName={locationLabel(session.location_id)} />
)
))}
</div>
)}
+36 -8
View File
@@ -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.
---
+4
View File
@@ -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,
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ادغام برنامه‌های هفتگیِ هر پزشک در یک برنامهٔ واحد.
*
* تا امروز هر پزشک به ازای مطب شخصی و هر کلینیک یک برنامهٔ جدا داشت، پس پزشکی که هر
* دو را دارد باید دو جا برنامه می‌نوشت و هیچ‌کدام دیگری را نمی‌دید. برنامه یکی می‌شود
* و آنچه بین روزها فرق می‌کند مکان است؛ محیطِ هر شیفت از `location_id` همان شیفت
* خوانده می‌شود، که از قبل روی هر شیفت ذخیره شده بود.
*
* رکورد شخصی مبنا می‌ماند (اگر نبود، قدیمی‌ترین رکورد) و شیفت‌های بقیه به همان روزها
* اضافه می‌شوند. `meta` رکورد مبنا برنده است: نوع نوبت‌دهی و بازهٔ رزرو ویژگی پزشک‌اند
* نه ویژگی روز، و ادغامشان معنا ندارد.
*/
final class Version20260820120000 extends AbstractMigration
{
private const META_KEY = 'meta';
public function getDescription(): string
{
return 'Merge per-context weekly schedules into one schedule per doctor';
}
public function up(Schema $schema): void
{
$rows = $this->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<int, list<array<string, mixed>>> $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<string, mixed> */
private function decode(mixed $raw): array
{
$decoded = json_decode((string) $raw, true);
return is_array($decoded) ? $decoded : [];
}
/**
* @param array<string, mixed> $base
* @param array<string, mixed> $extra
*
* @return array<string, mixed>
*/
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;
}
}
@@ -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());
// محلی که آدرسی ندارد، محل نیست — چیزی برای مراجعهٔ بیمار وجود ندارد.
@@ -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<string, mixed> $incoming
* @param array<string, mixed> $stored
* @param array<int, true> $selectableIds
*
* @return array<string, mixed>
*/
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) {
+10 -4
View File
@@ -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(),
@@ -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
@@ -0,0 +1,143 @@
<?php
namespace App\Appointment\Schedule;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Repository\DoctorAddressRepository;
/**
* کدام آدرس‌ها در برنامهٔ هفتگی یک پزشک دیده و انتخاب می‌شوند.
*
* برنامهٔ هفتگی یکی است و مال خودِ پزشک؛ آنچه بین روزها فرق می‌کند مکان است. پس
* «این شیفت مال کدام محیط است» از روی آدرسِ همان شیفت خوانده می‌شود، نه از روی
* رکورد برنامه. این کلاس تنها جایی است که آن نگاشت انجام می‌شود.
*
* دو پرسش متفاوت‌اند و عمداً دو متد دارند:
* • {@see selectableFor()} — این کاربر حق دارد چه آدرسی را روی برنامه بنشاند؟
* مدیر کلینیک کل برنامه را می‌بیند ولی فقط آدرس کلینیک خودش را می‌تواند انتخاب کند.
* • {@see contextAddressIds()} — رزرو در این محیط، کدام شیفت‌ها را می‌بیند؟
* بی‌ارتباط با کاربر: منشیِ مطب شخصی روزِ کلینیک را اصلاً نباید ببیند.
*/
final class ScheduleLocationScope
{
/**
* حافظهٔ درون-درخواست.
*
* محاسبهٔ محیط‌ها برای هر روزِ اسکن دوباره صدا زده می‌شود؛ بدون این کش، پیدا کردن
* «زودترین نوبت آزاد» برای پزشکِ چندکلینیکی ده‌ها کوئری تکراری می‌زند و تست
* بودجهٔ کوئری قرمز می‌شود.
*
* @var array<int, DoctorAddress[]>
*/
private array $allCache = [];
/** @var array<string, array<int, true>> */
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<int, true>
*/
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<int, true>
*/
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<int, true>
*/
public static function idMap(array $addresses): array
{
$map = [];
foreach ($addresses as $address) {
$map[(int) $address->getId()] = true;
}
return $map;
}
}
@@ -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<int, array<string, mixed>> $sessions
* @param array<int, true> $foreignAddressIds
*
* @return array<int, array<string, mixed>>
*/
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')
@@ -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
@@ -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;
@@ -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
+20 -2
View File
@@ -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());
}
}
@@ -0,0 +1,170 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* پزشکی که هم مطب شخصی دارد هم کلینیک، یک برنامهٔ هفتگی دارد و نه دو تا.
*
* آنچه بین روزها فرق می‌کند مکان است: شنبه مطب، دوشنبه کلینیک. محیطِ هر روز از آدرسِ
* همان شیفت خوانده می‌شود، پس منشیِ مطب شخصی روزِ کلینیک را نمی‌بیند و برعکس.
*/
class UnifiedDoctorScheduleTest extends ApiTestCase
{
/** @return array{0: Doctor, 1: Clinic, 2: \App\Auth\Entity\User, 3: DoctorAddress, 4: DoctorAddress} */
private function makeDoctorWithBothPlaces(): array
{
$doctorUser = $this->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<int, array{location: int, start: string, end: string}> $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, 'روز کلینیک باید در محیط کلینیک نوبت بدهد');
}
}
}