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