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
@@ -21,7 +21,7 @@ const ADDRESSES: AddressData[] = [{
}]; }];
/** پاسخ برنامهٔ هفتگی؛ فقط `meta` بین تست‌ها فرق می‌کند. */ /** پاسخ برنامهٔ هفتگی؛ فقط `meta` بین تست‌ها فرق می‌کند. */
function mockSchedule(meta: Record<string, unknown>, locked = false) { function mockSchedule(meta: Record<string, unknown>, locked = false, changeable = false) {
get.mockImplementation((url?: string) => { get.mockImplementation((url?: string) => {
if (typeof url === 'string' && url.includes('weekly-schedule')) { if (typeof url === 'string' && url.includes('weekly-schedule')) {
return Promise.resolve({ return Promise.resolve({
@@ -32,6 +32,7 @@ function mockSchedule(meta: Record<string, unknown>, locked = false) {
schedule: {}, schedule: {},
meta: { online_booking_enabled: true, booking_window_value: 3, booking_window_unit: 'month', buffer_minutes: 0, ...meta }, meta: { online_booking_enabled: true, booking_window_value: 3, booking_window_unit: 'month', buffer_minutes: 0, ...meta },
booking_mode_locked: locked, booking_mode_locked: locked,
booking_mode_changeable: changeable,
}, },
}); });
} }
@@ -77,3 +78,21 @@ describe('WeeklyScheduleTab — روش نوبت‌دهی', () => {
expect(screen.queryByRole('button', { name: /نوبت‌دهی منبع‌محور/ })).toBeNull(); expect(screen.queryByRole('button', { name: /نوبت‌دهی منبع‌محور/ })).toBeNull();
}); });
}); });
describe('WeeklyScheduleTab — قفل نوع نوبت‌دهی', () => {
it('برای کاربر عادی، نوعِ ثبت‌شده قفل است', async () => {
mockSchedule({ booking_mode: 'slot' }, true);
renderWithProviders(<WeeklyScheduleTab doctorUuid="d1" addresses={ADDRESSES} />);
expect(await screen.findByText('نوع نوبت‌دهی ثبت شده و دیگر قابل تغییر نیست.')).toBeInTheDocument();
expect(screen.getByText('نوبت‌دهی سرویسی').closest('button')).toBeDisabled();
});
it('برای ادمین، همان نوع قابل تغییر است و هشدارش را می‌گوید', async () => {
mockSchedule({ booking_mode: 'slot' }, true, true);
renderWithProviders(<WeeklyScheduleTab doctorUuid="d1" addresses={ADDRESSES} />);
expect(await screen.findByText(/فقط ادمین می‌تواند عوضش کند/)).toBeInTheDocument();
expect(screen.getByText('نوبت‌دهی سرویسی').closest('button')).not.toBeDisabled();
});
});
@@ -112,6 +112,8 @@ interface WeeklyScheduleData {
locations?: AddressData[]; locations?: AddressData[];
/** مکان‌هایی که همین کاربر حق دارد روی برنامه بنشاند. */ /** مکان‌هایی که همین کاربر حق دارد روی برنامه بنشاند. */
selectable_location_ids?: number[]; selectable_location_ids?: number[];
/** آیا همین کاربر می‌تواند قفلِ نوع نوبت‌دهی را باز کند — امروز فقط ادمین پلتفرم. */
booking_mode_changeable?: boolean;
} }
// ── Persian (Jalali) date utilities ─────────────────────────────────────── // ── Persian (Jalali) date utilities ───────────────────────────────────────
@@ -606,6 +608,12 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
const [meta, setMeta] = useState<BookingMeta>(DEFAULT_BOOKING_META); const [meta, setMeta] = useState<BookingMeta>(DEFAULT_BOOKING_META);
// نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول. // نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول.
const [modeLocked, setModeLocked] = useState(false); const [modeLocked, setModeLocked] = useState(false);
// قفل برای همه هست، کلیدش فقط دست ادمین. نوعِ ثبت‌شده هم نگه داشته می‌شود تا
// بدانیم کاربر واقعاً عوضش کرده یا فقط ذخیرهٔ معمولی است.
const [modeChangeable, setModeChangeable] = useState(false);
const [savedMode, setSavedMode] = useState<BookingMeta['booking_mode'] | null>(null);
// پیام واقعی سرور دربارهٔ نوبت‌های آینده؛ تا وقتی null است دیالوگ تأیید بسته می‌ماند.
const [modeChangeWarning, setModeChangeWarning] = useState<string | null>(null);
const [confirmMode, setConfirmMode] = useState(false); const [confirmMode, setConfirmMode] = useState(false);
@@ -632,6 +640,8 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
if (d?.locations) setAllLocations(d.locations); if (d?.locations) setAllLocations(d.locations);
if (d?.selectable_location_ids) setSelectableIds(d.selectable_location_ids.map(Number)); if (d?.selectable_location_ids) setSelectableIds(d.selectable_location_ids.map(Number));
setModeLocked(!!d?.booking_mode_locked); setModeLocked(!!d?.booking_mode_locked);
setModeChangeable(!!d?.booking_mode_changeable);
setSavedMode(d?.meta?.booking_mode ?? null);
} else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) { } else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) {
setScheduleMap(EMPTY_NEW_SCHEDULE); setScheduleUuid(null); setScheduleMap(EMPTY_NEW_SCHEDULE); setScheduleUuid(null);
} }
@@ -665,22 +675,34 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
(scheduleMap[d.key]?.sessions ?? []).some(s => s.active && s.location_id === null) (scheduleMap[d.key]?.sessions ?? []).some(s => s.active && s.location_id === null)
); );
const modeChanged = savedMode !== null && savedMode !== meta.booking_mode;
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: () => { mutationFn: ({ forceModeChange = false }: { forceModeChange?: boolean } = {}) => {
if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد'); if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد');
if (missingLocation) throw new Error('مکان مطب برای همه بازه‌های فعال الزامی است'); if (missingLocation) throw new Error('مکان مطب برای همه بازه‌های فعال الزامی است');
const body = { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null, force_mode_change: forceModeChange };
return scheduleUuid return scheduleUuid
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null }) ? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, body)
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null }); : api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, ...body });
}, },
onSuccess: (res) => { onSuccess: (res) => {
const d: WeeklyScheduleData = res?.data?.data ?? res?.data; const d: WeeklyScheduleData = res?.data?.data ?? res?.data;
if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid); if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid);
setModeLocked(true); // پس از ثبت، نوع نوبت‌دهی قفل می‌شود setModeLocked(true); // پس از ثبت، نوع نوبت‌دهی قفل می‌شود
setSavedMode(meta.booking_mode);
toast.success('برنامه هفتگی ذخیره شد'); toast.success('برنامه هفتگی ذخیره شد');
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null] }); qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null] });
}, },
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => {
// سرور تغییرِ نوعِ ثبت‌شده را بار اول رد می‌کند و تعداد نوبت‌های فعال آینده را
// می‌گوید. همان جمله در دیالوگ تأیید نشان داده می‌شود، نه یک متن حدسی.
if (e instanceof ApiError && e.status === 422 && modeChanged) {
setModeChangeWarning(e.message);
return;
}
toast.error(e.message);
},
}); });
const setDaySessions = (key: string, sessions: SessionConfig[]) => const setDaySessions = (key: string, sessions: SessionConfig[]) =>
@@ -785,13 +807,13 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
<button <button
key={val} key={val}
type="button" type="button"
disabled={modeLocked} disabled={modeLocked && !modeChangeable}
onClick={() => !modeLocked && setMeta(m => ({ ...m, booking_mode: val }))} onClick={() => (!modeLocked || modeChangeable) && setMeta(m => ({ ...m, booking_mode: val }))}
className={`text-right p-3 rounded-lg border transition-colors ${ className={`text-right p-3 rounded-lg border transition-colors ${
selected selected
? 'border-[var(--primary)] bg-[var(--primary)]/5' ? 'border-[var(--primary)] bg-[var(--primary)]/5'
: 'border-[var(--border)] bg-[var(--surface)]' : 'border-[var(--border)] bg-[var(--surface)]'
} ${modeLocked ? 'opacity-70 cursor-not-allowed' : 'hover:border-[var(--primary)]'}`} } ${modeLocked && !modeChangeable ? 'opacity-70 cursor-not-allowed' : 'hover:border-[var(--primary)]'}`}
> >
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className={`w-3.5 h-3.5 rounded-full border shrink-0 ${selected ? 'border-[var(--primary)] bg-[var(--primary)]' : 'border-[var(--border-2)]'}`} /> <span className={`w-3.5 h-3.5 rounded-full border shrink-0 ${selected ? 'border-[var(--primary)] bg-[var(--primary)]' : 'border-[var(--border-2)]'}`} />
@@ -802,7 +824,12 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
); );
})} })}
</div> </div>
{modeLocked ? ( {modeLocked && modeChangeable ? (
<p className="text-xs text-[var(--warning)] flex items-center gap-1.5">
<ExclamationTriangleIcon className="w-3.5 h-3.5 shrink-0" />
نوع نوبتدهی برای این پزشک ثبت شده است. فقط ادمین میتواند عوضش کند و نوبتهای ثبتشده با قواعد نوع قبلی محاسبه شدهاند.
</p>
) : modeLocked ? (
<p className="text-xs text-[var(--text-2)] flex items-center gap-1.5"> <p className="text-xs text-[var(--text-2)] flex items-center gap-1.5">
<LockClosedIcon className="w-3.5 h-3.5 shrink-0" /> <LockClosedIcon className="w-3.5 h-3.5 shrink-0" />
نوع نوبتدهی ثبت شده و دیگر قابل تغییر نیست. نوع نوبتدهی ثبت شده و دیگر قابل تغییر نیست.
@@ -965,7 +992,10 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
</p> </p>
)} )}
{!readOnly && ( {!readOnly && (
<button type="button" onClick={() => modeLocked ? saveMut.mutate() : setConfirmMode(true)} <button type="button" onClick={() => {
if (modeLocked) { saveMut.mutate({}); return; }
setConfirmMode(true);
}}
disabled={saveMut.isPending || hasAnyOverlap || missingLocation} disabled={saveMut.isPending || hasAnyOverlap || missingLocation}
className="btn primary sm" style={{ marginInlineStart: 'auto', opacity: (saveMut.isPending || hasAnyOverlap || missingLocation) ? 0.5 : 1 }}> className="btn primary sm" style={{ marginInlineStart: 'auto', opacity: (saveMut.isPending || hasAnyOverlap || missingLocation) ? 0.5 : 1 }}>
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره برنامه هفتگی'} {saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره برنامه هفتگی'}
@@ -980,9 +1010,22 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
message={`روش «${MODE_LABELS[meta.booking_mode]}» را انتخاب کرده‌اید. این انتخاب پس از ثبت به‌هیچ‌عنوان قابل تغییر نیست. ادامه می‌دهید؟`} message={`روش «${MODE_LABELS[meta.booking_mode]}» را انتخاب کرده‌اید. این انتخاب پس از ثبت به‌هیچ‌عنوان قابل تغییر نیست. ادامه می‌دهید؟`}
confirmLabel="ثبت و قفل" confirmLabel="ثبت و قفل"
loading={saveMut.isPending} loading={saveMut.isPending}
onConfirm={() => { setConfirmMode(false); saveMut.mutate(); }} onConfirm={() => { setConfirmMode(false); saveMut.mutate({}); }}
onCancel={() => setConfirmMode(false)} onCancel={() => setConfirmMode(false)}
/> />
{/* تغییر نوعِ ثبت‌شده مسیر جداست: بازگشتی ندارد و نوبت‌های آینده را زیر قواعد
تازه می‌برد. متن هشدار از خود سرور می‌آید تا تعداد واقعی گفته شود. */}
<ConfirmDialog
open={modeChangeWarning !== null}
danger
title="تغییر نوع نوبت‌دهی"
message={`${modeChangeWarning ?? ''}\n\nنوع نوبت‌دهی از «${savedMode ? MODE_LABELS[savedMode] : '—'}» به «${MODE_LABELS[meta.booking_mode]}» تغییر می‌کند و این کار برگشت‌پذیر نیست.`}
confirmLabel="بله، تغییر بده"
loading={saveMut.isPending}
onConfirm={() => { setModeChangeWarning(null); saveMut.mutate({ forceModeChange: true }); }}
onCancel={() => setModeChangeWarning(null)}
/>
</div> </div>
); );
} }
+10
View File
@@ -88,6 +88,14 @@ Create or update the weekly schedule for a doctor (upsert).
> >
> **ادغام هنگام ذخیره:** شیفت‌هایی که آدرسشان بیرون از دسترس درخواست‌کننده است، از نسخهٔ ذخیره‌شده دست‌نخورده برمی‌گردند؛ ورودی نه می‌تواند حذفشان کند نه عوضشان. > **ادغام هنگام ذخیره:** شیفت‌هایی که آدرسشان بیرون از دسترس درخواست‌کننده است، از نسخهٔ ذخیره‌شده دست‌نخورده برمی‌گردند؛ ورودی نه می‌تواند حذفشان کند نه عوضشان.
> >
> **تغییر نوع نوبت‌دهی:** نوع پس از اولین ثبت قفل می‌شود، چون نوبت‌های ثبت‌شده با قواعد همان نوع محاسبه شده‌اند. فقط `ROLE_ADMIN` می‌تواند بازش کند:
>
> - غیر ادمین → `422 ERR_VALIDATION_001` روی `booking_mode` («نوع نوبت‌دهی پس از ثبت قابل تغییر نیست»).
> - ادمین، وقتی نوبت فعالی در ۳۶۵ روز آینده هست → `422` با تعداد آن نوبت‌ها. درخواست دوم با `force_mode_change: true` انجام می‌شود.
> - ادمین، بدون نوبت فعال آینده → بدون نیاز به `force_mode_change` انجام می‌شود.
>
> `force_mode_change` از سمت غیر ادمین بی‌اثر است.
> **نوبت‌دهی سرویسی:** با `meta.booking_mode = "service"` صاحبِ همان context باید حداقل یک سرویس با `bookable = true` داشته باشد؛ وگرنه `422 ERR_VALIDATION_001` روی فیلد `booking_mode`. پیام در محیط کلینیک به کلینیک اشاره می‌کند. > **نوبت‌دهی سرویسی:** با `meta.booking_mode = "service"` صاحبِ همان context باید حداقل یک سرویس با `bookable = true` داشته باشد؛ وگرنه `422 ERR_VALIDATION_001` روی فیلد `booking_mode`. پیام در محیط کلینیک به کلینیک اشاره می‌کند.
### Request Body (`application/json`) ### Request Body (`application/json`)
@@ -715,6 +723,8 @@ Returns all locations a doctor can assign as `location_id` in their schedule ses
| `context` | `"personal" \| "clinic"` | **legacy**, always `"personal"` for the same reason | | `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 | | `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 | | `selectable_location_ids` | `int[]` | the subset of those the **caller** may assign — `GET` only |
| `booking_mode_locked` | `bool` | a mode has already been stored |
| `booking_mode_changeable` | `bool` | this caller may unlock it — `ROLE_ADMIN` only, `GET` only |
`DateOverride.toArray()` returns the same two fields. `Holiday.toArray()` returns `clinic_uuid` `DateOverride.toArray()` returns the same two fields. `Holiday.toArray()` returns `clinic_uuid`
plus `scope` (`"global" | "clinic"`), and the list endpoint adds `editable` (see below). plus `scope` (`"global" | "clinic"`), and the list endpoint adds `editable` (see below).
@@ -43,6 +43,7 @@ class AppointmentSettingsController extends BaseController
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker, private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
private readonly \App\Appointment\Schedule\ScheduleLocationScope $locationScope, private readonly \App\Appointment\Schedule\ScheduleLocationScope $locationScope,
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
) {} ) {}
/** /**
@@ -60,16 +61,56 @@ class AppointmentSettingsController extends BaseController
return (int) strtotime(date('Y-m-d', $ts) . ' 00:00:00'); return (int) strtotime(date('Y-m-d', $ts) . ' 00:00:00');
} }
/** پنجرهٔ نگاه به آینده برای شمردن نوبت‌های فعال، هنگام تغییر نوع نوبت‌دهی. */
private const MODE_CHANGE_SCAN_DAYS = 365;
/** /**
* نوع نوبت‌دهی پس از اولین ثبت غیرقابل‌تغییر است — اما فقط داخل همان context. * نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود.
* پزشکی که در مطب شخصی نوبت‌دهی اسلاتی دارد، همچنان می‌تواند در کلینیک سرویسی *
* انتخاب کند. * دلیل قفل این است که نوبت‌های ثبت‌شده با قواعد همان نوع محاسبه شده‌اند؛ عوض‌کردنش
* یعنی نوبت‌های آینده روی برنامه‌ای بنشینند که دیگر وجود ندارد. ولی قفلِ بدون کلید،
* کلینیکی را که اشتباه انتخاب کرده تا ابد گیر می‌اندازد، پس ادمین پلتفرم می‌تواند
* بازش کند.
*
* اگر نوبت فعالی در آینده باشد، همان بار اول رد می‌شود و تعدادش گفته می‌شود؛
* ادمین با `force_mode_change` تأیید می‌کند که خودش تکلیفشان را می‌داند. تأیید
* دوباره عمدی است: این کار قابل بازگشت نیست.
*/ */
private function assertModeImmutable(?string $prevMode, array $newMeta): ?JsonResponse private function assertModeImmutable(
{ ?string $prevMode,
if ($prevMode !== null && ($newMeta['booking_mode'] ?? null) !== $prevMode) { array $newMeta,
Doctor $doctor,
User $user,
bool $force = false,
): ?JsonResponse {
if ($prevMode === null || ($newMeta['booking_mode'] ?? null) === $prevMode) {
return null;
}
if (!$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع نوبت‌دهی پس از ثبت قابل تغییر نیست', 422, 'booking_mode'); return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع نوبت‌دهی پس از ثبت قابل تغییر نیست', 422, 'booking_mode');
} }
if ($force) {
return null;
}
$now = time();
$upcoming = count($this->appointmentRepo->findBlockingIntervals(
$doctor,
$now,
$now + self::MODE_CHANGE_SCAN_DAYS * 86400,
));
if ($upcoming > 0) {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
sprintf('این پزشک %d نوبت فعال در آینده دارد. برای تغییر نوع نوبت‌دهی، تأیید صریح لازم است', $upcoming),
422,
'booking_mode',
);
}
return null; return null;
} }
@@ -229,7 +270,7 @@ class AppointmentSettingsController extends BaseController
$schedule->setMeta($data['meta']); $schedule->setMeta($data['meta']);
} }
if (($err = $this->assertModeImmutable($prevMode, $schedule->getMeta())) !== null) { if (($err = $this->assertModeImmutable($prevMode, $schedule->getMeta(), $doctor, $user, (bool) ($data['force_mode_change'] ?? false))) !== null) {
return $err; return $err;
} }
@@ -298,7 +339,7 @@ class AppointmentSettingsController extends BaseController
$schedule->setMeta($data['meta']); $schedule->setMeta($data['meta']);
} }
if (($err = $this->assertModeImmutable($prevMode, $schedule->getMeta())) !== null) { if (($err = $this->assertModeImmutable($prevMode, $schedule->getMeta(), $schedule->getDoctor(), $user, (bool) ($data['force_mode_change'] ?? false))) !== null) {
return $err; return $err;
} }
@@ -355,6 +396,9 @@ class AppointmentSettingsController extends BaseController
static fn(DoctorAddress $a): int => (int) $a->getId(), static fn(DoctorAddress $a): int => (int) $a->getId(),
$selectable, $selectable,
), ),
// قفلِ نوع نوبت‌دهی برای همه هست، ولی کلیدش فقط دست ادمین پلتفرم است.
// پنل نباید این را از روی نقش حدس بزند.
'booking_mode_changeable' => $user->hasRole('ROLE_ADMIN'),
])]); ])]);
} }
@@ -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(), 'پرچم تأیید برای غیر ادمین بی‌اثر است');
}
}