# پاک‌سازی نوبت‌دهی سرویسی: حذف شعبه، تعطیلات سراسری، تنظیمات منابع، تایم‌لاین یکپارچه ## پروژه `clinicpro` (بک‌اند Symfony + پنل ادمین React). یک وظیفه **cross-repo** است و علامت‌گذاری شده: حذف شعبه به `nobat724_front` می‌رسد (`nobat724_front/services/response.js:165` اندپوینت `doctor-address/{id}` را صدا می‌زند). ## زمینه مدل Resource-First پیاده شده است: منبع، سرویس، گزینهٔ سرویس، دستهٔ سراسری. حالا مالک محصول می‌خواهد لایه‌هایی که در این مدل مصرف‌کننده ندارند برداشته شوند (شعبه/اتاق، گروه‌های انتخاب، تب بخش‌های نوبت)، تعطیلات یک بار سراسری تعریف شود، و تنظیمات نوبت‌دهی منابع هم‌شکل پزشکان شود. ## مشکل / هدف پنج تغییر مستقل، به همین ترتیب: 1. **حذف شعبه و اتاق** از محصول — بدون تغییر منطق نوبت‌دهی. 2. **تعطیلات سراسری**: مدیر سیستم تعطیلات رسمی سال را ثبت کند؛ هر محیط بتواند غیرفعالشان کند؛ پزشک و منبع تعطیلی اختصاصی خودشان را داشته باشند. 3. **تب منابع** در `/admin/settings/appointment-settings`، هم‌شکل تب پزشک. 4. **حذف تب‌های «گروه‌ها و آیتم‌ها» و «بخش‌های نوبت»** از صفحهٔ سرویس. 5. **تایم‌لاین یکپارچه**: پزشکانِ سرویسی و منابعِ قابل‌رزرو در یک نما، با ظرفیت و وقت آزاد. --- ## ⚠️ نقد پرامپت — قبل از شروع بخوان خواستهٔ «همه‌چیز شعبه حذف شود، شامل `DoctorAddress`» با «منطق نوبت‌دهی بدون تغییر بماند» قابل جمع نیست. شواهد از خود کد و دیتابیس: | شاهد | یعنی | |---|---| | `appointments.address_id` — **۷۵ از ۷۵ نوبت مقدار دارد** | آدرس، محلِ خودِ نوبت است نه یک بخش تنظیمات | | `nobat724_front/services/response.js:165` → `api/v1/clinic-pro/doctor-address/{id}` | سایت عمومی روی همین قرارداد رزرو می‌گیرد | | `ClinicResource::__construct()` → `assignTenantPair($address->tenantEntityType(), …)` | جفت محیط هر منبع از آدرس مشتق می‌شود | | `price_lists.address_id` · `resource_pools.address_id` · `service_branch_overrides.address_id` | سه زیرسیستم دیگر هم به آن گره خورده‌اند | پس **`DoctorAddress` در این پرامپت حذف نمی‌شود**؛ به یک لنگرِ نامرئی تنزل می‌کند: هیچ صفحه، منو یا مفهومی به کاربر نشان نمی‌دهد، ولی جدولش سر جایش می‌ماند. آنچه واقعاً حذف می‌شود، دامنهٔ `Branch` است (اتاق، ساعت کاری شعبه، صفحه‌ها، اندپوینت‌ها). حذف کامل `DoctorAddress` یک پرامپت جداست و بازنویسی جریان رزرو در **دو ریپو** را می‌خواهد؛ قبل از شروع باید مالک محصول هزینه‌اش را ببیند. اگر پس از دیدن این ارقام باز هم حذف کامل خواسته شد، همان‌جا توقف کن و تکلیف را بپرس — با این پرامپت انجامش نده. --- ## معیار پذیرش ### قابلیت ۱ — حذف شعبه و اتاق - ✅ موفق: `/admin/branches` و زیرصفحه‌هایش ۴۰۴ می‌دهند، آیتم «شعبه‌ها و اتاق‌ها» از منوی تنظیمات رفته، و `ddev exec php bin/phpunit` کامل سبز است — یعنی جستجوی آزاد و رزرو دقیقاً همان نتایج قبلی را می‌دهد. - ❌ خطا: `GET /api/v1/branches` → **۴۰۴** (روت وجود ندارد)، نه ۵۰۰. - ⚠️ مرزی: منبعی که `subject_kind = 'room'` دارد باید همچنان کار کند — اتاق به‌عنوان *منبع* می‌ماند، فقط موجودیت `Room` می‌رود. ### قابلیت ۲ — تعطیلات سراسری - ✅ موفق: با توکن `ROLE_ADMIN`، `POST /api/v1/admin/national-holidays` یک تعطیل می‌سازد و همان روز بلافاصله در `GET /api/v1/resource/{uuid}/availability` با دلیل `national_holiday` خالی برمی‌گردد. - ❌ خطا: همان `POST` با توکن پزشک → **۴۰۳**. - ⚠️ مرزی: محیطی که `TenantHolidayOverride(is_working = true)` دارد، همان روز **باز** است و ساعتش برمی‌گردد. ### قابلیت ۳ — تب منابع در تنظیمات نوبت‌دهی - ✅ موفق: در `/admin/settings/appointment-settings` تب «منابع» ساعت کاری هفتگی، تاریخ‌های خاص و تعطیلات هر منبع را می‌دهد و ذخیره‌اش در `GET /api/v1/resource/{uuid}/calendar` دیده می‌شود. - ❌ خطا: منشیِ بدون مجوز `appointment_settings.update` فیلدها را read-only می‌بیند و `PUT` سرور ۴۰۳ می‌دهد. - ⚠️ مرزی: کلینیکِ بدون هیچ منبعی، حالت خالی با لینک «تنظیمات ← منابع» نشان دهد، نه صفحهٔ سفید. ### قابلیت ۴ — حذف تب‌های سرویس - ✅ موفق: `/admin/service/{uuid}` پنج تب دارد (اطلاعات، تعرفه‌ها، بیمه‌ها، کالاها، دسته‌بندی‌ها، لاگ) و هیچ ورودی به گروه‌ها و بخش‌های نوبت ندارد. - ❌ خطا: باز کردن مستقیم `?tab=segments` به تب اطلاعات برگردد، نه خطای رندر. - ⚠️ مرزی: سرویسی که همین حالا `SegmentTemplate` دارد باید **دقیقاً مثل قبل** رزرو شود — تست‌های موجود `tests/Appointment` سبز بمانند. ### قابلیت ۵ — تایم‌لاین یکپارچه - ✅ موفق: نمای «زمانبندی» هم ردیف پزشکانِ سرویسی و هم ردیف منابعِ قابل‌رزرو را نشان دهد، با بازهٔ اشغال و وقت آزاد و ظرفیت هر ردیف. - ❌ خطا: روزی که هیچ ردیفی داده ندارد، پیام خالیِ صریح بدهد نه اسکلتِ همیشگی. - ⚠️ مرزی: منبعی با `capacity = 3` و دو نوبت هم‌زمان، «۱ ظرفیت آزاد» نشان دهد نه «پر». --- ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Branch/` | کل دامنه: `BranchController`, `Room`, `BranchWorkingHours`, `RoomService`, `WorkingHoursService`, `BranchResolver` | | `src/Resource/Service/ResourceAvailabilityService.php` | تنها مصرف‌کنندهٔ `BranchWorkingHoursRepository` بیرون از `src/Branch` | | `src/Resource/Entity/ClinicResource.php` | `subject_kind='room'` و `ResourceLinker` به `Room` وصل‌اند | | `src/Resource/Controller/HolidayController.php` | `GET /national-holidays` + `POST/DELETE /holiday-overrides` — **POST برای national ندارد** | | `src/Resource/Entity/NationalHoliday.php` · `TenantHolidayOverride.php` | مدل تعطیلات، از قبل درست است | | `src/Appointment/Controller/AppointmentSettingsController.php` | تعطیلی اختصاصی پزشک (`Holiday`) | | `assets/admin/pages/HolidaysSettingsPage.tsx` | صفحهٔ `/admin/holidays` | | `assets/admin/pages/ClinicAppointmentSettingsPage.tsx` | تب‌بندی per پزشک با `.seg` | | `assets/admin/pages/ServiceDetailPage.tsx` | `TABS` — گروه‌ها و بخش‌های نوبت اینجاست | | `assets/admin/pages/AppointmentsPage.tsx` · `components/appointments/ResourceTimeline.tsx` · `TurnsTimeline.tsx` | سه نمای فعلی | | `assets/admin/components/resources/ResourceWorkingHoursPanel.tsx` · `ResourceExceptionsPanel.tsx` | پنل‌های آمادهٔ منبع — در تب جدید همین‌ها مصرف می‌شوند | ## وضعیت فعلی `ResourceAvailabilityService` ساعت واقعی منبع را از تقاطع با ساعت شعبه می‌سازد: ```php // src/Resource/Service/ResourceAvailabilityService.php private readonly BranchWorkingHoursRepository $branchHours, … $branchByDay = $this->branchHoursByDay($resource); … if ($branchByDay !== null) { $branchWindows = $branchByDay[$dayOfWeek] ?? []; if ($branchWindows === []) { // روز، بدون ساعت شعبه یعنی بسته ``` `HolidayController` فقط خواندن تعطیلات ملی را دارد؛ هیچ مسیری برای ساختنشان نیست: ```php #[Route('/api/v1/national-holidays', name: 'national_holidays_list', methods: ['GET'])] #[Route('/api/v1/holiday-overrides', name: 'holiday_override_create', methods: ['POST'])] #[Route('/api/v1/holiday-override/{uuid}', name: 'holiday_override_delete', methods: ['DELETE'])] ``` `ServiceDetailPage` هفت تب دارد: ```tsx const TABS = [ { id: 'info', label: 'اطلاعات سرویس' }, { id: 'tariffs', label: 'تعرفه‌ها' }, { id: 'insurance', label: 'بیمه‌ها' }, { id: 'groups', label: 'گروه‌ها و آیتم‌ها' }, { id: 'segments', label: 'بخش‌های نوبت' }, { id: 'categories',label: 'دسته‌بندی‌ها' }, { id: 'goods', label: 'کالاهای مرتبط' }, { id: 'history', label: 'لاگ تغییرات' }, ] as const; ``` --- ## وظایف ### ۱. حذف دامنهٔ شعبه و اتاق **دامنهٔ حذف:** `src/Branch/` کامل، سه صفحهٔ پنل (`BranchesPage`, `BranchRoomsPage`, `BranchWorkingHoursPage`)، آیتم `branches` در `settingsMenu.ts`، و روت‌هایشان در `App.tsx`. **دامنهٔ نگه‌داشتن:** `DoctorAddress` (لنگر محیط و محل نوبت — بالا را بخوان). قبل از حذف، مهاجرت منابعِ نوع اتاق: ```php // ClinicResource.subject_kind === 'room' امروز به rooms.id اشاره می‌کند. // یک migration، آن ردیف‌ها را به منبع بی‌subject تبدیل می‌کند (نامشان می‌ماند): UPDATE clinic_resources SET subject_kind = NULL, room_id = NULL WHERE subject_kind = 'room'; ``` سپس لایهٔ شعبه از موتور دسترس‌پذیری برداشته می‌شود. **این تنها جای منطق است که واقعاً تغییر می‌کند**، پس صریح بنویسش: ```php // ResourceAvailabilityService: تزریق BranchWorkingHoursRepository حذف، و // $branchByDay همه‌جا null می‌شود → لایهٔ «ساعت شعبه» از کسر بیرون می‌رود. // دلیل معماری: با حذف شعبه، تنها مرجع ساعت کاری، شیفت خودِ منبع است. ``` دلیلِ `outside_branch_hours` و `branch_closed` و `branch_inactive` از `REASON_LABELS` فرانت هم برداشته شوند (`ResourceExceptionsPanel.tsx`). **نحوه تست:** ```bash ddev exec php bin/console doctrine:migrations:migrate --no-interaction ddev exec php bin/phpunit # همه سبز — مخصوصاً tests/Appointment و tests/Resource ddev exec php bin/console debug:router | grep -c "branch\|room" # باید 0 باشد npx vitest run # خط پایه: ۱۰۰ فایل / ۶۶۰ تست ``` و یک رزرو واقعی از مسیر عمومی بگیر (`POST /api/v1/appointment-availability` سپس hold → confirm) تا ثابت شود همان اسلات‌های قبلی برمی‌گردند. **cross-repo:** بعد از حذف، در `nobat724_front` دنبال `doctor-address` بگرد و گزارش بده کدام صفحه‌ها مصرفش می‌کنند. اگر اندپوینت عمومی `clinic-pro/doctor-address/{id}` را دست نزدی (نباید بزنی)، سایت نمی‌شکند — همین را صریح در گزارش بنویس. --- ### ۲. تعطیلات سراسری، سه لایه مدل از قبل درست است و ساخته نمی‌شود؛ فقط سه چیزِ کم اضافه می‌شود. **الف) CRUD مدیر سیستم روی `NationalHoliday`:** ```php // src/Resource/Controller/HolidayController.php #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/admin/national-holidays', methods: ['POST'])] // {jalali_date, title} #[Route('/api/v1/admin/national-holiday/{uuid}', methods: ['PATCH','DELETE'])] ``` `jalali_date` ورودی است و `date` (نیمه‌شب تهران) و `jalali_year` از آن مشتق می‌شوند — مسئولیت تبدیل در یک Service بماند، نه Controller. **ب) نمایش تعطیلات سراسری در تب تعطیلاتِ پزشک و منبع:** هر دو تب علاوه بر تعطیلی اختصاصی، فهرست تعطیلات ملی سال را **فقط‌خواندنی** با یک سوییچ «این روز باز هستیم» نشان دهند؛ سوییچ همان `POST /api/v1/holiday-overrides` موجود را صدا بزند. **ج) `/admin/holidays`** جای مدیریت سراسری هر محیط بماند (همین حالا هست) و در توضیح صفحه بنویسد که این‌ها پیش‌فرضِ همهٔ پزشکان و منابع‌اند. **نحوه تست:** ```bash # ✅ ادمین می‌سازد curl -X POST .../api/v1/admin/national-holidays -H "Authorization: Bearer $ADMIN" \ -d '{"jalali_date":"1405-01-13","title":"سیزده‌بدر"}' # ✅ همان روز در دسترس‌پذیری منبع خالی است، با دلیل national_holiday curl ".../api/v1/resource/$R/availability?from=…&to=…" -H "Authorization: Bearer $DOC" # ❌ پزشک نمی‌سازد → 403 # ⚠️ بعد از POST /holiday-overrides با is_working=true همان روز باز می‌شود ``` تست PHPUnit: `tests/Resource/NationalHolidayEndpointTest.php` با هر سه سناریو. --- ### ۳. تب «منابع» در تنظیمات نوبت‌دهی کلینیک در `ClinicAppointmentSettingsPage` یک سطح تب بالاتر اضافه کن: **پزشکان | منابع**. سطح دوم برای منابع همان الگوی فعلی است (یک `.seg` با نام هر منبع). کامپوننت جدید لازم نیست — پنل‌ها ساخته شده‌اند: ```tsx {scope === 'resources' && selectedResource && (
)} ``` `.seg` نکته دارد: کلاس فعالش `on` است (`active` هم alias شده) — تب بدون آن هیچ نشانه‌ای ندارد. **نحوه تست:** `npx vitest run assets/admin/pages/ClinicAppointmentSettingsPage.test.tsx` با سه تست: تب منابع فهرست منابع را می‌دهد · انتخاب منبع پنل ساعت کاری را می‌آورد · کلینیک بدون منبع حالت خالی می‌دهد. سپس اسکرین‌شات: `node .claude/skills/redesign-page/driver.mjs variants "https://clinic-pro.ddev.site/admin/settings/appointment-settings"` --- ### ۴. حذف دو تب از صفحهٔ سرویس فقط **UI** حذف می‌شود: دو ورودی از `TABS` و رندرشان، به‌علاوهٔ کامپوننت‌های `ServiceGroupsTab` و `ServiceSegmentsTab` و تست‌هایشان. `SegmentTemplate`، `ServiceSelectionGroup`، `ServiceItemRelation` و اندپوینت‌هایشان **می‌مانند**: `AppointmentPlanBuilder` ورودی‌اش همین‌هاست و سرویسی که امروز اتاق و دستگاه را با هم می‌گیرد، بدونشان می‌شکند. سرویسِ بدون template هم از قبل با `singleSegment()` رزرو می‌شود، پس حذف تب هیچ رفتاری را عوض نمی‌کند. `tab` در URL می‌نشیند؛ مقدار ناشناخته باید به `info` برگردد نه اینکه چیزی رندر نشود. **نحوه تست:** `npx vitest run assets/admin/pages/ServiceDetailPage.test.tsx` + `ddev exec php bin/phpunit tests/Appointment` (باید بدون تغییر سبز بماند) + باز کردن `/admin/service/{uuid}?tab=segments` و دیدن تب اطلاعات. --- ### ۵. تایم‌لاین یکپارچه سه نمای فعلی (`table` · `timeline` · `resources`) به دو نما می‌رسند: **جدولی** و **زمانبندی**. نمای زمانبندی دو گروه ردیف دارد: ``` پزشکان ← پزشکانی که حالت نوبت‌دهی‌شان service است منابع ← منابعی که برای سرویسی قابل رزروند (ResourceServiceOffering فعال دارند) ``` هر ردیف باید سه چیز بدهد: بازهٔ اشغال، وقت آزاد، و ظرفیت. برای منبع، «آزاد» یعنی `capacity` منهای تعداد اشغال هم‌پوشان در آن لحظه — نه صفر و یک؛ منبعِ ظرفیت‌۳ با دو نوبت هم‌زمان هنوز یک جا دارد. سمت سرور، `GET /api/v1/resources/timeline` موجود را توسعه بده (ساخت اندپوینت جدید ممنوع است تا وقتی این کافی است): فیلتر `only_bookable=1` و فیلد `free_slots` به هر ردیف اضافه شود. ردیف پزشک از همان `slots` فعلی می‌آید و در فرانت با ردیف منابع در یک نما ادغام می‌شود. **نحوه تست:** تست PHPUnit برای `free_slots` روی منبع ظرفیت‌۳ با دو اشغال هم‌پوشان (انتظار: ۱)؛ `npx vitest run assets/admin/components/appointments/`؛ و اسکرین‌شات نمای زمانبندی در تاریخ `2026-08-05` که دادهٔ واقعی دارد. --- ## نکات مهم - **ترتیب اجرا اجباری است.** قابلیت ۱ موتور دسترس‌پذیری را تغییر می‌دهد؛ اگر بعد از قابلیت ۵ انجام شود، تایم‌لاین را می‌شکند و علتش پیدا نیست. - **خط پایهٔ تست را اول بگیر:** `ddev exec php bin/phpunit` و `npx vitest run` (۱۰۰ فایل / ۶۶۰ تست، همه سبز در ۲۰۲۶-۰۸-۰۲). هر شکستی بعد از این، مالِ همین کار است. - **مسیر اسلاتی دست نخورد.** تست‌های `--group=slot-mode-frozen` نگهبانند؛ حتی یک شکست یعنی توقف. - **`vitest` داخل ddev اجرا نمی‌شود** — روی هاست با `npx vitest run`. - **دو صفحه، یک منوی تنظیمات:** `settingsMenu.ts` منبع واحد سایدبار دسکتاپ و فهرست موبایل است. حذف آیتم شعبه فقط همان‌جا انجام شود. - **مستندات همین جلسه:** `docs/api/branch.md` حذف، `docs/api/resource.md` و `docs/api/resource-calendar.md` و `docs/api/clinic-services.md` به‌روز، و `docs/architecture/resource-first-model.md` باید بگوید شعبه از مدل بیرون رفته. - **الگو:** برداشتن لایهٔ شعبه از `ResourceAvailabilityService` را به‌صورت حذف یک لایه از زنجیرهٔ کسر انجام بده (همان ساختار فعلی)، نه با `if` تازه — کلاس باید کوچک‌تر شود نه شاخه‌دارتر. - **دادهٔ تست:** `ddev exec php bin/console app:seed-scenarios --reset -n` سه سناریو را می‌سازد؛ کاربرها در `TEST_USERS.md`. کاربر `0912000301` رمز ندارد — از `0912000201` استفاده کن.