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) => ( -
- - -
- ) - : 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 ( - -
- - setName(e.target.value)} placeholder="اتاق تزریق" /> - - - setRoomType(e.target.value)} placeholder="تزریقات" /> - - - setCapacity(e.target.value)} - /> - - - setFloor(e.target.value)} placeholder="۲" /> - - - -
- - -
-
-
- ); -} - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- - {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 && ( - - )} - -
- )} -
- -
- {ranges.map((range, index) => ( -
- - editRange(day, index, { start: e.target.value })} - style={{ width: 120 }} - /> - - {range.endOfDay ? ( - ۲۴:۰۰ - ) : ( - editRange(day, index, { end: e.target.value })} - style={{ width: 120 }} - /> - )} - - {canUpdate && ( - - )} -
- ))} -
-
- ); - })} -
- )} -
-
- ); -} - -/** - * فهرست منطقهٔ زمانی کوتاه و ثابت است — بک‌اند با `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 && ( - - )} -
- )} - /> -
-
- ); -} - -function StatusFilter({ value, onChange }: { value: string; onChange: (v: string) => void }) { - const options = [ - { value: '', label: 'همه' }, - { value: 'active', label: 'فعال' }, - { value: 'inactive', label: 'غیرفعال' }, - ]; - - return ( -
- {options.map((o) => ( - - ))} -
- ); -} 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 {