diff --git a/.claude/prompt/service-first-cleanup.md b/.claude/prompt/service-first-cleanup.md
new file mode 100644
index 00000000..29ff5fab
--- /dev/null
+++ b/.claude/prompt/service-first-cleanup.md
@@ -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 && (
+
+
+
+
+)}
+```
+
+`.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`
+ استفاده کن.
diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx
index e0ceae47..1fe8c876 100644
--- a/assets/admin/App.tsx
+++ b/assets/admin/App.tsx
@@ -74,11 +74,8 @@ import AppointmentSettingsPage from './pages/AppointmentSettingsPage';
import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage';
import PatientsListPage from './pages/PatientsListPage';
import InventoryPage from './pages/InventoryPage';
-import BranchesPage from './pages/BranchesPage';
import ResourceBookingPage from './pages/ResourceBookingPage';
import PriceListsPage from './pages/PriceListsPage';
-import BranchWorkingHoursPage from './pages/BranchWorkingHoursPage';
-import BranchRoomsPage from './pages/BranchRoomsPage';
import ResourcesPage from './pages/ResourcesPage';
import ResourceTypesPage from './pages/ResourceTypesPage';
import CatalogCategoriesPage from './pages/CatalogCategoriesPage';
@@ -294,9 +291,6 @@ export default function App() {
} />
} />
} />
- } />
- } />
- } />
} />
} />
} />
diff --git a/assets/admin/components/ServiceSegmentsTab.tsx b/assets/admin/components/ServiceSegmentsTab.tsx
index b4c564ce..f9b35683 100644
--- a/assets/admin/components/ServiceSegmentsTab.tsx
+++ b/assets/admin/components/ServiceSegmentsTab.tsx
@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import SearchableSelect from './ui/SearchableSelect';
import { api, ApiError, type ApiResponse } from '../lib/api';
-import { useBranches } from '../hooks/useBranches';
+import { useAddresses } from '../hooks/useAddresses';
interface SegmentRequirementDraft {
type_uuid: string;
@@ -71,7 +71,7 @@ interface Props {
*/
export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
const qc = useQueryClient();
- const { branches } = useBranches();
+ const { addresses } = useAddresses();
const [segments, setSegments] = useState([]);
const [branchUuid, setBranchUuid] = useState('');
@@ -387,7 +387,7 @@ export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
setBranchUuid(String(v ?? ''))}
- options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
+ options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
placeholder="انتخاب شعبه"
/>
diff --git a/assets/admin/components/layout/SettingsLayout.test.tsx b/assets/admin/components/layout/SettingsLayout.test.tsx
index ce81a028..41a06c4e 100644
--- a/assets/admin/components/layout/SettingsLayout.test.tsx
+++ b/assets/admin/components/layout/SettingsLayout.test.tsx
@@ -44,7 +44,6 @@ describe('SettingsLayout', () => {
expect(screen.getByText('منابع').closest('a')).toHaveAttribute('href', '/admin/resources');
expect(screen.getByText('دستهبندیها').closest('a')).toHaveAttribute('href', '/admin/service-categories');
- expect(screen.getByText('شعبهها و اتاقها').closest('a')).toHaveAttribute('href', '/admin/branches');
expect(screen.getByText('منابع').closest('a')).toHaveAttribute('aria-current', 'page');
});
diff --git a/assets/admin/components/layout/settingsMenu.ts b/assets/admin/components/layout/settingsMenu.ts
index e2f911cc..54bda3b9 100644
--- a/assets/admin/components/layout/settingsMenu.ts
+++ b/assets/admin/components/layout/settingsMenu.ts
@@ -3,7 +3,7 @@ import {
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
BanknotesIcon, UsersIcon, ShieldCheckIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon,
- MapPinIcon, CubeIcon, RectangleStackIcon,
+ CubeIcon, RectangleStackIcon,
} from '@heroicons/react/24/outline';
/**
@@ -34,7 +34,6 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
- { key: 'branches', label: 'شعبهها و اتاقها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'service-categories', label: 'دستهبندیها', icon: RectangleStackIcon, to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
diff --git a/assets/admin/components/resources/ResourceExceptionsPanel.tsx b/assets/admin/components/resources/ResourceExceptionsPanel.tsx
index ceff410e..ec054f1e 100644
--- a/assets/admin/components/resources/ResourceExceptionsPanel.tsx
+++ b/assets/admin/components/resources/ResourceExceptionsPanel.tsx
@@ -12,11 +12,9 @@ const REASON_LABELS: Record = {
national_holiday: 'تعطیل رسمی',
tenant_holiday: 'تعطیلی این محیط',
no_shift: 'شیفتی تعریف نشده',
- branch_closed: 'شعبه این روز بسته است',
- outside_branch_hours: 'شیفت بیرون از ساعت کاری شعبه',
exception: 'مرخصی یا سرویس',
resource_inactive: 'منبع غیرفعال است',
- branch_inactive: 'شعبه غیرفعال است',
+ address_inactive: 'محل نوبتدهی غیرفعال است',
};
const EXCEPTION_TYPES = [
diff --git a/assets/admin/components/resources/ResourceFormModal.test.tsx b/assets/admin/components/resources/ResourceFormModal.test.tsx
index 37758bf2..288025a7 100644
--- a/assets/admin/components/resources/ResourceFormModal.test.tsx
+++ b/assets/admin/components/resources/ResourceFormModal.test.tsx
@@ -28,7 +28,7 @@ const resource = {
const props = {
open: true,
resource: resource as never,
- branches: [{ uuid: 'b-1', name: 'شعبه' }] as never,
+ addresses: [{ uuid: 'b-1', name: 'محل نوبتدهی' }] as never,
types: [{ uuid: 't-1', name: 'دستگاه', code: 'device' }] as never,
saving: false,
onClose: () => {},
diff --git a/assets/admin/components/resources/ResourceFormModal.tsx b/assets/admin/components/resources/ResourceFormModal.tsx
index 09c93026..c154a74e 100644
--- a/assets/admin/components/resources/ResourceFormModal.tsx
+++ b/assets/admin/components/resources/ResourceFormModal.tsx
@@ -13,7 +13,7 @@ type AttributeRow = { key: string; value: string };
interface Props {
open: boolean;
resource: ClinicResource | null;
- branches: Branch[];
+ addresses: Branch[];
types: ResourceType[];
saving: boolean;
onClose: () => void;
@@ -21,7 +21,7 @@ interface Props {
}
export default function ResourceFormModal({
- open, resource, branches, types, saving, onClose, onSave,
+ open, resource, addresses, types, saving, onClose, onSave,
}: Props) {
// شمار نوبتهای آینده فقط برای منبعِ موجود معنا دارد و فقط وقتی مودال باز است.
const { upcomingAppointments: upcoming } = useResourceDetail(open ? resource?.uuid : undefined);
@@ -91,7 +91,7 @@ export default function ResourceFormModal({
({ value: b.uuid, label: b.name || 'بدون نام' }))}
+ options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
value={addressUuid}
onChange={(v) => setAddressUuid(v ? String(v) : null)}
placeholder="شعبه را انتخاب کنید"
diff --git a/assets/admin/hooks/useAddresses.ts b/assets/admin/hooks/useAddresses.ts
new file mode 100644
index 00000000..2a4cf702
--- /dev/null
+++ b/assets/admin/hooks/useAddresses.ts
@@ -0,0 +1,23 @@
+import { useQuery } from '@tanstack/react-query';
+import { api, type ApiResponse } from '../lib/api';
+import type { Branch } from '../types';
+
+/**
+ * محلهای نوبتدهی محیط جاری — همان `doctor_addresses`.
+ *
+ * جانشین `useBranches` است. مفهوم «شعبه» از محصول حذف شد، ولی فرم منبع، لیست قیمت و
+ * استخر منبع هنوز باید بگویند «کجا»، پس فهرست آدرسها فقط برای انتخاب میماند. ساخت و
+ * ویرایش آدرس همانجایی است که همیشه بود (جزئیات کلینیک/پزشک).
+ */
+export function useAddresses() {
+ const query = useQuery({
+ queryKey: ['addresses'],
+ queryFn: () => api.get>('/api/v1/addresses'),
+ });
+
+ // پاسخِ غیرآرایه (خطای سرور، شکل دیگر) نباید صفحه را با «map is not a function»
+ // بترکاند؛ فهرست خالی رفتار درست است.
+ const addresses = Array.isArray(query.data?.data) ? query.data.data : [];
+
+ return { addresses, loading: query.isLoading };
+}
diff --git a/assets/admin/hooks/useBranches.ts b/assets/admin/hooks/useBranches.ts
deleted file mode 100644
index 1d3e63d8..00000000
--- a/assets/admin/hooks/useBranches.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { toast } from 'sonner';
-import { api, ApiError, type ApiResponse } from '../lib/api';
-import type { Branch, BranchWorkingHours, Room, RoomPayload, WorkingHoursPayload } from '../types';
-
-/**
- * «شعبه» یک جدول تازه نیست — همان آدرس محل نوبتدهی است (`doctor_addresses`).
- * ساخت/ویرایش نام و آدرس همانجایی انجام میشود که همیشه (جزئیات کلینیک/پزشک)؛
- * این هوک فقط چیزهای شعبهای را میدهد: فعال/غیرفعال، منطقهٔ زمانی، ساعت کاری، اتاق.
- */
-const BRANCHES_KEY = ['branches'];
-
-function fail(e: unknown, fallback: string) {
- toast.error(e instanceof ApiError ? e.message : fallback);
-}
-
-export function useBranches() {
- const qc = useQueryClient();
-
- const query = useQuery({
- queryKey: BRANCHES_KEY,
- queryFn: () => api.get>('/api/v1/branches'),
- });
-
- const update = useMutation({
- mutationFn: ({ uuid, d }: { uuid: string; d: { active?: boolean; timezone?: string } }) =>
- api.patch>(`/api/v1/branch/${uuid}`, d),
- onSuccess: () => {
- toast.success('شعبه بهروزرسانی شد');
- qc.invalidateQueries({ queryKey: BRANCHES_KEY });
- },
- onError: (e) => fail(e, 'بهروزرسانی شعبه ناموفق بود'),
- });
-
- // پاسخِ غیرآرایه (خطای سرور، شکل دیگر) نباید صفحه را با «map is not a function»
- // بترکاند؛ فهرست خالی رفتار درست است.
- const branches = Array.isArray(query.data?.data) ? query.data.data : [];
-
- return { branches, loading: query.isLoading, update };
-}
-
-export function useBranchWorkingHours(branchUuid: string | undefined) {
- const qc = useQueryClient();
- const key = ['branch-working-hours', branchUuid];
-
- const query = useQuery({
- queryKey: key,
- queryFn: () => api.get>(`/api/v1/branch/${branchUuid}/working-hours`),
- enabled: !!branchUuid,
- });
-
- /** PUT قرارداد جایگزینی کامل دارد: آرایهٔ خالی یعنی شعبه بسته، نه «تغییری نده». */
- const save = useMutation({
- mutationFn: (days: WorkingHoursPayload) =>
- api.put>(`/api/v1/branch/${branchUuid}/working-hours`, { days }),
- onSuccess: () => {
- toast.success('ساعت کاری ذخیره شد');
- qc.invalidateQueries({ queryKey: key });
- qc.invalidateQueries({ queryKey: BRANCHES_KEY });
- },
- onError: (e) => fail(e, 'ذخیرهٔ ساعت کاری ناموفق بود'),
- });
-
- return { workingHours: query.data?.data, loading: query.isLoading, save };
-}
-
-export function useBranchRooms(branchUuid: string | undefined) {
- const qc = useQueryClient();
- const key = ['branch-rooms', branchUuid];
-
- const invalidate = () => {
- qc.invalidateQueries({ queryKey: key });
- qc.invalidateQueries({ queryKey: BRANCHES_KEY });
- };
-
- const query = useQuery({
- queryKey: key,
- queryFn: () => api.get>(`/api/v1/branch/${branchUuid}/rooms`),
- enabled: !!branchUuid,
- });
-
- const create = useMutation({
- mutationFn: (d: RoomPayload) =>
- api.post>('/api/v1/room', { ...d, address_uuid: branchUuid }),
- onSuccess: () => { toast.success('اتاق افزوده شد'); invalidate(); },
- onError: (e) => fail(e, 'افزودن اتاق ناموفق بود'),
- });
-
- const update = useMutation({
- mutationFn: ({ uuid, d }: { uuid: string; d: RoomPayload }) =>
- api.patch>(`/api/v1/room/${uuid}`, d),
- onSuccess: () => { toast.success('اتاق بهروزرسانی شد'); invalidate(); },
- onError: (e) => fail(e, 'بهروزرسانی اتاق ناموفق بود'),
- });
-
- const remove = useMutation({
- mutationFn: (uuid: string) => api.delete>(`/api/v1/room/${uuid}`),
- onSuccess: () => { toast.success('اتاق حذف شد'); invalidate(); },
- onError: (e) => fail(e, 'حذف اتاق ناموفق بود'),
- });
-
- return { rooms: query.data?.data ?? [], loading: query.isLoading, create, update, remove };
-}
diff --git a/assets/admin/pages/BranchRoomsPage.tsx b/assets/admin/pages/BranchRoomsPage.tsx
deleted file mode 100644
index 996ca463..00000000
--- a/assets/admin/pages/BranchRoomsPage.tsx
+++ /dev/null
@@ -1,199 +0,0 @@
-import React, { useMemo, useState } from 'react';
-import { useParams } from 'react-router-dom';
-import { PlusIcon } from '@heroicons/react/24/outline';
-import PageHeader from '../components/ui/PageHeader';
-import DataTable, { type Column } from '../components/ui/DataTable';
-import Modal from '../components/ui/Modal';
-import ConfirmDialog from '../components/ui/ConfirmDialog';
-import { ActiveBadge } from '../components/ui/StatusBadge';
-import { useUrlState } from '../hooks/useUrlState';
-import { usePermissions } from '../hooks/usePermissions';
-import { useBranchRooms, useBranches } from '../hooks/useBranches';
-import type { Room, RoomPayload } from '../types';
-import SettingsLayout from '../components/layout/SettingsLayout';
-
-/** اتاقهای یک شعبه. ظرفیت = چند بیمار همزمان، نه چند اتاق. */
-export default function BranchRoomsPage() {
- const { branchUuid } = useParams<{ branchUuid: string }>();
- const { rooms, loading, create, update, remove } = useBranchRooms(branchUuid);
- const { branches } = useBranches();
- const { can } = usePermissions();
- const canUpdate = can('appointment_settings', 'update');
-
- const branch = branches.find((b) => b.uuid === branchUuid);
-
- const [urlState, setUrlState] = useUrlState({ search: '' });
- const [editing, setEditing] = useState<{ open: boolean; room: Room | null }>({ open: false, room: null });
- const [toDelete, setToDelete] = useState(null);
-
- const rows = useMemo(() => {
- const q = urlState.search.trim();
- return q === '' ? rooms : rooms.filter((r) => `${r.name} ${r.room_type ?? ''}`.includes(q));
- }, [rooms, urlState.search]);
-
- const columns: Column[] = [
- { key: 'name', header: 'نام اتاق', render: (r) => {r.name} },
- { key: 'room_type', header: 'نوع', render: (r) => {r.room_type || '—'} },
- {
- key: 'capacity',
- header: 'ظرفیت همزمان',
- render: (r) => {r.capacity} نفر ,
- },
- { key: 'floor', header: 'طبقه', render: (r) => {r.floor || '—'} },
- { key: 'active', header: 'وضعیت', render: (r) => },
- ];
-
- const save = (payload: RoomPayload) => {
- const opts = { onSuccess: () => setEditing({ open: false, room: null }) };
- if (editing.room) update.mutate({ uuid: editing.room.uuid, d: payload }, opts);
- else create.mutate(payload, opts);
- };
-
- return (
-
-
-
setEditing({ open: true, room: null })}>
- افزودن اتاق
-
- ) : undefined
- }
- />
-
- setUrlState({ search: v })}
- searchPlaceholder="جستجو در اتاقها..."
- emptyMessage="هنوز اتاقی برای این شعبه ثبت نشده است"
- actions={
- canUpdate
- ? (r) => (
-
- setEditing({ open: true, room: r })}>
- ویرایش
-
- setToDelete(r)}>
- حذف
-
-
- )
- : undefined
- }
- />
-
- setEditing({ open: false, room: null })}
- onSave={save}
- />
-
- toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
- onCancel={() => setToDelete(null)}
- />
-
-
- );
-}
-
-function RoomModal({
- open, room, saving, onClose, onSave,
-}: {
- open: boolean;
- room: Room | null;
- saving: boolean;
- onClose: () => void;
- onSave: (payload: RoomPayload) => void;
-}) {
- const [name, setName] = useState('');
- const [roomType, setRoomType] = useState('');
- const [capacity, setCapacity] = useState('1');
- const [floor, setFloor] = useState('');
- const [active, setActive] = useState(true);
-
- // فرم با هر بازشدن از روی اتاقِ هدف بازنشانی میشود؛ key در والد باعث remount
- // نمیشود چون Modal همیشه mounted است.
- React.useEffect(() => {
- if (!open) return;
- setName(room?.name ?? '');
- setRoomType(room?.room_type ?? '');
- setCapacity(String(room?.capacity ?? 1));
- setFloor(room?.floor ?? '');
- setActive(room?.active ?? true);
- }, [open, room]);
-
- const parsedCapacity = Number(capacity);
- const invalid = name.trim() === '' || !Number.isFinite(parsedCapacity) || parsedCapacity < 1;
-
- return (
-
-
-
- );
-}
-
-function Field({ label, children }: { label: string; children: React.ReactNode }) {
- return (
-
- {label}
- {children}
-
- );
-}
diff --git a/assets/admin/pages/BranchWorkingHoursPage.test.tsx b/assets/admin/pages/BranchWorkingHoursPage.test.tsx
deleted file mode 100644
index 50dc4750..00000000
--- a/assets/admin/pages/BranchWorkingHoursPage.test.tsx
+++ /dev/null
@@ -1,166 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { screen, fireEvent, waitFor } from '@testing-library/react';
-import { renderWithProviders } from '../test/utils';
-
-vi.mock('../lib/api', () => ({
- api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
- ApiError: class extends Error {},
-}));
-
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
-
-import { Routes, Route } from 'react-router-dom';
-import { api } from '../lib/api';
-import BranchWorkingHoursPage from './BranchWorkingHoursPage';
-
-const get = api.get as ReturnType;
-const put = api.put as ReturnType;
-
-const branch = {
- id: '1', uuid: 'b1', type: 'clinic', clinic_id: 3, clinic_name: 'کلینیک ما',
- name: 'شعبهٔ مرکزی', map: { latitude: null, longitude: null },
- address: 'خیابان اول', telephone: '03511111111', active: true,
- timezone: 'Asia/Tehran', city: null, province: null,
- working_hours_defined: true, rooms_count: 0,
-};
-
-function emptyDays(): Record {
- return Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), []]));
-}
-
-function mockApi(days: Record) {
- get.mockImplementation((path: string) => {
- if (path === '/api/v1/branches') return Promise.resolve({ success: true, data: [branch] });
- if (path.endsWith('/working-hours')) {
- return Promise.resolve({
- success: true,
- data: { branch_uuid: 'b1', timezone: 'Asia/Tehran', defined: true, days },
- });
- }
- return Promise.resolve({ success: true, data: null });
- });
- put.mockResolvedValue({ success: true, data: { branch_uuid: 'b1', timezone: 'Asia/Tehran', defined: true, days } });
-}
-
-function renderPage() {
- return renderWithProviders(
-
- } />
- ,
- { route: '/admin/branches/b1/working-hours' },
- );
-}
-
-describe('BranchWorkingHoursPage', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it('renders all seven days and marks the empty ones closed', async () => {
- mockApi(emptyDays());
- renderPage();
-
- await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
- expect(screen.getByText('جمعه')).toBeInTheDocument();
- expect(screen.getAllByText('بسته')).toHaveLength(7);
- });
-
- it('shows stored ranges as times, converting minutes from midnight', async () => {
- const days = emptyDays();
- days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 780, start_time: '09:00', end_time: '13:00', active: true }];
- mockApi(days);
- renderPage();
-
- await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
- expect(screen.getByDisplayValue('13:00')).toBeInTheDocument();
- });
-
- /**
- * ` ` سقفش ۲۳:۵۹ است، پس ۱۴۴۰ با پرچم «تا پایان روز» نمایش داده
- * میشود و همان ۱۴۴۰ برمیگردد — وگرنه اولین ذخیره بازهٔ شبانهروزی را خراب میکرد.
- */
- it('keeps an all-day range at 1440 through a round trip', async () => {
- const days = emptyDays();
- days['3'] = [{ sequence: 0, start_minute: 0, end_minute: 1440, start_time: '00:00', end_time: '24:00', active: true }];
- mockApi(days);
- renderPage();
-
- await waitFor(() => expect(screen.getByText('۲۴:۰۰')).toBeInTheDocument());
- expect((screen.getByLabelText('تا پایان روز') as HTMLInputElement).checked).toBe(true);
-
- fireEvent.click(screen.getByText('ذخیرهٔ هفته'));
-
- await waitFor(() => expect(put).toHaveBeenCalled());
- expect(put.mock.calls[0][1].days['3']).toEqual([{ start_minute: 0, end_minute: 1440 }]);
- });
-
- it('turns a normal range into an all-day one when the flag is checked', async () => {
- const days = emptyDays();
- days['6'] = [{ sequence: 0, start_minute: 540, end_minute: 660, start_time: '09:00', end_time: '11:00', active: true }];
- mockApi(days);
- renderPage();
-
- await waitFor(() => expect(screen.getByDisplayValue('11:00')).toBeInTheDocument());
- fireEvent.click(screen.getByLabelText('تا پایان روز'));
- fireEvent.click(screen.getByText('ذخیرهٔ هفته'));
-
- await waitFor(() => expect(put).toHaveBeenCalled());
- expect(put.mock.calls[0][1].days['6']).toEqual([{ start_minute: 540, end_minute: 1440 }]);
- });
-
- it('sends minutes, not time strings, on save', async () => {
- const days = emptyDays();
- days['1'] = [{ sequence: 0, start_minute: 600, end_minute: 720, start_time: '10:00', end_time: '12:00', active: true }];
- mockApi(days);
- renderPage();
-
- await waitFor(() => expect(screen.getByDisplayValue('10:00')).toBeInTheDocument());
- fireEvent.click(screen.getByText('ذخیرهٔ هفته'));
-
- await waitFor(() => expect(put).toHaveBeenCalled());
- const [path, body] = put.mock.calls[0];
- expect(path).toBe('/api/v1/branch/b1/working-hours');
- expect(body.days['1']).toEqual([{ start_minute: 600, end_minute: 720 }]);
- // هر هفت روز فرستاده میشود، چون PUT جایگزینی کامل است نه merge تفاضلی.
- expect(Object.keys(body.days)).toHaveLength(7);
- });
-
- it('blocks a save whose end is not after its start, without calling the API', async () => {
- const days = emptyDays();
- days['2'] = [{ sequence: 0, start_minute: 600, end_minute: 720, start_time: '10:00', end_time: '12:00', active: true }];
- mockApi(days);
- renderPage();
-
- await waitFor(() => expect(screen.getByDisplayValue('12:00')).toBeInTheDocument());
- fireEvent.change(screen.getByDisplayValue('12:00'), { target: { value: '09:00' } });
- fireEvent.click(screen.getByText('ذخیرهٔ هفته'));
-
- await waitFor(() => expect(screen.getByText(/پایان بازه باید بعد از شروع/)).toBeInTheDocument());
- expect(put).not.toHaveBeenCalled();
- });
-
- it('copies one day onto the whole week', async () => {
- const days = emptyDays();
- days['0'] = [{ sequence: 0, start_minute: 480, end_minute: 600, start_time: '08:00', end_time: '10:00', active: true }];
- mockApi(days);
- renderPage();
-
- await waitFor(() => expect(screen.getByDisplayValue('08:00')).toBeInTheDocument());
- fireEvent.click(screen.getByText('اعمال روی همهٔ روزها'));
-
- expect(screen.getAllByDisplayValue('08:00')).toHaveLength(7);
- expect(screen.queryByText('بسته')).not.toBeInTheDocument();
- });
-
- it('removes a range so the day becomes closed', async () => {
- const days = emptyDays();
- days['4'] = [{ sequence: 0, start_minute: 540, end_minute: 660, start_time: '09:00', end_time: '11:00', active: true }];
- mockApi(days);
- renderPage();
-
- await waitFor(() => expect(screen.getByDisplayValue('11:00')).toBeInTheDocument());
- fireEvent.click(screen.getByLabelText('حذف بازه'));
-
- expect(screen.getAllByText('بسته')).toHaveLength(7);
- });
-});
diff --git a/assets/admin/pages/BranchWorkingHoursPage.tsx b/assets/admin/pages/BranchWorkingHoursPage.tsx
deleted file mode 100644
index cb20f840..00000000
--- a/assets/admin/pages/BranchWorkingHoursPage.tsx
+++ /dev/null
@@ -1,265 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { useParams } from 'react-router-dom';
-import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
-import PageHeader from '../components/ui/PageHeader';
-import SearchableSelect from '../components/ui/SearchableSelect';
-import { usePermissions } from '../hooks/usePermissions';
-import { useBranchWorkingHours, useBranches } from '../hooks/useBranches';
-import type { WorkingHourRange } from '../types';
-import SettingsLayout from '../components/layout/SettingsLayout';
-
-/** ۰ = شنبه — همان قرارداد محاسبهٔ اسلات در بکاند. */
-const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
-
-const MINUTES_IN_DAY = 1440;
-
-/**
- * `endOfDay` وجود دارد چون ` ` سقفش ۲۳:۵۹ است و مقدار ۲۴:۰۰ را
- * نه نشان میدهد و نه میسازد. بدون این پرچم، بازهٔ شبانهروزیِ ذخیرهشده (۱۴۴۰)
- * بیصدا از فرم میافتاد و اولین ذخیره آن را خراب میکرد.
- */
-type Draft = { start: string; end: string; endOfDay: boolean };
-
-function toTime(minute: number): string {
- return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`;
-}
-
-/** `"24:00"` باید ۱۴۴۰ بدهد نه صفر — پایان روز است، نه آغازش. */
-function toMinutes(time: string): number | null {
- const m = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
- if (!m) return null;
- const minutes = Number(m[1]) * 60 + Number(m[2]);
- return minutes >= 0 && minutes <= MINUTES_IN_DAY ? minutes : null;
-}
-
-/**
- * ساعت کاری هفتگی یک شعبه.
- *
- * ذخیره یک PUT است و کل هفته را جایگزین میکند؛ روزِ خالی یعنی شعبه آن روز بسته
- * است. اعتبارسنجی نهایی سمت سرور است — این فرم فقط جلوی ارسال ورودی واضحاً خراب
- * را میگیرد تا کاربر منتظر رفتوبرگشت نماند.
- */
-export default function BranchWorkingHoursPage() {
- const { branchUuid } = useParams<{ branchUuid: string }>();
- const { workingHours, loading, save } = useBranchWorkingHours(branchUuid);
- const { branches } = useBranches();
- const { can } = usePermissions();
- const canUpdate = can('appointment_settings', 'update');
-
- const branch = branches.find((b) => b.uuid === branchUuid);
-
- const [draft, setDraft] = useState>({});
- const [error, setError] = useState(null);
-
- useEffect(() => {
- if (!workingHours) return;
- const next: Record = {};
- DAY_LABELS.forEach((_, day) => {
- next[day] = (workingHours.days[String(day)] ?? []).map((r: WorkingHourRange) => ({
- start: toTime(r.start_minute),
- end: r.end_minute === MINUTES_IN_DAY ? '23:59' : toTime(r.end_minute),
- endOfDay: r.end_minute === MINUTES_IN_DAY,
- }));
- });
- setDraft(next);
- }, [workingHours]);
-
- const addRange = (day: number) => {
- setDraft((d) => ({ ...d, [day]: [...(d[day] ?? []), { start: '09:00', end: '13:00', endOfDay: false }] }));
- };
-
- const removeRange = (day: number, index: number) => {
- setDraft((d) => ({ ...d, [day]: (d[day] ?? []).filter((_, i) => i !== index) }));
- };
-
- const editRange = (day: number, index: number, patch: Partial) => {
- setDraft((d) => ({
- ...d,
- [day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)),
- }));
- };
-
- const copyToWholeWeek = (day: number) => {
- const source = draft[day] ?? [];
- const next: Record = {};
- DAY_LABELS.forEach((_, d) => { next[d] = source.map((r) => ({ ...r })); });
- setDraft(next);
- };
-
- const submit = () => {
- const days: Record = {};
-
- for (const [dayKey, ranges] of Object.entries(draft)) {
- const parsed: { start_minute: number; end_minute: number }[] = [];
-
- for (const range of ranges) {
- const start = toMinutes(range.start);
- const end = range.endOfDay ? MINUTES_IN_DAY : toMinutes(range.end);
-
- if (start === null || end === null) {
- setError(`ساعت روز ${DAY_LABELS[Number(dayKey)]} را به شکل ۰۹:۰۰ وارد کنید`);
- return;
- }
- if (end <= start) {
- setError(`در روز ${DAY_LABELS[Number(dayKey)]} پایان بازه باید بعد از شروع آن باشد`);
- return;
- }
- parsed.push({ start_minute: start, end_minute: end });
- }
-
- days[dayKey] = parsed;
- }
-
- setError(null);
- save.mutate(days);
- };
-
- const totalRanges = Object.values(draft).reduce((sum, ranges) => sum + ranges.length, 0);
-
- return (
-
-
-
- {save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ هفته'}
-
- ) : undefined
- }
- />
-
- {error && (
-
- {error}
-
- )}
-
- {branch && canUpdate && (
-
-
منطقهٔ زمانی شعبه
-
-
-
-
- {totalRanges === 0 ? 'هیچ بازهای تعریف نشده' : `${totalRanges} بازه در هفته`}
-
-
- )}
-
- {loading ? (
- در حال بارگذاری...
- ) : (
-
- {DAY_LABELS.map((label, day) => {
- const ranges = draft[day] ?? [];
- return (
-
-
-
- {label}
- {ranges.length === 0 && (
- بسته
- )}
-
- {canUpdate && (
-
- {ranges.length > 0 && (
-
copyToWholeWeek(day)}>
- اعمال روی همهٔ روزها
-
- )}
-
addRange(day)}>
- بازه
-
-
- )}
-
-
-
-
- );
- })}
-
- )}
-
-
- );
-}
-
-/**
- * فهرست منطقهٔ زمانی کوتاه و ثابت است — بکاند با `DateTimeZone::listIdentifiers()`
- * اعتبارسنجی میکند، پس این فهرست تنها راحتی است و منبع حقیقت نیست.
- */
-const TIMEZONES = ['Asia/Tehran', 'Asia/Dubai', 'Asia/Baghdad', 'Europe/Istanbul', 'UTC'];
-
-function TimezoneSelect({ branchUuid, value }: { branchUuid: string; value: string }) {
- const { update } = useBranches();
- const options = TIMEZONES.includes(value) ? TIMEZONES : [value, ...TIMEZONES];
-
- return (
- ({ value: tz, label: tz }))}
- value={value}
- onChange={(v) => v && update.mutate({ uuid: branchUuid, d: { timezone: String(v) } })}
- placeholder="منطقهٔ زمانی"
- height={38}
- />
- );
-}
diff --git a/assets/admin/pages/BranchesPage.tsx b/assets/admin/pages/BranchesPage.tsx
deleted file mode 100644
index 3c5449d5..00000000
--- a/assets/admin/pages/BranchesPage.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import React, { useMemo } from 'react';
-import { Link } from 'react-router-dom';
-import { ClockIcon, Squares2X2Icon } from '@heroicons/react/24/outline';
-import PageHeader from '../components/ui/PageHeader';
-import DataTable, { type Column } from '../components/ui/DataTable';
-import { ActiveBadge } from '../components/ui/StatusBadge';
-import { useUrlState } from '../hooks/useUrlState';
-import { usePermissions } from '../hooks/usePermissions';
-import { useBranches } from '../hooks/useBranches';
-import type { Branch } from '../types';
-import SettingsLayout from '../components/layout/SettingsLayout';
-
-/**
- * شعبهها — همان محلهای نوبتدهی محیط جاری.
- *
- * این صفحه شعبه نمیسازد و نام/آدرس را ویرایش نمیکند؛ آن کار از قبل در جزئیات
- * کلینیک و پزشک هست و تکرارش دو منبع حقیقت میساخت. اینجا فقط دروازهٔ ساعت کاری و
- * اتاقهاست، بهعلاوهٔ دو ویژگی شعبهای: فعالبودن و منطقهٔ زمانی.
- */
-export default function BranchesPage() {
- const { branches, loading, update } = useBranches();
- const { can } = usePermissions();
- const canUpdate = can('appointment_settings', 'update');
-
- const [urlState, setUrlState] = useUrlState({ search: '', status: '' });
-
- const rows = useMemo(() => {
- const q = urlState.search.trim();
- return branches.filter((b) => {
- const haystack = `${b.name ?? ''} ${b.address ?? ''} ${b.telephone ?? ''}`;
- const matchesQuery = q === '' || haystack.includes(q);
- const matchesStatus =
- urlState.status === '' ||
- (urlState.status === 'active' ? b.active : !b.active);
- return matchesQuery && matchesStatus;
- });
- }, [branches, urlState.search, urlState.status]);
-
- const activeCount = branches.filter((b) => b.active).length;
-
- const columns: Column[] = [
- {
- key: 'name',
- header: 'شعبه',
- render: (b) => (
-
- {b.name || 'بدون نام'}
-
- {b.type === 'clinic' ? b.clinic_name || 'کلینیک' : 'مطب شخصی'}
- {b.city ? ` · ${b.city.name}` : ''}
-
-
- ),
- },
- {
- key: 'address',
- header: 'آدرس',
- render: (b) => (
- {b.address || '—'}
- ),
- },
- {
- key: 'telephone',
- header: 'تلفن',
- render: (b) => {b.telephone || '—'} ,
- },
- {
- key: 'working_hours',
- header: 'ساعت کاری',
- render: (b) =>
- b.working_hours_defined ? (
- تعریفشده
- ) : (
- تعریفنشده
- ),
- },
- {
- key: 'rooms_count',
- header: 'اتاق فعال',
- render: (b) => {b.rooms_count ?? 0} ,
- },
- {
- key: 'timezone',
- header: 'منطقهٔ زمانی',
- render: (b) => {b.timezone} ,
- },
- {
- key: 'active',
- header: 'وضعیت',
- render: (b) => ,
- },
- ];
-
- return (
-
-
-
-
-
setUrlState({ search: v })}
- searchPlaceholder="جستجو در شعبهها..."
- emptyMessage="هیچ شعبهای برای این محیط ثبت نشده است"
- headerExtra={
-
- setUrlState({ status: v })}
- />
-
- }
- actions={(b) => (
-
-
- ساعت کاری
-
-
- اتاقها
-
- {canUpdate && (
- {
- if (
- b.active &&
- activeCount === 1 &&
- !window.confirm('این تنها شعبهٔ فعال است. با غیرفعال کردن آن، هیچ شعبهٔ فعالی باقی نمیماند. ادامه میدهید؟')
- ) {
- return;
- }
- update.mutate({ uuid: b.uuid, d: { active: !b.active } });
- }}
- >
- {b.active ? 'غیرفعال کردن' : 'فعال کردن'}
-
- )}
-
- )}
- />
-
-
- );
-}
-
-function StatusFilter({ value, onChange }: { value: string; onChange: (v: string) => void }) {
- const options = [
- { value: '', label: 'همه' },
- { value: 'active', label: 'فعال' },
- { value: 'inactive', label: 'غیرفعال' },
- ];
-
- return (
-
- {options.map((o) => (
- onChange(o.value)}
- >
- {o.label}
-
- ))}
-
- );
-}
diff --git a/assets/admin/pages/PriceListsPage.tsx b/assets/admin/pages/PriceListsPage.tsx
index adfaaefd..4ac4a820 100644
--- a/assets/admin/pages/PriceListsPage.tsx
+++ b/assets/admin/pages/PriceListsPage.tsx
@@ -9,7 +9,7 @@ import PersianDateInput from '../components/ui/PersianDateInput';
import { formatDate, isoToUnix, unixToIso } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
-import { useBranches } from '../hooks/useBranches';
+import { useAddresses } from '../hooks/useAddresses';
import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists';
import { useAllServiceItems } from '../hooks/useServiceCatalog';
import SettingsLayout from '../components/layout/SettingsLayout';
@@ -38,7 +38,7 @@ function emptyDraft(): Draft {
*/
export default function PriceListsPage() {
const { lists, loading, create, update, setItems, activate, remove } = usePriceLists();
- const { branches } = useBranches();
+ const { addresses } = useAddresses();
const { items: services } = useAllServiceItems();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
@@ -258,7 +258,7 @@ export default function PriceListsPage() {
onChange={(v) => setDraft({ ...draft, address_uuid: v ? String(v) : null })}
options={[
{ value: '', label: 'همهٔ شعبهها' },
- ...branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' })),
+ ...addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' })),
]}
/>
diff --git a/assets/admin/pages/ResourceBookingPage.tsx b/assets/admin/pages/ResourceBookingPage.tsx
index 6185b057..9774d7cc 100644
--- a/assets/admin/pages/ResourceBookingPage.tsx
+++ b/assets/admin/pages/ResourceBookingPage.tsx
@@ -4,7 +4,7 @@ import PageHeader from '../components/ui/PageHeader';
import SearchableSelect from '../components/ui/SearchableSelect';
import HoldCountdown from '../components/HoldCountdown';
import { formatDate, formatNumber } from '../lib/utils';
-import { useBranches } from '../hooks/useBranches';
+import { useAddresses } from '../hooks/useAddresses';
import { useAllServiceItems } from '../hooks/useServiceCatalog';
import {
REASON_LABELS,
@@ -32,7 +32,7 @@ function timeOf(ts: number): string {
export default function ResourceBookingPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
- const { branches } = useBranches();
+ const { addresses } = useAddresses();
const { items: services } = useAllServiceItems();
const { create, release, confirm, rebook } = useHold();
@@ -162,7 +162,7 @@ export default function ResourceBookingPage() {
setBranchUuid(String(v ?? ''));
setPickedSlot(null);
}}
- options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
+ options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
placeholder="انتخاب شعبه"
/>
diff --git a/assets/admin/pages/ResourceDetailPage.test.tsx b/assets/admin/pages/ResourceDetailPage.test.tsx
index 462e3325..a6c6930a 100644
--- a/assets/admin/pages/ResourceDetailPage.test.tsx
+++ b/assets/admin/pages/ResourceDetailPage.test.tsx
@@ -114,15 +114,15 @@ describe('ResourceDetailPage', () => {
it('دلیل خالی بودن روز را فارسی میکند', async () => {
mockApi(emptyDays(), [
{ date: 1785529800, day_of_week: 0, intervals: [], total_minutes: 0, reasons: ['national_holiday'] },
- { date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['outside_branch_hours'] },
+ { date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['no_shift'] },
{ date: 1785702600, day_of_week: 2, intervals: [], total_minutes: 0, reasons: ['exception'] },
]);
renderPage('/admin/resources/r1?tab=exceptions');
await waitFor(() => expect(screen.getByText('تعطیل رسمی')).toBeInTheDocument());
- expect(screen.getByText('شیفت بیرون از ساعت کاری شعبه')).toBeInTheDocument();
+ expect(screen.getByText('شیفتی تعریف نشده')).toBeInTheDocument();
expect(screen.getByText('مرخصی یا سرویس')).toBeInTheDocument();
- expect(screen.queryByText('outside_branch_hours')).not.toBeInTheDocument();
+ expect(screen.queryByText('no_shift')).not.toBeInTheDocument();
});
/** پیشنمایش نباید «وقت قابل رزرو» خوانده شود — نوبتها هنوز کسر نشدهاند. */
diff --git a/assets/admin/pages/ResourceDetailPage.tsx b/assets/admin/pages/ResourceDetailPage.tsx
index a46c2275..e11e87a8 100644
--- a/assets/admin/pages/ResourceDetailPage.tsx
+++ b/assets/admin/pages/ResourceDetailPage.tsx
@@ -12,7 +12,7 @@ import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesP
import SettingsLayout from '../components/layout/SettingsLayout';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
-import { useBranches } from '../hooks/useBranches';
+import { useAddresses } from '../hooks/useAddresses';
import { useResourceDetail, useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
import { useAllServiceItems } from '../hooks/useServiceCatalog';
import type { ClinicResource } from '../types';
@@ -46,7 +46,7 @@ export default function ResourceDetailPage() {
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId;
const { resource, loading } = useResourceDetail(resourceUuid);
- const { branches } = useBranches();
+ const { addresses } = useAddresses();
const { types } = useResourceTypes();
const { skills } = useSkills();
const { update, setSkills, setCategories } = useResources();
@@ -144,7 +144,7 @@ export default function ResourceDetailPage() {
setEditOpen(false)}
diff --git a/assets/admin/pages/ResourcePoolsPage.tsx b/assets/admin/pages/ResourcePoolsPage.tsx
index 16b98a45..8eb2be7c 100644
--- a/assets/admin/pages/ResourcePoolsPage.tsx
+++ b/assets/admin/pages/ResourcePoolsPage.tsx
@@ -8,7 +8,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
-import { useBranches } from '../hooks/useBranches';
+import { useAddresses } from '../hooks/useAddresses';
import { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources';
import type { ResourcePool } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
@@ -22,7 +22,7 @@ import ResourcesSubNav from '../components/resources/ResourcesSubNav';
*/
export default function ResourcePoolsPage() {
const { pools, loading, create, update, remove, setMembers } = useResourcePools();
- const { branches } = useBranches();
+ const { addresses } = useAddresses();
const { types } = useResourceTypes();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
@@ -118,7 +118,7 @@ export default function ResourcePoolsPage() {
setCreating(false)}
@@ -150,10 +150,10 @@ export default function ResourcePoolsPage() {
}
function CreatePoolModal({
- open, branches, types, saving, onClose, onSave,
+ open, addresses, types, saving, onClose, onSave,
}: {
open: boolean;
- branches: ReturnType['branches'];
+ addresses: ReturnType['addresses'];
types: ReturnType['types'];
saving: boolean;
onClose: () => void;
@@ -183,7 +183,7 @@ function CreatePoolModal({
شعبه
({ value: b.uuid, label: b.name || 'بدون نام' }))}
+ options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
value={addressUuid}
onChange={(v) => setAddressUuid(v ? String(v) : null)}
placeholder="شعبه را انتخاب کنید"
diff --git a/assets/admin/pages/ResourcesPage.test.tsx b/assets/admin/pages/ResourcesPage.test.tsx
index abae5905..67e9c020 100644
--- a/assets/admin/pages/ResourcesPage.test.tsx
+++ b/assets/admin/pages/ResourcesPage.test.tsx
@@ -39,7 +39,7 @@ const resource = {
function mockApi() {
get.mockImplementation((path: string) => {
- if (path.startsWith('/api/v1/branches')) return Promise.resolve({ success: true, data: [branch] });
+ if (path.startsWith('/api/v1/addresses')) return Promise.resolve({ success: true, data: [branch] });
if (path.startsWith('/api/v1/resource-types')) return Promise.resolve({ success: true, data: [laserType] });
if (path.startsWith('/api/v1/skills')) return Promise.resolve({ success: true, data: [skill] });
if (path.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: [resource] });
diff --git a/assets/admin/pages/ResourcesPage.tsx b/assets/admin/pages/ResourcesPage.tsx
index 6b9266a3..c9a21cfc 100644
--- a/assets/admin/pages/ResourcesPage.tsx
+++ b/assets/admin/pages/ResourcesPage.tsx
@@ -9,7 +9,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
-import { useBranches } from '../hooks/useBranches';
+import { useAddresses } from '../hooks/useAddresses';
import { useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
import ResourceFormModal from '../components/resources/ResourceFormModal';
import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
@@ -38,7 +38,7 @@ export default function ResourcesPage() {
search: '', address: '', type: '', skill: '', status: '',
});
- const { branches } = useBranches();
+ const { addresses } = useAddresses();
const { types } = useResourceTypes();
const { skills } = useSkills();
const { can } = usePermissions();
@@ -143,7 +143,7 @@ export default function ResourcesPage() {
({ value: b.uuid, label: b.name || 'بدون نام' }))}
+ options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
value={urlState.address || null}
onChange={(v) => setUrlState({ address: v ? String(v) : '' })}
placeholder="همهٔ شعبهها"
@@ -225,7 +225,7 @@ export default function ResourcesPage() {
setEditing({ open: false, resource: null })}
diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts
index dd2911a1..00455c38 100644
--- a/assets/admin/types/index.ts
+++ b/assets/admin/types/index.ts
@@ -889,9 +889,9 @@ export interface Branch {
timezone: string;
city: { id: string; name: string } | null;
province: { id: string; name: string } | null;
- /** فقط در `GET /api/v1/branches` — شعبهٔ بدون ساعت «تعریفنشده» است، نه همیشهباز */
+ /** بازمانده از دورهٔ شعبه؛ اندپوینت آدرسها دیگر برنمیگرداند */
working_hours_defined?: boolean;
- /** فقط در `GET /api/v1/branches` — تعداد اتاقهای فعال */
+ /** بازمانده از دورهٔ شعبه؛ اندپوینت آدرسها دیگر برنمیگرداند */
rooms_count?: number;
}
diff --git a/config/services.yaml b/config/services.yaml
index f6ceb073..79b66622 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -23,13 +23,7 @@ services:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
- # Tag every room-deletion reason so RoomService can iterate them without knowing
- # who they are. Tasks 02 (active resources) and 07 (future appointments) each add
- # one implementation and RoomService itself stays untouched.
_instanceof:
- App\Branch\Service\RoomDeletionGuardInterface:
- tags: ['app.room_deletion_guard']
-
# Ordering strategies for resource assignment (task 06). The engine asks the
# registry by code, so adding a strategy means adding one class — nothing
# in the engine or the settings controller changes.
diff --git a/docs/api/README.md b/docs/api/README.md
index 0c874b7d..2072136d 100644
--- a/docs/api/README.md
+++ b/docs/api/README.md
@@ -82,7 +82,6 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb
| [doctor.md](doctor.md) | Doctor profile & addresses | 11 |
| [clinic.md](clinic.md) | Clinics | 7 |
| [clinic-invitation.md](clinic-invitation.md) | Doctor invitations to clinics | 8 |
-| [branch.md](branch.md) | Branches (= addresses), working hours, rooms | 8 |
| [resource.md](resource.md) | Resources, types, skills, pools | 16 |
| [resource-calendar.md](resource-calendar.md) | Resource calendars, exceptions, national holidays | 9 |
| [appointment-plan.md](appointment-plan.md) | Appointment segments and plan preview | 3 |
diff --git a/docs/api/branch.md b/docs/api/branch.md
deleted file mode 100644
index b212a0d3..00000000
--- a/docs/api/branch.md
+++ /dev/null
@@ -1,351 +0,0 @@
-# Branch API — شعبه، ساعت کاری و اتاق
-
-> **Base:** `/api/v1` · **Auth:** JWT روی همهٔ اندپوینتها
-> **مجوز:** `appointment_settings` (`view` برای خواندن، `update` برای نوشتن) — همان مجوزی
-> که تنظیمات نوبتدهی با آن سنجیده میشود. مجوز تازهای اضافه نشده.
-
----
-
-## «شعبه» جدول تازهای نیست
-
-شعبه همان رکورد **آدرس محل نوبتدهی** است: `doctor_addresses` — همان چیزی که
-`WeeklySchedule.setting[day].sessions[].location_id` به آن اشاره میکند و
-`GET /api/v1/appointment-booking-locations/{doctorUuid}` آن را «محل نوبتدهی» مینامد.
-پس `{addressUuid}` در مسیرهای زیر همان `uuid` رکورد آدرس است.
-
-**ساختن، ویرایش و حذف شعبه اندپوینت جدید ندارد** — از قبل موجود است:
-
-| کار | اندپوینت موجود |
-|---|---|
-| CRUD آدرسهای کلینیک | `GET/POST/PATCH/DELETE /api/v1/clinic/{clinicUuid}/addresses` |
-| CRUD آدرسهای پزشک | `POST/GET/PATCH/DELETE /api/v1/clinic-pro/doctor-address[/{id}]` |
-| آدرسهای یک پزشک | `GET /api/v1/clinic-pro/doctor-addresses/{doctorId}` |
-
-این سند فقط چیزهایی را پوشش میدهد که آنجا نبودند: فهرست شعبههای محیط جاری،
-دو ویژگی `active`/`timezone`، ساعت کاری هفتگی، و اتاقها.
-
-> ⚠️ `doctor_addresses` در `GlobalTables::ENTITIES` سراسری اعلام شده و `TenantFilter`
-> رویش اعمال **نمیشود**. هر مسیری که `addressUuid` میگیرد از `BranchResolver` رد
-> میشود که آدرس را با محیط جاری تطبیق میدهد و در غیر این صورت **۴۰۴** میدهد
-> (نه ۴۰۳ — وجود دادهٔ محیط بیگانه لو نمیرود).
-
----
-
-## دو قرارداد که باید بدانید
-
-**۱. شعبهٔ بدون ساعت کاری = «تعریفنشده»، نه «همیشهباز».**
-`defined: false` یعنی هیچ بازهای ثبت نشده. محاسبهٔ اسلات در این حالت به رفتار فعلی
-برمیگردد و برنامهٔ هفتگی پزشک تنها مرجع است. پس همهٔ دادهٔ موجود — که هیچ ساعت کاری
-شعبه ندارد — دقیقاً مثل قبل کار میکند.
-
-**۲. `active` در این فاز فقط ذخیره میشود.**
-غیرفعال کردن شعبه هیچ اثری بر اسلاتهای تولیدشده ندارد؛ اعمالش در تسک ۰۳ است، چون
-تغییر `SlotCalculatorService` در فاز فعلی ممنوع است.
-
----
-
-## `GET /api/v1/branches`
-
-شعبههای محیط جاری. برای منشی، محیط از رابطهٔ فعال او حل میشود؛ برای بقیه از
-`clinic_uuid` درخواست، بعد محیط فعال، بعد نقش.
-
-**Query:** `clinic_uuid` (اختیاری) — انتخاب صریح محیط کلینیک.
-
-**پاسخ ۲۰۰** (خروجی واقعی):
-
-```json
-{
- "success": true,
- "data": [
- {
- "id": "11547",
- "uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
- "type": "clinic",
- "clinic_id": 4,
- "clinic_name": null,
- "name": "درمانگاه شبانه روزی صدرا ",
- "map": { "latitude": "30.667110344662", "longitude": "51.597043275833" },
- "address": "خیابا پزشک روبه روی لوازم خانگی هرمزی ",
- "telephone": "07433221212",
- "active": true,
- "timezone": "Asia/Tehran",
- "city": { "id": "123", "name": "یاسوج" },
- "province": { "id": "23", "name": "کهگیلویه و بویراحمد" },
- "working_hours_defined": false,
- "rooms_count": 0
- }
- ]
-}
-```
-
-`working_hours_defined` و `rooms_count` فقط در این اندپوینت هستند و با **دو کوئری
-گروهی** پر میشوند، نه دو کوئری per شعبه — `BranchFieldsTest::testListQueryCountDoesNotGrowWithBranches`
-همین را قفل میکند. `rooms_count` فقط اتاق **فعال** را میشمارد.
-
-`active` و `timezone` روی خروجی **همهٔ ۹ اندپوینت موجود آدرس** هم ظاهر میشوند، چون از
-`DoctorAddress::toArray()` میآیند. تغییر additive است و هیچ فیلدی حذف نشده.
-
----
-
-## `PATCH /api/v1/branch/{addressUuid}`
-
-فقط دو ویژگی شعبهای. نام/آدرس/تلفن/مختصات همانجایی ویرایش میشوند که همیشه.
-
-| فیلد | نوع | توضیح |
-|---|---|---|
-| `active` | bool | اختیاری |
-| `timezone` | string | اختیاری — با `DateTimeZone::listIdentifiers()` سنجیده میشود، نه regex |
-
-**۲۰۰** بدنهٔ کامل شعبه را برمیگرداند (همان شکل بالا).
-
-**۴۲۲ — منطقهٔ زمانی ناشناخته** (خروجی واقعی برای `{"timezone":"Tehran"}`):
-
-```json
-{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"منطقهٔ زمانی نامعتبر است","field":"timezone"}]}
-```
-
-**۴۰۴** — آدرسی که به محیط جاری تعلق ندارد.
-
----
-
-## `GET /api/v1/branch/{addressUuid}/working-hours`
-
-```json
-{
- "success": true,
- "data": {
- "branch_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
- "timezone": "Asia/Tehran",
- "defined": false,
- "days": { "0": [], "1": [], "2": [], "3": [], "4": [], "5": [], "6": [] }
- }
-}
-```
-
-`days` همیشه **شیء** با هر هفت کلید `"0".."6"` است — ۰ = شنبه، همان قرارداد
-`SlotCalculatorService`. روزِ خالی یعنی شعبه آن روز بسته است.
-
-> کلیدهای ۰..۶ پشتسرهماند، پس `json_encode` بیمراقبت آرایهٔ PHP را به **آرایهٔ
-> JSON** تبدیل میکرد. کنترلر عمداً به `stdClass` تبدیل میکند و
-> `WorkingHoursTest::testDaysIsAJsonObjectNotAnArray` شکل را قفل میکند.
-
----
-
-## `PUT /api/v1/branch/{addressUuid}/working-hours`
-
-**جایگزینی کامل** هفت روز. بدنه تمام حقیقت است: روزی که نفرستید خالی میشود و
-`{"days":{}}` همهٔ ساعتهای شعبه را پاک میکند (بستن کامل شعبه). merge تفاضلی نیست.
-
-```json
-{
- "days": {
- "0": [
- { "start_minute": 540, "end_minute": 780 },
- { "start_minute": 960, "end_minute": 1200 }
- ],
- "1": [{ "start_minute": 540, "end_minute": 780 }]
- }
-}
-```
-
-| فیلد | نوع | قاعده |
-|---|---|---|
-| کلید روز | `"0".."6"` | ۰ = شنبه |
-| `start_minute` | int | دقیقه از نیمهشب، `0..1440` |
-| `end_minute` | int | `0..1440` و **اکیداً** بزرگتر از `start_minute` |
-
-`sequence` را کلاینت نمیفرستد؛ سرور بعد از مرتبسازی بازهها تخصیص میدهد.
-
-زمانها عددیاند نه رشتهٔ `"09:00"`، چون تقاطع دو بازه محاسبهٔ عددی است و مقایسهٔ
-رشتهای `"9:00" < "10:00"` غلط جواب میدهد. `start_time`/`end_time` در پاسخ فقط برای
-نمایشاند. بازهٔ شبانهروزی `0..1440` **یک** ردیف است و `end_time` آن `"24:00"` میشود،
-نه `"00:00"`.
-
-**پاسخ ۲۰۰** (خروجی واقعی همان بدنهٔ بالا):
-
-```json
-{
- "success": true,
- "data": {
- "branch_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
- "timezone": "Asia/Tehran",
- "defined": true,
- "days": {
- "0": [
- { "sequence": 0, "start_minute": 540, "end_minute": 780, "start_time": "09:00", "end_time": "13:00", "active": true },
- { "sequence": 1, "start_minute": 960, "end_minute": 1200, "start_time": "16:00", "end_time": "20:00", "active": true }
- ],
- "1": [
- { "sequence": 0, "start_minute": 540, "end_minute": 780, "start_time": "09:00", "end_time": "13:00", "active": true }
- ],
- "2": [], "3": [], "4": [], "5": [], "6": []
- }
- }
-}
-```
-
-**۴۲۲ — همپوشانی** (خروجی واقعی):
-
-```json
-{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"بازههای روز 2 با هم همپوشانی دارند","field":"start_minute"}]}
-```
-
-سایر ۴۲۲ها: `end_minute <= start_minute` (field `end_minute`) · دقیقهٔ بیرون از
-`0..1440` · کلید روز بیرون از `0..6` (field `day_of_week`) · نبودِ `days` (field `days`).
-
-بازهٔ **چسبیده** خطا نیست: `13:00–15:00` بعد از `09:00–13:00` مجاز است.
-
-> **اتمی است.** اعتبارسنجی کاملِ هر هفت روز پیش از هر `DELETE` اجرا میشود، پس یک بازهٔ
-> نامعتبر در روز ششم، شش روز درستِ قبلی را پاک نمیکند و بعد ۴۲۲ برگرداند
-> (`WorkingHoursTest::testInvalidLaterDayLeavesTheStoredWeekUntouched`).
-
----
-
-## `GET /api/v1/branch/{addressUuid}/rooms`
-
-```json
-{
- "success": true,
- "data": [
- {
- "uuid": "5425f5c7-22da-4130-b45d-4708313460cd",
- "address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
- "address_name": "درمانگاه شبانه روزی صدرا ",
- "name": "اتاق تزریقات",
- "room_type": "تزریقات",
- "capacity": 3,
- "floor": "۱",
- "active": true,
- "created_at": 1785416929,
- "updated_at": 1785416929
- }
- ]
-}
-```
-
-هم فعال و هم غیرفعال برمیگردد؛ فیلتر در UI است.
-
----
-
-## `POST /api/v1/room`
-
-| فیلد | نوع | الزامی | توضیح |
-|---|---|---|---|
-| `address_uuid` | string | ✅ | شعبهای که اتاق در آن است |
-| `name` | string | ✅ | حداکثر ۱۲۰ نویسه |
-| `room_type` | string\|null | — | متن آزاد؛ نوع اتاق را کلینیک تعریف میکند |
-| `capacity` | int | — | پیشفرض ۱، حداقل ۱ |
-| `floor` | string\|null | — | حداکثر ۲۰ نویسه |
-| `active` | bool | — | پیشفرض `true` |
-
-**`capacity` تعداد بیمار همزمان است.** اتاق تزریق سهتخته **یک** اتاق با ظرفیت ۳ است،
-نه سه اتاق (بند ۶ مستند طراحی).
-
-> جفت محیط اتاق در سازندهٔ entity **از خودِ آدرس مشتق** میشود، نه از بدنهٔ درخواست:
-> آدرس `type=clinic` ⇒ `(clinic, clinic_id)` و `type=personal` ⇒ `(doctor, doctor_id)`.
-> پس کلاینت نمیتواند اتاقی را به محیط دیگری بچسباند.
-
-**۲۰۱** (خروجی واقعی):
-
-```json
-{
- "success": true,
- "data": {
- "uuid": "5425f5c7-22da-4130-b45d-4708313460cd",
- "address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
- "address_name": "درمانگاه شبانه روزی صدرا ",
- "name": "اتاق تزریقات",
- "room_type": "تزریقات",
- "capacity": 3,
- "floor": "۱",
- "active": true,
- "created_at": 1785416929,
- "updated_at": 1785416929
- }
-}
-```
-
-**۴۲۲ — ظرفیت صفر** (خروجی واقعی):
-
-```json
-{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"ظرفیت اتاق حداقل ۱ است","field":"capacity"}]}
-```
-
-سایر ۴۲۲ها: `address_uuid` نبود (field `address_uuid`) · نام خالی یا فقط فاصله
-(field `name`، کد `ERR_VALIDATION_002`).
-**۴۰۴** — آدرس متعلق به محیط جاری نیست.
-
----
-
-## `PATCH /api/v1/room/{uuid}`
-
-همان فیلدهای `POST` منهای `address_uuid` — اتاق بین شعبهها جابهجا نمیشود (جفت محیطش
-از آدرس مشتق شده و write-once است). فیلدِ نفرستاده دستنخورده میماند؛ رشتهٔ خالی روی
-`room_type`/`floor` یعنی «پاک کن» و `null` ذخیره میشود.
-
-**۲۰۰** (خروجی واقعی برای `{"capacity":2,"active":false}`):
-
-```json
-{
- "success": true,
- "data": {
- "uuid": "5425f5c7-22da-4130-b45d-4708313460cd",
- "address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
- "address_name": "درمانگاه شبانه روزی صدرا ",
- "name": "اتاق تزریقات",
- "room_type": "تزریقات",
- "capacity": 2,
- "floor": "۱",
- "active": false,
- "created_at": 1785416929,
- "updated_at": 1785416942
- }
-}
-```
-
-**۴۰۴** — اتاق محیط دیگر.
-
-> مالکیت **صریح** سنجیده میشود و به `TenantFilter` تکیه نمیشود: جداسازی سختِ فیلتر
-> فقط روی محیطِ *انتخابشده* اعمال میشود، پس پزشکی که هنوز محیطی برنگزیده بود
-> میتوانست اتاق کلینیک دیگری را PATCH کند. با
-> `RoomCrudTest::testForeignRoomIsNotFound` گرفته و بسته شد.
-
----
-
-## `DELETE /api/v1/room/{uuid}`
-
-**۲۰۰** (خروجی واقعی): `{"success":true,"data":null}`
-**۴۰۴** — اتاق محیط دیگر.
-
-در این فاز حذف اتاق قید ندارد، چون اتاق هنوز وابستهٔ زندهای ندارد. دلایل منع حذف
-از راه `RoomDeletionGuardInterface` تزریق میشوند: تسک ۰۲ (منبع فعال روی اتاق) و تسک ۰۷
-(نوبت آیندهٔ آن منابع) هرکدام یک پیادهسازی اضافه میکنند و `RoomService` دست نمیخورد.
-
-مسیر اصلیِ «کنار گذاشتن» اتاق `active=false` است، نه `DELETE`.
-
-⚠️ حذف **آدرس** ساعتهای کاری و اتاقهایش را با `ON DELETE CASCADE` میبرد. تا وقتی
-نوبت به اتاق وصل نشده (تسک ۰۷) بیخطر است؛ آنجا باید گاردِ حذف آدرس اضافه شود.
-
----
-
-## طبقهبندی محیط
-
-| جدول | وضعیت |
-|---|---|
-| `doctor_addresses` | `GlobalTables::ENTITIES` — سراسری، محافظش `BranchResolver` |
-| `branch_working_hours` | جفت `(entity_type, entity_id)` مشتق از آدرس در سازنده |
-| `rooms` | جفت `(entity_type, entity_id)` مشتق از آدرس در سازنده |
-
-`branch_working_hours` اول بهعنوان فرزند aggregate با ریشهٔ `DoctorAddress` ثبت شد و
-`TenantSchemaCoverageTest` درست ردش کرد: آن ریشه خودش سراسری است، پس آن مسیر هیچ
-تضمینی نمیداد. حالا جفت واقعی دارد.
-
----
-
-## تستها
-
-```bash
-ddev exec php bin/phpunit tests/Branch # ۳۶ تست / ۱۰۱ assertion
-ddev exec php bin/phpunit --group=slot-mode-frozen # منطق اسلاتی دستنخورده
-npx vitest run assets/admin/pages/BranchWorkingHoursPage.test.tsx
-```
diff --git a/docs/api/doctor.md b/docs/api/doctor.md
index c75d5dd3..02b33210 100644
--- a/docs/api/doctor.md
+++ b/docs/api/doctor.md
@@ -602,3 +602,24 @@ Address object created from clinic data.
|------|------|-------------|
| `ERR_FORBIDDEN_001` | 403 | Not associated with this clinic |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
+
+---
+
+## `GET /api/v1/addresses`
+
+مجوز: `appointment_settings.view`. فهرست محلهای نوبتدهی محیط جاری، فقط برای انتخاب.
+
+جانشین `GET /api/v1/branches` است که با حذف دامنهٔ شعبه برداشته شد. خودِ آدرس نمیرود:
+هر منبع، لیست قیمت و نوبت به یکی از اینها بسته است. ساخت و ویرایش آدرس همانجایی است
+که همیشه بود (`ClinicController` و `AppointmentSettingsController`).
+
+```json
+{
+ "success": true,
+ "data": [
+ { "uuid": "…", "name": "کلینیک تخصصی مهر", "active": true }
+ ]
+}
+```
+
+**۴۰۳:** منشیِ بدون مجوز `appointment_settings.view`.
diff --git a/docs/api/resource-calendar.md b/docs/api/resource-calendar.md
index afa72233..83cf55fd 100644
--- a/docs/api/resource-calendar.md
+++ b/docs/api/resource-calendar.md
@@ -11,10 +11,11 @@
## کسرِ لایهها
-بند ۹ مستند ساعت آزاد را از کسر هفت لایه میسازد. آنچه این بخش میدهد **چهار لایهٔ اول** است:
+بند ۹ مستند ساعت آزاد را از کسر هفت لایه میسازد. آنچه این بخش میدهد **سه لایهٔ اول** است
+(لایهٔ «ساعت کاری شعبه» با حذف دامنهٔ شعبه برداشته شد — تنها مرجع ساعت، شیفت خودِ منبع است):
```
-ساعت کاری شعبه ∩ شیفت منبع − تعطیلات رسمی − استثناهای منبع
+شیفت منبع − تعطیلات رسمی − استثناهای منبع
```
**نوبتهای ثبتشده و رزروهای موقت اینجا کسر نمیشوند.** خروجی این اندپوینت «وقت قابل
@@ -44,13 +45,13 @@
}
```
-`days` همیشه **شیء** با هر هفت کلید `"0".."6"` است؛ ۰ = شنبه. `timezone` از شعبهٔ منبع
+`days` همیشه **شیء** با هر هفت کلید `"0".."6"` است؛ ۰ = شنبه. `timezone` از محل نوبتدهی منبع
میآید و مبنای «روز» در محاسبهٔ ساعت آزاد است.
## `PUT /api/v1/resource/{uuid}/calendar`
جایگزینی کامل هفت روز — روزی که نفرستید خالی میشود. قواعد و خطاها دقیقاً مثل ساعت
-کاری شعبه ([branch.md](branch.md)): دقیقه از نیمهشب `0..1440`، `end > start`، بدون
+کاری: دقیقه از نیمهشب `0..1440`، `end > start`، بدون
همپوشانی در یک روز، `sequence` را سرور میدهد، و **اعتبارسنجی کامل پیش از هر حذفی**.
```json
@@ -136,12 +137,10 @@
| کد | یعنی |
|---|---|
| `no_shift` | منبع آن روز شیفتی ندارد |
-| `branch_closed` | شعبه آن روز ساعت کاری ندارد |
-| `outside_branch_hours` | شیفت هست ولی تقاطعش با ساعت شعبه خالی شد |
| `national_holiday` | تعطیل رسمی کشور |
| `tenant_holiday` | این محیط آن روز را تعطیل اعلام کرده |
| `exception` | مرخصی/غیبت/سرویس بخشی یا تمام روز را بریده |
-| `resource_inactive` / `branch_inactive` | منبع یا شعبه غیرفعال است |
+| `resource_inactive` / `address_inactive` | منبع یا محل نوبتدهی غیرفعال است |
**۴۲۲:** نبودِ `from`/`to` · `to < from` · بازهٔ بیش از ۹۲ روز (خروجی واقعی):
@@ -149,14 +148,11 @@
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"بازهٔ درخواستی حداکثر 92 روز است","field":"to"}]}
```
-### دو قرارداد مهم
+### یک قرارداد مهم
-**شعبهٔ بدون ساعت کاری = «تعریفنشده»، نه «بسته».** شیفت منبع بیقید اعمال میشود تا
-دادهٔ موجود دقیقاً مثل امروز کار کند. این با `branch_closed` — که یعنی ساعت تعریف شده
-ولی آن روز خالی است — فرق دارد.
-
-**شیفت بیرون از ساعت شعبه رد نمیشود، تقاطع گرفته میشود.** شیفت ۹–۱۷ روی شعبهای که
-۱۰–۱۲ باز است، ۱۲۰ دقیقه میدهد.
+**تنها مرجع ساعت کاری، شیفت خودِ منبع است.** تا پیش از حذف دامنهٔ شعبه، این شیفت با
+ساعت کاری شعبه تقاطع میگرفت و دلیلهای `branch_closed` و `outside_branch_hours` را
+میساخت؛ آن لایه و آن دو دلیل دیگر وجود ندارند.
---
diff --git a/migrations/Version20260802113956.php b/migrations/Version20260802113956.php
new file mode 100644
index 00000000..4588299e
--- /dev/null
+++ b/migrations/Version20260802113956.php
@@ -0,0 +1,50 @@
+addSql('ALTER TABLE clinic_resources DROP FOREIGN KEY `FK_10DABCC554177093`');
+ $this->addSql('DROP INDEX uniq_resource_room ON clinic_resources');
+ $this->addSql('ALTER TABLE clinic_resources DROP room_id');
+
+ $this->addSql('ALTER TABLE branch_working_hours DROP FOREIGN KEY `FK_E8C43E37F5B7AF75`');
+ $this->addSql('ALTER TABLE rooms DROP FOREIGN KEY `FK_7CA11A96F5B7AF75`');
+ $this->addSql('DROP TABLE IF EXISTS branch_working_hours');
+ $this->addSql('DROP TABLE IF EXISTS rooms');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('CREATE TABLE branch_working_hours (id INT AUTO_INCREMENT NOT NULL, day_of_week SMALLINT NOT NULL, sequence SMALLINT DEFAULT 0 NOT NULL, start_minute SMALLINT NOT NULL, end_minute SMALLINT NOT NULL, active TINYINT DEFAULT 1 NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, address_id INT NOT NULL, INDEX idx_bwh_tenant (entity_type, entity_id), INDEX idx_bwh_address_day (address_id, day_of_week, active), INDEX IDX_E8C43E37F5B7AF75 (address_id), UNIQUE INDEX uniq_bwh_address_day_seq (address_id, day_of_week, sequence), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB');
+ $this->addSql('CREATE TABLE rooms (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, name VARCHAR(120) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, room_type VARCHAR(60) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, capacity SMALLINT DEFAULT 1 NOT NULL, floor VARCHAR(20) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_520_ci`, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, address_id INT NOT NULL, UNIQUE INDEX UNIQ_7CA11A96D17F50A6 (uuid), INDEX IDX_7CA11A96F5B7AF75 (address_id), INDEX idx_rooms_address (address_id, active), INDEX idx_rooms_tenant (entity_type, entity_id, active), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB');
+ $this->addSql('ALTER TABLE branch_working_hours ADD CONSTRAINT `FK_E8C43E37F5B7AF75` FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE');
+ $this->addSql('ALTER TABLE rooms ADD CONSTRAINT `FK_7CA11A96F5B7AF75` FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE');
+
+ // ردیفهای اتاق برنمیگردند؛ ستون و قیدش برمیگردد تا اسکیمای قبلی بازسازی شود.
+ $this->addSql('ALTER TABLE clinic_resources ADD room_id INT DEFAULT NULL');
+ $this->addSql('ALTER TABLE clinic_resources ADD CONSTRAINT `FK_10DABCC554177093` FOREIGN KEY (room_id) REFERENCES rooms (id) ON DELETE CASCADE');
+ $this->addSql('CREATE UNIQUE INDEX uniq_resource_room ON clinic_resources (room_id)');
+ }
+}
diff --git a/src/Appointment/Availability/Controller/AvailabilityController.php b/src/Appointment/Availability/Controller/AvailabilityController.php
index f0c7a076..9e145251 100644
--- a/src/Appointment/Availability/Controller/AvailabilityController.php
+++ b/src/Appointment/Availability/Controller/AvailabilityController.php
@@ -8,7 +8,7 @@ use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Auth\Entity\User;
-use App\Branch\Service\BranchResolver;
+use App\Doctor\Service\AddressResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Doctor\Entity\DoctorAddress;
@@ -39,7 +39,7 @@ class AvailabilityController extends BaseController
private readonly ServiceItemRepository $items,
private readonly WeeklyScheduleRepository $schedules,
private readonly DoctorRepository $doctors,
- private readonly BranchResolver $branches,
+ private readonly AddressResolver $branches,
) {}
#[Route('/api/v1/appointment-availability', name: 'appointment_availability', methods: ['POST'])]
diff --git a/src/Appointment/Availability/Controller/ResourceBlockController.php b/src/Appointment/Availability/Controller/ResourceBlockController.php
index f96ae4ff..90cfacf7 100644
--- a/src/Appointment/Availability/Controller/ResourceBlockController.php
+++ b/src/Appointment/Availability/Controller/ResourceBlockController.php
@@ -5,7 +5,7 @@ namespace App\Appointment\Availability\Controller;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Availability\Repository\ResourceOccupancyRepository;
use App\Auth\Entity\User;
-use App\Branch\Service\BranchResolver;
+use App\Doctor\Service\AddressResolver;
use App\Resource\Entity\ClinicResource;
use App\Resource\Repository\ClinicResourceRepository;
use App\Shared\Constant\ErrorCodes;
@@ -41,7 +41,7 @@ class ResourceBlockController extends BaseController
public function __construct(
private readonly ClinicResourceRepository $resources,
private readonly ResourceOccupancyRepository $occupancy,
- private readonly BranchResolver $branches,
+ private readonly AddressResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
diff --git a/src/Appointment/Booking/Controller/BookingController.php b/src/Appointment/Booking/Controller/BookingController.php
index 2ffb07e6..960f6c45 100644
--- a/src/Appointment/Booking/Controller/BookingController.php
+++ b/src/Appointment/Booking/Controller/BookingController.php
@@ -16,7 +16,7 @@ use App\Pricing\Service\PriceSnapshotService;
use App\Pricing\Service\PricingEngine;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Auth\Entity\User;
-use App\Branch\Service\BranchResolver;
+use App\Doctor\Service\AddressResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Resource\Entity\ClinicResource;
@@ -51,7 +51,7 @@ class BookingController extends BaseController
private readonly UserRepository $users,
private readonly PricingEngine $pricing,
private readonly PriceSnapshotService $snapshots,
- private readonly BranchResolver $branches,
+ private readonly AddressResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly AppointmentSegmentRepository $segments,
private readonly EntityManagerInterface $em,
diff --git a/src/Appointment/Plan/Controller/AppointmentPlanController.php b/src/Appointment/Plan/Controller/AppointmentPlanController.php
index c357af0c..77020fe2 100644
--- a/src/Appointment/Plan/Controller/AppointmentPlanController.php
+++ b/src/Appointment/Plan/Controller/AppointmentPlanController.php
@@ -7,7 +7,7 @@ use App\Appointment\Plan\Entity\SegmentTemplate;
use App\Appointment\Plan\Repository\SegmentTemplateRepository;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Auth\Entity\User;
-use App\Branch\Service\BranchResolver;
+use App\Doctor\Service\AddressResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Resource\Service\ResourceContext;
@@ -30,7 +30,7 @@ class AppointmentPlanController extends BaseController
private readonly SegmentTemplateRepository $templates,
private readonly ServiceItemRepository $items,
private readonly AppointmentPlanBuilder $builder,
- private readonly BranchResolver $branches,
+ private readonly AddressResolver $branches,
private readonly ResourceContext $resourceContext,
private readonly EntityManagerInterface $em,
) {}
diff --git a/src/Branch/Controller/BranchController.php b/src/Branch/Controller/BranchController.php
deleted file mode 100644
index ceab6b4f..00000000
--- a/src/Branch/Controller/BranchController.php
+++ /dev/null
@@ -1,161 +0,0 @@
-secretaryAccess->denyUnlessGranted($user, 'appointment_settings', $action);
- $this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', $action);
- }
-
- #[Route('/api/v1/branches', name: 'branch_list', methods: ['GET'])]
- public function list(#[CurrentUser] User $user): JsonResponse
- {
- $this->denyUnlessGranted($user, 'view');
-
- $addresses = $this->branches->listForContext($user);
- $ids = array_map(static fn (DoctorAddress $a): int => (int) $a->getId(), $addresses);
-
- // دو کوئری گروهی بهجای دو کوئری per شعبه.
- $hourCounts = $this->hoursRepo->countByAddressIds($ids);
- $roomCounts = $this->roomRepo->countActiveByAddressIds($ids);
-
- $rows = array_map(static function (DoctorAddress $address) use ($hourCounts, $roomCounts): array {
- $id = (int) $address->getId();
- $row = $address->toArray();
-
- $row['working_hours_defined'] = ($hourCounts[$id] ?? 0) > 0;
- $row['rooms_count'] = $roomCounts[$id] ?? 0;
-
- return $row;
- }, $addresses);
-
- return $this->success($rows);
- }
-
- /** فقط دو ویژگی شعبهای؛ نام/آدرس/تلفن همانجایی ویرایش میشوند که همیشه. */
- #[Route('/api/v1/branch/{addressUuid}', name: 'branch_update', methods: ['PATCH'])]
- public function update(#[CurrentUser] User $user, string $addressUuid, Request $request): JsonResponse
- {
- $this->denyUnlessGranted($user, 'update');
-
- $address = $this->branches->resolve($user, $addressUuid);
- $data = json_decode($request->getContent(), true);
-
- if (!is_array($data)) {
- return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
- }
-
- if (array_key_exists('active', $data)) {
- $address->setActive((bool) $data['active']);
- }
-
- if (array_key_exists('timezone', $data)) {
- if (!is_string($data['timezone'])) {
- return $this->error(ErrorCodes::ERR_VALIDATION_001, 'منطقهٔ زمانی نامعتبر است', 422, 'timezone');
- }
-
- try {
- $address->setTimezone($data['timezone']);
- } catch (\InvalidArgumentException) {
- return $this->error(ErrorCodes::ERR_VALIDATION_001, 'منطقهٔ زمانی نامعتبر است', 422, 'timezone');
- }
- }
-
- $this->em->flush();
-
- return $this->success($address->toArray());
- }
-
- #[Route('/api/v1/branch/{addressUuid}/working-hours', name: 'branch_working_hours_show', methods: ['GET'])]
- public function showWorkingHours(#[CurrentUser] User $user, string $addressUuid): JsonResponse
- {
- $this->denyUnlessGranted($user, 'view');
-
- $address = $this->branches->resolve($user, $addressUuid);
-
- return $this->success([
- 'branch_uuid' => $address->getUuid(),
- 'timezone' => $address->getTimezone(),
- 'defined' => $this->workingHours->isDefined($address),
- 'days' => self::daysObject($this->workingHours->read($address)),
- ]);
- }
-
- /**
- * جایگزینی کامل هفت روز. آرایهٔ خالی یعنی شعبه کاملاً بسته است — نه «تغییری نده».
- */
- #[Route('/api/v1/branch/{addressUuid}/working-hours', name: 'branch_working_hours_replace', methods: ['PUT'])]
- public function replaceWorkingHours(#[CurrentUser] User $user, string $addressUuid, Request $request): JsonResponse
- {
- $this->denyUnlessGranted($user, 'update');
-
- $address = $this->branches->resolve($user, $addressUuid);
- $data = json_decode($request->getContent(), true);
-
- if (!is_array($data) || !is_array($data['days'] ?? null)) {
- return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد days الزامی است', 422, 'days');
- }
-
- $days = $this->workingHours->replace($address, $data['days']);
-
- return $this->success([
- 'branch_uuid' => $address->getUuid(),
- 'timezone' => $address->getTimezone(),
- 'defined' => $days !== array_fill_keys(WorkingHoursService::DAYS, []),
- 'days' => self::daysObject($days),
- ]);
- }
-
- /**
- * کلیدهای ۰..۶ پشتسرهماند، پس json_encode آرایهٔ PHP را به **آرایهٔ JSON**
- * تبدیل میکرد نه به شیئی با کلیدهای "0".."6". کلاینت با `days["0"]` هر دو را
- * میخواند، ولی شکل پاسخ ناپایدار میشد: کافی بود یک روز جا بیفتد تا همان فیلد
- * شیء برگردد. (کلید رشتهایِ عددی هم چاره نیست — PHP خودش به int برش میگرداند.)
- *
- * @param array>> $days
- */
- private static function daysObject(array $days): \stdClass
- {
- return (object) $days;
- }
-}
diff --git a/src/Branch/Controller/RoomController.php b/src/Branch/Controller/RoomController.php
deleted file mode 100644
index 7d4c356d..00000000
--- a/src/Branch/Controller/RoomController.php
+++ /dev/null
@@ -1,120 +0,0 @@
-secretaryAccess->denyUnlessGranted($user, 'appointment_settings', $action);
- $this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', $action);
- }
-
- /**
- * مالکیت صریح سنجیده میشود و به TenantFilter تکیه نمیکنیم: جداسازی سختِ فیلتر
- * فقط روی محیطِ «انتخابشده» اعمال میشود ({@see EntityContext::$chosen}) و پزشکی
- * که هنوز محیطی برنگزیده، اتاق کلینیک دیگر را میدید — با تست
- * RoomCrudTest::testForeignRoomIsNotFound گرفته شد.
- *
- * ۴۰۴ نه ۴۰۳، همان رفتار فیلتر: وجود دادهٔ محیط بیگانه لو نمیرود.
- */
- private function requireRoom(User $user, string $uuid): Room
- {
- $room = $this->rooms->findByUuid($uuid);
- [$entityType, $entityId] = $this->branches->pair($user);
-
- if ($room === null || !$this->ownership->belongsToPair($entityType, $entityId, $room)) {
- throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'اتاق یافت نشد', 404);
- }
-
- return $room;
- }
-
- #[Route('/api/v1/branch/{addressUuid}/rooms', name: 'branch_rooms_list', methods: ['GET'])]
- public function list(#[CurrentUser] User $user, string $addressUuid): JsonResponse
- {
- $this->denyUnlessGranted($user, 'view');
-
- $address = $this->branches->resolve($user, $addressUuid);
-
- return $this->success(array_map(
- static fn (Room $room): array => $room->toArray(),
- $this->rooms->findForAddress($address),
- ));
- }
-
- #[Route('/api/v1/room', name: 'room_create', methods: ['POST'])]
- public function create(#[CurrentUser] User $user, Request $request): JsonResponse
- {
- $this->denyUnlessGranted($user, 'update');
-
- $data = json_decode($request->getContent(), true);
-
- if (!is_array($data) || !is_string($data['address_uuid'] ?? null)) {
- return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد address_uuid الزامی است', 422, 'address_uuid');
- }
-
- // جفت محیط اتاق از همین آدرس مشتق میشود، نه از بدنهٔ درخواست.
- $address = $this->branches->resolve($user, $data['address_uuid']);
- $room = $this->roomService->create($address, $data);
-
- return $this->success($room->toArray(), 201);
- }
-
- #[Route('/api/v1/room/{uuid}', name: 'room_update', methods: ['PATCH'])]
- public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
- {
- $this->denyUnlessGranted($user, 'update');
-
- $data = json_decode($request->getContent(), true);
-
- if (!is_array($data)) {
- return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
- }
-
- $room = $this->roomService->update($this->requireRoom($user, $uuid), $data);
-
- return $this->success($room->toArray());
- }
-
- #[Route('/api/v1/room/{uuid}', name: 'room_delete', methods: ['DELETE'])]
- public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse
- {
- $this->denyUnlessGranted($user, 'update');
-
- $this->roomService->delete($this->requireRoom($user, $uuid));
-
- return $this->success(null);
- }
-}
diff --git a/src/Branch/Entity/BranchWorkingHours.php b/src/Branch/Entity/BranchWorkingHours.php
deleted file mode 100644
index f7457014..00000000
--- a/src/Branch/Entity/BranchWorkingHours.php
+++ /dev/null
@@ -1,105 +0,0 @@
- 0])]
- private int $sequence = 0;
-
- #[ORM\Column(name: 'start_minute', type: 'smallint')]
- private int $startMinute;
-
- #[ORM\Column(name: 'end_minute', type: 'smallint')]
- private int $endMinute;
-
- #[ORM\Column(type: 'boolean', options: ['default' => true])]
- private bool $active = true;
-
- public function __construct(
- DoctorAddress $address,
- int $dayOfWeek,
- int $startMinute,
- int $endMinute,
- int $sequence = 0,
- ) {
- $this->address = $address;
- $this->dayOfWeek = $dayOfWeek;
- $this->startMinute = $startMinute;
- $this->endMinute = $endMinute;
- $this->sequence = $sequence;
-
- $this->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId());
- }
-
- public function getId(): ?int { return $this->id; }
- public function getAddress(): DoctorAddress { return $this->address; }
- public function getDayOfWeek(): int { return $this->dayOfWeek; }
- public function getSequence(): int { return $this->sequence; }
- public function getStartMinute(): int { return $this->startMinute; }
- public function getEndMinute(): int { return $this->endMinute; }
- public function isActive(): bool { return $this->active; }
-
- public function setActive(bool $v): self { $this->active = $v; return $this; }
-
- public function toArray(): array
- {
- return [
- 'sequence' => $this->sequence,
- 'start_minute' => $this->startMinute,
- 'end_minute' => $this->endMinute,
- 'start_time' => self::formatMinute($this->startMinute),
- 'end_time' => self::formatMinute($this->endMinute),
- 'active' => $this->active,
- ];
- }
-
- /** ۱۴۴۰ به «۲۴:۰۰» تبدیل میشود، نه «۰۰:۰۰» — پایانِ روز است نه آغازش. */
- public static function formatMinute(int $minute): string
- {
- return sprintf('%02d:%02d', intdiv($minute, 60), $minute % 60);
- }
-}
diff --git a/src/Branch/Entity/Room.php b/src/Branch/Entity/Room.php
deleted file mode 100644
index 44c7e132..00000000
--- a/src/Branch/Entity/Room.php
+++ /dev/null
@@ -1,120 +0,0 @@
- 1])]
- private int $capacity = 1;
-
- #[ORM\Column(type: 'string', length: 20, nullable: true)]
- private ?string $floor = null;
-
- #[ORM\Column(type: 'boolean', options: ['default' => true])]
- private bool $active = true;
-
- #[ORM\Column(name: 'created_at', type: 'integer')]
- private int $createdAt;
-
- #[ORM\Column(name: 'updated_at', type: 'integer')]
- private int $updatedAt;
-
- public function __construct(DoctorAddress $address, string $name)
- {
- $this->uuid = Uuid::v4()->toRfc4122();
- $this->address = $address;
- $this->name = $name;
- $this->createdAt = time();
- $this->updatedAt = time();
-
- $this->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId());
- }
-
- public function getId(): ?int { return $this->id; }
- public function getUuid(): string { return $this->uuid; }
- public function getAddress(): DoctorAddress { return $this->address; }
- public function getName(): string { return $this->name; }
- public function getRoomType(): ?string { return $this->roomType; }
- public function getCapacity(): int { return $this->capacity; }
- public function getFloor(): ?string { return $this->floor; }
- public function isActive(): bool { return $this->active; }
- public function getCreatedAt(): int { return $this->createdAt; }
- public function getUpdatedAt(): int { return $this->updatedAt; }
-
- public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
- public function setRoomType(?string $v): self { $this->roomType = $v; $this->touch(); return $this; }
- public function setFloor(?string $v): self { $this->floor = $v; $this->touch(); return $this; }
- public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
-
- /** @throws \InvalidArgumentException روی ظرفیت کمتر از ۱ */
- public function setCapacity(int $v): self
- {
- if ($v < 1) {
- throw new \InvalidArgumentException('Room capacity must be at least 1.');
- }
-
- $this->capacity = $v;
- $this->touch();
-
- return $this;
- }
-
- private function touch(): void { $this->updatedAt = time(); }
-
- public function toArray(): array
- {
- return [
- 'uuid' => $this->uuid,
- 'address_uuid' => $this->address->getUuid(),
- 'address_name' => $this->address->getName(),
- 'name' => $this->name,
- 'room_type' => $this->roomType,
- 'capacity' => $this->capacity,
- 'floor' => $this->floor,
- 'active' => $this->active,
- 'created_at' => $this->createdAt,
- 'updated_at' => $this->updatedAt,
- ];
- }
-}
diff --git a/src/Branch/Repository/BranchWorkingHoursRepository.php b/src/Branch/Repository/BranchWorkingHoursRepository.php
deleted file mode 100644
index a8a5b9a6..00000000
--- a/src/Branch/Repository/BranchWorkingHoursRepository.php
+++ /dev/null
@@ -1,70 +0,0 @@
-
- */
-class BranchWorkingHoursRepository extends ServiceEntityRepository
-{
- public function __construct(ManagerRegistry $registry)
- {
- parent::__construct($registry, BranchWorkingHours::class);
- }
-
- /** @return BranchWorkingHours[] مرتب بر روز و سپس بازه */
- public function findForAddress(DoctorAddress $address): array
- {
- return $this->createQueryBuilder('h')
- ->where('h.address = :address')
- ->setParameter('address', $address)
- ->orderBy('h.dayOfWeek', 'ASC')
- ->addOrderBy('h.sequence', 'ASC')
- ->getQuery()
- ->getResult();
- }
-
- public function deleteForAddress(DoctorAddress $address): int
- {
- return (int) $this->createQueryBuilder('h')
- ->delete()
- ->where('h.address = :address')
- ->setParameter('address', $address)
- ->getQuery()
- ->execute();
- }
-
- /**
- * آدرسهایی که ساعت کاری تعریفشده دارند — برای نشان دادن وضعیت در لیست شعبهها
- * بدون N+۱ کوئری.
- *
- * @param int[] $addressIds
- * @return array شناسهٔ آدرس => تعداد بازهها
- */
- public function countByAddressIds(array $addressIds): array
- {
- if ($addressIds === []) {
- return [];
- }
-
- $rows = $this->createQueryBuilder('h')
- ->select('IDENTITY(h.address) AS address_id, COUNT(h.id) AS total')
- ->where('h.address IN (:ids)')
- ->setParameter('ids', $addressIds)
- ->groupBy('h.address')
- ->getQuery()
- ->getArrayResult();
-
- $counts = [];
- foreach ($rows as $row) {
- $counts[(int) $row['address_id']] = (int) $row['total'];
- }
-
- return $counts;
- }
-}
diff --git a/src/Branch/Repository/RoomRepository.php b/src/Branch/Repository/RoomRepository.php
deleted file mode 100644
index 19655e5c..00000000
--- a/src/Branch/Repository/RoomRepository.php
+++ /dev/null
@@ -1,78 +0,0 @@
-
- */
-class RoomRepository extends ServiceEntityRepository
-{
- public function __construct(ManagerRegistry $registry)
- {
- parent::__construct($registry, Room::class);
- }
-
- /**
- * uuid از request میآید، ولی Room جفت محیط دارد پس TenantFilter اتاق محیط دیگر را
- * پیش از رسیدن به اینجا حذف میکند — همان دلیلی که در TenantLookupInventoryTest
- * برای این lookup ثبت شده.
- */
- public function findByUuid(string $uuid): ?Room
- {
- return $this->findOneBy(['uuid' => $uuid]);
- }
-
- /** @return Room[] */
- public function findForAddress(DoctorAddress $address): array
- {
- return $this->createQueryBuilder('r')
- ->where('r.address = :address')
- ->setParameter('address', $address)
- ->orderBy('r.name', 'ASC')
- ->getQuery()
- ->getResult();
- }
-
- public function countActiveForAddress(DoctorAddress $address): int
- {
- return (int) $this->createQueryBuilder('r')
- ->select('COUNT(r.id)')
- ->where('r.address = :address')
- ->andWhere('r.active = true')
- ->setParameter('address', $address)
- ->getQuery()
- ->getSingleScalarResult();
- }
-
- /**
- * @param int[] $addressIds
- * @return array شناسهٔ آدرس => تعداد اتاق فعال
- */
- public function countActiveByAddressIds(array $addressIds): array
- {
- if ($addressIds === []) {
- return [];
- }
-
- $rows = $this->createQueryBuilder('r')
- ->select('IDENTITY(r.address) AS address_id, COUNT(r.id) AS total')
- ->where('r.address IN (:ids)')
- ->andWhere('r.active = true')
- ->setParameter('ids', $addressIds)
- ->groupBy('r.address')
- ->getQuery()
- ->getArrayResult();
-
- $counts = [];
- foreach ($rows as $row) {
- $counts[(int) $row['address_id']] = (int) $row['total'];
- }
-
- return $counts;
- }
-}
diff --git a/src/Branch/Service/RoomDeletionGuardInterface.php b/src/Branch/Service/RoomDeletionGuardInterface.php
deleted file mode 100644
index 05be25b9..00000000
--- a/src/Branch/Service/RoomDeletionGuardInterface.php
+++ /dev/null
@@ -1,20 +0,0 @@
- $deletionGuards
- */
- public function __construct(
- private readonly EntityManagerInterface $em,
- #[AutowireIterator('app.room_deletion_guard')]
- private readonly iterable $deletionGuards = [],
- ) {}
-
- /** @param array $data */
- public function create(DoctorAddress $address, array $data): Room
- {
- $room = new Room($address, $this->assertName($data['name'] ?? null));
- $this->applyOptional($room, $data);
-
- $this->em->persist($room);
- $this->em->flush();
-
- return $room;
- }
-
- /** @param array $data */
- public function update(Room $room, array $data): Room
- {
- if (array_key_exists('name', $data)) {
- $room->setName($this->assertName($data['name']));
- }
-
- $this->applyOptional($room, $data);
- $this->em->flush();
-
- return $room;
- }
-
- public function delete(Room $room): void
- {
- foreach ($this->deletionGuards as $guard) {
- $guard->assertDeletable($room);
- }
-
- $this->em->remove($room);
- $this->em->flush();
- }
-
- /** @param array $data */
- private function applyOptional(Room $room, array $data): void
- {
- if (array_key_exists('capacity', $data)) {
- $room->setCapacity($this->assertCapacity($data['capacity']));
- }
-
- if (array_key_exists('room_type', $data)) {
- $room->setRoomType($this->trimOrNull($data['room_type']));
- }
-
- if (array_key_exists('floor', $data)) {
- $room->setFloor($this->trimOrNull($data['floor']));
- }
-
- if (array_key_exists('active', $data)) {
- $room->setActive((bool) $data['active']);
- }
- }
-
- private function assertName(mixed $value): string
- {
- $name = is_string($value) ? trim($value) : '';
-
- if ($name === '') {
- throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'نام اتاق الزامی است', 422, 'name');
- }
-
- if (mb_strlen($name) > 120) {
- throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام اتاق حداکثر ۱۲۰ نویسه است', 422, 'name');
- }
-
- return $name;
- }
-
- private function assertCapacity(mixed $value): int
- {
- if (!is_numeric($value) || (int) $value < 1) {
- throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'ظرفیت اتاق حداقل ۱ است', 422, 'capacity');
- }
-
- return (int) $value;
- }
-
- private function trimOrNull(mixed $value): ?string
- {
- if (!is_string($value)) {
- return null;
- }
-
- $trimmed = trim($value);
-
- return $trimmed === '' ? null : $trimmed;
- }
-}
diff --git a/src/Branch/Service/WorkingHoursService.php b/src/Branch/Service/WorkingHoursService.php
deleted file mode 100644
index 9ee7b731..00000000
--- a/src/Branch/Service/WorkingHoursService.php
+++ /dev/null
@@ -1,197 +0,0 @@
->> کلیدهای ۰..۶ همیشه هر هفت روز
- */
- public function read(DoctorAddress $address): array
- {
- $result = array_fill_keys(self::DAYS, []);
-
- foreach ($this->hours->findForAddress($address) as $row) {
- $result[$row->getDayOfWeek()][] = $row->toArray();
- }
-
- return $result;
- }
-
- public function isDefined(DoctorAddress $address): bool
- {
- return $this->hours->findForAddress($address) !== [];
- }
-
- /**
- * جایگزینی کامل هفت روز.
- *
- * @param array $days نگاشت روز => فهرست بازهها
- * @return array>>
- * @throws AppException روی هر ورودی نامعتبر — پیش از هر تغییری در دیتابیس
- */
- public function replace(DoctorAddress $address, array $days): array
- {
- $normalized = $this->validate($days);
-
- // اعتبارسنجی کاملِ هر هفت روز قبل از DELETE: بازهٔ نامعتبر در روز ششم نباید
- // شش روز درستِ قبلی را هم پاک کند و بعد ۴۲۲ برگرداند.
- $this->hours->deleteForAddress($address);
-
- foreach ($normalized as $dayOfWeek => $ranges) {
- foreach ($ranges as $sequence => $range) {
- $this->em->persist(new BranchWorkingHours(
- $address,
- $dayOfWeek,
- $range['start_minute'],
- $range['end_minute'],
- $sequence,
- ));
- }
- }
-
- $this->em->flush();
-
- return $this->read($address);
- }
-
- /**
- * @param array $days
- * @return array> مرتبشده، بدون همپوشانی
- * @throws AppException
- */
- private function validate(array $days): array
- {
- $normalized = array_fill_keys(self::DAYS, []);
-
- foreach ($days as $rawDay => $ranges) {
- $day = $this->assertDay($rawDay);
-
- if (!is_array($ranges)) {
- throw new AppException(
- ErrorCodes::ERR_VALIDATION_001,
- sprintf('بازههای روز %d باید یک آرایه باشد', $day),
- 422,
- (string) $rawDay,
- );
- }
-
- $normalized[$day] = $this->assertRanges($day, $ranges);
- }
-
- return $normalized;
- }
-
- private function assertDay(int|string $rawDay): int
- {
- if (!is_numeric($rawDay) || !in_array((int) $rawDay, self::DAYS, true)) {
- throw new AppException(
- ErrorCodes::ERR_VALIDATION_001,
- 'روز هفته باید عددی بین ۰ (شنبه) و ۶ (جمعه) باشد',
- 422,
- 'day_of_week',
- );
- }
-
- return (int) $rawDay;
- }
-
- /**
- * @param array $ranges
- * @return list
- */
- private function assertRanges(int $day, array $ranges): array
- {
- $parsed = [];
-
- foreach ($ranges as $range) {
- if (!is_array($range)) {
- throw new AppException(
- ErrorCodes::ERR_VALIDATION_001,
- sprintf('بازهٔ روز %d ساختار درستی ندارد', $day),
- 422,
- 'start_minute',
- );
- }
-
- $start = $this->assertMinute($range['start_minute'] ?? null, $day, 'start_minute');
- $end = $this->assertMinute($range['end_minute'] ?? null, $day, 'end_minute');
-
- if ($end <= $start) {
- throw new AppException(
- ErrorCodes::ERR_VALIDATION_001,
- sprintf('در روز %d، پایان بازه باید بعد از شروع آن باشد', $day),
- 422,
- 'end_minute',
- );
- }
-
- $parsed[] = ['start_minute' => $start, 'end_minute' => $end];
- }
-
- usort($parsed, static fn (array $a, array $b): int => $a['start_minute'] <=> $b['start_minute']);
-
- // sequence از همین ترتیب مشتق میشود، پس تشخیص همپوشانی فقط مقایسهٔ همسایههاست.
- foreach ($parsed as $i => $range) {
- if ($i > 0 && $range['start_minute'] < $parsed[$i - 1]['end_minute']) {
- throw new AppException(
- ErrorCodes::ERR_VALIDATION_001,
- sprintf('بازههای روز %d با هم همپوشانی دارند', $day),
- 422,
- 'start_minute',
- );
- }
- }
-
- return $parsed;
- }
-
- private function assertMinute(mixed $value, int $day, string $field): int
- {
- if (!is_numeric($value)) {
- throw new AppException(
- ErrorCodes::ERR_VALIDATION_002,
- sprintf('در روز %d مقدار %s الزامی است', $day, $field),
- 422,
- $field,
- );
- }
-
- $minute = (int) $value;
-
- if ($minute < 0 || $minute > BranchWorkingHours::MINUTES_IN_DAY) {
- throw new AppException(
- ErrorCodes::ERR_VALIDATION_001,
- sprintf('در روز %d مقدار %s باید بین ۰ و ۱۴۴۰ باشد', $day, $field),
- 422,
- $field,
- );
- }
-
- return $minute;
- }
-}
diff --git a/src/ClinicService/Controller/ServiceCatalogController.php b/src/ClinicService/Controller/ServiceCatalogController.php
index 5609afc8..29765c12 100644
--- a/src/ClinicService/Controller/ServiceCatalogController.php
+++ b/src/ClinicService/Controller/ServiceCatalogController.php
@@ -3,7 +3,7 @@
namespace App\ClinicService\Controller;
use App\Auth\Entity\User;
-use App\Branch\Service\BranchResolver;
+use App\Doctor\Service\AddressResolver;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\CatalogCategoryInclude;
use App\ClinicService\Repository\CatalogCategoryIncludeRepository;
@@ -50,7 +50,7 @@ class ServiceCatalogController extends BaseController
private readonly ServiceItemRelationRepository $relations,
private readonly ServiceBranchOverrideRepository $overrides,
private readonly ServiceSelectionValidator $validator,
- private readonly BranchResolver $branches,
+ private readonly AddressResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly CatalogCategoryIncludeRepository $includes,
private readonly CategoryClosureResolver $closure,
diff --git a/src/Doctor/Controller/AddressController.php b/src/Doctor/Controller/AddressController.php
new file mode 100644
index 00000000..867d0ab0
--- /dev/null
+++ b/src/Doctor/Controller/AddressController.php
@@ -0,0 +1,46 @@
+secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'view');
+ $this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', 'view');
+
+ return $this->success(array_map(
+ static fn (DoctorAddress $a): array => $a->toArray(),
+ $this->addresses->listForContext($user),
+ ));
+ }
+}
diff --git a/src/Doctor/Repository/DoctorAddressRepository.php b/src/Doctor/Repository/DoctorAddressRepository.php
index eb82a939..9235b0e7 100644
--- a/src/Doctor/Repository/DoctorAddressRepository.php
+++ b/src/Doctor/Repository/DoctorAddressRepository.php
@@ -77,7 +77,7 @@ class DoctorAddressRepository extends ServiceEntityRepository
* `doctor_addresses` جفت محیط ندارد (عمداً — در GlobalTables::ENTITIES ثبت شده)
* پس TenantFilter رویش اعمال نمیشود و `findOneBy(['uuid' => …])` آدرس محیط دیگر
* را هم برمیگرداند. هر مسیری که uuid آدرس را از request میگیرد باید از این
- * متد یا از {@see \App\Branch\Service\BranchResolver} رد شود.
+ * متد یا از {@see \App\Doctor\Service\AddressResolver} رد شود.
*/
public function findByUuidForEntityPair(string $uuid, string $entityType, int $entityId): ?DoctorAddress
{
diff --git a/src/Branch/Service/BranchResolver.php b/src/Doctor/Service/AddressResolver.php
similarity index 80%
rename from src/Branch/Service/BranchResolver.php
rename to src/Doctor/Service/AddressResolver.php
index 672c7dbd..30277612 100644
--- a/src/Branch/Service/BranchResolver.php
+++ b/src/Doctor/Service/AddressResolver.php
@@ -1,6 +1,6 @@
…]) آدرس کلینیک دیگری را هم برمیگرداند. هر سه کنترلر این
- * دامنه از اینجا رد میشوند تا این بررسی جایی جا نیفتد.
+ * findOneBy(['uuid' => …]) آدرس کلینیک دیگری را هم برمیگرداند. هر کنترلری که
+ * address_uuid میگیرد از اینجا رد میشود تا این بررسی جایی جا نیفتد.
+ *
+ * قبلاً `App\Branch\Service\BranchResolver` بود. با حذف مفهوم «شعبه» از محصول، اسم
+ * و خانهاش عوض شد ولی خودش نمیتوانست حذف شود: موتور رزرو، دسترسپذیری، قیمت و
+ * کاتالوگ همگی از همین رد میشوند.
*/
-final class BranchResolver
+final class AddressResolver
{
public function __construct(
private readonly DoctorAddressRepository $addresses,
@@ -63,13 +67,13 @@ final class BranchResolver
$address = $this->addresses->findByUuidForEntityPair($addressUuid, $entityType, $entityId);
if ($address === null) {
- throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'شعبه یافت نشد', 404);
+ throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'محل نوبتدهی یافت نشد', 404);
}
return $address;
}
- /** @return DoctorAddress[] شعبههای محیط جاری */
+ /** @return DoctorAddress[] محلهای نوبتدهی محیط جاری */
public function listForContext(User $user): array
{
[$entityType, $entityId] = $this->pair($user);
diff --git a/src/Pricing/Controller/PricingController.php b/src/Pricing/Controller/PricingController.php
index 6886e3eb..a353a13d 100644
--- a/src/Pricing/Controller/PricingController.php
+++ b/src/Pricing/Controller/PricingController.php
@@ -4,7 +4,7 @@ namespace App\Pricing\Controller;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
-use App\Branch\Service\BranchResolver;
+use App\Doctor\Service\AddressResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Pricing\Entity\PriceList;
@@ -36,7 +36,7 @@ class PricingController extends BaseController
private readonly PriceSnapshotRepository $snapshots,
private readonly ServiceItemRepository $items,
private readonly PricingEngine $engine,
- private readonly BranchResolver $branches,
+ private readonly AddressResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
diff --git a/src/Resource/Command/BackfillResourceCommand.php b/src/Resource/Command/BackfillResourceCommand.php
index f80431a4..f51f735e 100644
--- a/src/Resource/Command/BackfillResourceCommand.php
+++ b/src/Resource/Command/BackfillResourceCommand.php
@@ -3,7 +3,6 @@
namespace App\Resource\Command;
use App\Appointment\Entity\WeeklySchedule;
-use App\Branch\Entity\Room;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ResourceType;
use App\Resource\Repository\ClinicResourceRepository;
@@ -26,7 +25,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*/
#[AsCommand(
name: 'app:resource:backfill',
- description: 'Bridge existing doctors, staff and rooms to clinic resources',
+ description: 'Bridge existing doctors and staff to clinic resources',
)]
class BackfillResourceCommand extends Command
{
@@ -53,7 +52,7 @@ class BackfillResourceCommand extends Command
$io->note('Dry run — nothing will be written. Re-run with --force to apply.');
}
- $created = ['room' => 0, 'staff' => 0, 'doctor' => 0];
+ $created = ['staff' => 0, 'doctor' => 0];
$skipped = [];
/** @var list $rows */
$rows = [];
@@ -65,7 +64,7 @@ class BackfillResourceCommand extends Command
$addressesByPair = array_intersect_key($addressesByPair, [$only => true]);
if ($addressesByPair === []) {
- $io->warning(sprintf('محیط «%s» هیچ شعبهای ندارد.', $only));
+ $io->warning(sprintf('محیط «%s» هیچ محل نوبتدهیای ندارد.', $only));
return Command::SUCCESS;
}
@@ -78,22 +77,6 @@ class BackfillResourceCommand extends Command
$this->linker->systemType($entityType, (int) $entityId, $code);
}
- // ── اتاقها: آدرسشان را خودشان دارند، پس بیابهاماند ──────────────────
- foreach ($addresses as $address) {
- foreach ($this->em->getRepository(Room::class)->findForAddress($address) as $room) {
- if ($this->resources->findForSubject($room) !== null) {
- continue;
- }
-
- $rows[] = ['room', (string) $room->getName(), (string) ($address->getName() ?? '—')];
- $created['room']++;
-
- if ($force) {
- $this->linker->link($room, $address, $room->getName());
- }
- }
- }
-
// ── پرسنل: هیچ ستونی آدرسش را نمیگوید ────────────────────────────────
$staffMembers = $this->em->getRepository(ClinicStaff::class)
->findBy(['entityType' => $entityType, 'entityId' => (int) $entityId, 'active' => true]);
@@ -145,7 +128,7 @@ class BackfillResourceCommand extends Command
}
if ($rows !== []) {
- $io->table(['نوع', 'نام', 'شعبه'], $rows);
+ $io->table(['نوع', 'نام', 'محل'], $rows);
}
foreach ($skipped as $reason) {
@@ -153,9 +136,8 @@ class BackfillResourceCommand extends Command
}
$io->success(sprintf(
- '%s — اتاق: %d · پرسنل: %d · پزشک: %d',
+ '%s — پرسنل: %d · پزشک: %d',
$force ? 'ساخته شد' : 'ساخته میشود',
- $created['room'],
$created['staff'],
$created['doctor'],
));
@@ -165,7 +147,7 @@ class BackfillResourceCommand extends Command
/**
* منبعِ پزشک از `location_id`های برنامهٔ هفتگی مشتق میشود: آنجا دقیقاً نوشته که
- * این پزشک در کدام آدرسها شیفت دارد. «اولین شعبهٔ محیط» حدس میبود.
+ * این پزشک در کدام آدرسها شیفت دارد. «اولین محل نوبتدهی محیط» حدس میبود.
*
* یک پاس روی همهٔ برنامهها، نه یک پاس بهازای هر محیط: محیطِ هر برنامه از خودش
* خوانده میشود.
diff --git a/src/Resource/Entity/ClinicResource.php b/src/Resource/Entity/ClinicResource.php
index d46fea3c..5eec6735 100644
--- a/src/Resource/Entity/ClinicResource.php
+++ b/src/Resource/Entity/ClinicResource.php
@@ -4,7 +4,6 @@ namespace App\Resource\Entity;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
-use App\Branch\Entity\Room;
use App\Resource\Repository\ClinicResourceRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use App\Staff\Entity\ClinicStaff;
@@ -22,7 +21,7 @@ use Symfony\Component\Uid\Uuid;
*
* ## پل، نه ادغام
*
- * `Doctor`، `ClinicStaff` و `Room` هرکدام هویت مستقل و مصرفکنندهٔ زنده دارند
+ * `Doctor` و `ClinicStaff` هرکدام هویت مستقل و مصرفکنندهٔ زنده دارند
* (`appointments.doctor_id`، `service_item_staff`، سایت عمومی). تبدیلشان به زیرکلاس
* یعنی مهاجرت همزمان همهٔ آن مسیرها. بهجایش حداکثر **یکی** از سه ستون پل پر است؛
* منبعِ بدون پل یعنی دستگاه یا تجهیزات.
@@ -33,7 +32,6 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Index(columns: ['address_id', 'resource_type_id', 'active'], name: 'idx_resources_address_type')]
#[ORM\UniqueConstraint(name: 'uniq_resource_doctor_address', columns: ['doctor_id', 'address_id'])]
#[ORM\UniqueConstraint(name: 'uniq_resource_staff_address', columns: ['staff_id', 'address_id'])]
-#[ORM\UniqueConstraint(name: 'uniq_resource_room', columns: ['room_id'])]
class ClinicResource
{
use TenantOwnedTrait;
@@ -86,10 +84,6 @@ class ClinicResource
#[ORM\JoinColumn(name: 'staff_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?ClinicStaff $staff = null;
- #[ORM\ManyToOne(targetEntity: Room::class)]
- #[ORM\JoinColumn(name: 'room_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
- private ?Room $room = null;
-
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
@@ -149,7 +143,6 @@ class ClinicResource
public function getAttributes(): array { return $this->attributes ?? []; }
public function getDoctor(): ?Doctor { return $this->doctor; }
public function getStaff(): ?ClinicStaff { return $this->staff; }
- public function getRoom(): ?Room { return $this->room; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
@@ -204,7 +197,7 @@ class ClinicResource
*
* @throws \InvalidArgumentException روی پل دوم
*/
- public function linkTo(Doctor|ClinicStaff|Room $subject): self
+ public function linkTo(Doctor|ClinicStaff $subject): self
{
if ($this->subject() !== null) {
throw new \InvalidArgumentException('A resource can bridge to at most one subject.');
@@ -213,7 +206,6 @@ class ClinicResource
match (true) {
$subject instanceof Doctor => $this->doctor = $subject,
$subject instanceof ClinicStaff => $this->staff = $subject,
- $subject instanceof Room => $this->room = $subject,
};
$this->touch();
@@ -222,9 +214,9 @@ class ClinicResource
}
/** موجودیت اصلی پشت این منبع؛ `null` یعنی دستگاه/تجهیزات. */
- public function subject(): Doctor|ClinicStaff|Room|null
+ public function subject(): Doctor|ClinicStaff|null
{
- return $this->doctor ?? $this->staff ?? $this->room;
+ return $this->doctor ?? $this->staff;
}
private function assertMinutes(int $v, string $field): int
@@ -257,7 +249,6 @@ class ClinicResource
'subject_kind' => match (true) {
$this->doctor !== null => 'doctor',
$this->staff !== null => 'staff',
- $this->room !== null => 'room',
default => null,
},
'subject_uuid' => $subject?->getUuid(),
diff --git a/src/Resource/Repository/ClinicResourceRepository.php b/src/Resource/Repository/ClinicResourceRepository.php
index 9e69f0b5..b6ffb80d 100644
--- a/src/Resource/Repository/ClinicResourceRepository.php
+++ b/src/Resource/Repository/ClinicResourceRepository.php
@@ -5,7 +5,6 @@ namespace App\Resource\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
-use App\Branch\Entity\Room;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
use App\Staff\Entity\ClinicStaff;
@@ -160,12 +159,11 @@ class ClinicResourceRepository extends ServiceEntityRepository
return $qb->orderBy('r.name', 'ASC')->getQuery()->getResult();
}
- public function findForSubject(Doctor|ClinicStaff|Room $subject, ?DoctorAddress $address = null): ?ClinicResource
+ public function findForSubject(Doctor|ClinicStaff $subject, ?DoctorAddress $address = null): ?ClinicResource
{
$field = match (true) {
$subject instanceof Doctor => 'doctor',
$subject instanceof ClinicStaff => 'staff',
- $subject instanceof Room => 'room',
};
$qb = $this->createQueryBuilder('r')
@@ -173,7 +171,7 @@ class ClinicResourceRepository extends ServiceEntityRepository
->setParameter('subject', $subject);
// اتاق فقط در یک آدرس است، پس آدرس برایش شرط اضافه نیست.
- if ($address !== null && !$subject instanceof Room) {
+ if ($address !== null) {
$qb->andWhere('r.address = :address')->setParameter('address', $address);
}
@@ -186,12 +184,11 @@ class ClinicResourceRepository extends ServiceEntityRepository
*
* @return ClinicResource[]
*/
- public function findAllForSubject(Doctor|ClinicStaff|Room $subject): array
+ public function findAllForSubject(Doctor|ClinicStaff $subject): array
{
$field = match (true) {
$subject instanceof Doctor => 'doctor',
$subject instanceof ClinicStaff => 'staff',
- $subject instanceof Room => 'room',
};
return $this->createQueryBuilder('r')
diff --git a/src/Resource/Service/ResourceAvailabilityService.php b/src/Resource/Service/ResourceAvailabilityService.php
index cbaa51e8..b278d106 100644
--- a/src/Resource/Service/ResourceAvailabilityService.php
+++ b/src/Resource/Service/ResourceAvailabilityService.php
@@ -2,7 +2,6 @@
namespace App\Resource\Service;
-use App\Branch\Repository\BranchWorkingHoursRepository;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceException;
use App\Resource\Repository\NationalHolidayRepository;
@@ -34,7 +33,6 @@ final class ResourceAvailabilityService
public function __construct(
private readonly ResourceCalendarRepository $calendars,
private readonly ResourceExceptionRepository $exceptions,
- private readonly BranchWorkingHoursRepository $branchHours,
private readonly NationalHolidayRepository $holidays,
private readonly TenantHolidayOverrideRepository $overrides,
) {}
@@ -52,7 +50,6 @@ final class ResourceAvailabilityService
// شیفتها و ساعت شعبه یک بار خوانده میشوند، نه per روز.
$shiftsByDay = $this->shiftsByDay($resource);
- $branchByDay = $this->branchHoursByDay($resource);
$holidayMap = $this->holidays->mapForRange($startDay, $endDay);
$overrideMap = $this->overrides->mapForRange(
@@ -76,7 +73,6 @@ final class ResourceAvailabilityService
$day,
$timezone,
$shiftsByDay,
- $branchByDay,
$holidayMap,
$overrideMap,
$exceptions,
@@ -115,7 +111,6 @@ final class ResourceAvailabilityService
$endDay,
);
- $branchByDay = $this->branchHoursByDay($first);
$ids = array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources);
$shiftsById = $this->calendars->findForResources($ids);
@@ -142,7 +137,6 @@ final class ResourceAvailabilityService
$day,
$timezone,
$shiftsByDay,
- $branchByDay,
$holidayMap,
$overrideMap,
$exceptionsById[$id] ?? [],
@@ -157,7 +151,6 @@ final class ResourceAvailabilityService
/**
* @param array> $shiftsByDay
- * @param array>|null $branchByDay
* @param array $holidayMap
* @param array $overrideMap
* @param ResourceException[] $exceptions
@@ -167,7 +160,6 @@ final class ResourceAvailabilityService
int $midnight,
\DateTimeZone $timezone,
array $shiftsByDay,
- ?array $branchByDay,
array $holidayMap,
array $overrideMap,
array $exceptions,
@@ -180,7 +172,7 @@ final class ResourceAvailabilityService
}
if (!$resource->getAddress()->isActive()) {
- return new DayAvailability($midnight, $dayOfWeek, [], ['branch_inactive']);
+ return new DayAvailability($midnight, $dayOfWeek, [], ['address_inactive']);
}
$override = $overrideMap[$midnight] ?? null;
@@ -201,26 +193,6 @@ final class ResourceAvailabilityService
return new DayAvailability($midnight, $dayOfWeek, [], ['no_shift']);
}
- // شعبهٔ بدون ساعت کاری = «تعریفنشده»، نه «بسته»: شیفت منبع بیقید اعمال
- // میشود تا دادهٔ موجود دقیقاً مثل امروز کار کند (قرارداد تسک ۰۱).
- if ($branchByDay !== null) {
- $branchWindows = $branchByDay[$dayOfWeek] ?? [];
-
- if ($branchWindows === []) {
- return new DayAvailability($midnight, $dayOfWeek, [], ['branch_closed']);
- }
-
- $intersected = TimeInterval::intersectAll($shifts, $branchWindows);
-
- // شیفت هست ولی تقاطعش با ساعت شعبه خالی شد — این با «شیفتی نیست» فرق دارد
- // و بدون دلیل صریح، پاسخِ خالی از یک باگ قابل تشخیص نیست.
- if ($intersected === []) {
- $reasons[] = 'outside_branch_hours';
- }
-
- $shifts = $intersected;
- }
-
$absolute = array_map(
static fn (TimeInterval $i): TimeInterval => $i->minutesToAbsolute($midnight),
$shifts,
@@ -263,31 +235,6 @@ final class ResourceAvailabilityService
return array_map(TimeInterval::mergeAll(...), $byDay);
}
- /**
- * `null` یعنی این شعبه اصلاً ساعت کاری تعریفشده ندارد — که با «همهٔ روزها بسته»
- * فرق دارد و نباید با آن یکی گرفته شود.
- *
- * @return array>|null
- */
- private function branchHoursByDay(ClinicResource $resource): ?array
- {
- $rows = $this->branchHours->findForAddress($resource->getAddress());
-
- if ($rows === []) {
- return null;
- }
-
- $byDay = [];
- foreach ($rows as $row) {
- if (!$row->isActive()) {
- continue;
- }
-
- $byDay[$row->getDayOfWeek()][] = new TimeInterval($row->getStartMinute(), $row->getEndMinute());
- }
-
- return array_map(TimeInterval::mergeAll(...), $byDay);
- }
/** ۰=شنبه … ۶=جمعه — همان قرارداد بقیهٔ سامانه، نه `w` استاندارد PHP. */
public function dayOfWeek(int $timestamp, \DateTimeZone $timezone): int
diff --git a/src/Resource/Service/ResourceContext.php b/src/Resource/Service/ResourceContext.php
index b3c7f379..c71f052f 100644
--- a/src/Resource/Service/ResourceContext.php
+++ b/src/Resource/Service/ResourceContext.php
@@ -3,7 +3,7 @@
namespace App\Resource\Service;
use App\Auth\Entity\User;
-use App\Branch\Service\BranchResolver;
+use App\Doctor\Service\AddressResolver;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourcePool;
@@ -30,7 +30,7 @@ use App\Shared\Tenant\TenantOwnershipChecker;
final class ResourceContext
{
public function __construct(
- private readonly BranchResolver $branches,
+ private readonly AddressResolver $branches,
private readonly ResourceTypeRepository $types,
private readonly ClinicResourceRepository $resources,
private readonly SkillRepository $skills,
diff --git a/src/Resource/Service/ResourceLinker.php b/src/Resource/Service/ResourceLinker.php
index 44c4b45c..00175774 100644
--- a/src/Resource/Service/ResourceLinker.php
+++ b/src/Resource/Service/ResourceLinker.php
@@ -2,7 +2,6 @@
namespace App\Resource\Service;
-use App\Branch\Entity\Room;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
@@ -66,8 +65,8 @@ final class ResourceLinker
$this->pendingTypes = [];
}
- /** منبعِ متناظر با یک موجودیت در یک شعبه؛ اگر نبود میسازد. */
- public function link(Doctor|ClinicStaff|Room $subject, DoctorAddress $address, string $name): ClinicResource
+ /** منبعِ متناظر با یک موجودیت در یک محل نوبتدهی؛ اگر نبود میسازد. */
+ public function link(Doctor|ClinicStaff $subject, DoctorAddress $address, string $name): ClinicResource
{
$existing = $this->resources->findForSubject($subject, $address);
@@ -78,32 +77,25 @@ final class ResourceLinker
$code = match (true) {
$subject instanceof Doctor => ResourceType::CODE_DOCTOR,
$subject instanceof ClinicStaff => ResourceType::CODE_STAFF,
- $subject instanceof Room => ResourceType::CODE_ROOM,
};
$type = $this->systemType($address->tenantEntityType(), $address->tenantEntityId(), $code);
$resource = new ClinicResource($address, $type, $name);
$resource->linkTo($subject);
- // اتاق ظرفیت خودش را دارد؛ شخص همیشه ظرفیت ۱.
- if ($subject instanceof Room) {
- $resource->setCapacity($subject->getCapacity());
- $resource->setActive($subject->isActive());
- }
-
$this->em->persist($resource);
return $resource;
}
/** برعکس: منبع → موجودیت اصلی. `null` یعنی دستگاه/تجهیزات. */
- public function subject(ClinicResource $resource): Doctor|ClinicStaff|Room|null
+ public function subject(ClinicResource $resource): Doctor|ClinicStaff|null
{
return $resource->subject();
}
/**
- * غیرفعال شدن پرسنل/اتاق باید منبعش را هم غیرفعال کند، وگرنه در جستجوی وقتِ تسک ۰۶
+ * غیرفعال شدن پرسنل باید منبعش را هم غیرفعال کند، وگرنه در جستجوی وقتِ تسک ۰۶
* ظاهر میشود.
*
* عمداً فراخوانی صریح است و نه Doctrine lifecycle callback: آن callback در
@@ -113,7 +105,7 @@ final class ResourceLinker
* عکسش برقرار نیست: غیرفعال کردن منبع، پرسنل را غیرفعال نمیکند (پرسنل ممکن است
* فقط نقش اداری داشته باشد).
*/
- public function syncActive(Doctor|ClinicStaff|Room $subject, bool $active): int
+ public function syncActive(Doctor|ClinicStaff $subject, bool $active): int
{
$touched = 0;
diff --git a/tests/Branch/BranchFieldsTest.php b/tests/Branch/BranchFieldsTest.php
deleted file mode 100644
index b3b0de87..00000000
--- a/tests/Branch/BranchFieldsTest.php
+++ /dev/null
@@ -1,144 +0,0 @@
-doctorWithAddress();
-
- self::assertTrue($address->isActive());
- self::assertSame(DoctorAddress::DEFAULT_TIMEZONE, $address->getTimezone());
-
- $body = $this->authJson('GET', '/api/v1/branches', $user);
-
- self::assertSame(200, $this->responseCode());
- self::assertTrue($body['data'][0]['active']);
- self::assertSame('Asia/Tehran', $body['data'][0]['timezone']);
- }
-
- public function testListReportsWorkingHoursAndRoomCounts(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $before = $this->authJson('GET', '/api/v1/branches', $user);
- self::assertFalse($before['data'][0]['working_hours_defined']);
- self::assertSame(0, $before['data'][0]['rooms_count']);
-
- $this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, [
- 'days' => [1 => [['start_minute' => 540, 'end_minute' => 780]]],
- ]);
- $this->authJson('POST', '/api/v1/room', $user, [
- 'address_uuid' => $address->getUuid(),
- 'name' => 'اتاق ۱',
- ]);
-
- $after = $this->authJson('GET', '/api/v1/branches', $user);
-
- self::assertTrue($after['data'][0]['working_hours_defined']);
- self::assertSame(1, $after['data'][0]['rooms_count']);
- }
-
- /** فقط اتاق فعال شمرده میشود — اتاق غیرفعال ظرفیت واقعی شعبه نیست. */
- public function testInactiveRoomIsNotCounted(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
- $room = $this->authJson('POST', '/api/v1/room', $user, [
- 'address_uuid' => $address->getUuid(),
- 'name' => 'اتاق بسته',
- ]);
- $this->authJson('PATCH', "/api/v1/room/{$room['data']['uuid']}", $user, ['active' => false]);
-
- $body = $this->authJson('GET', '/api/v1/branches', $user);
-
- self::assertSame(0, $body['data'][0]['rooms_count']);
- }
-
- public function testListShowsOnlyTheCurrentContextBranches(): void
- {
- [$doctorUser, , $doctorAddress] = $this->doctorWithAddress('مطب شخصی');
- $this->clinicWithAddress('شعبهٔ کلینیک بیگانه');
-
- $body = $this->authJson('GET', '/api/v1/branches', $doctorUser);
-
- self::assertCount(1, $body['data']);
- self::assertSame($doctorAddress->getUuid(), $body['data'][0]['uuid']);
- }
-
- public function testBranchIsDeactivated(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $body = $this->authJson('PATCH', "/api/v1/branch/{$address->getUuid()}", $user, ['active' => false]);
-
- self::assertSame(200, $this->responseCode());
- self::assertFalse($body['data']['active']);
- }
-
- public function testTimezoneIsUpdated(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $body = $this->authJson('PATCH', "/api/v1/branch/{$address->getUuid()}", $user, [
- 'timezone' => 'Asia/Dubai',
- ]);
-
- self::assertSame(200, $this->responseCode());
- self::assertSame('Asia/Dubai', $body['data']['timezone']);
- }
-
- /** با DateTimeZone::listIdentifiers سنجیده میشود، نه با regex. */
- public function testUnknownTimezoneIsRejected(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $body = $this->authJson('PATCH', "/api/v1/branch/{$address->getUuid()}", $user, ['timezone' => 'Tehran']);
-
- self::assertSame(422, $this->responseCode());
- self::assertSame('timezone', $body['errors'][0]['field']);
- }
-
- public function testForeignBranchCannotBePatched(): void
- {
- [$doctorUser] = $this->doctorWithAddress();
- [, , $foreignAddress] = $this->clinicWithAddress();
-
- $this->authJson('PATCH', "/api/v1/branch/{$foreignAddress->getUuid()}", $doctorUser, ['active' => false]);
-
- self::assertSame(404, $this->responseCode());
- }
-
- /** شمارشها گروهیاند: تعداد کوئریها با تعداد شعبهها رشد نمیکند. */
- public function testListQueryCountDoesNotGrowWithBranches(): void
- {
- // یک کرنل برای هر دو اندازهگیری، وگرنه reboot دادهٔ کوئریها را میریزد.
- $this->client->disableReboot();
-
- [$user, $doctor] = $this->doctorWithAddress();
-
- $queriesForOne = $this->countQueries(
- fn () => $this->authJson('GET', '/api/v1/branches', $user)
- );
-
- for ($i = 0; $i < 4; $i++) {
- $extra = DoctorAddress::forDoctor($doctor);
- $extra->setName("شعبهٔ $i");
- $this->em->persist($extra);
- }
- $this->em->flush();
-
- $queriesForFive = $this->countQueries(
- fn () => $this->authJson('GET', '/api/v1/branches', $user)
- );
-
- self::assertCount(5, json_decode($this->client->getResponse()->getContent(), true)['data']);
- self::assertSame($queriesForOne, $queriesForFive);
- }
-}
diff --git a/tests/Branch/BranchTestCase.php b/tests/Branch/BranchTestCase.php
deleted file mode 100644
index 7a82848f..00000000
--- a/tests/Branch/BranchTestCase.php
+++ /dev/null
@@ -1,50 +0,0 @@
-createUser(['ROLE_USER', 'ROLE_DOCTOR']);
- $doctor = new Doctor($user, 'دکتر شعبه');
- $doctor->setMobileNumber($user->getMobileNumber());
- $this->em->persist($doctor);
- $this->em->flush();
-
- $address = DoctorAddress::forDoctor($doctor);
- $address->setName($name);
- $this->em->persist($address);
- $this->em->flush();
-
- return [$user, $doctor, $address];
- }
-
- /** @return array{0: User, 1: Clinic, 2: DoctorAddress} */
- protected function clinicWithAddress(string $name = 'شعبهٔ کلینیک'): array
- {
- $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
- $clinic = new Clinic($user);
- $clinic->setName('کلینیک تست شعبه');
- $this->em->persist($clinic);
- $this->em->flush();
-
- $address = DoctorAddress::forClinic($clinic->getId());
- $address->setName($name);
- $this->em->persist($address);
- $this->em->flush();
-
- return [$user, $clinic, $address];
- }
-}
diff --git a/tests/Branch/RoomCrudTest.php b/tests/Branch/RoomCrudTest.php
deleted file mode 100644
index c254e566..00000000
--- a/tests/Branch/RoomCrudTest.php
+++ /dev/null
@@ -1,172 +0,0 @@
- $body */
- private function createRoom(\App\Auth\Entity\User $user, string $addressUuid, array $body = []): array
- {
- return $this->authJson('POST', '/api/v1/room', $user, $body + [
- 'address_uuid' => $addressUuid,
- 'name' => 'اتاق تزریق',
- ]);
- }
-
- public function testRoomIsCreatedWithTenantPairDerivedFromTheBranch(): void
- {
- [$clinicUser, $clinic, $address] = $this->clinicWithAddress();
-
- $body = $this->createRoom($clinicUser, $address->getUuid(), ['capacity' => 3, 'floor' => '2']);
-
- self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
- self::assertSame(3, $body['data']['capacity']);
- self::assertSame('2', $body['data']['floor']);
- self::assertSame($address->getUuid(), $body['data']['address_uuid']);
-
- $room = $this->em->getRepository(Room::class)->findOneBy(['uuid' => $body['data']['uuid']]);
- self::assertSame('clinic', $room->getEntityType());
- self::assertSame($clinic->getId(), $room->getEntityId());
- }
-
- public function testPersonalBranchRoomBelongsToTheDoctor(): void
- {
- [$doctorUser, $doctor, $address] = $this->doctorWithAddress();
-
- $body = $this->createRoom($doctorUser, $address->getUuid());
-
- self::assertSame(201, $this->responseCode());
-
- $room = $this->em->getRepository(Room::class)->findOneBy(['uuid' => $body['data']['uuid']]);
- self::assertSame('doctor', $room->getEntityType());
- self::assertSame($doctor->getId(), $room->getEntityId());
- }
-
- public function testCapacityDefaultsToOne(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $body = $this->createRoom($user, $address->getUuid());
-
- self::assertSame(1, $body['data']['capacity']);
- self::assertTrue($body['data']['active']);
- }
-
- public function testZeroCapacityIsRejected(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $body = $this->createRoom($user, $address->getUuid(), ['capacity' => 0]);
-
- self::assertSame(422, $this->responseCode());
- self::assertSame('capacity', $body['errors'][0]['field']);
- }
-
- public function testBlankNameIsRejected(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $body = $this->authJson('POST', '/api/v1/room', $user, [
- 'address_uuid' => $address->getUuid(),
- 'name' => ' ',
- ]);
-
- self::assertSame(422, $this->responseCode());
- self::assertSame('name', $body['errors'][0]['field']);
- }
-
- public function testMissingAddressUuidIsRejected(): void
- {
- [$user] = $this->doctorWithAddress();
-
- $body = $this->authJson('POST', '/api/v1/room', $user, ['name' => 'اتاق']);
-
- self::assertSame(422, $this->responseCode());
- self::assertSame('address_uuid', $body['errors'][0]['field']);
- }
-
- /** جفت محیط از آدرس میآید، پس نمیشود اتاق را روی شعبهٔ محیط دیگر نشاند. */
- public function testRoomCannotBeCreatedOnAForeignBranch(): void
- {
- [$doctorUser] = $this->doctorWithAddress();
- [, , $foreignAddress] = $this->clinicWithAddress();
-
- $this->createRoom($doctorUser, $foreignAddress->getUuid());
-
- self::assertSame(404, $this->responseCode());
- }
-
- public function testRoomIsUpdated(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
- $created = $this->createRoom($user, $address->getUuid());
-
- $body = $this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $user, [
- 'name' => 'اتاق پانسمان',
- 'capacity' => 2,
- 'room_type' => 'پانسمان',
- 'active' => false,
- ]);
-
- self::assertSame(200, $this->responseCode());
- self::assertSame('اتاق پانسمان', $body['data']['name']);
- self::assertSame(2, $body['data']['capacity']);
- self::assertSame('پانسمان', $body['data']['room_type']);
- self::assertFalse($body['data']['active']);
- }
-
- /** رشتهٔ خالی روی فیلد اختیاری یعنی «پاک کن»، نه ذخیرهٔ رشتهٔ خالی. */
- public function testBlankOptionalFieldBecomesNull(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
- $created = $this->createRoom($user, $address->getUuid(), ['room_type' => 'تزریق']);
-
- $body = $this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $user, ['room_type' => '']);
-
- self::assertNull($body['data']['room_type']);
- }
-
- public function testRoomIsDeleted(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
- $created = $this->createRoom($user, $address->getUuid());
-
- $this->authJson('DELETE', "/api/v1/room/{$created['data']['uuid']}", $user);
- self::assertSame(200, $this->responseCode());
-
- $this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $user, ['name' => 'x']);
- self::assertSame(404, $this->responseCode());
- }
-
- public function testForeignRoomIsNotFound(): void
- {
- [$clinicUser, , $clinicAddress] = $this->clinicWithAddress();
- $created = $this->createRoom($clinicUser, $clinicAddress->getUuid());
-
- [$doctorUser] = $this->doctorWithAddress();
-
- $this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $doctorUser, ['name' => 'دزدیدهشده']);
- self::assertSame(404, $this->responseCode());
-
- $this->authJson('DELETE', "/api/v1/room/{$created['data']['uuid']}", $doctorUser);
- self::assertSame(404, $this->responseCode());
- }
-
- public function testBranchRoomsAreListedForItsOwnerOnly(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
- $this->createRoom($user, $address->getUuid(), ['name' => 'اتاق ۱']);
- $this->createRoom($user, $address->getUuid(), ['name' => 'اتاق ۲']);
-
- $body = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/rooms", $user);
-
- self::assertSame(200, $this->responseCode());
- self::assertCount(2, $body['data']);
-
- [, , $foreignAddress] = $this->clinicWithAddress();
- $this->authJson('GET', "/api/v1/branch/{$foreignAddress->getUuid()}/rooms", $user);
- self::assertSame(404, $this->responseCode());
- }
-}
diff --git a/tests/Branch/WorkingHoursTest.php b/tests/Branch/WorkingHoursTest.php
deleted file mode 100644
index bdbe7688..00000000
--- a/tests/Branch/WorkingHoursTest.php
+++ /dev/null
@@ -1,229 +0,0 @@
-> $days */
- private function put(\App\Auth\Entity\User $user, string $addressUuid, array $days): array
- {
- return $this->authJson('PUT', "/api/v1/branch/$addressUuid/working-hours", $user, ['days' => $days]);
- }
-
- public function testEmptyBranchReportsSevenEmptyDaysAndUndefined(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $body = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/working-hours", $user);
-
- self::assertSame(200, $this->responseCode());
- self::assertFalse($body['data']['defined'], 'شعبهٔ بدون ساعت باید «تعریفنشده» باشد، نه همیشهباز');
- self::assertSame(range(0, 6), array_map('intval', array_keys($body['data']['days'])));
- foreach ($body['data']['days'] as $ranges) {
- self::assertSame([], $ranges);
- }
- }
-
- public function testFullWeekIsStoredAndReadBackIdentically(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $days = [];
- foreach (range(0, 6) as $day) {
- $days[$day] = [
- ['start_minute' => 540, 'end_minute' => 780], // 09:00-13:00
- ['start_minute' => 960, 'end_minute' => 1200], // 16:00-20:00
- ];
- }
-
- $written = $this->put($user, $address->getUuid(), $days);
- self::assertSame(200, $this->responseCode(), json_encode($written, JSON_UNESCAPED_UNICODE));
- self::assertTrue($written['data']['defined']);
-
- $read = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/working-hours", $user);
-
- self::assertSame($written['data']['days'], $read['data']['days']);
- self::assertSame('09:00', $read['data']['days'][0][0]['start_time']);
- self::assertSame('20:00', $read['data']['days'][0][1]['end_time']);
- self::assertSame([0, 1], array_column($read['data']['days'][0], 'sequence'));
- }
-
- /** PUT قرارداد جایگزینی کامل دارد: آرایهٔ خالی یعنی شعبه بسته، نه «تغییری نده». */
- public function testEmptyPayloadClosesTheBranch(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
- $this->put($user, $address->getUuid(), [3 => [['start_minute' => 600, 'end_minute' => 700]]]);
-
- $body = $this->put($user, $address->getUuid(), []);
-
- self::assertSame(200, $this->responseCode());
- self::assertFalse($body['data']['defined']);
- self::assertSame([], $body['data']['days'][3]);
- }
-
- public function testEndBeforeStartIsRejected(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $body = $this->put($user, $address->getUuid(), [0 => [['start_minute' => 800, 'end_minute' => 800]]]);
-
- self::assertSame(422, $this->responseCode());
- self::assertSame('end_minute', $body['errors'][0]['field']);
- }
-
- public function testOverlappingRangesInOneDayAreRejected(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $body = $this->put($user, $address->getUuid(), [2 => [
- ['start_minute' => 540, 'end_minute' => 780],
- ['start_minute' => 700, 'end_minute' => 900],
- ]]);
-
- self::assertSame(422, $this->responseCode());
- self::assertStringContainsString('همپوشانی', $body['errors'][0]['message']);
- }
-
- /** بازهٔ چسبیده مجاز است: پایان یکی = شروع بعدی، همپوشانی نیست. */
- public function testTouchingRangesAreAccepted(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $this->put($user, $address->getUuid(), [2 => [
- ['start_minute' => 540, 'end_minute' => 780],
- ['start_minute' => 780, 'end_minute' => 900],
- ]]);
-
- self::assertSame(200, $this->responseCode());
- }
-
- public function testAllDayRangeIsOneRow(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $body = $this->put($user, $address->getUuid(), [
- 5 => [['start_minute' => 0, 'end_minute' => BranchWorkingHours::MINUTES_IN_DAY]],
- ]);
-
- self::assertSame(200, $this->responseCode());
- self::assertCount(1, $body['data']['days'][5]);
- self::assertSame('24:00', $body['data']['days'][5][0]['end_time']);
- }
-
- public function testMinuteBeyondOneDayIsRejected(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $this->put($user, $address->getUuid(), [1 => [['start_minute' => 0, 'end_minute' => 1441]]]);
-
- self::assertSame(422, $this->responseCode());
- }
-
- public function testInvalidDayKeyIsRejected(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $body = $this->put($user, $address->getUuid(), [7 => [['start_minute' => 0, 'end_minute' => 60]]]);
-
- self::assertSame(422, $this->responseCode());
- self::assertSame('day_of_week', $body['errors'][0]['field']);
- }
-
- /**
- * اتمی بودن: بازهٔ نامعتبر در روز ششم نباید روزهای درستِ قبل را پاک کند.
- * بدون اعتبارسنجیِ کاملِ پیش از DELETE، این تست هفتهٔ ذخیرهشده را خالی میبیند.
- */
- public function testInvalidLaterDayLeavesTheStoredWeekUntouched(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $valid = [];
- foreach (range(0, 6) as $day) {
- $valid[$day] = [['start_minute' => 540, 'end_minute' => 780]];
- }
- $this->put($user, $address->getUuid(), $valid);
-
- $broken = $valid;
- $broken[5] = [['start_minute' => 900, 'end_minute' => 100]];
- $this->put($user, $address->getUuid(), $broken);
- self::assertSame(422, $this->responseCode());
-
- $read = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/working-hours", $user);
-
- self::assertTrue($read['data']['defined']);
- foreach (range(0, 6) as $day) {
- self::assertCount(1, $read['data']['days'][$day], "روز $day نباید پاک شده باشد");
- }
- }
-
- public function testMissingDaysFieldIsRejected(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
-
- $body = $this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, ['x' => 1]);
-
- self::assertSame(422, $this->responseCode());
- self::assertSame('days', $body['errors'][0]['field']);
- }
-
- /** آدرس محیط دیگر: ۴۰۴ نه ۴۰۳ — وجود دادهٔ محیط بیگانه لو نمیرود. */
- public function testForeignBranchIsNotFound(): void
- {
- [$doctorUser] = $this->doctorWithAddress();
- [, , $foreignAddress] = $this->clinicWithAddress();
-
- $this->authJson('GET', "/api/v1/branch/{$foreignAddress->getUuid()}/working-hours", $doctorUser);
-
- self::assertSame(404, $this->responseCode());
- }
-
- public function testForeignBranchCannotBeWritten(): void
- {
- [$doctorUser] = $this->doctorWithAddress();
- [, , $foreignAddress] = $this->clinicWithAddress();
-
- $this->put($doctorUser, $foreignAddress->getUuid(), [0 => [['start_minute' => 0, 'end_minute' => 60]]]);
-
- self::assertSame(404, $this->responseCode());
- }
-
- /**
- * `days` باید **شیء** JSON با کلیدهای "0".."6" باشد، نه آرایه.
- * کلیدهای ۰..۶ پشتسرهماند و json_encode بیمراقبت آرایه میساخت؛ کلاینت
- * `days["0"]` هر دو را میخواند، ولی شکل پاسخ با جا افتادن یک روز عوض میشد.
- */
- public function testDaysIsAJsonObjectNotAnArray(): void
- {
- [$user, , $address] = $this->doctorWithAddress();
- $this->put($user, $address->getUuid(), [0 => [['start_minute' => 540, 'end_minute' => 780]]]);
-
- foreach (['PUT', 'GET'] as $method) {
- if ($method === 'GET') {
- $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/working-hours", $user);
- }
-
- $raw = json_decode($this->client->getResponse()->getContent(), false);
- self::assertInstanceOf(\stdClass::class, $raw->data->days, "$method: days باید شیء باشد");
- // get_object_vars نامِ عددیِ ویژگیها را به int برمیگرداند؛ آنچه مهم است
- // stdClass بودن بالا سنجیده شد. اینجا فقط کامل بودن هفت روز.
- self::assertSame(range(0, 6), array_keys(get_object_vars($raw->data->days)));
- }
- }
-
- public function testClinicOwnerManagesItsOwnBranch(): void
- {
- [$clinicUser, , $address] = $this->clinicWithAddress();
-
- $body = $this->put($clinicUser, $address->getUuid(), [
- 0 => [['start_minute' => 480, 'end_minute' => 1020]],
- ]);
-
- self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
- self::assertSame('08:00', $body['data']['days'][0][0]['start_time']);
- }
-}
diff --git a/tests/Resource/BackfillResourceTest.php b/tests/Resource/BackfillResourceTest.php
index 5a7e3a58..fd8f0a0c 100644
--- a/tests/Resource/BackfillResourceTest.php
+++ b/tests/Resource/BackfillResourceTest.php
@@ -57,30 +57,12 @@ class BackfillResourceTest extends ResourceTestCase
public function testDryRunWritesNothing(): void
{
[, , $address] = $this->clinicWithAddress();
- $room = $this->room($address, 'اتاق دراِیران');
+ $staff = $this->staff($address, 'اپراتور دراِیران');
$output = $this->runBackfill(false, $this->pairOf($address));
self::assertStringContainsString('Dry run', $output);
- self::assertNull($this->resources()->findForSubject($room));
- }
-
- public function testRoomBecomesAResourceCarryingItsCapacity(): void
- {
- [, , $address] = $this->clinicWithAddress();
- $room = $this->room($address, 'اتاق تزریق سهتخته', 3);
-
- $this->runBackfill(true, $this->pairOf($address));
- $this->em->clear();
-
- $resource = $this->resources()->findForSubject(
- $this->em->getRepository(\App\Branch\Entity\Room::class)->find($room->getId())
- );
-
- self::assertNotNull($resource);
- self::assertSame(3, $resource->getCapacity(), 'اتاق سهتخته یک منبع با ظرفیت ۳ است، نه سه منبع');
- self::assertSame(ResourceType::CODE_ROOM, $resource->getType()->getCode());
- self::assertTrue($resource->getType()->isSystem());
+ self::assertNull($this->resources()->findForSubject($staff));
}
public function testStaffOfASingleBranchEnvironmentIsBridged(): void
@@ -182,7 +164,6 @@ class BackfillResourceTest extends ResourceTestCase
public function testRunningTwiceCreatesNothingNew(): void
{
[, , $address] = $this->clinicWithAddress();
- $this->room($address, 'اتاق تکراری');
$this->staff($address, 'اپراتور تکراری');
$this->runBackfill(true, $this->pairOf($address));
@@ -190,7 +171,6 @@ class BackfillResourceTest extends ResourceTestCase
$secondOutput = $this->runBackfill(true, $this->pairOf($address));
- self::assertStringContainsString('اتاق: 0', $secondOutput);
self::assertStringContainsString('پرسنل: 0', $secondOutput);
}
@@ -291,15 +271,15 @@ class BackfillResourceTest extends ResourceTestCase
public function testASecondBridgeIsRefused(): void
{
[, , $address] = $this->clinicWithAddress();
- $room = $this->room($address, 'اتاق دوپل');
$staff = $this->staff($address, 'پرسنل دوپل');
+ $other = $this->staff($address, 'پرسنل دوپل دوم');
$type = $this->resourceType($address, 'mixed', 'ترکیبی');
$resource = new \App\Resource\Entity\ClinicResource($address, $type, 'منبع دوپل');
- $resource->linkTo($room);
+ $resource->linkTo($staff);
$this->expectException(\InvalidArgumentException::class);
- $resource->linkTo($staff);
+ $resource->linkTo($other);
}
/**
diff --git a/tests/Resource/ResourceAvailabilityTest.php b/tests/Resource/ResourceAvailabilityTest.php
index af4326e0..128de1e2 100644
--- a/tests/Resource/ResourceAvailabilityTest.php
+++ b/tests/Resource/ResourceAvailabilityTest.php
@@ -244,42 +244,14 @@ class ResourceAvailabilityTest extends ResourceTestCase
self::assertContains('tenant_holiday', $days[0]['reasons']);
}
- /** شیفت بیرون از ساعت شعبه رد نمیشود — تقاطع گرفته میشود. */
- public function testShiftIsIntersectedWithBranchHours(): void
- {
- [$user, $address, $uuid] = $this->resourceWithShifts([0]);
-
- // شعبه فقط ۱۰ تا ۱۲ باز است؛ شیفت منبع ۹ تا ۱۷.
- $this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, [
- 'days' => [0 => [['start_minute' => 600, 'end_minute' => 720]]],
- ]);
- self::assertSame(200, $this->responseCode());
-
- $saturday = $this->nextSaturday();
- $days = $this->availability($user, $uuid, $saturday, $saturday);
-
- self::assertCount(1, $days[0]['intervals']);
- self::assertSame(120, $days[0]['total_minutes'], 'تقاطع ۱۰ تا ۱۲');
- }
-
- /** تقاطع خالی → روز خالی، با دلیل صریح تا از یک باگ قابل تشخیص باشد. */
- public function testEmptyIntersectionReportsOutsideBranchHours(): void
- {
- [$user, $address, $uuid] = $this->resourceWithShifts([0]);
-
- $this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, [
- 'days' => [0 => [['start_minute' => 1080, 'end_minute' => 1200]]], // 18:00-20:00
- ]);
-
- $saturday = $this->nextSaturday();
- $days = $this->availability($user, $uuid, $saturday, $saturday);
-
- self::assertSame([], $days[0]['intervals']);
- self::assertContains('outside_branch_hours', $days[0]['reasons']);
- }
-
- /** شعبهٔ بدون ساعت کاری = «تعریفنشده»، پس شیفت منبع بیقید اعمال میشود. */
- public function testBranchWithoutHoursDoesNotConstrainTheShift(): void
+ /**
+ * شیفت خودِ منبع تنها مرجع ساعت کاری است.
+ *
+ * تا پیش از حذف دامنهٔ شعبه، این شیفت با ساعت کاری شعبه تقاطع میگرفت و سه تست
+ * جداگانه آن لایه را میسنجیدند. با رفتن شعبه، لایه هم رفت و «۴۸۰ دقیقه» یعنی
+ * دقیقاً همان چیزی که در تقویم منبع نوشته شده.
+ */
+ public function testTheResourceShiftAloneDecidesTheDay(): void
{
[$user, , $uuid] = $this->resourceWithShifts([0]);
@@ -289,23 +261,6 @@ class ResourceAvailabilityTest extends ResourceTestCase
self::assertSame(480, $days[0]['total_minutes']);
}
- /** روزی که شعبه بسته است با «تعریفنشده» یکی نیست. */
- public function testBranchClosedDayIsDistinctFromUndefined(): void
- {
- [$user, $address, $uuid] = $this->resourceWithShifts([0, 1]);
-
- $this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, [
- 'days' => [0 => [['start_minute' => 540, 'end_minute' => 1020]]],
- ]);
-
- $saturday = $this->nextSaturday();
- $days = $this->availability($user, $uuid, $saturday, $this->dayAfter($saturday, 1));
-
- self::assertNotSame([], $days[0]['intervals']);
- self::assertSame([], $days[1]['intervals']);
- self::assertContains('branch_closed', $days[1]['reasons']);
- }
-
public function testInactiveResourceHasNoAvailability(): void
{
[$user, , $uuid] = $this->resourceWithShifts();
diff --git a/tests/Resource/ResourceTestCase.php b/tests/Resource/ResourceTestCase.php
index 64a93778..ebb5d59c 100644
--- a/tests/Resource/ResourceTestCase.php
+++ b/tests/Resource/ResourceTestCase.php
@@ -3,7 +3,6 @@
namespace App\Tests\Resource;
use App\Auth\Entity\User;
-use App\Branch\Entity\Room;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
@@ -80,16 +79,6 @@ abstract class ResourceTestCase extends ApiTestCase
return $staff;
}
- protected function room(DoctorAddress $address, string $name = 'اتاق تزریق', int $capacity = 1): Room
- {
- $room = new Room($address, $name);
- $room->setCapacity($capacity);
- $this->em->persist($room);
- $this->em->flush();
-
- return $room;
- }
-
/** @param array $body */
protected function createResource(User $user, DoctorAddress $address, ResourceType $type, array $body = []): array
{