feat(admin): resource booking mode with a readiness guard
Task 06's engine could only be switched on through the API, and nothing checked whether the environment was ready for it. Since the mode choice is irreversible, picking it with no resources defined would lock a clinic into a state where no appointment is ever computable. Backend now refuses that: resource mode requires at least one active resource, with a message that says what to define first. Same shape as the existing service-mode guard, applied on both save paths. The panel shows the same conditions as a ✓/✗ list before the choice is made, each unmet one linking to where it gets fixed — a 422 after an irreversible decision is the wrong place to learn about a prerequisite. Also adds the search step (minimum 5 minutes) and extends the existing mode cards to three rather than building a parallel component. No strategy picker: task 06 never built the strategies, and an empty menu reads worse than an absent one. GET /api/v1/service-items now returns has_segments, computed with one aggregate query for the whole list rather than one per service. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,9 @@ interface BookingMeta {
|
||||
online_booking_enabled: boolean;
|
||||
booking_window_value: number;
|
||||
booking_window_unit: BookingWindowUnit;
|
||||
booking_mode: 'slot' | 'service';
|
||||
booking_mode: 'slot' | 'service' | 'resource';
|
||||
/** گام جستجوی وقت در حالت منبعمحور — دقیقه */
|
||||
step_minutes?: number;
|
||||
buffer_minutes: number;
|
||||
}
|
||||
type BookingWindowUnit = 'day' | 'week' | 'month';
|
||||
@@ -68,6 +70,13 @@ const BOOKING_WINDOW_UNITS: { value: BookingWindowUnit; label: string }[] = [
|
||||
{ value: 'week', label: 'هفته' },
|
||||
{ value: 'month', label: 'ماه' },
|
||||
];
|
||||
/** برچسب فارسی هر حالت — یک جا، تا پیام تأیید و کارتها از هم واگرا نشوند. */
|
||||
const MODE_LABELS: Record<BookingMeta['booking_mode'], string> = {
|
||||
slot: 'نوبتدهی اسلاتی',
|
||||
service: 'نوبتدهی سرویسی',
|
||||
resource: 'نوبتدهی منبعمحور',
|
||||
};
|
||||
|
||||
const DEFAULT_BOOKING_META: BookingMeta = {
|
||||
online_booking_enabled: true,
|
||||
booking_window_value: 3,
|
||||
@@ -542,6 +551,32 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
|
||||
const [meta, setMeta] = useState<BookingMeta>(DEFAULT_BOOKING_META);
|
||||
// نوع نوبتدهی پس از اولین ثبت قفل میشود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول.
|
||||
const [modeLocked, setModeLocked] = useState(false);
|
||||
|
||||
/**
|
||||
* شرطهای آمادگیِ حالت منبعمحور.
|
||||
*
|
||||
* فقط وقتی پرسیده میشود که کاربر واقعاً همان حالت را انتخاب کرده باشد — دو
|
||||
* درخواست اضافه روی هر بازکردن تنظیمات، برای چیزی که اکثر کلینیکها انتخابش
|
||||
* نمیکنند، هزینهٔ بیدلیل است.
|
||||
*/
|
||||
const readinessQ = useQuery({
|
||||
queryKey: ['resource-mode-readiness'],
|
||||
queryFn: async () => {
|
||||
const [resources, services] = await Promise.all([
|
||||
api.get<ApiResponse<{ uuid: string }[]>>('/api/v1/resources'),
|
||||
api.get<ApiResponse<{ uuid: string; has_segments?: boolean }[]>>('/api/v1/service-items'),
|
||||
]);
|
||||
|
||||
return {
|
||||
hasResources: (resources.data ?? []).length > 0,
|
||||
hasSegments: (services.data ?? []).some((s) => s.has_segments === true),
|
||||
};
|
||||
},
|
||||
enabled: meta.booking_mode === 'resource' && !modeLocked,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const resourceReadiness = readinessQ.data ?? { hasResources: false, hasSegments: false };
|
||||
const [confirmMode, setConfirmMode] = useState(false);
|
||||
|
||||
const scheduleQ = useQuery({
|
||||
@@ -695,6 +730,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
|
||||
{([
|
||||
['slot', 'نوبتدهی اسلاتی', 'شما بازههای کاری و «مدت هر نوبت» را مشخص میکنید؛ سیستم بازه را به نوبتهای هماندازه تقسیم میکند. مناسب ویزیتهای با زمان یکسان.'],
|
||||
['service', 'نوبتدهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود؛ سیستم نزدیکترین زمان خالیِ کافی را پیشنهاد میدهد. مناسب خدمات با زمان متفاوت.'],
|
||||
['resource', 'نوبتدهی منبعمحور', 'نوبت به چند بخش تقسیم میشود و هر بخش منابع خودش (اتاق، اپراتور، دستگاه) را میگیرد؛ وقت آزاد از تقاطع تقویم همان منابع میآید. مناسب کلینیک زیبایی و لیزر.'],
|
||||
] as const).map(([val, lbl, desc]) => {
|
||||
const selected = meta.booking_mode === val;
|
||||
return (
|
||||
@@ -728,6 +764,58 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
|
||||
⚠️ توجه: نوع نوبتدهی پس از اولین ثبت <span className="font-medium">بههیچعنوان قابل تغییر نیست</span>. پیش از ذخیره با دقت انتخاب کنید.
|
||||
</p>
|
||||
)}
|
||||
{meta.booking_mode === 'resource' && !modeLocked && (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3 space-y-2">
|
||||
<span className="text-xs font-medium text-[var(--text)]">
|
||||
پیش از انتخاب این حالت، اینها باید آماده باشند:
|
||||
</span>
|
||||
{/* انتخاب برگشتناپذیر است، پس شرطها باید **قبل** از ثبت دیده شوند نه در قالب خطای ۴۲۲ بعدش. */}
|
||||
<ul className="space-y-1.5">
|
||||
{[
|
||||
['حداقل یک منبع فعال (اتاق، اپراتور یا دستگاه)', resourceReadiness.hasResources],
|
||||
['حداقل یک سرویس با بخشهای تعریفشده', resourceReadiness.hasSegments],
|
||||
].map(([label, ok]) => (
|
||||
<li key={String(label)} className="flex items-center gap-2 text-xs">
|
||||
<span className={ok ? 'text-[var(--success)]' : 'text-[var(--danger)]'}>
|
||||
{ok ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className="text-[var(--text-2)]">{label}</span>
|
||||
{!ok && (
|
||||
<a href="/admin/resources" className="text-[var(--primary)] underline">
|
||||
تعریف کنید
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!resourceReadiness.hasResources && (
|
||||
<p className="text-xs text-[var(--danger)]">
|
||||
بدون منبع فعال، هیچ وقتی محاسبه نمیشود و چون این انتخاب برگشتناپذیر است، محیط قفل میماند.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.booking_mode === 'resource' && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-2)]">گام جستجوی وقت</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
value={meta.step_minutes ?? 15}
|
||||
onChange={(e) =>
|
||||
setMeta(m => ({ ...m, step_minutes: Math.max(5, Number(digitsOnly(e.target.value)) || 5) }))
|
||||
}
|
||||
className="w-16 text-center text-sm rounded-lg border border-[var(--border)] bg-[var(--surface)] px-2 py-1.5 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
<span className="text-sm text-[var(--text-2)]">دقیقه</span>
|
||||
<p className="w-full text-xs text-[var(--text-3)] leading-relaxed">
|
||||
گام کوچکتر وقتهای بیشتری پیدا میکند ولی جستجو را کندتر میکند؛ پنج دقیقه کمترین مقدار مجاز است.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.booking_mode === 'service' ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -894,7 +982,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
|
||||
open={confirmMode}
|
||||
danger
|
||||
title="تأیید نوع نوبتدهی"
|
||||
message={`روش «${meta.booking_mode === 'service' ? 'نوبتدهی سرویسی' : 'نوبتدهی اسلاتی'}» را انتخاب کردهاید. این انتخاب پس از ثبت بههیچعنوان قابل تغییر نیست. ادامه میدهید؟`}
|
||||
message={`روش «${MODE_LABELS[meta.booking_mode]}» را انتخاب کردهاید. این انتخاب پس از ثبت بههیچعنوان قابل تغییر نیست. ادامه میدهید؟`}
|
||||
confirmLabel="ثبت و قفل"
|
||||
loading={saveMut.isPending}
|
||||
onConfirm={() => { setConfirmMode(false); saveMut.mutate(); }}
|
||||
|
||||
@@ -728,3 +728,44 @@ union, because an override changes working hours and working hours are themselve
|
||||
|
||||
Now takes `?clinic_uuid=`. Without it, only the doctor's `personal` addresses are returned; with it,
|
||||
only that clinic's addresses. The two sets are never merged (they used to be).
|
||||
|
||||
---
|
||||
|
||||
## حالت سوم: نوبتدهی منبعمحور
|
||||
|
||||
`meta.booking_mode` مقدار سوم `resource` را هم میپذیرد (تسک ۰۶). در این حالت نوبت به
|
||||
بخشها تقسیم میشود و وقت آزاد از تقاطع تقویم منابع میآید.
|
||||
|
||||
| فیلد `meta` | معنی |
|
||||
|---|---|
|
||||
| `booking_mode: "resource"` | حالت منبعمحور |
|
||||
| `step_minutes` | گام جستجوی وقت؛ پیشفرض ۱۵، کمینه ۵ |
|
||||
|
||||
### شرط آمادگی
|
||||
|
||||
انتخاب این حالت **برگشتناپذیر** است، پس پیش از ثبت سنجیده میشود: محیط باید حداقل یک
|
||||
**منبع فعال** داشته باشد.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [{
|
||||
"code": "ERR_VALIDATION_001",
|
||||
"message": "برای نوبتدهی منبعمحور حداقل یک منبع فعال لازم است؛ اول اتاق، اپراتور یا دستگاه تعریف کنید",
|
||||
"field": "booking_mode"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
بدون این نگهبان، کلینیک حالتی را برای همیشه قفل میکرد که هیچ وقتی در آن محاسبه نمیشود.
|
||||
|
||||
پنل همین شرطها را **پیش از** ثبت بهصورت ✓/✗ نشان میدهد تا کاربر به ۴۲۲ نخورد.
|
||||
|
||||
### `has_segments` روی فهرست سرویسها
|
||||
|
||||
`GET /api/v1/service-items` حالا فیلد `has_segments` هم میدهد — با یک کوئری تجمعی برای
|
||||
کل فهرست، نه یکی per سرویس. تنظیمات نوبتدهی از همین میفهمد آمادگیِ حالت منبعمحور
|
||||
هست یا نه.
|
||||
|
||||
جزئیات بخشها: [appointment-plan.md](appointment-plan.md) · منابع: [resource.md](resource.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# چکلیست — تسک ۰۶ (موتور جستجوی وقت چندمنبعی)
|
||||
|
||||
**وضعیت کلی:** ✅ بکاند، موتور، کارایی و مستندات تکمیل (UI انتخاب حالت ⏳) · **آخرین بازبینی:** —
|
||||
**وضعیت کلی:** ✅ بکاند، موتور، کارایی، مستندات و UI انتخاب حالت (جریان رزرو ⏳ با مقصد) · **آخرین بازبینی:** —
|
||||
|
||||
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
||||
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
|
||||
@@ -64,18 +64,20 @@
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | `AppointmentSettingsPage` انتخاب حالت `resource` + گام + استراتژی | ⏳ | UI انتخاب حالت `resource` ساخته نشد؛ حالت از API قابل تنظیم است. مقصد: پاس UI تنظیمات |
|
||||
| ۴.۲ | چکلیست پیش از ارتقا با علامت ✓/✗ هر شرط | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینتها کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI نوبتدهی چندمنبعی |
|
||||
| ۴.۳ | تیک «میدانم برگشتناپذیر است» اجباری | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینتها کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI نوبتدهی چندمنبعی |
|
||||
| ۴.۴ | جدول وقتها با ستون «منابع پیشنهادی» و `SearchableSelect` per منبع | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینتها کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI نوبتدهی چندمنبعی |
|
||||
| ۴.۵ | عوض کردن یک منبع → اعتبارسنجی **همان زمان**، نه کل لیست | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینتها کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI نوبتدهی چندمنبعی |
|
||||
| ۴.۶ | `reason` خالیبودن با پیام فارسی + دکمهٔ پیشنهادی | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینتها کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI نوبتدهی چندمنبعی |
|
||||
| ۴.۷ | `assignment` به بیمار نمایش داده **نمیشود** | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینتها کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI نوبتدهی چندمنبعی |
|
||||
| ۴.۸ | هیچ رنگ/شعاع hard-code | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینتها کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI نوبتدهی چندمنبعی |
|
||||
| ۴.۹ | دارکمود و حالت فشرده | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینتها کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI نوبتدهی چندمنبعی |
|
||||
| ۴.۱۰ | RTL و موبایل | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینتها کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI نوبتدهی چندمنبعی |
|
||||
| ۴.۱۱ | همهٔ رشتهها فارسی | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینتها کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI نوبتدهی چندمنبعی |
|
||||
| ۴.۱۲ | `ScheduleSection.tsx` موجود توسعه یافت، کامپوننت موازی ساخته نشد | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینتها کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI نوبتدهی چندمنبعی |
|
||||
| ۴.۱ | انتخاب حالت `resource` + گام | ⚠️ | حالت سوم و «گام جستجوی وقت» به `ScheduleSection` اضافه شد. انتخابگر **استراتژی** ساخته نشد چون استراتژیای در بکاند وجود ندارد (ردیف ۱.۲) — منوی خالی بدتر از نبودنش است |
|
||||
| ۴.۲ | چکلیست پیش از ارتقا با ✓/✗ | ✅ | ⭐ «حداقل یک منبع فعال» و «حداقل یک سرویس با بخش» با لینک اصلاح؛ همان شرطی که بکاند هم اعمال میکند |
|
||||
| ۴.۳ | تأیید برگشتناپذیری | ✅ | `ConfirmDialog` موجود، حالا با برچسب درست هر سه حالت |
|
||||
| ۴.۴ | جدول وقتها با ستون «منابع پیشنهادی» | ⏳ | جریان **رزرو** منبعمحور در پنل ساخته نشد؛ `POST /appointment-availability` و `assignment` از API کاملاند. مقصد: پاس جریان رزرو |
|
||||
| ۴.۵ | عوض کردن یک منبع → اعتبارسنجی همان زمان | ⏳ | با ۴.۴ یک بسته است |
|
||||
| ۴.۶ | `reason` خالیبودن با پیام فارسی | ⏳ | با ۴.۴ یک بسته است |
|
||||
| ۴.۷ | `assignment` به بیمار نمایش داده نمیشود | ⏳ | با ۴.۴ یک بسته است |
|
||||
| ۴.۸ | هیچ رنگ/شعاع hard-code | ✅ | فقط `var(--…)` |
|
||||
| ۴.۹ | دارکمود و حالت فشرده | ⚠️ | فقط توکنها؛ بازبینی چشمی انجام نشد |
|
||||
| ۴.۱۰ | RTL و موبایل | ✅ | کارتهای حالت روی موبایل تکستونه میشوند |
|
||||
| ۴.۱۱ | همهٔ رشتهها فارسی | ✅ | |
|
||||
| ۴.۱۲ | `ScheduleSection.tsx` توسعه یافت، کامپوننت موازی نه | ✅ | ⭐ همان فایل، سه کارت بهجای دو |
|
||||
| ۴.۱۳ | نگهبان بکاند برای آمادگی حالت | ✅ | ⭐ تازه اضافه شد: بدون منبع فعال، `422` — وگرنه انتخابِ برگشتناپذیر محیط را قفل میکرد |
|
||||
| ۴.۱۴ | تست نگهبان | ✅ | `ResourceModeReadinessTest` — سه تست |
|
||||
|
||||
## ۵. تست
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class AppointmentSettingsController extends BaseController
|
||||
private readonly DoctorAddressRepository $addressRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
|
||||
private readonly \App\Resource\Repository\ClinicResourceRepository $resourceRepo,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
|
||||
) {}
|
||||
@@ -90,6 +91,33 @@ class AppointmentSettingsController extends BaseController
|
||||
return $this->itemRepo->countBookableByEntity($type, $id) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* حالت منبعمحور بدون هیچ منبع فعال، هیچ نوبتی نمیسازد — و چون انتخابِ حالت
|
||||
* برگشتناپذیر است، محیط برای همیشه قفل میشد.
|
||||
*/
|
||||
private function resourceModeHasNoResources(array $meta, Doctor $doctor, ?Clinic $clinic): bool
|
||||
{
|
||||
if (($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) !== WeeklySchedule::MODE_RESOURCE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$type, $id] = $clinic !== null
|
||||
? [EntityContext::TYPE_CLINIC, $clinic->getId()]
|
||||
: [EntityContext::TYPE_DOCTOR, $doctor->getId()];
|
||||
|
||||
return $this->resourceRepo->countActiveForPair($type, (int) $id) === 0;
|
||||
}
|
||||
|
||||
private function noResourceError(): JsonResponse
|
||||
{
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
'برای نوبتدهی منبعمحور حداقل یک منبع فعال لازم است؛ اول اتاق، اپراتور یا دستگاه تعریف کنید',
|
||||
422,
|
||||
'booking_mode',
|
||||
);
|
||||
}
|
||||
|
||||
private function noBookableServiceError(?Clinic $clinic): JsonResponse
|
||||
{
|
||||
$message = $clinic !== null
|
||||
@@ -163,6 +191,10 @@ class AppointmentSettingsController extends BaseController
|
||||
return $err;
|
||||
}
|
||||
|
||||
if ($this->resourceModeHasNoResources($schedule->getMeta(), $doctor, $clinic)) {
|
||||
return $this->noResourceError();
|
||||
}
|
||||
|
||||
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor, $clinic)) {
|
||||
return $this->noBookableServiceError($clinic);
|
||||
}
|
||||
@@ -213,6 +245,10 @@ class AppointmentSettingsController extends BaseController
|
||||
return $err;
|
||||
}
|
||||
|
||||
if ($this->resourceModeHasNoResources($schedule->getMeta(), $schedule->getDoctor(), $clinic)) {
|
||||
return $this->noResourceError();
|
||||
}
|
||||
|
||||
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor(), $clinic)) {
|
||||
return $this->noBookableServiceError($clinic);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,34 @@ class SegmentTemplateRepository extends ServiceEntityRepository
|
||||
return $byService;
|
||||
}
|
||||
|
||||
/**
|
||||
* کدام سرویسها اصلاً بخش تعریفشده دارند — یک کوئری، نه یکی per سرویس.
|
||||
*
|
||||
* @param int[] $serviceIds
|
||||
* @return array<int, bool> شناسهٔ سرویس => دارد/ندارد
|
||||
*/
|
||||
public function hasSegmentsMap(array $serviceIds): array
|
||||
{
|
||||
if ($serviceIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('s')
|
||||
->select('IDENTITY(s.service) AS service_id', 'COUNT(s.id) AS total')
|
||||
->where('IDENTITY(s.service) IN (:ids)')
|
||||
->setParameter('ids', $serviceIds)
|
||||
->groupBy('service_id')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int) $row['service_id']] = ((int) $row['total']) > 0;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
public function deleteForService(ServiceItem $service): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('s')
|
||||
|
||||
@@ -46,6 +46,7 @@ class ClinicServiceController extends BaseController
|
||||
private readonly TariffRepository $tariffRepo,
|
||||
private readonly TariffService $tariffService,
|
||||
private readonly InventoryPackageRepository $packageRepo,
|
||||
private readonly \App\Appointment\Plan\Repository\SegmentTemplateRepository $segmentRepo,
|
||||
private readonly InventoryItemRepository $inventoryItemRepo,
|
||||
private readonly ServiceItemAuditService $auditService,
|
||||
private readonly ServiceItemAuditLogRepository $auditLogRepo,
|
||||
@@ -76,11 +77,18 @@ class ClinicServiceController extends BaseController
|
||||
array_map(fn(ServiceItem $i) => $i->getInventoryPackageId(), $items)
|
||||
);
|
||||
|
||||
return array_map(function (ServiceItem $i) use ($packages) {
|
||||
// یک کوئری برای همهٔ سرویسها؛ تنظیمات نوبتدهی از همین میفهمد آمادگیِ حالت
|
||||
// منبعمحور هست یا نه، بدون اینکه per سرویس بپرسد.
|
||||
$hasSegments = $this->segmentRepo->hasSegmentsMap(
|
||||
array_map(fn(ServiceItem $i) => (int) $i->getId(), $items)
|
||||
);
|
||||
|
||||
return array_map(function (ServiceItem $i) use ($packages, $hasSegments) {
|
||||
$row = $i->toArray();
|
||||
$package = $packages[$i->getInventoryPackageId()] ?? null;
|
||||
$row['inventory_package_uuid'] = $package?->getUuid();
|
||||
$row['inventory_package_title'] = $package?->getTitle();
|
||||
$row['has_segments'] = $hasSegments[(int) $i->getId()] ?? false;
|
||||
|
||||
return $row;
|
||||
}, $items);
|
||||
|
||||
@@ -62,6 +62,20 @@ class ClinicResourceRepository extends ServiceEntityRepository
|
||||
return $qb->orderBy('r.name', 'ASC')->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/** منابع فعال یک محیط — شرط آمادگیِ حالت نوبتدهی منبعمحور. */
|
||||
public function countActiveForPair(string $entityType, int $entityId): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('r')
|
||||
->select('COUNT(r.id)')
|
||||
->where('r.entityType = :type')
|
||||
->andWhere('r.entityId = :id')
|
||||
->andWhere('r.active = true')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* همهٔ منابع فعال یک شعبه — ورودی گزارش بهرهوری.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* حالت نوبتدهی منبعمحور بدون منبع فعال، هیچ وقتی نمیسازد — و چون انتخابِ حالت
|
||||
* برگشتناپذیر است، محیط برای همیشه قفل میشد. پس شرطش پیش از ثبت سنجیده میشود.
|
||||
*/
|
||||
class ResourceModeReadinessTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: Doctor} */
|
||||
private function makeDoctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر منبع');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
private function giveResource(Doctor $doctor): void
|
||||
{
|
||||
$address = DoctorAddress::forDoctor($doctor);
|
||||
$address->setName('مطب');
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
$type = new ResourceType('doctor', (int) $doctor->getId(), 'room', 'اتاق');
|
||||
$this->em->persist($type);
|
||||
$this->em->flush();
|
||||
|
||||
$resource = new ClinicResource($address, $type, 'اتاق ۱');
|
||||
$this->em->persist($resource);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function testResourceModeIsRejectedWithoutAnyResource(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->makeDoctor();
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'schedule' => [],
|
||||
'meta' => ['booking_mode' => 'resource'],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertStringContainsString('حداقل یک منبع فعال', json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
public function testResourceModeIsAcceptedOnceAResourceExists(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->makeDoctor();
|
||||
$this->giveResource($doctor);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'schedule' => [],
|
||||
'meta' => ['booking_mode' => 'resource'],
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/** حالت اسلاتی هیچ شرط منبعی ندارد؛ نگهبان نباید مسیر موجود را بشکند. */
|
||||
public function testSlotModeStillNeedsNothing(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->makeDoctor();
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'schedule' => [],
|
||||
'meta' => ['booking_mode' => 'slot'],
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user