Files
clinicpro/.claude/prompt/resource-first-appointments.md
T
hamedandClaude Opus 5 b03ae95bf8 docs(prompt): record what actually happened in the resource-first task
The prompt was wrong three times and the file now says so up front: the
doctor-less appointment path was built and fully reverted, the getDoctor()
blast radius was overstated, and the booking engine it asked to build already
existed. Also records two tooling traps found on the way — phpstan runs at
level 5 here so it never checks nullability, and migrations:diff missed the
nullable change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:44:54 +03:30

369 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# نوبت‌دهی منبع‌محور در صفحهٔ نوبت‌ها — تب هر منبع + رزرو سرویسی + حذف تایم‌لاین منابع
> **وضعیت: انجام شد (۱۴۰۵/۰۵/۱۲).** کامیت‌ها: `3e3a2482` (تب منابع + فیلتر)،
> `fd27ceef` (مودال رزرو + حذف تایم‌لاین).
>
> ## این پرامپت در اجرا سه‌بار غلط از آب درآمد — چیزی که واقعاً شد:
>
> **۱. «نوبت بدون پزشک» پیاده شد و بعد کاملاً برگردانده شد.** `doctor` تهی‌پذیر شد و
> migration اجرا شد، ولی وسط کار معلوم شد مدل مجوز منشی روی سه‌تایی
> `(منشی، کلینیک، پزشک)` بنا شده و `DoctorSecretary.doctor` تهی‌پذیر نیست — یعنی برای
> نوبتِ بدون پزشک **هیچ ردیف مجوزی وجود ندارد**. تصمیم کاربر: منبع پزشکِ مسئول داشته
> باشد. migration رول‌بک و کد `git checkout` شد.
>
> **۲. «۷۳ فراخوانی getDoctor()» بیش‌برآورد بود.** بیشترشان روی entityهای دیگرند
> (`WeeklySchedule`، `Holiday`، `Rate`، `DoctorAddress`). عدد واقعی روی `Appointment`
> حدود ۲۵ بود، و diff سطح ۸ دقیقاً **۳۳ نقطهٔ جدید در ۱۶ فایل** داد.
>
> **۳. مهم‌ترین: کل موتور از قبل ساخته شده بود و این پرامپت از وجودش بی‌خبر بود.**
> `POST /api/v1/appointment-availability` (با `assignment` per اسلات)، `appointment-hold`،
> `appointment-confirm`، هوک `useResourceBooking.ts`، و حتی یک صفحهٔ کامل
> `ResourceBookingPage.tsx` روی `/admin/resource-booking`. `confirm` هم از قبل
> `doctor_uuid` می‌گیرد. پس **هیچ تغییر بک‌اندی برای رزرو لازم نبود** و تنها افزودنی
> بک‌اند، فیلتر `resource_uuid` روی فهرست نوبت‌ها شد.
>
> ## دو تلهٔ ابزاری که باید بدانی
>
> - **`phpstan` این پروژه تهی‌پذیری را چک نمی‌کند.** بررسی «صدا زدن متد روی تهی» سطح ۸
> است و `phpstan.neon` روی سطح ۵. با یک خطای عمدی تست شد: `[OK] No errors`. برای این
> جنس تغییر، گیت واقعی PHPUnit است، نه phpstan.
> - **`doctrine:migrations:diff` تغییر nullable را ندید** و به‌جایش یک migration بی‌ربط
> `messenger_messages` ساخت. migration دستی نوشته شد.
>
> ## معماری‌ای که ماند
>
> - رزرو منبع از `hold → confirm` می‌رود، نه `POST /api/v1/appointment`. دلیلش حیاتی است:
> فقط `HoldService` رکورد `resource_occupancy_buckets` می‌نویسد و قید یکتای
> `uniq_bucket_resource_seat` تداخل را غیرممکن می‌کند. `book()` هیچ occupancy نمی‌نویسد،
> پس رزرو منبع از آن مسیر بی‌گارد است.
> - موتور خدمت‌محور جواب می‌دهد؛ مودال نتیجه را به اسلات‌هایی تنگ می‌کند که `assignment`شان
> همین منبع را دارد و آن نقش را به منبع قفل می‌کند.
>
> بقیهٔ این فایل متن اولیهٔ پرامپت است و برای تاریخچه نگه داشته شده — **به‌عنوان دستورالعمل
> اجرا معتبر نیست.**
---
## زمینه
صفحهٔ `/admin/appointments` امروز کاملاً پزشک‌محور است: تب‌ها فقط پزشک‌اند
(`AppointmentsPage.tsx:841`)، و منابع فقط یک نوار **فقط‌خواندنی** زیر زمان‌بندی دارند
(`AppointmentsPage.tsx:892-903`) که هیچ اقدامی روی آن ممکن نیست.
در مدل Resource-First، منبع واحد ظرفیت است: «لیزر CO2» و «لیزر NdYAG» سرویس‌های خودشان
(`ResourceServiceOffering`)، تقویم خودشان (`ResourceCalendar`) و استثناهای خودشان را دارند.
ولی کاربر نمی‌تواند برای آن‌ها نوبت ثبت کند، چون کل مسیر رزرو از پزشک عبور می‌کند.
## مشکل / هدف
**هدف:** هر منبع مثل پزشک تب خودش را داشته باشد؛ «افزودن نوبت» روی تب یک منبع، مودال
نوبت‌دهی **سرویسی** را برای همان منبع باز کند (لیست سرویس‌های همان منبع → انتخاب →
زمان خالی → بیمار → ثبت)؛ و نوار «منابع» زیر زمان‌بندی حذف شود.
**سه مانع واقعی در کد امروز:**
۱. **نوبت بدون پزشک ممکن نیست.** `Appointment::$doctor` با `nullable: false` تعریف شده
(`Appointment.php:100-101`) و سازندهٔ entity هم `Doctor` می‌گیرد (`Appointment.php:256`).
`book()` بدون `doctor_uuid` خطای ۴۲۲ می‌دهد (`AppointmentController.php:484`) و منبع فقط
وقتی پزشک پیدا می‌کند که خودش پزشک باشد (`AppointmentController.php:471-473`). یعنی
دستگاهِ بدون پزشک اصلاً قابل رزرو نیست.
۲. **اسلات سرویسی فقط پزشک‌محور است.** `GET /api/v1/appointment-service-slots` پزشک
می‌خواهد و شرط می‌کند `booking_mode` همان پزشک `service` باشد
(`AppointmentController.php:193-209`). منبع `WeeklySchedule` ندارد.
۳. تایم‌لاین منابع باید حذف شود.
**تصمیم گرفته‌شده (توسط کاربر):** مسیر «نوبت بدون پزشک» — `doctor` تهی‌پذیر شود.
### چرا این تصمیم آن‌قدر که به‌نظر می‌رسد پرریسک نیست (شواهد از کد)
- `Appointment::$resource` **از قبل وجود دارد** و تهی‌پذیر است (`Appointment.php:174-176`).
- تداخل منابع **از قبل در سطح دیتابیس** تضمین شده، نه در کد: `OccupancyBucket` با
`UniqueConstraint('uniq_bucket_resource_seat', ['resource_id','bucket_at','seat'])`
(`OccupancyBucket.php:22`). پس `activeSlotKey` مسئول تداخل **منبع** نیست.
- `activeSlotKey` فقط دوباره‌رزروی **همان پزشک** را می‌گیرد:
`sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)` (`Appointment.php:283`).
وقتی پزشکی وجود ندارد، «دوباره‌رزروی پزشک» بی‌معناست و `null` بودنِ کلید معنای درستی
است — نه یک حفرهٔ ایمنی.
**ریسک واقعی و باقی‌مانده:** ۷۳ فراخوانی `getDoctor()` در ۲۱+ فایل `src/` که همه امروز
`Doctor` غیرتهی فرض می‌کنند. این بخش سنگین کار است و باید تک‌تک بررسی شود.
## معیار پذیرش
-**موفق:** با توکن مالک کلینیک، `POST /api/v1/appointment` با بدنهٔ
`{resource_uuid, service_item_uuids[], slot_start, patient_national_code, patient_gender}`
و **بدون** `doctor_uuid`، برای منبعِ دستگاهی (`subject_kind = null`) → `200` و نوبت ذخیره
می‌شود با `doctor_id = NULL` و `resource_id` پرشده. در UI: تب «لیزر CO2» → «افزودن نوبت» →
انتخاب سرویس → انتخاب زمان → ثبت → نوبت در فهرست همان تب دیده می‌شود.
-**موفق:** `GET /api/v1/appointment-service-slots?resource_uuid=…&date=…&service_item_uuids[]=…`
`200` با همان شکل پاسخِ حالت پزشک (`start_times[]`, `total_duration_minutes`).
-**خطا:** رزرو منبعی که آن سرویس را ارائه نمی‌دهد → `422` با پیام
«این منبع این سرویس را ارائه نمی‌دهد» و `field = resource_uuid` (این بررسی از قبل در
`AppointmentController.php:533-540` هست و باید در مسیر بدون‌پزشک هم اجرا شود).
-**خطا:** رزرو منبعِ محیط دیگر → `422` «منبع یافت نشد» با `field = resource_uuid`.
-**خطا:** نه `doctor_uuid` و نه `resource_uuid``422` با envelope خطا.
- ⚠️ **مرزی:** دو رزروِ هم‌زمان روی یک منبع با ظرفیت ۱ در یک بازه → دومی باید با
`409` رد شود (از قید یکتای `uniq_bucket_resource_seat`، نه از بررسی در کد).
- ⚠️ **مرزی:** منبعی که آن روز شیفت ندارد → `start_times` خالی و پیام «زمان خالی کافی
نیست»، نه خطای ۵۰۰.
- ⚠️ **مرزی:** نوبت‌های قدیمیِ دارای پزشک باید بدون تغییر کار کنند (هم API، هم پنل، هم
سایت عمومی) — `doctor` تهی‌پذیر شده، حذف نشده.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Appointment/Entity/Appointment.php` | `doctor` تهی‌پذیر، `activeSlotKey`، `toArray()` |
| `src/Appointment/Controller/AppointmentController.php` | `book()` و `serviceSlots()` |
| `src/Appointment/Booking/Entity/OccupancyBucket.php` | تضمین یکتاییِ اشغال منبع (فقط مرجع — تغییر نمی‌کند) |
| `src/Resource/Entity/ResourceServiceOffering.php` | سرویس‌ها و مدت مؤثرِ هر منبع |
| `src/Resource/Entity/ResourceCalendar.php` | شیفت هفتگی منبع (مبنای اسلات) |
| `migrations/` | migration تهی‌پذیر کردن `appointments.doctor_id` |
| `assets/admin/pages/AppointmentsPage.tsx` | تب‌ها، مودال ثبت، حذف بخش منابع |
| `assets/admin/components/appointments/DoctorTabs.tsx` | تب‌ها (باید عمومی شود) |
| `assets/admin/components/appointments/ServiceSlotPicker.tsx` | انتخاب سرویس/زمان (باید منبع را هم بپذیرد) |
| `assets/admin/components/appointments/ResourceTimeline.tsx` + `.test.tsx` | **حذف** |
| `assets/admin/hooks/useResourceTimeline.ts` | **حذف** اگر مصرف‌کنندهٔ دیگری ندارد |
| `docs/api/appointment-booking.md`, `docs/api/appointment.md`, `docs/api/resource.md` | مستندات |
## وضعیت فعلی
`Appointment.php` — پزشک اجباری و کلید یکتا بر پایهٔ پزشک:
```php
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private Doctor $doctor;
public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd)
private function refreshActiveSlotKey(): void
{
$this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
}
public function getDoctor(): Doctor { return $this->doctor; }
```
`AppointmentController::book()` — بدون پزشک رد می‌شود:
```php
if ($doctorUuid === '' && $resource->subject() instanceof \App\Doctor\Entity\Doctor) {
$doctorUuid = $resource->subject()->getUuid();
}
if ($doctorUuid === '' || $slotStart <= 0 || (!$hasServices && $slotEnd <= $slotStart)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid یا resource_uuid به‌همراه slot_start الزامی است', 422);
}
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
```
`AppointmentController::serviceSlots()` — پزشک‌محور و مقیّد به `booking_mode` پزشک:
```php
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
$mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
if ($mode !== WeeklySchedule::MODE_SERVICE) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبت‌دهی سرویسی نیست', 422);
}
```
`AppointmentsPage.tsx` — تب فقط پزشک، و بخش منابع که باید حذف شود:
```tsx
{showDoctorTabs && (
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} showAll={isAdmin} />
)}
{/* منابعِ قابل رزرو، زیر همان روز — یک نوبت می‌تواند هم‌زمان اتاق و
دستگاه را بگیرد و آن است که ظرفیت را تمام می‌کند. */}
<div style={{ marginTop: 'var(--gap)', paddingTop: 'var(--gap)', borderTop: '1px solid var(--border)' }}>
<h2 className="section-title" style={{ margin: '0 0 12px', fontSize: 15 }}>منابع</h2>
<ResourceTimeline
lanes={resourceDay?.resources ?? []}
dayStart={resourceDay?.date ?? 0}
loading={resourceTimelineLoading}
error={resourceTimelineError}
/>
</div>
```
## وظایف
> ترتیب اجباری است: بک‌اند اول. وظیفهٔ ۱ پایهٔ بقیه است و اگر ناقص بماند، بقیه روی
> خرابه ساخته می‌شوند.
### ۱. تهی‌پذیر کردن `Appointment::$doctor`
```php
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
private ?Doctor $doctor = null;
public function __construct(?Doctor $doctor, User $user, int $slotStart, int $slotEnd)
public function getDoctor(): ?Doctor { return $this->doctor; }
/**
* کلید یکتای اسلات فقط دوباره‌رزروی «همان پزشک» را می‌گیرد. نوبتِ منبع‌محورِ بدون
* پزشک چنین تداخلی ندارد؛ تداخلِ خودِ منبع را قید یکتای
* `uniq_bucket_resource_seat` روی `resource_occupancy_buckets` می‌گیرد.
*/
private function refreshActiveSlotKey(): void
{
$this->activeSlotKey = $this->doctor !== null
&& !$this->isReserve
&& in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
}
```
سپس **همهٔ ۷۳ فراخوانی `getDoctor()`** را بررسی کن:
```bash
ddev exec grep -rn "getDoctor()" src/ | wc -l # باید ۷۳ باشد
ddev exec php vendor/bin/phpstan analyse # سطح ۵ — تهی‌پذیرهای بررسی‌نشده را می‌گیرد
```
قاعده: هر جا پزشک واقعاً لازم است (تعرفه، برنامهٔ هفتگی، پیامک پزشک) → `null` را با
خطای معنادار رد کن؛ هر جا فقط نمایشی است (`toArray()` خط ۴۹۹ و ۵۰۳) → مقدار تهی برگردان
نه استثنا.
**نحوه تست:** `ddev exec php bin/phpunit tests/Appointment/` باید کامل سبز بماند (رگرسیون
مسیر پزشک‌دار). به‌علاوه `ddev exec php vendor/bin/phpstan analyse` بدون خطای تهی‌پذیری.
### ۲. Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
**نحوه تست:** بعد از migrate، `DESCRIBE appointments;` باید `doctor_id` را `YES` (nullable)
نشان دهد، و ردیف‌های موجود دست‌نخورده بمانند:
```bash
ddev exec mysql -e "SELECT COUNT(*) FROM appointments WHERE doctor_id IS NULL;" # قبل از فیچر: 0
```
### ۳. اسلات سرویسیِ منبع‌محور
`GET /api/v1/appointment-service-slots` را طوری توسعه بده که **یا** `doctor_uuid` بگیرد
**یا** `resource_uuid` — با همان شکل پاسخ.
**الگو: Strategy.** یک interface مثل `ServiceSlotSource` با دو پیاده‌سازی
`DoctorServiceSlotSource` و `ResourceServiceSlotSource`. دلیل انتخاب (guidelines §۵):
همین حالا **دو** پیاده‌سازی واقعی وجود دارد — پزشک از `WeeklySchedule` و اسلات‌های پزشک
می‌آید، منبع از `ResourceCalendar` + `ResourceException` + `resource_blocks` + اشغال.
بدون Strategy، این می‌شد یک `if` روی نوعِ شناسه داخل کنترلر که با هر منبعِ جدید رشد می‌کند.
نکات محاسبه برای منبع:
- مدت هر سرویس از `ResourceServiceOffering` همان منبع (مدت مؤثر)، نه از پیش‌فرض سرویس.
- شرط `booking_mode === MODE_SERVICE` برای منبع **اعمال نشود** — آن قید مالِ برنامهٔ
هفتگی پزشک است و منبع اصلاً `WeeklySchedule` ندارد.
- بازه‌های اشغال از همان مسیری بیاید که تایم‌لاین منبع می‌خواند
(`ResourceOccupancyRepository`)، نه یک کوئری موازیِ جدید.
**نحوه تست:**
```bash
TOKEN=$(curl -sk -X POST https://clinic-pro.ddev.site/api/v1/user/login \
-H 'Content-Type: application/json' \
-d '{"mobile_number":"0912000201","password":"QaTest@1234"}' | jq -r .access_token)
curl -sk "https://clinic-pro.ddev.site/api/v1/appointment-service-slots?resource_uuid=<UUID>&date=$(date +%F)&management=1&service_item_uuids[]=<SERVICE_UUID>" \
-H "Authorization: Bearer $TOKEN" | jq
# انتظار: success:true و start_times آرایه‌ای از {start,end,start_time}
```
### ۴. رزرو بدون پزشک در `book()`
- اگر `resource_uuid` آمده و منبع پزشک‌پشت ندارد، دیگر به `doctor_uuid` اصرار نکن.
- شرط خطا به این تغییر کند: «حداقل یکی از `doctor_uuid` یا `resource_uuid` لازم است».
- بررسی «این منبع این سرویس را ارائه نمی‌دهد» (`AppointmentController.php:533-540`) باید
در مسیر بدون‌پزشک هم اجرا شود — امروز داخل بلوکی است که به `$duration` وابسته است.
- بررسی مالکیت محیط منبع (`EntityContext::forBooking`) وقتی پزشک نداریم باید از **محیط
کاربر جاری** بیاید، نه از پزشک.
**نحوه تست:** رزرو واقعی روی یک منبع دستگاهی (بدون `doctor_uuid`) → ۲۰۰؛ سپس همان بازه
دوباره → ۴۰۹. هر دو با `curl` و توکن بالا. و
`ddev exec mysql -e "SELECT doctor_id, resource_id FROM appointments ORDER BY id DESC LIMIT 1;"`.
### ۵. تب منابع در کنار تب پزشکان
`DoctorTabs.tsx` امروز فقط `{uuid, name}` می‌گیرد و رفتارش کاملاً عمومی است — **همان را
عمومی کن** (مثلاً `EntityTabs`) به‌جای ساختن کامپوننت دوم؛ اسم فعلی‌اش تنها چیزِ پزشکیِ
آن است (guidelines §۵: اول بگرد، بعد توسعه بده، در آخر بساز).
تب انتخاب‌شده باید در URL بنشیند (`useUrlState`) وگرنه «بازگشت» نما را می‌پراند —
همان قاعده‌ای که در `CLAUDE.md` برای وضعیت لیست آمده. پیشنهاد: `?tab=doctor:<uuid>` و
`?tab=resource:<uuid>` تا یک کلید هر دو نوع را بگیرد.
**نحوه تست:** `npx vitest run assets/admin/pages/AppointmentsPage.test.tsx` + تست جدید:
کلیک روی تب یک منبع → فهرست همان منبع؛ رفرش صفحه → همان تب فعال بماند.
### ۶. مودال ثبت نوبت برای منبع
`ServiceSlotPicker` باید به‌جای `doctorUuid` اجباری، یکی از این دو را بگیرد. سرویس‌ها
برای منبع از `GET /api/v1/resource/{uuid}/services` می‌آید (نه از
`appointment-booking-services/{doctorUuid}`).
ترتیب مودال دقیقاً مثل عکس مرجع و مثل حالت پزشک بماند:
سرویس‌های منبع → سرویس‌های انتخاب‌شده (با مدت قابل ویرایش) → زمان‌های خالی → جستجوی
بیمار → هزینه → ثبت.
**نحوه تست:** `npx vitest run assets/admin/components/appointments/ServiceSlotPicker.test.tsx`
(تست موجود نباید بشکند) + تست جدید برای حالت منبع. سناریوی UI: تب «لیزر CO2» → افزودن
نوبت → یک سرویس → یک زمان → کد ملی بیمار → ثبت → نوبت در فهرست ظاهر شود.
### ۷. حذف تایم‌لاین منابع
- بلوک `<h2>منابع</h2> + <ResourceTimeline …>` از `AppointmentsPage.tsx` حذف شود.
- `ResourceTimeline.tsx` و `ResourceTimeline.test.tsx` حذف شوند.
- `useResourceTimeline.ts` **فقط اگر** مصرف‌کنندهٔ دیگری ندارد حذف شود:
```bash
grep -rn "useResourceTimeline" assets/admin/
```
- `GET /api/v1/resources/timeline` در بک‌اند **دست‌نخورده** بماند (اندپوینت خودش
مشکلی ندارد؛ فقط این مصرف‌کننده حذف می‌شود). اگر بعد از حذف هیچ کلاینتی ندارد، در
گزارش پایانی ذکر کن تا کاربر تصمیم بگیرد.
**نحوه تست:** `npx vitest run` کامل سبز؛ و اسکرین‌شات صفحه بعد از
`ddev exec yarn dev` که دیگر بخش «منابع» زیر زمان‌بندی ندارد.
### ۸. مستندات
- `docs/api/appointment-booking.md`: `resource_uuid` بدون `doctor_uuid`، و اینکه
`doctor` در پاسخ می‌تواند `null` باشد.
- `docs/api/appointment.md`: پارامتر `resource_uuid` در `appointment-service-slots` با
JSON واقعیِ اجرا (نه دست‌ساز).
- `docs/api/resource.md`: اشاره به اینکه سرویس‌های منبع مبنای رزرو منبع‌محورند.
## نکات مهم
- **این تغییر cross-repo است.** `nobat724_front` و `clinic-pro-tauri` هر دو کلاینت همین
API‌اند و `appointment.doctor` را غیرتهی فرض می‌کنند. تغییر قرارداد در build آن‌ها خطا
**نمی‌دهد** و در رانتایم می‌شکند (guidelines §۳). بعد از وظیفهٔ ۱، مصرف واقعی را در
`nobat724_front/services/response.js` و صفحات نوبت دستی دنبال کن و نتیجه را گزارش بده.
- `activeSlotKey` را برای نوبت بدون پزشک `null` بگذار — این حفره نیست؛ تداخل منبع را
`uniq_bucket_resource_seat` می‌گیرد. اگر وسوسه شدی کلید را `r<id>:<slot>` کنی، اول
بررسی کن که با ظرفیت >۱ منبع نمی‌شکند (منبع سه‌ظرفیتی سه رزرو هم‌زمان دارد).
- Controller نازک بماند: منطق انتخاب اسلات در Service/Strategy، کوئری در Repository،
تزریق با constructor injection.
- خطاها با `AppException(ErrorCodes::ERR_XXX)` و پیام فارسی؛ پاسخ‌ها با
`$this->success()` / `$this->error()`.
- تاریخ‌ها Unix timestamp صحیح؛ رشته‌های UI فارسی و تاریخ‌های نمایشی جلالی.
- اگر وسط کار معلوم شد یکی از ۷۳ فراخوانی `getDoctor()` نیازمند تصمیم محصولی است
(مثلاً «سهم منشی از نوبت بدون پزشک چطور حساب شود؟»)، **متوقف شو و بپرس** — حدس نزن.