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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 15:25:32 +03:30
co-authored by Claude Opus 5
parent 1c4f2a2451
commit dd284ec622
59 changed files with 566 additions and 3128 deletions
+324
View File
@@ -0,0 +1,324 @@
# پاک‌سازی نوبت‌دهی سرویسی: حذف شعبه، تعطیلات سراسری، تنظیمات منابع، تایم‌لاین یکپارچه
## پروژه
`clinicpro` (بک‌اند Symfony + پنل ادمین React).
یک وظیفه **cross-repo** است و علامت‌گذاری شده: حذف شعبه به `nobat724_front` می‌رسد
(`nobat724_front/services/response.js:165` اندپوینت `doctor-address/{id}` را صدا می‌زند).
## زمینه
مدل Resource-First پیاده شده است: منبع، سرویس، گزینهٔ سرویس، دستهٔ سراسری. حالا مالک
محصول می‌خواهد لایه‌هایی که در این مدل مصرف‌کننده ندارند برداشته شوند (شعبه/اتاق، گروه‌های
انتخاب، تب بخش‌های نوبت)، تعطیلات یک بار سراسری تعریف شود، و تنظیمات نوبت‌دهی منابع
هم‌شکل پزشکان شود.
## مشکل / هدف
پنج تغییر مستقل، به همین ترتیب:
1. **حذف شعبه و اتاق** از محصول — بدون تغییر منطق نوبت‌دهی.
2. **تعطیلات سراسری**: مدیر سیستم تعطیلات رسمی سال را ثبت کند؛ هر محیط بتواند
غیرفعالشان کند؛ پزشک و منبع تعطیلی اختصاصی خودشان را داشته باشند.
3. **تب منابع** در `/admin/settings/appointment-settings`، هم‌شکل تب پزشک.
4. **حذف تب‌های «گروه‌ها و آیتم‌ها» و «بخش‌های نوبت»** از صفحهٔ سرویس.
5. **تایم‌لاین یکپارچه**: پزشکانِ سرویسی و منابعِ قابل‌رزرو در یک نما، با ظرفیت و وقت آزاد.
---
## ⚠️ نقد پرامپت — قبل از شروع بخوان
خواستهٔ «همه‌چیز شعبه حذف شود، شامل `DoctorAddress`» با «منطق نوبت‌دهی بدون تغییر بماند»
قابل جمع نیست. شواهد از خود کد و دیتابیس:
| شاهد | یعنی |
|---|---|
| `appointments.address_id`**۷۵ از ۷۵ نوبت مقدار دارد** | آدرس، محلِ خودِ نوبت است نه یک بخش تنظیمات |
| `nobat724_front/services/response.js:165``api/v1/clinic-pro/doctor-address/{id}` | سایت عمومی روی همین قرارداد رزرو می‌گیرد |
| `ClinicResource::__construct()``assignTenantPair($address->tenantEntityType(), …)` | جفت محیط هر منبع از آدرس مشتق می‌شود |
| `price_lists.address_id` · `resource_pools.address_id` · `service_branch_overrides.address_id` | سه زیرسیستم دیگر هم به آن گره خورده‌اند |
پس **`DoctorAddress` در این پرامپت حذف نمی‌شود**؛ به یک لنگرِ نامرئی تنزل می‌کند: هیچ
صفحه، منو یا مفهومی به کاربر نشان نمی‌دهد، ولی جدولش سر جایش می‌ماند. آنچه واقعاً حذف
می‌شود، دامنهٔ `Branch` است (اتاق، ساعت کاری شعبه، صفحه‌ها، اندپوینت‌ها).
حذف کامل `DoctorAddress` یک پرامپت جداست و بازنویسی جریان رزرو در **دو ریپو** را می‌خواهد؛
قبل از شروع باید مالک محصول هزینه‌اش را ببیند. اگر پس از دیدن این ارقام باز هم حذف کامل
خواسته شد، همان‌جا توقف کن و تکلیف را بپرس — با این پرامپت انجامش نده.
---
## معیار پذیرش
### قابلیت ۱ — حذف شعبه و اتاق
- ✅ موفق: `/admin/branches` و زیرصفحه‌هایش ۴۰۴ می‌دهند، آیتم «شعبه‌ها و اتاق‌ها» از منوی
تنظیمات رفته، و `ddev exec php bin/phpunit` کامل سبز است — یعنی جستجوی آزاد و رزرو
دقیقاً همان نتایج قبلی را می‌دهد.
- ❌ خطا: `GET /api/v1/branches`**۴۰۴** (روت وجود ندارد)، نه ۵۰۰.
- ⚠️ مرزی: منبعی که `subject_kind = 'room'` دارد باید همچنان کار کند — اتاق به‌عنوان
*منبع* می‌ماند، فقط موجودیت `Room` می‌رود.
### قابلیت ۲ — تعطیلات سراسری
- ✅ موفق: با توکن `ROLE_ADMIN`، `POST /api/v1/admin/national-holidays` یک تعطیل می‌سازد و
همان روز بلافاصله در `GET /api/v1/resource/{uuid}/availability` با دلیل
`national_holiday` خالی برمی‌گردد.
- ❌ خطا: همان `POST` با توکن پزشک → **۴۰۳**.
- ⚠️ مرزی: محیطی که `TenantHolidayOverride(is_working = true)` دارد، همان روز **باز**
است و ساعتش برمی‌گردد.
### قابلیت ۳ — تب منابع در تنظیمات نوبت‌دهی
- ✅ موفق: در `/admin/settings/appointment-settings` تب «منابع» ساعت کاری هفتگی، تاریخ‌های
خاص و تعطیلات هر منبع را می‌دهد و ذخیره‌اش در `GET /api/v1/resource/{uuid}/calendar`
دیده می‌شود.
- ❌ خطا: منشیِ بدون مجوز `appointment_settings.update` فیلدها را read-only می‌بیند و
`PUT` سرور ۴۰۳ می‌دهد.
- ⚠️ مرزی: کلینیکِ بدون هیچ منبعی، حالت خالی با لینک «تنظیمات ← منابع» نشان دهد، نه صفحهٔ سفید.
### قابلیت ۴ — حذف تب‌های سرویس
- ✅ موفق: `/admin/service/{uuid}` پنج تب دارد (اطلاعات، تعرفه‌ها، بیمه‌ها، کالاها،
دسته‌بندی‌ها، لاگ) و هیچ ورودی به گروه‌ها و بخش‌های نوبت ندارد.
- ❌ خطا: باز کردن مستقیم `?tab=segments` به تب اطلاعات برگردد، نه خطای رندر.
- ⚠️ مرزی: سرویسی که همین حالا `SegmentTemplate` دارد باید **دقیقاً مثل قبل** رزرو شود —
تست‌های موجود `tests/Appointment` سبز بمانند.
### قابلیت ۵ — تایم‌لاین یکپارچه
- ✅ موفق: نمای «زمانبندی» هم ردیف پزشکانِ سرویسی و هم ردیف منابعِ قابل‌رزرو را نشان دهد،
با بازهٔ اشغال و وقت آزاد و ظرفیت هر ردیف.
- ❌ خطا: روزی که هیچ ردیفی داده ندارد، پیام خالیِ صریح بدهد نه اسکلتِ همیشگی.
- ⚠️ مرزی: منبعی با `capacity = 3` و دو نوبت هم‌زمان، «۱ ظرفیت آزاد» نشان دهد نه «پر».
---
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Branch/` | کل دامنه: `BranchController`, `Room`, `BranchWorkingHours`, `RoomService`, `WorkingHoursService`, `BranchResolver` |
| `src/Resource/Service/ResourceAvailabilityService.php` | تنها مصرف‌کنندهٔ `BranchWorkingHoursRepository` بیرون از `src/Branch` |
| `src/Resource/Entity/ClinicResource.php` | `subject_kind='room'` و `ResourceLinker` به `Room` وصل‌اند |
| `src/Resource/Controller/HolidayController.php` | `GET /national-holidays` + `POST/DELETE /holiday-overrides`**POST برای national ندارد** |
| `src/Resource/Entity/NationalHoliday.php` · `TenantHolidayOverride.php` | مدل تعطیلات، از قبل درست است |
| `src/Appointment/Controller/AppointmentSettingsController.php` | تعطیلی اختصاصی پزشک (`Holiday`) |
| `assets/admin/pages/HolidaysSettingsPage.tsx` | صفحهٔ `/admin/holidays` |
| `assets/admin/pages/ClinicAppointmentSettingsPage.tsx` | تب‌بندی per پزشک با `.seg` |
| `assets/admin/pages/ServiceDetailPage.tsx` | `TABS` — گروه‌ها و بخش‌های نوبت اینجاست |
| `assets/admin/pages/AppointmentsPage.tsx` · `components/appointments/ResourceTimeline.tsx` · `TurnsTimeline.tsx` | سه نمای فعلی |
| `assets/admin/components/resources/ResourceWorkingHoursPanel.tsx` · `ResourceExceptionsPanel.tsx` | پنل‌های آمادهٔ منبع — در تب جدید همین‌ها مصرف می‌شوند |
## وضعیت فعلی
`ResourceAvailabilityService` ساعت واقعی منبع را از تقاطع با ساعت شعبه می‌سازد:
```php
// src/Resource/Service/ResourceAvailabilityService.php
private readonly BranchWorkingHoursRepository $branchHours,
$branchByDay = $this->branchHoursByDay($resource);
if ($branchByDay !== null) {
$branchWindows = $branchByDay[$dayOfWeek] ?? [];
if ($branchWindows === []) {
// روز، بدون ساعت شعبه یعنی بسته
```
`HolidayController` فقط خواندن تعطیلات ملی را دارد؛ هیچ مسیری برای ساختنشان نیست:
```php
#[Route('/api/v1/national-holidays', name: 'national_holidays_list', methods: ['GET'])]
#[Route('/api/v1/holiday-overrides', name: 'holiday_override_create', methods: ['POST'])]
#[Route('/api/v1/holiday-override/{uuid}', name: 'holiday_override_delete', methods: ['DELETE'])]
```
`ServiceDetailPage` هفت تب دارد:
```tsx
const TABS = [
{ id: 'info', label: 'اطلاعات سرویس' },
{ id: 'tariffs', label: 'تعرفه‌ها' },
{ id: 'insurance', label: 'بیمه‌ها' },
{ id: 'groups', label: 'گروه‌ها و آیتم‌ها' },
{ id: 'segments', label: 'بخش‌های نوبت' },
{ id: 'categories',label: 'دسته‌بندی‌ها' },
{ id: 'goods', label: 'کالاهای مرتبط' },
{ id: 'history', label: 'لاگ تغییرات' },
] as const;
```
---
## وظایف
### ۱. حذف دامنهٔ شعبه و اتاق
**دامنهٔ حذف:** `src/Branch/` کامل، سه صفحهٔ پنل (`BranchesPage`, `BranchRoomsPage`,
`BranchWorkingHoursPage`)، آیتم `branches` در `settingsMenu.ts`، و روت‌هایشان در `App.tsx`.
**دامنهٔ نگه‌داشتن:** `DoctorAddress` (لنگر محیط و محل نوبت — بالا را بخوان).
قبل از حذف، مهاجرت منابعِ نوع اتاق:
```php
// ClinicResource.subject_kind === 'room' امروز به rooms.id اشاره می‌کند.
// یک migration، آن ردیف‌ها را به منبع بی‌subject تبدیل می‌کند (نامشان می‌ماند):
UPDATE clinic_resources SET subject_kind = NULL, room_id = NULL WHERE subject_kind = 'room';
```
سپس لایهٔ شعبه از موتور دسترس‌پذیری برداشته می‌شود. **این تنها جای منطق است که واقعاً
تغییر می‌کند**، پس صریح بنویسش:
```php
// ResourceAvailabilityService: تزریق BranchWorkingHoursRepository حذف، و
// $branchByDay همه‌جا null می‌شود → لایهٔ «ساعت شعبه» از کسر بیرون می‌رود.
// دلیل معماری: با حذف شعبه، تنها مرجع ساعت کاری، شیفت خودِ منبع است.
```
دلیلِ `outside_branch_hours` و `branch_closed` و `branch_inactive` از
`REASON_LABELS` فرانت هم برداشته شوند (`ResourceExceptionsPanel.tsx`).
**نحوه تست:**
```bash
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/phpunit # همه سبز — مخصوصاً tests/Appointment و tests/Resource
ddev exec php bin/console debug:router | grep -c "branch\|room" # باید 0 باشد
npx vitest run # خط پایه: ۱۰۰ فایل / ۶۶۰ تست
```
و یک رزرو واقعی از مسیر عمومی بگیر (`POST /api/v1/appointment-availability` سپس
hold → confirm) تا ثابت شود همان اسلات‌های قبلی برمی‌گردند.
**cross-repo:** بعد از حذف، در `nobat724_front` دنبال `doctor-address` بگرد و گزارش بده
کدام صفحه‌ها مصرفش می‌کنند. اگر اندپوینت عمومی `clinic-pro/doctor-address/{id}` را دست
نزدی (نباید بزنی)، سایت نمی‌شکند — همین را صریح در گزارش بنویس.
---
### ۲. تعطیلات سراسری، سه لایه
مدل از قبل درست است و ساخته نمی‌شود؛ فقط سه چیزِ کم اضافه می‌شود.
**الف) CRUD مدیر سیستم روی `NationalHoliday`:**
```php
// src/Resource/Controller/HolidayController.php
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/national-holidays', methods: ['POST'])] // {jalali_date, title}
#[Route('/api/v1/admin/national-holiday/{uuid}', methods: ['PATCH','DELETE'])]
```
`jalali_date` ورودی است و `date` (نیمه‌شب تهران) و `jalali_year` از آن مشتق می‌شوند —
مسئولیت تبدیل در یک Service بماند، نه Controller.
**ب) نمایش تعطیلات سراسری در تب تعطیلاتِ پزشک و منبع:** هر دو تب علاوه بر تعطیلی
اختصاصی، فهرست تعطیلات ملی سال را **فقط‌خواندنی** با یک سوییچ «این روز باز هستیم» نشان
دهند؛ سوییچ همان `POST /api/v1/holiday-overrides` موجود را صدا بزند.
**ج) `/admin/holidays`** جای مدیریت سراسری هر محیط بماند (همین حالا هست) و در توضیح
صفحه بنویسد که این‌ها پیش‌فرضِ همهٔ پزشکان و منابع‌اند.
**نحوه تست:**
```bash
# ✅ ادمین می‌سازد
curl -X POST .../api/v1/admin/national-holidays -H "Authorization: Bearer $ADMIN" \
-d '{"jalali_date":"1405-01-13","title":"سیزده‌بدر"}'
# ✅ همان روز در دسترس‌پذیری منبع خالی است، با دلیل national_holiday
curl ".../api/v1/resource/$R/availability?from=…&to=…" -H "Authorization: Bearer $DOC"
# ❌ پزشک نمی‌سازد → 403
# ⚠️ بعد از POST /holiday-overrides با is_working=true همان روز باز می‌شود
```
تست PHPUnit: `tests/Resource/NationalHolidayEndpointTest.php` با هر سه سناریو.
---
### ۳. تب «منابع» در تنظیمات نوبت‌دهی کلینیک
در `ClinicAppointmentSettingsPage` یک سطح تب بالاتر اضافه کن: **پزشکان | منابع**. سطح
دوم برای منابع همان الگوی فعلی است (یک `.seg` با نام هر منبع).
کامپوننت جدید لازم نیست — پنل‌ها ساخته شده‌اند:
```tsx
{scope === 'resources' && selectedResource && (
<div key={selectedResource}>
<ResourceWorkingHoursPanel resourceUuid={selectedResource} canUpdate={canUpdate} />
<ResourceExceptionsPanel resourceUuid={selectedResource} canUpdate={canUpdate} />
</div>
)}
```
`.seg` نکته دارد: کلاس فعالش `on` است (`active` هم alias شده) — تب بدون آن هیچ نشانه‌ای
ندارد.
**نحوه تست:** `npx vitest run assets/admin/pages/ClinicAppointmentSettingsPage.test.tsx`
با سه تست: تب منابع فهرست منابع را می‌دهد · انتخاب منبع پنل ساعت کاری را می‌آورد ·
کلینیک بدون منبع حالت خالی می‌دهد. سپس اسکرین‌شات:
`node .claude/skills/redesign-page/driver.mjs variants "https://clinic-pro.ddev.site/admin/settings/appointment-settings"`
---
### ۴. حذف دو تب از صفحهٔ سرویس
فقط **UI** حذف می‌شود: دو ورودی از `TABS` و رندرشان، به‌علاوهٔ کامپوننت‌های
`ServiceGroupsTab` و `ServiceSegmentsTab` و تست‌هایشان.
`SegmentTemplate`، `ServiceSelectionGroup`، `ServiceItemRelation` و اندپوینت‌هایشان
**می‌مانند**: `AppointmentPlanBuilder` ورودی‌اش همین‌هاست و سرویسی که امروز اتاق و دستگاه
را با هم می‌گیرد، بدونشان می‌شکند. سرویسِ بدون template هم از قبل با `singleSegment()`
رزرو می‌شود، پس حذف تب هیچ رفتاری را عوض نمی‌کند.
`tab` در URL می‌نشیند؛ مقدار ناشناخته باید به `info` برگردد نه اینکه چیزی رندر نشود.
**نحوه تست:** `npx vitest run assets/admin/pages/ServiceDetailPage.test.tsx` +
`ddev exec php bin/phpunit tests/Appointment` (باید بدون تغییر سبز بماند) + باز کردن
`/admin/service/{uuid}?tab=segments` و دیدن تب اطلاعات.
---
### ۵. تایم‌لاین یکپارچه
سه نمای فعلی (`table` · `timeline` · `resources`) به دو نما می‌رسند: **جدولی** و
**زمانبندی**. نمای زمانبندی دو گروه ردیف دارد:
```
پزشکان ← پزشکانی که حالت نوبت‌دهی‌شان service است
منابع ← منابعی که برای سرویسی قابل رزروند (ResourceServiceOffering فعال دارند)
```
هر ردیف باید سه چیز بدهد: بازهٔ اشغال، وقت آزاد، و ظرفیت. برای منبع، «آزاد» یعنی
`capacity` منهای تعداد اشغال هم‌پوشان در آن لحظه — نه صفر و یک؛ منبعِ ظرفیت‌۳ با دو نوبت
هم‌زمان هنوز یک جا دارد.
سمت سرور، `GET /api/v1/resources/timeline` موجود را توسعه بده (ساخت اندپوینت جدید ممنوع
است تا وقتی این کافی است): فیلتر `only_bookable=1` و فیلد `free_slots` به هر ردیف اضافه
شود. ردیف پزشک از همان `slots` فعلی می‌آید و در فرانت با ردیف منابع در یک نما ادغام
می‌شود.
**نحوه تست:** تست PHPUnit برای `free_slots` روی منبع ظرفیت‌۳ با دو اشغال هم‌پوشان
(انتظار: ۱`npx vitest run assets/admin/components/appointments/`؛ و اسکرین‌شات نمای
زمانبندی در تاریخ `2026-08-05` که دادهٔ واقعی دارد.
---
## نکات مهم
- **ترتیب اجرا اجباری است.** قابلیت ۱ موتور دسترس‌پذیری را تغییر می‌دهد؛ اگر بعد از
قابلیت ۵ انجام شود، تایم‌لاین را می‌شکند و علتش پیدا نیست.
- **خط پایهٔ تست را اول بگیر:** `ddev exec php bin/phpunit` و `npx vitest run`
(۱۰۰ فایل / ۶۶۰ تست، همه سبز در ۲۰۲۶-۰۸-۰۲). هر شکستی بعد از این، مالِ همین کار است.
- **مسیر اسلاتی دست نخورد.** تست‌های `--group=slot-mode-frozen` نگهبانند؛ حتی یک شکست
یعنی توقف.
- **`vitest` داخل ddev اجرا نمی‌شود** — روی هاست با `npx vitest run`.
- **دو صفحه، یک منوی تنظیمات:** `settingsMenu.ts` منبع واحد سایدبار دسکتاپ و فهرست
موبایل است. حذف آیتم شعبه فقط همان‌جا انجام شود.
- **مستندات همین جلسه:** `docs/api/branch.md` حذف، `docs/api/resource.md` و
`docs/api/resource-calendar.md` و `docs/api/clinic-services.md` به‌روز، و
`docs/architecture/resource-first-model.md` باید بگوید شعبه از مدل بیرون رفته.
- **الگو:** برداشتن لایهٔ شعبه از `ResourceAvailabilityService` را به‌صورت حذف یک لایه از
زنجیرهٔ کسر انجام بده (همان ساختار فعلی)، نه با `if` تازه — کلاس باید کوچک‌تر شود نه شاخه‌دارتر.
- **دادهٔ تست:** `ddev exec php bin/console app:seed-scenarios --reset -n` سه سناریو را
می‌سازد؛ کاربرها در `TEST_USERS.md`. کاربر `0912000301` رمز ندارد — از `0912000201`
استفاده کن.
-6
View File
@@ -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() {
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
<Route path="branches" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchesPage /></RoleRoute>} />
<Route path="branches/:branchUuid/working-hours" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchWorkingHoursPage /></RoleRoute>} />
<Route path="branches/:branchUuid/rooms" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchRoomsPage /></RoleRoute>} />
<Route path="price-lists" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><PriceListsPage /></RoleRoute>} />
<Route path="resource-booking" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointments', 'create']}><ResourceBookingPage /></RoleRoute>} />
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
@@ -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<SegmentDraft[]>([]);
const [branchUuid, setBranchUuid] = useState('');
@@ -387,7 +387,7 @@ export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
<SearchableSelect
value={branchUuid}
onChange={(v) => setBranchUuid(String(v ?? ''))}
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
placeholder="انتخاب شعبه"
/>
</div>
@@ -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');
});
@@ -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'] },
@@ -12,11 +12,9 @@ const REASON_LABELS: Record<string, string> = {
national_holiday: 'تعطیل رسمی',
tenant_holiday: 'تعطیلی این محیط',
no_shift: 'شیفتی تعریف نشده',
branch_closed: 'شعبه این روز بسته است',
outside_branch_hours: 'شیفت بیرون از ساعت کاری شعبه',
exception: 'مرخصی یا سرویس',
resource_inactive: 'منبع غیرفعال است',
branch_inactive: 'شعبه غیرفعال است',
address_inactive: 'محل نوبت‌دهی غیرفعال است',
};
const EXCEPTION_TYPES = [
@@ -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: () => {},
@@ -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({
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
<Field label="شعبه">
<SearchableSelect
options={branches.map((b) => ({ 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="شعبه را انتخاب کنید"
+23
View File
@@ -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<ApiResponse<Branch[]>>('/api/v1/addresses'),
});
// پاسخِ غیرآرایه (خطای سرور، شکل دیگر) نباید صفحه را با «map is not a function»
// بترکاند؛ فهرست خالی رفتار درست است.
const addresses = Array.isArray(query.data?.data) ? query.data.data : [];
return { addresses, loading: query.isLoading };
}
-103
View File
@@ -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<ApiResponse<Branch[]>>('/api/v1/branches'),
});
const update = useMutation({
mutationFn: ({ uuid, d }: { uuid: string; d: { active?: boolean; timezone?: string } }) =>
api.patch<ApiResponse<Branch>>(`/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<ApiResponse<BranchWorkingHours>>(`/api/v1/branch/${branchUuid}/working-hours`),
enabled: !!branchUuid,
});
/** PUT قرارداد جایگزینی کامل دارد: آرایهٔ خالی یعنی شعبه بسته، نه «تغییری نده». */
const save = useMutation({
mutationFn: (days: WorkingHoursPayload) =>
api.put<ApiResponse<BranchWorkingHours>>(`/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<ApiResponse<Room[]>>(`/api/v1/branch/${branchUuid}/rooms`),
enabled: !!branchUuid,
});
const create = useMutation({
mutationFn: (d: RoomPayload) =>
api.post<ApiResponse<Room>>('/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<ApiResponse<Room>>(`/api/v1/room/${uuid}`, d),
onSuccess: () => { toast.success('اتاق به‌روزرسانی شد'); invalidate(); },
onError: (e) => fail(e, 'به‌روزرسانی اتاق ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/room/${uuid}`),
onSuccess: () => { toast.success('اتاق حذف شد'); invalidate(); },
onError: (e) => fail(e, 'حذف اتاق ناموفق بود'),
});
return { rooms: query.data?.data ?? [], loading: query.isLoading, create, update, remove };
}
-199
View File
@@ -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<Room | null>(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<Room>[] = [
{ key: 'name', header: 'نام اتاق', render: (r) => <span style={{ fontWeight: 600 }}>{r.name}</span> },
{ key: 'room_type', header: 'نوع', render: (r) => <span style={{ fontSize: 13 }}>{r.room_type || '—'}</span> },
{
key: 'capacity',
header: 'ظرفیت هم‌زمان',
render: (r) => <span style={{ fontSize: 13 }}>{r.capacity} نفر</span>,
},
{ key: 'floor', header: 'طبقه', render: (r) => <span style={{ fontSize: 13 }}>{r.floor || '—'}</span> },
{ key: 'active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.active} /> },
];
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 (
<SettingsLayout active="branches">
<div className="fade-in">
<PageHeader
title={`اتاق‌های ${branch?.name ?? 'شعبه'}`}
description="ظرفیت هر اتاق تعداد بیمارِ هم‌زمان است — اتاق تزریق سه‌تخته یک اتاق با ظرفیت ۳ است، نه سه اتاق."
backTo="/admin/branches"
breadcrumbs={[{ label: 'شعبه‌ها', to: '/admin/branches' }, { label: 'اتاق‌ها' }]}
action={
canUpdate ? (
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, room: null })}>
<PlusIcon style={{ width: 16 }} /> افزودن اتاق
</button>
) : undefined
}
/>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در اتاق‌ها..."
emptyMessage="هنوز اتاقی برای این شعبه ثبت نشده است"
actions={
canUpdate
? (r) => (
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, room: r })}>
ویرایش
</button>
<button type="button" className="btn secondary sm" onClick={() => setToDelete(r)}>
حذف
</button>
</div>
)
: undefined
}
/>
<RoomModal
open={editing.open}
room={editing.room}
saving={create.isPending || update.isPending}
onClose={() => setEditing({ open: false, room: null })}
onSave={save}
/>
<ConfirmDialog
open={!!toDelete}
title="حذف اتاق"
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
confirmLabel="حذف"
loading={remove.isPending}
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
onCancel={() => setToDelete(null)}
/>
</div>
</SettingsLayout>
);
}
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 (
<Modal open={open} onClose={onClose} title={room ? 'ویرایش اتاق' : 'افزودن اتاق'}>
<div style={{ display: 'grid', gap: 14 }}>
<Field label="نام اتاق">
<input className="field" value={name} onChange={(e) => setName(e.target.value)} placeholder="اتاق تزریق" />
</Field>
<Field label="نوع اتاق (اختیاری)">
<input className="field" value={roomType} onChange={(e) => setRoomType(e.target.value)} placeholder="تزریقات" />
</Field>
<Field label="ظرفیت هم‌زمان">
<input
className="field"
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
/>
</Field>
<Field label="طبقه (اختیاری)">
<input className="field" value={floor} onChange={(e) => setFloor(e.target.value)} placeholder="۲" />
</Field>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
اتاق فعال است
</label>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
<button
type="button"
className="btn primary"
disabled={saving || invalid}
onClick={() => onSave({
name: name.trim(),
room_type: roomType.trim() === '' ? null : roomType.trim(),
capacity: parsedCapacity,
floor: floor.trim() === '' ? null : floor.trim(),
active,
})}
>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</Modal>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>{label}</label>
{children}
</div>
);
}
@@ -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<typeof vi.fn>;
const put = api.put as ReturnType<typeof vi.fn>;
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<string, unknown[]> {
return Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), []]));
}
function mockApi(days: Record<string, unknown[]>) {
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(
<Routes>
<Route path="/admin/branches/:branchUuid/working-hours" element={<BranchWorkingHoursPage />} />
</Routes>,
{ 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();
});
/**
* `<input type="time">` سقفش ۲۳:۵۹ است، پس ۱۴۴۰ با پرچم «تا پایان روز» نمایش داده
* می‌شود و همان ۱۴۴۰ برمی‌گردد — وگرنه اولین ذخیره بازهٔ شبانه‌روزی را خراب می‌کرد.
*/
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);
});
});
@@ -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` وجود دارد چون `<input type="time">` سقفش ۲۳:۵۹ است و مقدار ۲۴:۰۰ را
* نه نشان می‌دهد و نه می‌سازد. بدون این پرچم، بازهٔ شبانه‌روزیِ ذخیره‌شده (۱۴۴۰)
* بی‌صدا از فرم می‌افتاد و اولین ذخیره آن را خراب می‌کرد.
*/
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<Record<number, Draft[]>>({});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!workingHours) return;
const next: Record<number, Draft[]> = {};
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<Draft>) => {
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<number, Draft[]> = {};
DAY_LABELS.forEach((_, d) => { next[d] = source.map((r) => ({ ...r })); });
setDraft(next);
};
const submit = () => {
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
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 (
<SettingsLayout active="branches">
<div className="fade-in">
<PageHeader
title={`ساعت کاری ${branch?.name ?? 'شعبه'}`}
description="روز بدون بازه یعنی شعبه آن روز بسته است. شعبهٔ بدون هیچ ساعتی «تعریف‌نشده» است، نه همیشه‌باز."
backTo="/admin/branches"
breadcrumbs={[
{ label: 'شعبه‌ها', to: '/admin/branches' },
{ label: 'ساعت کاری' },
]}
action={
canUpdate ? (
<button type="button" className="btn primary" disabled={save.isPending} onClick={submit}>
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ هفته'}
</button>
) : undefined
}
/>
{error && (
<div
className="card"
style={{ padding: '12px 16px', marginBottom: 16, color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
>
{error}
</div>
)}
{branch && canUpdate && (
<div className="card" style={{ padding: 16, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>منطقهٔ زمانی شعبه</span>
<div style={{ minWidth: 240 }}>
<TimezoneSelect branchUuid={branch.uuid} value={branch.timezone} />
</div>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{totalRanges === 0 ? 'هیچ بازه‌ای تعریف نشده' : `${totalRanges} بازه در هفته`}
</span>
</div>
)}
{loading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{DAY_LABELS.map((label, day) => {
const ranges = draft[day] ?? [];
return (
<div key={day} className="card" style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: ranges.length ? 12 : 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontWeight: 700, fontSize: 14 }}>{label}</span>
{ranges.length === 0 && (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>بسته</span>
)}
</div>
{canUpdate && (
<div style={{ display: 'flex', gap: 6 }}>
{ranges.length > 0 && (
<button type="button" className="btn secondary sm" onClick={() => copyToWholeWeek(day)}>
اعمال روی همهٔ روزها
</button>
)}
<button type="button" className="btn secondary sm" onClick={() => addRange(day)}>
<PlusIcon style={{ width: 15 }} /> بازه
</button>
</div>
)}
</div>
<div style={{ display: 'grid', gap: 8 }}>
{ranges.map((range, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>از</label>
<input
type="time"
className="field"
value={range.start}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { start: e.target.value })}
style={{ width: 120 }}
/>
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>تا</label>
{range.endOfDay ? (
<span className="field" style={{ width: 120, color: 'var(--text-2)' }}>۲۴:۰۰</span>
) : (
<input
type="time"
className="field"
value={range.end}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { end: e.target.value })}
style={{ width: 120 }}
/>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'var(--text-3)' }}>
<input
type="checkbox"
checked={range.endOfDay}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { endOfDay: e.target.checked })}
/>
تا پایان روز
</label>
{canUpdate && (
<button
type="button"
className="btn secondary sm"
onClick={() => removeRange(day, index)}
aria-label="حذف بازه"
>
<TrashIcon style={{ width: 15 }} />
</button>
)}
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
</SettingsLayout>
);
}
/**
* فهرست منطقهٔ زمانی کوتاه و ثابت است — بک‌اند با `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 (
<SearchableSelect
options={options.map((tz) => ({ value: tz, label: tz }))}
value={value}
onChange={(v) => v && update.mutate({ uuid: branchUuid, d: { timezone: String(v) } })}
placeholder="منطقهٔ زمانی"
height={38}
/>
);
}
-180
View File
@@ -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<Branch>[] = [
{
key: 'name',
header: 'شعبه',
render: (b) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{b.name || 'بدون نام'}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{b.type === 'clinic' ? b.clinic_name || 'کلینیک' : 'مطب شخصی'}
{b.city ? ` · ${b.city.name}` : ''}
</span>
</div>
),
},
{
key: 'address',
header: 'آدرس',
render: (b) => (
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>{b.address || '—'}</span>
),
},
{
key: 'telephone',
header: 'تلفن',
render: (b) => <span style={{ fontSize: 13 }}>{b.telephone || '—'}</span>,
},
{
key: 'working_hours',
header: 'ساعت کاری',
render: (b) =>
b.working_hours_defined ? (
<span className="badge green"><span className="bdot" />تعریفشده</span>
) : (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>تعریفنشده</span>
),
},
{
key: 'rooms_count',
header: 'اتاق فعال',
render: (b) => <span style={{ fontSize: 13 }}>{b.rooms_count ?? 0}</span>,
},
{
key: 'timezone',
header: 'منطقهٔ زمانی',
render: (b) => <span style={{ fontSize: 12, color: 'var(--text-2)' }}>{b.timezone}</span>,
},
{
key: 'active',
header: 'وضعیت',
render: (b) => <ActiveBadge active={b.active} />,
},
];
return (
<SettingsLayout active="branches">
<div className="fade-in">
<PageHeader
title="شعبه‌ها و اتاق‌ها"
description="ساعت کاری هر محل نوبت‌دهی و اتاق‌های آن. نام و آدرس شعبه در صفحهٔ همان کلینیک یا پزشک ویرایش می‌شود."
backTo="/admin/settings-menu"
/>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در شعبه‌ها..."
emptyMessage="هیچ شعبه‌ای برای این محیط ثبت نشده است"
headerExtra={
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginRight: 'auto' }}>
<StatusFilter
value={urlState.status}
onChange={(v) => setUrlState({ status: v })}
/>
</div>
}
actions={(b) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Link className="btn secondary sm" to={`/admin/branches/${b.uuid}/working-hours`}>
<ClockIcon style={{ width: 15 }} /> ساعت کاری
</Link>
<Link className="btn secondary sm" to={`/admin/branches/${b.uuid}/rooms`}>
<Squares2X2Icon style={{ width: 15 }} /> اتاقها
</Link>
{canUpdate && (
<button
type="button"
className="btn secondary sm"
disabled={update.isPending}
title={
b.active && activeCount === 1
? 'با غیرفعال کردن این شعبه، هیچ شعبهٔ فعالی باقی نمی‌ماند'
: undefined
}
onClick={() => {
if (
b.active &&
activeCount === 1 &&
!window.confirm('این تنها شعبهٔ فعال است. با غیرفعال کردن آن، هیچ شعبهٔ فعالی باقی نمی‌ماند. ادامه می‌دهید؟')
) {
return;
}
update.mutate({ uuid: b.uuid, d: { active: !b.active } });
}}
>
{b.active ? 'غیرفعال کردن' : 'فعال کردن'}
</button>
)}
</div>
)}
/>
</div>
</SettingsLayout>
);
}
function StatusFilter({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const options = [
{ value: '', label: 'همه' },
{ value: 'active', label: 'فعال' },
{ value: 'inactive', label: 'غیرفعال' },
];
return (
<div style={{ display: 'flex', gap: 4 }}>
{options.map((o) => (
<button
key={o.value}
type="button"
className={`btn sm ${value === o.value ? 'primary' : 'secondary'}`}
onClick={() => onChange(o.value)}
>
{o.label}
</button>
))}
</div>
);
}
+3 -3
View File
@@ -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 || 'بدون نام' })),
]}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
+3 -3
View File
@@ -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="انتخاب شعبه"
/>
</div>
@@ -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();
});
/** پیش‌نمایش نباید «وقت قابل رزرو» خوانده شود — نوبت‌ها هنوز کسر نشده‌اند. */
+3 -3
View File
@@ -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() {
<ResourceFormModal
open={editOpen}
resource={resource}
branches={branches}
addresses={addresses}
types={types}
saving={update.isPending}
onClose={() => setEditOpen(false)}
+6 -6
View File
@@ -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() {
<CreatePoolModal
open={creating}
branches={branches}
addresses={addresses}
types={types}
saving={create.isPending}
onClose={() => 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<typeof useBranches>['branches'];
addresses: ReturnType<typeof useAddresses>['addresses'];
types: ReturnType<typeof useResourceTypes>['types'];
saving: boolean;
onClose: () => void;
@@ -183,7 +183,7 @@ function CreatePoolModal({
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>شعبه</label>
<SearchableSelect
options={branches.map((b) => ({ 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="شعبه را انتخاب کنید"
+1 -1
View File
@@ -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] });
+4 -4
View File
@@ -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() {
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginRight: 'auto' }}>
<div style={{ minWidth: 180 }}>
<SearchableSelect
options={branches.map((b) => ({ 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() {
<ResourceFormModal
open={editing.open}
resource={editing.resource}
branches={branches}
addresses={addresses}
types={types}
saving={create.isPending || update.isPending}
onClose={() => setEditing({ open: false, resource: null })}
+2 -2
View File
@@ -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;
}
-6
View File
@@ -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.
-1
View File
@@ -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 |
-351
View File
@@ -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:0015:00` بعد از `09:0013: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
```
+21
View File
@@ -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`.
+10 -14
View File
@@ -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` را
می‌ساخت؛ آن لایه و آن دو دلیل دیگر وجود ندارند.
---
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Drops the Branch domain: rooms and branch working hours.
*
* A room stays visible to the user as a *resource* — `clinic_resources` already has a
* row per room, and only its bridge back to `rooms` goes away. That bridge is dropped
* before the tables, because `clinic_resources.room_id` is ON DELETE CASCADE and
* dropping `rooms` first would take those resources — and their appointments — with it.
*/
final class Version20260802113956 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remove the branch domain (rooms, branch working hours); rooms live on as plain resources';
}
public function up(Schema $schema): void
{
// اول پل منبع→اتاق، بعد خودِ جدول‌ها. برعکسش یعنی CASCADE منابع را هم می‌برد.
$this->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)');
}
}
@@ -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'])]
@@ -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,
) {}
@@ -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,
@@ -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,
) {}
-161
View File
@@ -1,161 +0,0 @@
<?php
namespace App\Branch\Controller;
use App\Auth\Entity\User;
use App\Branch\Repository\BranchWorkingHoursRepository;
use App\Branch\Repository\RoomRepository;
use App\Branch\Service\BranchResolver;
use App\Branch\Service\WorkingHoursService;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Doctor\Entity\DoctorAddress;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* شعبه = {@see DoctorAddress}. ساختن/ویرایش/حذف آدرس از قبل در ClinicController و
* AppointmentSettingsController هست و اینجا تکرار نمی‌شود؛ این کنترلر فقط چیزهایی را
* می‌دهد که آنجا نیست: فهرست شعبه‌های محیط جاری با شمارش، دو ویژگی تازهٔ
* active/timezone، و ساعت کاری هفتگی.
*/
#[OA\Tag(name: 'Branch')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class BranchController extends BaseController
{
public function __construct(
private readonly BranchResolver $branches,
private readonly WorkingHoursService $workingHours,
private readonly BranchWorkingHoursRepository $hoursRepo,
private readonly RoomRepository $roomRepo,
private readonly EntityManagerInterface $em,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
) {}
/** @param 'view'|'update' $action */
private function denyUnlessGranted(User $user, string $action): void
{
$this->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<int, list<array<string, mixed>>> $days
*/
private static function daysObject(array $days): \stdClass
{
return (object) $days;
}
}
-120
View File
@@ -1,120 +0,0 @@
<?php
namespace App\Branch\Controller;
use App\Auth\Entity\User;
use App\Branch\Entity\Room;
use App\Branch\Repository\RoomRepository;
use App\Branch\Service\BranchResolver;
use App\Branch\Service\RoomService;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Context\EntityContext;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Branch')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class RoomController extends BaseController
{
public function __construct(
private readonly BranchResolver $branches,
private readonly RoomRepository $rooms,
private readonly RoomService $roomService,
private readonly TenantOwnershipChecker $ownership,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
) {}
/** @param 'view'|'update' $action */
private function denyUnlessGranted(User $user, string $action): void
{
$this->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);
}
}
-105
View File
@@ -1,105 +0,0 @@
<?php
namespace App\Branch\Entity;
use App\Branch\Repository\BranchWorkingHoursRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
/**
* ساعت کاری هفتگی یک شعبه — و «شعبه» همان {@see DoctorAddress} است، نه جدولی جدا
* ({@see docs/new_feture/taskes/_shared/branch-is-doctor-address.md}).
*
* uuid ندارد (از request قابل ارجاع نیست) ولی جفت محیط دارد. اول به‌عنوان فرزند
* aggregate با ریشهٔ DoctorAddress ثبت شد و TenantSchemaCoverageTest درست ردش کرد:
* ریشه‌اش خودش در GlobalTables::ENTITIES سراسری است، پس آن مسیر هیچ تضمینی نمی‌داد.
* جفت گرفتن ممکن است چون type آدرس نگاشتی کامل به محیط دارد — personal ⇒ (doctor,
* doctorId) و clinic ⇒ (clinic, clinicId) — و آدرس هم فقط در همان محیط فهرست می‌شود،
* پس هیچ ردیفی بی‌دلیل پنهان نمی‌شود. نتیجه: TenantFilter واقعاً پوششش می‌دهد و
* {@see \App\Branch\Service\BranchResolver} لایهٔ دوم است نه تنها لایه.
*
* زمان‌ها «دقیقه از نیمه‌شب» است نه رشتهٔ "09:00": تقاطع دو بازه محاسبهٔ عددی است و
* مقایسهٔ رشته‌ای در «9:00» < «10:00» غلط جواب می‌دهد.
*/
#[ORM\Entity(repositoryClass: BranchWorkingHoursRepository::class)]
#[ORM\Table(name: 'branch_working_hours')]
#[ORM\UniqueConstraint(name: 'uniq_bwh_address_day_seq', columns: ['address_id', 'day_of_week', 'sequence'])]
#[ORM\Index(columns: ['address_id', 'day_of_week', 'active'], name: 'idx_bwh_address_day')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_bwh_tenant')]
class BranchWorkingHours
{
use TenantOwnedTrait;
public const MINUTES_IN_DAY = 1440;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private DoctorAddress $address;
/** ۰=شنبه … ۶=جمعه — همان قرارداد SlotCalculatorService */
#[ORM\Column(name: 'day_of_week', type: 'smallint')]
private int $dayOfWeek;
/** بازهٔ چندم آن روز؛ سرور تخصیصش می‌دهد، نه کلاینت */
#[ORM\Column(type: 'smallint', options: ['default' => 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);
}
}
-120
View File
@@ -1,120 +0,0 @@
<?php
namespace App\Branch\Entity;
use App\Branch\Repository\RoomRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* اتاق یک شعبه. برخلاف {@see BranchWorkingHours} جفت محیط دارد، چون uuidش از request
* می‌آید و بدون جفت، TenantFilter نمی‌تواند اتاق محیط دیگر را پنهان کند.
*
* جفت در سازنده از خودِ آدرس مشتق می‌شود نه از بدنهٔ درخواست — پس هیچ نقطهٔ ساختی
* نمی‌تواند فراموشش کند و کلاینت هم نمی‌تواند اتاقی را به محیط دیگری بچسباند.
*
* capacity یعنی چند بیمار هم‌زمان: اتاق تزریق سه‌تخته «یک منبع با ظرفیت ۳» است، نه
* سه منبع (بند ۶ مستند). تسک ۰۲ همین معنا را روی Resource تکرار می‌کند.
*/
#[ORM\Entity(repositoryClass: RoomRepository::class)]
#[ORM\Table(name: 'rooms')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_rooms_tenant')]
#[ORM\Index(columns: ['address_id', 'active'], name: 'idx_rooms_address')]
class Room
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private DoctorAddress $address;
#[ORM\Column(type: 'string', length: 120)]
private string $name;
/** متن آزاد — نوع اتاق را خود کلینیک تعریف می‌کند، نه یک enum سراسری */
#[ORM\Column(name: 'room_type', type: 'string', length: 60, nullable: true)]
private ?string $roomType = null;
#[ORM\Column(type: 'smallint', options: ['default' => 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,
];
}
}
@@ -1,70 +0,0 @@
<?php
namespace App\Branch\Repository;
use App\Branch\Entity\BranchWorkingHours;
use App\Doctor\Entity\DoctorAddress;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BranchWorkingHours>
*/
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<int, int> شناسهٔ آدرس => تعداد بازه‌ها
*/
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;
}
}
-78
View File
@@ -1,78 +0,0 @@
<?php
namespace App\Branch\Repository;
use App\Branch\Entity\Room;
use App\Doctor\Entity\DoctorAddress;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Room>
*/
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<int, int> شناسهٔ آدرس => تعداد اتاق فعال
*/
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;
}
}
@@ -1,20 +0,0 @@
<?php
namespace App\Branch\Service;
use App\Branch\Entity\Room;
use App\Shared\Exception\AppException;
/**
* دلیلی که یک اتاق را غیرقابل‌حذف می‌کند.
*
* الان هیچ پیاده‌سازی‌ای ندارد و این عمدی است: در این فاز اتاق هیچ وابستهٔ زنده‌ای
* ندارد. تسک ۰۲ (منبعِ فعال روی اتاق) و تسک ۰۷ (نوبت آیندهٔ آن منابع) هرکدام یک
* پیاده‌سازی اضافه می‌کنند و RoomService دست نمی‌خورد — به‌جای زنجیرهٔ if که هر تسک
* یک شرط به آن سنجاق کند.
*/
interface RoomDeletionGuardInterface
{
/** @throws AppException وقتی حذف مجاز نیست */
public function assertDeletable(Room $room): void;
}
-112
View File
@@ -1,112 +0,0 @@
<?php
namespace App\Branch\Service;
use App\Branch\Entity\Room;
use App\Doctor\Entity\DoctorAddress;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class RoomService
{
/**
* @param iterable<RoomDeletionGuardInterface> $deletionGuards
*/
public function __construct(
private readonly EntityManagerInterface $em,
#[AutowireIterator('app.room_deletion_guard')]
private readonly iterable $deletionGuards = [],
) {}
/** @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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;
}
}
-197
View File
@@ -1,197 +0,0 @@
<?php
namespace App\Branch\Service;
use App\Branch\Entity\BranchWorkingHours;
use App\Branch\Repository\BranchWorkingHoursRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
/**
* ساعت کاری هفتگی شعبه — اعتبارسنجی و ذخیره.
*
* قرارداد نوشتن PUT است نه PATCH: بدنه تمام حقیقتِ هفت روز است و آرایهٔ خالی یعنی
* «شعبه کاملاً بسته». دلیل: ساعت کاری یک شکل واحد است، و merge تفاضلی روی هفت روز و
* چند بازه در هر روز، دو کلاینت هم‌زمان را به وضعیت‌های ناسازگار می‌رساند.
*
* «شعبهٔ بدون هیچ ساعت کاری» = تعریف‌نشده، نه همیشه‌باز. تسک ۰۳ در آن حالت به رفتار
* فعلی برمی‌گردد (برنامهٔ پزشک تنها مرجع) تا دادهٔ موجود دقیقاً مثل امروز کار کند.
*/
final class WorkingHoursService
{
public const DAYS = [0, 1, 2, 3, 4, 5, 6];
public function __construct(
private readonly BranchWorkingHoursRepository $hours,
private readonly EntityManagerInterface $em,
) {}
/**
* @return array<int, list<array<string, mixed>>> کلیدهای ۰..۶ همیشه هر هفت روز
*/
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<int|string, mixed> $days نگاشت روز => فهرست بازه‌ها
* @return array<int, list<array<string, mixed>>>
* @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<int|string, mixed> $days
* @return array<int, list<array{start_minute: int, end_minute: int}>> مرتب‌شده، بدون هم‌پوشانی
* @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<int|string, mixed> $ranges
* @return list<array{start_minute: int, end_minute: int}>
*/
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;
}
}
@@ -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,
@@ -0,0 +1,46 @@
<?php
namespace App\Doctor\Controller;
use App\Auth\Entity\User;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Service\AddressResolver;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* محل‌های نوبت‌دهی محیط جاری — فهرست، فقط برای انتخاب.
*
* جانشین `GET /api/v1/branches` است. مفهوم «شعبه» از محصول حذف شد، ولی خودِ آدرس
* نمی‌رود: هر منبع، لیست قیمت و نوبت به یک آدرس بسته است. ساخت و ویرایش آدرس همان‌جایی
* است که همیشه بود (ClinicController و AppointmentSettingsController)؛ اینجا فقط
* خوانده می‌شود تا فرم‌های منبع و لیست قیمت بتوانند یکی را انتخاب کنند.
*/
#[OA\Tag(name: 'Doctor')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class AddressController extends BaseController
{
public function __construct(
private readonly AddressResolver $addresses,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
) {}
#[Route('/api/v1/addresses', name: 'address_list', methods: ['GET'])]
public function list(#[CurrentUser] User $user): JsonResponse
{
$this->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),
));
}
}
@@ -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
{
@@ -1,6 +1,6 @@
<?php
namespace App\Branch\Service;
namespace App\Doctor\Service;
use App\Auth\Entity\User;
use App\Doctor\Entity\DoctorAddress;
@@ -12,14 +12,18 @@ use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* تک‌نقطهٔ تبدیل «uuid شعبه در request» به یک {@see DoctorAddress} از محیط جاری.
* تک‌نقطهٔ تبدیل «uuid محل نوبت‌دهی در request» به یک {@see DoctorAddress} از محیط جاری.
*
* لازم است چون doctor_addresses جفت (entity_type, entity_id) ندارد و در
* GlobalTables::ENTITIES سراسری اعلام شده، پس TenantFilter رویش کار نمی‌کند:
* findOneBy(['uuid' => ]) آدرس کلینیک دیگری را هم برمی‌گرداند. هر سه کنترلر این
* دامنه از اینجا رد می‌شوند تا این بررسی جایی جا نیفتد.
* 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);
+2 -2
View File
@@ -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,
) {}
@@ -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<array{0: string, 1: string, 2: string}> $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`های برنامهٔ هفتگی مشتق می‌شود: آنجا دقیقاً نوشته که
* این پزشک در کدام آدرس‌ها شیفت دارد. «اولین شعبهٔ محیط» حدس می‌بود.
* این پزشک در کدام آدرس‌ها شیفت دارد. «اولین محل نوبت‌دهی محیط» حدس می‌بود.
*
* یک پاس روی همهٔ برنامه‌ها، نه یک پاس به‌ازای هر محیط: محیطِ هر برنامه از خودش
* خوانده می‌شود.
+4 -13
View File
@@ -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(),
@@ -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')
@@ -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<int, list<TimeInterval>> $shiftsByDay
* @param array<int, list<TimeInterval>>|null $branchByDay
* @param array<int, \App\Resource\Entity\NationalHoliday> $holidayMap
* @param array<int, \App\Resource\Entity\TenantHolidayOverride> $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<int, list<TimeInterval>>|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
+2 -2
View File
@@ -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,
+5 -13
View File
@@ -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;
-144
View File
@@ -1,144 +0,0 @@
<?php
namespace App\Tests\Branch;
use App\Doctor\Entity\DoctorAddress;
/**
* دو ویژگی تازهٔ شعبه (`active` / `timezone`) و فهرست شعبه‌های محیط جاری.
*/
class BranchFieldsTest extends BranchTestCase
{
/** ردیف‌های موجود بدون backfill درست می‌شوند؛ هیچ رفتار فعلی عوض نمی‌شود. */
public function testExistingBranchGetsSafeDefaults(): void
{
[$user, , $address] = $this->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);
}
}
-50
View File
@@ -1,50 +0,0 @@
<?php
namespace App\Tests\Branch;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* فیکسچرهای مشترک دامنهٔ شعبه. «شعبه» همان DoctorAddress است، پس هر تست به یک آدرس
* از محیط جاری و یک آدرس از محیط بیگانه نیاز دارد تا مرز ۴۰۴ را واقعاً بسنجد.
*/
abstract class BranchTestCase extends ApiTestCase
{
/** @return array{0: User, 1: Doctor, 2: DoctorAddress} */
protected function doctorWithAddress(string $name = 'مطب مرکزی'): array
{
$user = $this->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];
}
}
-172
View File
@@ -1,172 +0,0 @@
<?php
namespace App\Tests\Branch;
use App\Branch\Entity\Room;
class RoomCrudTest extends BranchTestCase
{
/** @param array<string, mixed> $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());
}
}
-229
View File
@@ -1,229 +0,0 @@
<?php
namespace App\Tests\Branch;
use App\Branch\Entity\BranchWorkingHours;
/**
* ساعت کاری هفتگی شعبه — GET/PUT روی /api/v1/branch/{addressUuid}/working-hours
*/
class WorkingHoursTest extends BranchTestCase
{
/** @param array<int, list<array{start_minute: int, end_minute: int}>> $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']);
}
}
+5 -25
View File
@@ -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);
}
/**
+8 -53
View File
@@ -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();
-11
View File
@@ -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<string, mixed> $body */
protected function createResource(User $user, DoctorAddress $address, ResourceType $type, array $body = []): array
{