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>
20 KiB
پاکسازی نوبتدهی سرویسی: حذف شعبه، تعطیلات سراسری، تنظیمات منابع، تایملاین یکپارچه
پروژه
clinicpro (بکاند Symfony + پنل ادمین React).
یک وظیفه cross-repo است و علامتگذاری شده: حذف شعبه به nobat724_front میرسد
(nobat724_front/services/response.js:165 اندپوینت doctor-address/{id} را صدا میزند).
زمینه
مدل Resource-First پیاده شده است: منبع، سرویس، گزینهٔ سرویس، دستهٔ سراسری. حالا مالک محصول میخواهد لایههایی که در این مدل مصرفکننده ندارند برداشته شوند (شعبه/اتاق، گروههای انتخاب، تب بخشهای نوبت)، تعطیلات یک بار سراسری تعریف شود، و تنظیمات نوبتدهی منابع همشکل پزشکان شود.
مشکل / هدف
پنج تغییر مستقل، به همین ترتیب:
- حذف شعبه و اتاق از محصول — بدون تغییر منطق نوبتدهی.
- تعطیلات سراسری: مدیر سیستم تعطیلات رسمی سال را ثبت کند؛ هر محیط بتواند غیرفعالشان کند؛ پزشک و منبع تعطیلی اختصاصی خودشان را داشته باشند.
- تب منابع در
/admin/settings/appointment-settings، همشکل تب پزشک. - حذف تبهای «گروهها و آیتمها» و «بخشهای نوبت» از صفحهٔ سرویس.
- تایملاین یکپارچه: پزشکانِ سرویسی و منابعِ قابلرزرو در یک نما، با ظرفیت و وقت آزاد.
⚠️ نقد پرامپت — قبل از شروع بخوان
خواستهٔ «همهچیز شعبه حذف شود، شامل 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 ساعت واقعی منبع را از تقاطع با ساعت شعبه میسازد:
// src/Resource/Service/ResourceAvailabilityService.php
private readonly BranchWorkingHoursRepository $branchHours,
…
$branchByDay = $this->branchHoursByDay($resource);
…
if ($branchByDay !== null) {
$branchWindows = $branchByDay[$dayOfWeek] ?? [];
if ($branchWindows === []) {
// روز، بدون ساعت شعبه یعنی بسته
HolidayController فقط خواندن تعطیلات ملی را دارد؛ هیچ مسیری برای ساختنشان نیست:
#[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 هفت تب دارد:
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 (لنگر محیط و محل نوبت — بالا را بخوان).
قبل از حذف، مهاجرت منابعِ نوع اتاق:
// ClinicResource.subject_kind === 'room' امروز به rooms.id اشاره میکند.
// یک migration، آن ردیفها را به منبع بیsubject تبدیل میکند (نامشان میماند):
UPDATE clinic_resources SET subject_kind = NULL, room_id = NULL WHERE subject_kind = 'room';
سپس لایهٔ شعبه از موتور دسترسپذیری برداشته میشود. این تنها جای منطق است که واقعاً تغییر میکند، پس صریح بنویسش:
// ResourceAvailabilityService: تزریق BranchWorkingHoursRepository حذف، و
// $branchByDay همهجا null میشود → لایهٔ «ساعت شعبه» از کسر بیرون میرود.
// دلیل معماری: با حذف شعبه، تنها مرجع ساعت کاری، شیفت خودِ منبع است.
دلیلِ outside_branch_hours و branch_closed و branch_inactive از
REASON_LABELS فرانت هم برداشته شوند (ResourceExceptionsPanel.tsx).
نحوه تست:
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:
// 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 جای مدیریت سراسری هر محیط بماند (همین حالا هست) و در توضیح
صفحه بنویسد که اینها پیشفرضِ همهٔ پزشکان و منابعاند.
نحوه تست:
# ✅ ادمین میسازد
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 با نام هر منبع).
کامپوننت جدید لازم نیست — پنلها ساخته شدهاند:
{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استفاده کن.