feat(appointment): implement immutable booking mode after first save and update API documentation
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon,
|
PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon,
|
||||||
CheckCircleIcon, XMarkIcon, ExclamationTriangleIcon,
|
CheckCircleIcon, XMarkIcon, ExclamationTriangleIcon,
|
||||||
HeartIcon, CheckIcon, IdentificationIcon, DocumentTextIcon, GlobeAltIcon,
|
HeartIcon, CheckIcon, IdentificationIcon, DocumentTextIcon, GlobeAltIcon,
|
||||||
|
LockClosedIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { StarIcon as StarSolid } from '@heroicons/react/24/solid';
|
import { StarIcon as StarSolid } from '@heroicons/react/24/solid';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -111,7 +112,7 @@ const DEFAULT_BOOKING_META: BookingMeta = {
|
|||||||
booking_mode: 'slot',
|
booking_mode: 'slot',
|
||||||
buffer_minutes: 0,
|
buffer_minutes: 0,
|
||||||
};
|
};
|
||||||
interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; meta?: BookingMeta; }
|
interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; meta?: BookingMeta; booking_mode_locked?: boolean; }
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1106,11 +1107,13 @@ function SlotEditor({ slots, onChange }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionEditor({ session, onChange, onRemove, addresses }: {
|
function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = false }: {
|
||||||
session: SessionConfig;
|
session: SessionConfig;
|
||||||
onChange: (s: SessionConfig) => void;
|
onChange: (s: SessionConfig) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
addresses: AddressData[];
|
addresses: AddressData[];
|
||||||
|
/** حالت نوبتدهی سرویسی: فیلدهای اسلاتی (بازه هر نوبت، استراحت، شمارش) نمایش داده نمیشوند. */
|
||||||
|
serviceMode?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const upd = <K extends keyof SessionConfig>(k: K, v: SessionConfig[K]) =>
|
const upd = <K extends keyof SessionConfig>(k: K, v: SessionConfig[K]) =>
|
||||||
onChange({ ...session, [k]: v });
|
onChange({ ...session, [k]: v });
|
||||||
@@ -1134,16 +1137,18 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Duration + Location */}
|
{/* Duration (slot mode only) + Location */}
|
||||||
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: serviceMode ? '1fr' : '1fr 1fr', gap: 12 }}>
|
||||||
<div>
|
{!serviceMode && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>بازه هر نوبت</div>
|
<div>
|
||||||
<GlobalSearchableSelect
|
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>بازه هر نوبت</div>
|
||||||
options={DURATION_OPTS.map(d => ({ value: d, label: `${d} دقیقه` }))}
|
<GlobalSearchableSelect
|
||||||
value={session.duration_per_patient}
|
options={DURATION_OPTS.map(d => ({ value: d, label: `${d} دقیقه` }))}
|
||||||
onChange={(v) => upd('duration_per_patient', Number(v))}
|
value={session.duration_per_patient}
|
||||||
/>
|
onChange={(v) => upd('duration_per_patient', Number(v))}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>مکان نوبت</div>
|
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>مکان نوبت</div>
|
||||||
<GlobalSearchableSelect
|
<GlobalSearchableSelect
|
||||||
@@ -1165,7 +1170,8 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rest toggle */}
|
{/* Rest toggle (slot mode only) */}
|
||||||
|
{!serviceMode && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '12px 16px', display: 'flex', alignItems: 'center',
|
padding: '12px 16px', display: 'flex', alignItems: 'center',
|
||||||
justifyContent: 'space-between', borderTop: '1px solid var(--border)',
|
justifyContent: 'space-between', borderTop: '1px solid var(--border)',
|
||||||
@@ -1189,9 +1195,10 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
|
|||||||
}} />
|
}} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Rest params */}
|
{/* Rest params */}
|
||||||
{session.has_rest && (
|
{!serviceMode && session.has_rest && (
|
||||||
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>هر (دقیقه کار)</div>
|
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>هر (دقیقه کار)</div>
|
||||||
@@ -1210,7 +1217,8 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Slot count footer */}
|
{/* Slot count footer (slot mode only) */}
|
||||||
|
{!serviceMode && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '10px 16px', display: 'flex', alignItems: 'center',
|
padding: '10px 16px', display: 'flex', alignItems: 'center',
|
||||||
justifyContent: 'space-between', borderTop: '1px solid var(--border)',
|
justifyContent: 'space-between', borderTop: '1px solid var(--border)',
|
||||||
@@ -1226,6 +1234,7 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
|
|||||||
<span className="muted" style={{ fontSize: 12 }}>—</span>
|
<span className="muted" style={{ fontSize: 12 }}>—</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1238,6 +1247,9 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
|||||||
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
|
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
|
||||||
const [expandedDay, setExpandedDay] = useState<string | null>(null);
|
const [expandedDay, setExpandedDay] = useState<string | null>(null);
|
||||||
const [meta, setMeta] = useState<BookingMeta>(DEFAULT_BOOKING_META);
|
const [meta, setMeta] = useState<BookingMeta>(DEFAULT_BOOKING_META);
|
||||||
|
// نوع نوبتدهی پس از اولین ثبت قفل میشود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول.
|
||||||
|
const [modeLocked, setModeLocked] = useState(false);
|
||||||
|
const [confirmMode, setConfirmMode] = useState(false);
|
||||||
|
|
||||||
const scheduleQ = useQuery({
|
const scheduleQ = useQuery({
|
||||||
queryKey: ['doctor-schedule', doctorUuid],
|
queryKey: ['doctor-schedule', doctorUuid],
|
||||||
@@ -1259,6 +1271,7 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
|||||||
setScheduleUuid(d.uuid);
|
setScheduleUuid(d.uuid);
|
||||||
}
|
}
|
||||||
if (d?.meta) setMeta({ ...DEFAULT_BOOKING_META, ...d.meta });
|
if (d?.meta) setMeta({ ...DEFAULT_BOOKING_META, ...d.meta });
|
||||||
|
setModeLocked(!!d?.booking_mode_locked);
|
||||||
} 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);
|
||||||
}
|
}
|
||||||
@@ -1291,6 +1304,7 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
|||||||
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); // پس از ثبت، نوع نوبتدهی قفل میشود
|
||||||
toast.success('برنامه هفتگی ذخیره شد');
|
toast.success('برنامه هفتگی ذخیره شد');
|
||||||
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid] });
|
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid] });
|
||||||
},
|
},
|
||||||
@@ -1394,22 +1408,43 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
|||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">روش نوبتدهی</span>
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">روش نوبتدهی</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-3 space-y-3">
|
<div className="px-4 py-3 space-y-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{([['slot', 'اسلاتی (مدت ثابت)'], ['service', 'بر اساس سرویس']] as const).map(([val, lbl]) => (
|
{([
|
||||||
<button
|
['slot', 'نوبتدهی اسلاتی', 'شما بازههای کاری و «مدت هر نوبت» را مشخص میکنید؛ سیستم بازه را به نوبتهای هماندازه تقسیم میکند. مناسب ویزیتهای با زمان یکسان.'],
|
||||||
key={val}
|
['service', 'نوبتدهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود؛ سیستم نزدیکترین زمان خالیِ کافی را پیشنهاد میدهد. مناسب خدمات با زمان متفاوت.'],
|
||||||
type="button"
|
] as const).map(([val, lbl, desc]) => {
|
||||||
onClick={() => setMeta(m => ({ ...m, booking_mode: val }))}
|
const selected = meta.booking_mode === val;
|
||||||
className={`px-3 py-1.5 text-sm rounded-lg border transition-colors ${
|
return (
|
||||||
meta.booking_mode === val
|
<button
|
||||||
? 'bg-[var(--primary)] text-white border-[var(--primary)]'
|
key={val}
|
||||||
: 'bg-white dark:bg-gray-900 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-gray-700'
|
type="button"
|
||||||
}`}
|
disabled={modeLocked}
|
||||||
>
|
onClick={() => !modeLocked && setMeta(m => ({ ...m, booking_mode: val }))}
|
||||||
{lbl}
|
className={`text-right p-3 rounded-lg border transition-colors ${
|
||||||
</button>
|
selected
|
||||||
))}
|
? 'border-[var(--primary)] bg-[var(--primary)]/5'
|
||||||
|
: 'border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900'
|
||||||
|
} ${modeLocked ? 'opacity-70 cursor-not-allowed' : 'hover:border-[var(--primary)]'}`}
|
||||||
|
>
|
||||||
|
<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-slate-300 dark:border-gray-600'}`} />
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{lbl}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">{desc}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{modeLocked ? (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||||
|
<LockClosedIcon className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
نوع نوبتدهی ثبت شده و دیگر قابل تغییر نیست.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400 leading-relaxed">
|
||||||
|
⚠️ توجه: نوع نوبتدهی پس از اولین ثبت <span className="font-medium">بههیچعنوان قابل تغییر نیست</span>. پیش از ذخیره با دقت انتخاب کنید.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{meta.booking_mode === 'service' ? (
|
{meta.booking_mode === 'service' ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -1544,6 +1579,7 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
|||||||
</div>
|
</div>
|
||||||
) : sessions.map((session, idx) => (
|
) : sessions.map((session, idx) => (
|
||||||
<SessionEditor key={idx} session={session} addresses={addresses}
|
<SessionEditor key={idx} session={session} addresses={addresses}
|
||||||
|
serviceMode={meta.booking_mode === 'service'}
|
||||||
onChange={s => updateSession(day.key, idx, s)}
|
onChange={s => updateSession(day.key, idx, s)}
|
||||||
onRemove={() => removeSession(day.key, idx)} />
|
onRemove={() => removeSession(day.key, idx)} />
|
||||||
))}
|
))}
|
||||||
@@ -1561,13 +1597,24 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button type="button" onClick={() => saveMut.mutate()}
|
<button type="button" onClick={() => modeLocked ? saveMut.mutate() : 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 ? 'در حال ذخیره...' : 'ذخیره برنامه هفتگی'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmMode}
|
||||||
|
danger
|
||||||
|
title="تأیید نوع نوبتدهی"
|
||||||
|
message={`روش «${meta.booking_mode === 'service' ? 'نوبتدهی سرویسی' : 'نوبتدهی اسلاتی'}» را انتخاب کردهاید. این انتخاب پس از ثبت بههیچعنوان قابل تغییر نیست. ادامه میدهید؟`}
|
||||||
|
confirmLabel="ثبت و قفل"
|
||||||
|
loading={saveMut.isPending}
|
||||||
|
onConfirm={() => { setConfirmMode(false); saveMut.mutate(); }}
|
||||||
|
onCancel={() => setConfirmMode(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ Create or update the weekly schedule for a doctor (upsert).
|
|||||||
> Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 1, booking_window_unit: "month", booking_mode: "slot", buffer_minutes: 0 }`. `meta` is stored inside the schedule `setting` JSON (no DB migration) and is **preserved** when only `schedule` is sent. `SlotCalculatorService` rejects any date in the past, beyond `today + value unit`, or when online booking is disabled — for the weekly schedule, date overrides, and `appointment-slots` alike.
|
> Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 1, booking_window_unit: "month", booking_mode: "slot", buffer_minutes: 0 }`. `meta` is stored inside the schedule `setting` JSON (no DB migration) and is **preserved** when only `schedule` is sent. `SlotCalculatorService` rejects any date in the past, beyond `today + value unit`, or when online booking is disabled — for the weekly schedule, date overrides, and `appointment-slots` alike.
|
||||||
>
|
>
|
||||||
> **اجبار حالت سرویسی:** اگر `booking_mode = service` ذخیره شود ولی پزشک هیچ سرویسِ «نمایش در نوبتدهی» (`bookable = true`) نداشته باشد، `POST`/`PATCH` برنامهٔ هفتگی با `422` (`ERR_VALIDATION_001`, field `booking_mode`) رد میشود.
|
> **اجبار حالت سرویسی:** اگر `booking_mode = service` ذخیره شود ولی پزشک هیچ سرویسِ «نمایش در نوبتدهی» (`bookable = true`) نداشته باشد، `POST`/`PATCH` برنامهٔ هفتگی با `422` (`ERR_VALIDATION_001`, field `booking_mode`) رد میشود.
|
||||||
|
>
|
||||||
|
> **غیرقابلتغییر پس از ثبت:** `booking_mode` فقط تا **اولین ثبت** قابلانتخاب است. پس از آنکه یکبار بهصورت صریح ذخیره شد (در `setting.meta.booking_mode` نوشته شد)، هر `POST`/`PATCH` که آن را تغییر دهد با `422` («نوع نوبتدهی پس از ثبت قابل تغییر نیست»، field `booking_mode`) رد میشود. پاسخِ `toArray` فیلد boolean `booking_mode_locked` را برمیگرداند (`true` = قفلشده) تا پنل توگل را غیرفعال کند. رکوردهای قدیمی که هنوز mode صریح ندارند، `booking_mode_locked=false` و یکبار قابلانتخاباند.
|
||||||
|
|
||||||
**Session Config Object:**
|
**Session Config Object:**
|
||||||
|
|
||||||
@@ -143,8 +145,11 @@ Create or update the weekly schedule for a doctor (upsert).
|
|||||||
"meta": {
|
"meta": {
|
||||||
"online_booking_enabled": true,
|
"online_booking_enabled": true,
|
||||||
"booking_window_value": 1,
|
"booking_window_value": 1,
|
||||||
"booking_window_unit": "month"
|
"booking_window_unit": "month",
|
||||||
|
"booking_mode": "slot",
|
||||||
|
"buffer_minutes": 0
|
||||||
},
|
},
|
||||||
|
"booking_mode_locked": true,
|
||||||
"created_at": 1717000000,
|
"created_at": 1717000000,
|
||||||
"updated_at": 1717000000
|
"updated_at": 1717000000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ class AppointmentSettingsController extends BaseController
|
|||||||
* در حالت نوبتدهی سرویسی، پزشک باید حداقل یک سرویسِ «نمایش در نوبتدهی»
|
* در حالت نوبتدهی سرویسی، پزشک باید حداقل یک سرویسِ «نمایش در نوبتدهی»
|
||||||
* (bookable) داشته باشد؛ وگرنه هیچ نوبتی قابلمحاسبه نیست.
|
* (bookable) داشته باشد؛ وگرنه هیچ نوبتی قابلمحاسبه نیست.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* نوع نوبتدهی پس از اولین ثبت غیرقابلتغییر است. اگر قبلاً mode ذخیره شده بود
|
||||||
|
* ($prevMode !== null) و meta جدید آن را تغییر دهد، خطای 422 برمیگرداند.
|
||||||
|
*/
|
||||||
|
private function assertModeImmutable(?string $prevMode, array $newMeta): ?JsonResponse
|
||||||
|
{
|
||||||
|
if ($prevMode !== null && ($newMeta['booking_mode'] ?? null) !== $prevMode) {
|
||||||
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع نوبتدهی پس از ثبت قابل تغییر نیست', 422, 'booking_mode');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private function serviceModeHasNoBookable(array $meta, \App\Doctor\Entity\Doctor $doctor): bool
|
private function serviceModeHasNoBookable(array $meta, \App\Doctor\Entity\Doctor $doctor): bool
|
||||||
{
|
{
|
||||||
return ($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) === WeeklySchedule::MODE_SERVICE
|
return ($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) === WeeklySchedule::MODE_SERVICE
|
||||||
@@ -70,6 +82,7 @@ class AppointmentSettingsController extends BaseController
|
|||||||
|
|
||||||
// Only one schedule per doctor — upsert
|
// Only one schedule per doctor — upsert
|
||||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||||
|
$prevMode = $schedule?->getStoredBookingMode();
|
||||||
if ($schedule !== null) {
|
if ($schedule !== null) {
|
||||||
$schedule->setSetting($data['schedule'] ?? []);
|
$schedule->setSetting($data['schedule'] ?? []);
|
||||||
} else {
|
} else {
|
||||||
@@ -80,6 +93,10 @@ class AppointmentSettingsController extends BaseController
|
|||||||
$schedule->setMeta($data['meta']);
|
$schedule->setMeta($data['meta']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (($err = $this->assertModeImmutable($prevMode, $schedule->getMeta())) !== null) {
|
||||||
|
return $err;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor)) {
|
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor)) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبتدهی سرویسی حداقل یک سرویس با «نمایش در نوبتدهی» لازم است', 422, 'booking_mode');
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبتدهی سرویسی حداقل یک سرویس با «نمایش در نوبتدهی» لازم است', 422, 'booking_mode');
|
||||||
}
|
}
|
||||||
@@ -107,6 +124,7 @@ class AppointmentSettingsController extends BaseController
|
|||||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$prevMode = $schedule->getStoredBookingMode();
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
if (isset($data['schedule'])) {
|
if (isset($data['schedule'])) {
|
||||||
if (($err = $this->validateSessionsHaveLocation($data['schedule'])) !== null) {
|
if (($err = $this->validateSessionsHaveLocation($data['schedule'])) !== null) {
|
||||||
@@ -118,6 +136,10 @@ class AppointmentSettingsController extends BaseController
|
|||||||
$schedule->setMeta($data['meta']);
|
$schedule->setMeta($data['meta']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (($err = $this->assertModeImmutable($prevMode, $schedule->getMeta())) !== null) {
|
||||||
|
return $err;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor())) {
|
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor())) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبتدهی سرویسی حداقل یک سرویس با «نمایش در نوبتدهی» لازم است', 422, 'booking_mode');
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبتدهی سرویسی حداقل یک سرویس با «نمایش در نوبتدهی» لازم است', 422, 'booking_mode');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,16 @@ class WeeklySchedule
|
|||||||
return array_merge(self::DEFAULT_META, $this->setting[self::META_KEY] ?? []);
|
return array_merge(self::DEFAULT_META, $this->setting[self::META_KEY] ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* booking_mode ذخیرهشده بهصورت خام (بدون merge پیشفرض). null یعنی هنوز
|
||||||
|
* صریحاً ثبت نشده — تا وقتی null است، انتخاب روش قابلتغییر است؛ پس از اولین
|
||||||
|
* ثبت، قفل میشود.
|
||||||
|
*/
|
||||||
|
public function getStoredBookingMode(): ?string
|
||||||
|
{
|
||||||
|
return $this->setting[self::META_KEY]['booking_mode'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
public function setMeta(array $meta): self
|
public function setMeta(array $meta): self
|
||||||
{
|
{
|
||||||
$current = $this->getMeta();
|
$current = $this->getMeta();
|
||||||
@@ -111,6 +121,8 @@ class WeeklySchedule
|
|||||||
'doctor_uuid' => $this->doctor->getUuid(),
|
'doctor_uuid' => $this->doctor->getUuid(),
|
||||||
'schedule' => $this->getDaySchedule(),
|
'schedule' => $this->getDaySchedule(),
|
||||||
'meta' => $this->getMeta(),
|
'meta' => $this->getMeta(),
|
||||||
|
// نوع نوبتدهی پس از اولین ثبت قفل میشود (پنل توگل را غیرفعال میکند).
|
||||||
|
'booking_mode_locked' => $this->getStoredBookingMode() !== null,
|
||||||
'created_at' => $this->createdAt,
|
'created_at' => $this->createdAt,
|
||||||
'updated_at' => $this->updatedAt,
|
'updated_at' => $this->updatedAt,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Appointment;
|
||||||
|
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نوع نوبتدهی (booking_mode) پس از اولین ثبت غیرقابلتغییر است.
|
||||||
|
*/
|
||||||
|
class BookingModeImmutableTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
private function makeDoctor(): array
|
||||||
|
{
|
||||||
|
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||||
|
$doctor = new Doctor($owner, 'دکتر قفل');
|
||||||
|
$this->em->persist($doctor);
|
||||||
|
$this->em->flush();
|
||||||
|
return [$owner, $doctor];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFirstSaveCommitsAndSecondSaveKeepsSameMode(): void
|
||||||
|
{
|
||||||
|
[$owner, $doctor] = $this->makeDoctor();
|
||||||
|
|
||||||
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [
|
||||||
|
'doctor_uuid' => $doctor->getUuid(),
|
||||||
|
'schedule' => [],
|
||||||
|
'meta' => ['booking_mode' => 'slot'],
|
||||||
|
]);
|
||||||
|
$this->assertSame(201, $this->responseCode());
|
||||||
|
|
||||||
|
// همان mode دوباره → مجاز
|
||||||
|
$this->authJson('PATCH', '/api/v1/appointment-settings/weekly-schedule/' . $doctor->getUuid(), $owner, [
|
||||||
|
'meta' => ['booking_mode' => 'slot'],
|
||||||
|
]);
|
||||||
|
$this->assertSame(200, $this->responseCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCannotChangeModeAfterCommit(): void
|
||||||
|
{
|
||||||
|
[$owner, $doctor] = $this->makeDoctor();
|
||||||
|
|
||||||
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [
|
||||||
|
'doctor_uuid' => $doctor->getUuid(),
|
||||||
|
'schedule' => [],
|
||||||
|
'meta' => ['booking_mode' => 'slot'],
|
||||||
|
]);
|
||||||
|
$this->assertSame(201, $this->responseCode());
|
||||||
|
|
||||||
|
// تلاش برای تغییر به سرویس → 422 قفل
|
||||||
|
$res = $this->authJson('PATCH', '/api/v1/appointment-settings/weekly-schedule/' . $doctor->getUuid(), $owner, [
|
||||||
|
'meta' => ['booking_mode' => 'service'],
|
||||||
|
]);
|
||||||
|
$this->assertSame(422, $this->responseCode());
|
||||||
|
$this->assertStringContainsString('قابل تغییر نیست', json_encode($res, JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user