refactor(branch): remove the branch domain, keep the address

Branches and rooms are not part of the resource-first product: a room is a
resource like any other, and the only thing the branch pages still managed —
opening hours — duplicated the resource's own shift.

What could not go is the address. Every appointment carries address_id (75 of
75 rows), the public booking site reads /clinic-pro/doctor-address/{id}, and a
resource derives its tenant pair from the address it belongs to. So
DoctorAddress stays as an invisible anchor with no page and no menu entry, and
GET /api/v1/addresses replaces GET /api/v1/branches for the forms that still
need to say "where".

BranchResolver was likewise not a branch feature. doctor_addresses is a global
table, so TenantFilter does not cover it and eight callers across booking,
availability, pricing and the catalog went through this resolver to avoid
leaking another clinic's address. It moved to Doctor\Service\AddressResolver
rather than dying with the domain.

The availability engine loses one layer: a resource's real hours were the
branch hours intersected with its shift, and are now the shift alone. That is
the single behavioural change, and the three tests that asserted the old
contract are replaced by one that states the new one.

Rooms already had a resource row each; the migration drops only the bridge
back to `rooms`, and drops it before the table — that foreign key is ON DELETE
CASCADE and the other order would take the resources, and their appointments,
with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 15:25:32 +03:30
co-authored by Claude Opus 5
parent 1c4f2a2451
commit dd284ec622
59 changed files with 566 additions and 3128 deletions
+324
View File
@@ -0,0 +1,324 @@
# پاک‌سازی نوبت‌دهی سرویسی: حذف شعبه، تعطیلات سراسری، تنظیمات منابع، تایم‌لاین یکپارچه
## پروژه
`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 && (
<div key={selectedResource}>
<ResourceWorkingHoursPanel resourceUuid={selectedResource} canUpdate={canUpdate} />
<ResourceExceptionsPanel resourceUuid={selectedResource} canUpdate={canUpdate} />
</div>
)}
```
`.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`
استفاده کن.