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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(), 'پرچم تأیید برای غیر ادمین بیاثر است');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user