# نوبتدهی منبعمحور در صفحهٔ نوبتها — تب هر منبع + رزرو سرویسی + حذف تایملاین منابع
## زمینه
صفحهٔ `/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 && (
)}
…
{/* منابعِ قابل رزرو، زیر همان روز — یک نوبت میتواند همزمان اتاق و
دستگاه را بگیرد و آن است که ظرفیت را تمام میکند. */}
منابع
```
## وظایف
> ترتیب اجباری است: بکاند اول. وظیفهٔ ۱ پایهٔ بقیه است و اگر ناقص بماند، بقیه روی
> خرابه ساخته میشوند.
### ۱. تهیپذیر کردن `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=&date=$(date +%F)&management=1&service_item_uuids[]=" \
-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:` و
`?tab=resource:` تا یک کلید هر دو نوع را بگیرد.
**نحوه تست:** `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» → افزودن
نوبت → یک سرویس → یک زمان → کد ملی بیمار → ثبت → نوبت در فهرست ظاهر شود.
### ۷. حذف تایملاین منابع
- بلوک `منابع
+ ` از `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:` کنی، اول
بررسی کن که با ظرفیت >۱ منبع نمیشکند (منبع سهظرفیتی سه رزرو همزمان دارد).
- Controller نازک بماند: منطق انتخاب اسلات در Service/Strategy، کوئری در Repository،
تزریق با constructor injection.
- خطاها با `AppException(ErrorCodes::ERR_XXX)` و پیام فارسی؛ پاسخها با
`$this->success()` / `$this->error()`.
- تاریخها Unix timestamp صحیح؛ رشتههای UI فارسی و تاریخهای نمایشی جلالی.
- اگر وسط کار معلوم شد یکی از ۷۳ فراخوانی `getDoctor()` نیازمند تصمیم محصولی است
(مثلاً «سهم منشی از نوبت بدون پزشک چطور حساب شود؟»)، **متوقف شو و بپرس** — حدس نزن.