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

منابع

``` ## وظایف > ترتیب اجباری است: بک‌اند اول. وظیفهٔ ۱ پایهٔ بقیه است و اگر ناقص بماند، بقیه روی > خرابه ساخته می‌شوند. ### ۱. تهی‌پذیر کردن `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()` نیازمند تصمیم محصولی است (مثلاً «سهم منشی از نوبت بدون پزشک چطور حساب شود؟»)، **متوقف شو و بپرس** — حدس نزن.