feat(appointment): let a platform admin change a doctor's locked booking mode

The booking mode locks after the first save because existing appointments were
computed under that mode's rules. A lock with no key, though, traps a practice
that picked the wrong mode on day one, so ROLE_ADMIN can now open it.

The first attempt is still refused when the doctor has active appointments in
the next year, and says how many; the admin repeats the request with
force_mode_change to confirm they know what happens to those. The flag does
nothing for anyone else. GET now returns booking_mode_changeable so the panel
enables the toggle from the server's answer rather than guessing from the role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-20 17:32:52 +03:30
co-authored by Claude Opus 5
parent 7096b8980d
commit a3fc7f5c8b
5 changed files with 274 additions and 19 deletions
@@ -0,0 +1,139 @@
<?php
namespace App\Tests\Appointment;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* قفلِ نوع نوبت‌دهی برای همه هست، کلیدش فقط دست ادمین پلتفرم.
*
* قفل بی‌دلیل نیست: نوبت‌های ثبت‌شده با قواعد همان نوع محاسبه شده‌اند. ولی قفلِ
* بی‌کلید، محیطی را که اشتباه انتخاب کرده تا ابد گیر می‌اندازد، پس ادمین می‌تواند
* بازش کند — با یک تأیید صریح وقتی نوبت فعالی در آینده هست.
*/
class AdminBookingModeChangeTest extends ApiTestCase
{
/** نوبت‌دهی سرویسی بدون سرویسِ قابل رزرو ذخیره نمی‌شود؛ این همان سرویس است. */
private function bookableServiceFor(Doctor $doctor): void
{
$section = new ServiceSection('doctor', (int) $doctor->getId(), 'بخش تست');
$this->em->persist($section);
$item = new ServiceItem($section, 'ویزیت', 500_000);
$item->setBookable(true);
$item->setDurationMinutes(20);
$this->em->persist($item);
$this->em->flush();
}
/** @return array{0: Doctor, 1: DoctorAddress} */
private function makeDoctorWithSlotSchedule(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($user, 'دکتر تست');
$doctor->setMobileNumber($user->getMobileNumber());
$this->em->persist($doctor);
$this->em->flush();
$address = DoctorAddress::forDoctor($doctor);
$this->em->persist($address);
$this->em->flush();
$this->bookableServiceFor($doctor);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $user, [
'doctor_uuid' => $doctor->getUuid(),
'schedule' => [['day' => 'saturday', 'sessions' => [
['active' => true, 'location_id' => $address->getId(), 'start' => '09:00', 'end' => '12:00'],
]]],
'meta' => ['booking_mode' => 'slot'],
]);
self::assertSame(201, $this->responseCode());
return [$doctor, $address];
}
private function switchToService(Doctor $doctor, \App\Auth\Entity\User $actor, bool $force = false): array
{
return $this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $actor, [
'meta' => ['booking_mode' => 'service'],
'force_mode_change' => $force,
]);
}
public function testDoctorStillCannotChangeTheirOwnLockedMode(): void
{
[$doctor] = $this->makeDoctorWithSlotSchedule();
$this->switchToService($doctor, $doctor->getUser());
self::assertSame(422, $this->responseCode());
}
public function testAdminChangesModeWhenNothingIsBooked(): void
{
[$doctor] = $this->makeDoctorWithSlotSchedule();
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$body = $this->switchToService($doctor, $admin);
self::assertSame(200, $this->responseCode());
self::assertSame('service', $body['data']['data']['meta']['booking_mode']);
}
public function testUpcomingAppointmentsBlockTheFirstAttemptAndAreReported(): void
{
[$doctor] = $this->makeDoctorWithSlotSchedule();
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$start = time() + 3 * 86400;
$this->em->persist($this->newAppointment($doctor, $this->createUser(), $start, $start + 1800));
$this->em->flush();
$body = $this->switchToService($doctor, $admin);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('نوبت فعال', $body['errors'][0]['message'] ?? '');
}
public function testAdminCanForceThroughUpcomingAppointments(): void
{
[$doctor] = $this->makeDoctorWithSlotSchedule();
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$start = time() + 3 * 86400;
$this->em->persist($this->newAppointment($doctor, $this->createUser(), $start, $start + 1800));
$this->em->flush();
$body = $this->switchToService($doctor, $admin, force: true);
self::assertSame(200, $this->responseCode());
self::assertSame('service', $body['data']['data']['meta']['booking_mode']);
}
public function testResponseTellsThePanelWhoHoldsTheKey(): void
{
[$doctor] = $this->makeDoctorWithSlotSchedule();
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$asDoctor = $this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $doctor->getUser());
self::assertTrue($asDoctor['data']['data']['booking_mode_locked']);
self::assertFalse($asDoctor['data']['data']['booking_mode_changeable']);
$asAdmin = $this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $admin);
self::assertTrue($asAdmin['data']['data']['booking_mode_changeable']);
}
public function testForceFlagFromANonAdminChangesNothing(): void
{
[$doctor] = $this->makeDoctorWithSlotSchedule();
$this->switchToService($doctor, $doctor->getUser(), force: true);
self::assertSame(422, $this->responseCode(), 'پرچم تأیید برای غیر ادمین بی‌اثر است');
}
}