# نوبتدهی منبعمحور در صفحهٔ نوبتها — تب هر منبع + رزرو سرویسی + حذف تایملاین منابع
> **وضعیت: انجام شد (۱۴۰۵/۰۵/۱۲).** کامیتها: `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 && (