feat(branch): branch working hours and rooms on the existing address entity

Task 01 planned a new `branches` table with `doctor_addresses.branch_id` bridging
to it. That plan was wrong: the branch already exists and is called
`DoctorAddress`. It carries name, address, telephone, coordinates, city/province
FKs and an owner (`forDoctor` / `forClinic` + `type`), and the whole system
already consumes it with exactly that meaning — `WeeklySchedule.sessions[].location_id`
points at `doctor_addresses.id`, `appointment-booking-locations` calls each row a
booking location, and nine CRUD endpoints plus four admin pages manage them.
A parallel table would mean two sources of truth for one physical place and a
branch that `location_id` never references.

So no `branches` table and no duplicate branch CRUD. Only the three genuinely
missing pieces:

- `doctor_addresses.active` / `.timezone`, both NOT NULL with a default so
  existing rows need no backfill and no current behaviour changes. `active` is
  stored only — applying it to slot calculation is task 03, since touching
  `SlotCalculatorService` is off limits in this phase.
- `branch_working_hours`, keyed to `doctor_addresses.id`. Minutes from midnight
  rather than "09:00" strings so range intersection stays arithmetic. PUT
  replaces all seven days; validation of the whole week runs before any DELETE,
  so an invalid sixth day cannot wipe the five valid ones and then answer 422.
- `rooms`, with `capacity` as concurrency (a three-bed injection room is one
  resource with capacity 3, not three resources) and a deletion-guard iterator
  so tasks 02 and 07 can add reasons without editing RoomService.

`BranchWorkingHours` first registered as an aggregate child of `DoctorAddress`;
TenantSchemaCoverageTest rejected it correctly, because that root is itself
declared global. It now carries a real tenant pair instead, derived in the
constructor from the address's `type` — a total mapping, and the address is only
ever listed in its own context, so nothing is hidden wrongly.

RoomController checks ownership explicitly rather than trusting TenantFilter:
hard isolation only applies to a *chosen* context, so a doctor who had not
selected one could PATCH another clinic's room. Caught by
RoomCrudTest::testForeignRoomIsNotFound, which failed with 200 before the fix.

35 tests, 97 assertions. Slot-mode frozen contract still green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 16:28:04 +03:30
co-authored by Claude Opus 5
parent a44cf8f9f7
commit eebb363b9f
24 changed files with 2262 additions and 248 deletions
+7
View File
@@ -23,6 +23,13 @@ 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']
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
@@ -0,0 +1,82 @@
# «شعبه» جدول تازه‌ای نیست — `doctor_addresses` است
**این سند بر همهٔ تسک‌هایی که `branches` یا `branch_id` می‌گویند حاکم است.**
نسخهٔ اول تسک ۰۱ یک جدول `branches` طراحی کرده بود؛ در اجرا معلوم شد آن موجودیت از قبل
وجود دارد. تسک ۰۱ اصلاح شد و جدول ساخته **نشد**.
هر جا در تسک‌های ۰۲، ۰۴، ۰۷، ۰۸، ۰۹، ۱۰، ۱۳ نوشته شده `branch_id INT NOT NULL FK →
branches(id)`، بخوانید:
```sql
address_id INT NOT NULL -- FK → doctor_addresses(id)
```
و هر جا `Branch $branch` نوشته شده، بخوانید `DoctorAddress $address`.
---
## چرا
`App\Doctor\Entity\DoctorAddress` تمام چیزی است که یک شعبه لازم دارد:
| نیاز شعبه | در `DoctorAddress` |
|---|---|
| نام | `name` |
| آدرس | `address` |
| تلفن | `telephone` |
| مختصات | `latitude` / `longitude` |
| شهر و استان | FK به entity `City` / `Province` |
| مالک (محیط) | `forDoctor(Doctor)` یا `forClinic(int $clinicId)` + ستون `type` |
| فعال/غیرفعال | `active`**تسک ۰۱ اضافه کرد** |
| منطقهٔ زمانی | `timezone`**تسک ۰۱ اضافه کرد** |
و از قبل در کل سیستم به همین معنا مصرف می‌شود:
- `WeeklySchedule.setting[day].sessions[].location_id``doctor_addresses.id`
- `SlotCalculatorService::buildSessionSlots()` آن را در هر اسلات کپی می‌کند
- `GET /api/v1/appointment-booking-locations/{doctorUuid}` هر آدرس را «محل نوبت‌دهی» می‌نامد
- `DoctorAddressRepository::findForContext($doctor, $clinicId)` چند آدرس per محیط می‌دهد
- **۹ endpoint CRUD** موجود: `clinic/{uuid}/addresses` (۴) و `clinic-pro/doctor-address*` (۵)
- UI ادمین: `ClinicDetailPage`، `ClinicFormPage`، `DoctorDetailPage`، `SettingsPage`
ساختن جدول موازی یعنی دو منبع حقیقت برای نام/آدرس/تلفن/مختصات یک مکان فیزیکی، و شعبه‌ای
که `location_id` هرگز به آن اشاره نمی‌کند — یعنی decorative. قاعدهٔ پروژه:
«API/جدول جدید فقط وقتی هیچ موجودی — حتی با توسعه — کافی نباشد.»
---
## پیامد برای تسک‌های بعدی
| تسک | چه چیزی عوض می‌شود |
|---|---|
| ۰۲ منابع | `clinic_resources.address_id``doctor_addresses(id)`. `ResourcePool` هم همین. «منابع مال شعبه‌اند» = مال یک آدرس‌اند |
| ۰۳ تقویم منبع | ساعت کاری شعبه از `branch_working_hours` که به `doctor_addresses.id` کلید می‌خورد |
| ۰۴ کاتالوگ | `service_branch_overrides.address_id` |
| ۰۷ رزرو | `appointments.address_id` **از قبل وجود دارد** (`Appointment::$addressId`) — ستون جدید لازم نیست |
| ۰۸ قیمت | `price_lists.address_id` |
| ۰۹/۱۰ قوانین | `policies.address_id` (اختصاصی‌بودن per شعبه) |
| ۱۳ لغو/انتظار | `waitlist_entries.address_id` |
⚠️ نکتهٔ تسک ۰۷: `Appointment` از قبل `address_id` دارد (ستون `addressId`, تهی‌پذیر) و
`SlotCalculatorService::resolveSlotLocationId()` پرش می‌کند. پس آنجا هم ستون تازه لازم نیست.
---
## جفت tenant اتاق و منابع
`DoctorAddress` ستون‌های `entity_type`/`entity_id` ندارد؛ مالکیتش با `type` +
`doctor_id`/`clinic_id` بیان می‌شود. موجودیت‌های جدیدی که به آدرس کلید می‌خورند و
`TenantOwnedTrait` دارند، جفتشان را در **سازنده از آدرس مشتق** می‌کنند:
```php
// App\Branch\Entity\Room::__construct()
$this->assignTenantPair(
$address->getType() === DoctorAddress::TYPE_CLINIC ? 'clinic' : 'doctor',
$address->getType() === DoctorAddress::TYPE_CLINIC
? (int) $address->getClinicId()
: (int) $address->getDoctor()->getId(),
);
```
همان قاعدهٔ `docs/architecture/tenancy.md`: جفت در سازنده از ریشه مشتق می‌شود، نه از
ورودی درخواست — پس هیچ نقطهٔ ساختی نمی‌تواند فراموشش کند و write-once می‌ماند.
@@ -1,101 +1,111 @@
# معماری — تسک ۰۱
> ⛔ نسخهٔ اول این فایل entity `Branch`، `BranchController` (CRUD شعبه)، `BranchService` و
> `BackfillBranchCommand` داشت. همه حذف شدند: «شعبه» = `DoctorAddress` و CRUDش از قبل
> وجود دارد. دلیل: [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md).
## ساختار فایل
```
src/Branch/
├── Controller/
│ ├── BranchController.php # CRUD شعبه + ساعت کاری
│ └── RoomController.php # CRUD اتاق
│ ├── BranchWorkingHoursController.php # GET/PUT ساعت کاری یک آدرس
│ └── RoomController.php # CRUD اتاق
├── Entity/
│ ├── Branch.php
── BranchWorkingHours.php
│ └── Room.php
│ ├── BranchWorkingHours.php # فرزند aggregate — بدون جفت tenant
── Room.php # TenantOwnedTrait
├── Repository/
│ ├── BranchRepository.php
│ ├── BranchWorkingHoursRepository.php
│ └── RoomRepository.php
── Service/
├── BranchService.php # ساخت/ویرایش/حذف + قواعد حذف
── WorkingHoursService.php # اعتبارسنجی و ذخیرهٔ هفت روز
└── Command/
└── BackfillBranchCommand.php # app:branch:backfill
── Service/
├── WorkingHoursService.php # اعتبارسنجی + جایگزینی هفت روز
── RoomService.php # ساخت/ویرایش/حذف + قواعد حذف
└── BranchResolver.php # uuid آدرس → DoctorAddress در محیط جاری
src/Doctor/Entity/DoctorAddress.php # + active + timezone
assets/admin/pages/
├── BranchesPage.tsx
├── BranchFormPage.tsx # شامل تب ساعت کاری
└── RoomsPage.tsx
├── BranchesPage.tsx # لیست شعبه‌های محیط جاری + دو اکشن
├── BranchWorkingHoursPage.tsx
└── BranchRoomsPage.tsx
```
## لایه‌بندی
دامنهٔ جدید `Branch` است نه `Doctor`، چون `BranchWorkingHours` و `Room` مفاهیم مکان‌اند و
تسک‌های ۰۲/۰۳ منابع را هم روی همین دامنه می‌سازند. `DoctorAddress` سرِ جایش در `Doctor`
می‌ماند — جابه‌جا کردنش namespace را می‌شکند بدون هیچ سودی.
`BranchController` نازک است: اعتبارسنجی ورودی + `EntityContextResolver` + صدا زدن سرویس.
همهٔ قواعد (حذف امن، یکتایی نام در محیط، نرمال‌سازی ساعت) در `BranchService` و
`WorkingHoursService`.
## `BranchResolver` — چرا لازم است
`doctor_addresses` **ستون `entity_type`/`entity_id` ندارد**، پس `TenantFilter` رویش اعمال
نمی‌شود. یعنی `findOneBy(['uuid' => $uuid])` آدرس محیط دیگر را هم برمی‌گرداند. هر endpoint
جدیدی که با uuid آدرس شروع می‌شود باید محیط را **دستی** بررسی کند — همان کاری که
`clinic/{uuid}/addresses` با `findByUuidAndClinic()` می‌کند.
یک نقطهٔ متمرکز به‌جای تکرار در سه کنترلر:
```php
final class BranchService
final class BranchResolver
{
public function __construct(
private readonly BranchRepository $branches,
private readonly RoomRepository $rooms,
private readonly EntityManagerInterface $em,
private readonly DoctorAddressRepository $addresses,
private readonly EntityContextResolver $context,
) {}
public function create(EntityContext $ctx, BranchInput $input): Branch
/** @throws AppException 404 وقتی آدرس در محیط جاری نیست */
public function resolve(string $addressUuid): DoctorAddress
{
$branch = new Branch($input->name);
$branch->assignTenant($ctx); // ← اجباری، وگرنه flush می‌شکند
// ...
}
/** حذف فقط وقتی هیچ اتاق یا منبعِ فعالی به شعبه وصل نیست. */
public function delete(Branch $branch): void
{
if ($this->rooms->countActiveByBranch($branch) > 0) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شعبه دارای اتاق فعال است', 422);
$address = $this->addresses->findOneBy(['uuid' => $addressUuid]);
if ($address === null || !$this->belongsToCurrentContext($address)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'شعبه یافت نشد', 404);
}
// ...
return $address;
}
}
```
## رابطهٔ Branch با DoctorAddress
`DoctorAddress` حذف نمی‌شود. یک ستون `branch_id` تهی‌پذیر می‌گیرد:
```
DoctorAddress.branch_id ──▶ branches.id (nullable, ON DELETE SET NULL)
```
دلیل: `location_id` در JSON برنامهٔ هفتگی به `doctor_addresses.id` اشاره دارد و در
`SlotCalculatorService` و `AppointmentController::bookingLocations()` و سایت عمومی مصرف می‌شود.
تغییر آن قرارداد یعنی شکستن سه کلاینت. پس شعبه یک **لایهٔ بالاتر** می‌نشیند و آدرس به آن
لینک می‌شود، نه برعکس.
`BackfillBranchCommand` برای هر محیطی که آدرس دارد یک شعبه با نام آدرس می‌سازد و
`branch_id` را پر می‌کند. dry-run پیش‌فرض، `--force` برای اجرا.
**۴۰۴ نه ۴۰۳** — همان رفتار `TenantFilter`: وجود دادهٔ محیط دیگر لو نمی‌رود.
## ساعت کاری شعبه
مثل `WeeklySchedule` یک JSON نیست — جدول جداست، چون تسک ۰۳ باید بتواند
`WHERE branch_id = ? AND day = ?` بزند بدون خواندن و decode کردن JSON برای هر روز از ۹۰ روز.
جدول جداست نه JSON مثل `WeeklySchedule`، چون تسک ۰۳ باید
`WHERE address_id = ? AND day_of_week = ?` بزند بدون decode کردن JSON برای هر روز از ۹۰ روز.
```php
#[ORM\Entity]
#[ORM\Table(name: 'branch_working_hours')]
#[ORM\UniqueConstraint(name: 'uniq_branch_day_seq', columns: ['branch_id', 'day_of_week', 'sequence'])]
#[ORM\UniqueConstraint(name: 'uniq_bwh_address_day_seq', columns: ['address_id', 'day_of_week', 'sequence'])]
class BranchWorkingHours
{
private DoctorAddress $address;
private int $dayOfWeek; // 0=شنبه … 6=جمعه — همان قرارداد SlotCalculatorService
private int $startMinute; // دقیقه از نیمه‌شب، 0..1440
private int $endMinute;
private int $sequence; // چند بازه در روز (صبح/عصر)
private int $sequence; // بازهٔ چندم آن روز (صبح/عصر)
private bool $active = true;
}
```
`startMinute`/`endMinute` به‌جای رشتهٔ `"08:30"` ذخیره می‌شوند تا مقایسه و تقاطع در تسک ۰۶
حسابی باشد نه رشته‌ای. تبدیل به `H:i` فقط در `toArray()`.
`startMinute`/`endMinute` عدد است نه رشتهٔ `"08:30"`، تا تقاطع در تسک ۰۶ حسابی باشد نه
رشته‌ای. تبدیل به `H:i` فقط در `toArray()`.
### `WorkingHoursService` — جایگزینی کامل، نه تفاضلی
```php
public function replace(DoctorAddress $address, array $days): array
{
$rows = $this->validate($days); // اول همه را اعتبارسنجی کن
$this->repository->deleteForAddress($address); // بعد پاک کن
foreach ($rows as $row) { $this->em->persist(...); }
$this->em->flush();
}
```
اعتبارسنجی **قبل از** حذف اتفاق می‌افتد؛ وگرنه یک بازهٔ نامعتبر در روز ششم، پنج روز درست
را هم پاک می‌کند و ۴۲۲ برمی‌گرداند. PUT semantics: بدنه تمام حقیقت است، آرایهٔ خالی =
شعبه کامل بسته.
قواعد اعتبارسنجی: `0 <= start < end <= 1440` · هیچ دو بازهٔ هم‌پوشان در یک روز
(بازه‌ها را per روز sort و همسایه‌ها را مقایسه کن) · `day_of_week ∈ 0..6`.
## اتاق
@@ -103,23 +113,40 @@ class BranchWorkingHours
class Room
{
use TenantOwnedTrait;
private Branch $branch;
private DoctorAddress $address;
private string $name;
private ?string $roomType = null; // متن آزاد — نوعِ اتاق را کلینیک تعریف می‌کند
private ?string $roomType = null; // متن آزاد — نوع اتاق را کلینیک تعریف می‌کند
private int $capacity = 1; // چند بیمار هم‌زمان (اتاق تزریق سه‌تخته = 3)
private ?string $floor = null;
private bool $active = true;
}
```
`capacity` از همین‌جا شروع می‌شود چون مستند بند ۶ صریح می‌گوید سه تخت = **یک منبع با
ظرفیت سه**، نه سه منبع. تسک ۰۲ همین معنا را روی `Resource` تکرار می‌کند و اتاق را
به‌عنوان یک `Resource` با `resource_type=room` منعکس می‌کند.
جفت tenant در **سازنده از آدرس مشتق** می‌شود، نه از بدنهٔ request — پس هیچ نقطهٔ ساختی
نمی‌تواند فراموشش کند. `capacity` از روز اول هست چون مستند بند ۶ صریح می‌گوید سه تخت =
**یک منبع با ظرفیت سه**، نه سه منبع؛ تسک ۰۲ همین معنا را روی `Resource` تکرار می‌کند و
اتاق را به‌عنوان `resource_type=room` منعکس می‌کند.
حذف اتاق در این تسک فقط `active` را چک می‌کند (اتاق فعال قابل حذف است، منبع هنوز وجود
ندارد). گاردِ «اتاقی که منبع فعال دارد حذف نشود» در تسک ۰۲ اضافه می‌شود — آنجاست که
`Resource.room_id` به وجود می‌آید. این را در checklist به‌عنوان ⏳ با مقصد صریح ثبت کن.
## پنل ادمین
- `BranchesPage.tsx``DataTable` + `PageHeader` با `backTo`، وضعیت لیست در URL با `useUrlState`
- `BranchFormPage.tsx` — دو تب: مشخصات / ساعت کاری. `SearchableSelect` برای شهر
(هرگز `<select>` بومی)
- `RoomsPage.tsx` — زیرصفحهٔ شعبه، `<BackButton fallback="/admin/branches" />`
- مسیرها در `App.tsx`: `/admin/branches`, `/admin/branches/new`, `/admin/branches/:uuid`,
`/admin/branches/:uuid/rooms`
سه صفحهٔ جدید، همه با الگوهای موجود (`_shared/ui-conventions.md`):
| صفحه | مسیر | نکات |
|---|---|---|
| `BranchesPage` | `/admin/branches` | `DataTable` + `PageHeader` با `backTo="/admin/settings-menu"` · وضعیت در URL با `useUrlState` · هر ردیف دو اکشن: «ساعت کاری» و «اتاق‌ها» |
| `BranchWorkingHoursPage` | `/admin/branches/:addressUuid/working-hours` | `<PageHeader backTo="/admin/branches">` · هفت کارت روز، هر کارت چند بازه با افزودن/حذف · ذخیره = یک PUT |
| `BranchRoomsPage` | `/admin/branches/:addressUuid/rooms` | `DataTable` + `Modal` برای ساخت/ویرایش + `ConfirmDialog` برای حذف |
`BranchesPage` **آدرس نمی‌سازد و ویرایش نمی‌کند** — آن کار در `ClinicDetailPage` و
`DoctorDetailPage` از قبل هست. این صفحه فقط دروازهٔ ساعت کاری و اتاق است، به‌علاوهٔ
سوییچ `active` و انتخاب `timezone`.
نقش‌ها: `RoleRoute roles={['clinic', 'doctor', 'secretary']}` با
`permission={['appointment_settings', 'view']}` — ساعت کاری شعبه از جنس تنظیمات نوبت است و
مجوز جدید ساختن یعنی یک ستون تازه در جدول مجوزها بدون نیاز واقعی.
ورودی منو: یک آیتم در `SettingsMenuPage.tsx` کنار «تنظیمات نوبت‌دهی».
@@ -1,9 +1,10 @@
# چک‌لیست — تسک ۰۱ (شعبه و اتاق)
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:**
**وضعیت کلی:** 🔄 در حال انجام · **آخرین بازبینی:**
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) ·
[branch-is-doctor-address.md](../_shared/branch-is-doctor-address.md)
---
@@ -13,80 +14,97 @@
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | |
| ۰.۲ | `SlotCalculatorService` دست‌نخورده | ⏳ | این تسک به آن کاری ندارد |
| ۰.۳ | `location_id` در JSON برنامهٔ هفتگی دست‌نخورده | ⏳ | شعبه **بالای** آدرس می‌نشیند |
| ۰.۴ | `DoctorAddress` هیچ ستونی حذف/تغییر نداد | ⏳ | فقط `branch_id` تهی‌پذیر اضافه شد |
| ۰.۳ | `location_id` در JSON برنامهٔ هفتگی دست‌نخورده | ⏳ | شعبه = همان `doctor_addresses.id` |
| ۰.۴ | `DoctorAddress` هیچ ستونی حذف/تغییر نداد | ⏳ | فقط `active` و `timezone` با `DEFAULT` |
| ۰.۵ | `active=false` هیچ اثری بر محاسبهٔ اسلات ندارد | ⏳ | اعمالش تسک ۰۳ است |
## ۱. بک‌اند
## ۱. طرح
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `Branch` + `BranchWorkingHours` + `Room` entity | ⏳ | |
| ۱.۲ | `BranchService` با گاردهای حذف قابل توسعه (`DeletionGuardInterface`) | ⏳ | تسک ۰۲ و ۰۷ گارد اضافه می‌کنند |
| ۱.۳ | `WorkingHoursService` — اعتبارسنجی و `sequence` سمت سرور | ⏳ | |
| ۱.۴ | ساعت با `start_minute`/`end_minute` عددی، نه رشتهٔ `"09:00"` | ⏳ | |
| ۱.۵ | هشت endpoint ساخته شد | ⏳ | |
| ۱.۶ | پزشک مستقل هم شعبه دارد (مطب = شعبه) | ⏳ | نه فقط `entity_type=clinic` |
| ۱.۷ | `app:branch:backfill` — dry-run پیش‌فرض، idempotent | ⏳ | |
| ۱.۸ | کنترلر نازک · `BaseController` · `success/paginated/error` | ⏳ | |
| ۱.۹ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | |
| ۱.۱ | ⛔ جدول `branches` **ساخته نشد** — دلیل مکتوب | ✅ | `_shared/branch-is-doctor-address.md` |
| ۱.۲ | `task.md` · `architecture.md` · `database.md` · `implementation_notes.md` تصحیح شد | ✅ | |
| ۱.۳ | ارجاع‌های `branch_id` در تسک‌های ۰۲/۰۴/۰۷/۰۸/۰۹/۱۰/۱۳ با سند حاکم پوشش داده شد | ✅ | ۲۴ ارجاع — یک سند واحد در `_shared` به‌جای ویرایش ۲۴ نقطه |
## ۲. دیتابیس
## ۲. بک‌اند
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | `branches` · `branch_working_hours` · `rooms` | ⏳ | |
| ۲.۲ | `entity_type, entity_id` ستون **اول** ایندکس‌های لیست | ⏳ | |
| ۲.۳ | `timezone` روی شعبه از روز اول | ⏳ | افزودن بعدی = backfill زمان‌دار |
| ۲.۴ | `rooms.capacity` — ظرفیت هم‌زمان | ⏳ | اتاق سه‌تخته = یک ردیف با ۳ |
| ۲.۵ | `branch_working_hours` در `GlobalTables::AGGREGATE_CHILDREN` | ⏳ | |
| ۲.۶ | `TenantSchemaCoverageTest` سبز | ⏳ | |
| ۲.۱ | `DoctorAddress` += `active` + `timezone` | ⏳ | |
| ۲.۲ | `timezone` با `DateTimeZone::listIdentifiers()` اعتبارسنجی می‌شود، نه regex | ⏳ | |
| ۲.۳ | `BranchWorkingHours` entity (فرزند aggregate) | ⏳ | |
| ۲.۴ | `Room` entity با `TenantOwnedTrait` و جفت مشتق از آدرس در سازنده | ⏳ | نه از بدنهٔ request |
| ۲.۵ | `BranchResolver` — تک‌نقطهٔ uuid آدرس → محیط جاری، ۴۰۴ نه ۴۰۳ | ⏳ | `TenantFilter` روی `doctor_addresses` کار نمی‌کند |
| ۲.۶ | `WorkingHoursService` — اعتبارسنجی کامل **قبل از** حذف (اتمی) | ⏳ | |
| ۲.۷ | ساعت با `start_minute`/`end_minute` عددی، نه رشتهٔ `"09:00"` | ⏳ | |
| ۲.۸ | `sequence` سمت سرور تخصیص می‌یابد، نه کلاینت | ⏳ | |
| ۲.۹ | `RoomService` با گارد حذف قابل توسعه (آرایهٔ تزریقی، نه زنجیرهٔ `if`) | ⏳ | تسک ۰۲ و ۰۷ گارد اضافه می‌کنند |
| ۲.۱۰ | شش endpoint ساخته شد | ⏳ | صفر endpoint CRUD شعبه — موجود است |
| ۲.۱۱ | پزشک مستقل هم شعبه دارد | ⏳ | `type='personal'` از قبل کار می‌کند |
| ۲.۱۲ | کنترلر نازک · `BaseController` · `success/paginated/error` | ⏳ | |
## ۳. UI
## ۳. دیتابیس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `BranchesPage` · `BranchFormPage` · `RoomsPage` | ⏳ | |
| ۳.۲ | `DataTable` با skeleton و empty state فارسی | ⏳ | |
| ۳.۳ | `PageHeader` با `backTo` روی زیرصفحه‌ها | ⏳ | |
| ۳.۴ | شهر/استان با `SearchableSelect` — هیچ `<select>` بومی | ⏳ | |
| ۳.۵ | وضعیت لیست در URL با `useUrlState` | ⏳ | |
| ۳.۶ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن | ⏳ | |
| ۳.۷ | دارک‌مود و حالت فشرده بررسی شد | ⏳ | |
| ۳.۸ | RTL و موبایل بررسی شد | ⏳ | |
| ۳.۹ | همهٔ رشته‌ها فارسی از i18n | ⏳ | |
| ۳.۱۰ | هشدار UI: «هیچ شعبهٔ فعالی باقی نمی‌ماند» | ⏳ | |
| ۳.۱۱ | مسیرها در `App.tsx` | ⏳ | |
| ۳.۱ | `branch_working_hours` · `rooms` | ⏳ | |
| ۳.۲ | `entity_type, entity_id` ستون **اول** ایندکس `rooms` | ⏳ | |
| ۳.۳ | `timezone` روی آدرس از روز اول | ⏳ | افزودن بعدی = backfill زمان‌دار |
| ۳.۴ | `rooms.capacity` — ظرفیت هم‌زمان | ⏳ | اتاق سه‌تخته = یک ردیف با ۳ |
| ۳.۵ | `branch_working_hours` در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح | ⏳ | |
| ۳.۶ | ستون‌ها و جدول‌ها روی `db_test` هم ساخته شد | ⏳ | تاریخچهٔ migration جدا |
| ۳.۷ | `TenantSchemaCoverageTest` سبز | ⏳ | |
| ۳.۸ | `TenantLookupInventoryTest` سبز — repository جدید ثبت شد | ⏳ | |
## ۴. تست
## ۴. UI
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `BranchCrudTest` — نقش‌ها، ۴۰۴ نه ۴۰۳ برای محیط دیگر | ⏳ | |
| ۴.۲ | `WorkingHoursTest``end<=start`، هم‌پوشانی، شبانه‌روزی `0..1440` | ⏳ | |
| ۴.۳ | `BranchDeletionTest` — شعبهٔ دارای اتاق فعال → ۴۲۲ | ⏳ | |
| ۴.۴ | `capacity=0` → ۴۲۲ | ⏳ | |
| ۴.۵ | `phpstan analyse src/Branch` بدون خطا | ⏳ | |
| ۴.۱ | `BranchesPage` · `BranchWorkingHoursPage` · `BranchRoomsPage` | ⏳ | |
| ۴.۲ | `DataTable` با skeleton و empty state فارسی | ⏳ | |
| ۴.۳ | `PageHeader` با `backTo` روی زیرصفحه‌ها | ⏳ | |
| ۴.۴ | هر `select` با `SearchableSelect` — هیچ `<select>` بومی | ⏳ | |
| ۴.۵ | وضعیت لیست در URL با `useUrlState` | ⏳ | |
| ۴.۶ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن | ⏳ | |
| ۴.۷ | دارک‌مود و حالت فشرده بررسی شد | ⏳ | |
| ۴.۸ | RTL و موبایل بررسی شد | ⏳ | |
| ۴.۹ | هشدار UI: «هیچ شعبهٔ فعالی باقی نمی‌ماند» | ⏳ | |
| ۴.۱۰ | مسیرها در `App.tsx` + ورودی در `SettingsMenuPage` | ⏳ | |
| ۴.۱۱ | مجوز موجود `appointment_settings` استفاده شد، نه مجوز تازه | ⏳ | |
## ۵. مستندات
## ۵. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `docs/api/branch.md` + ثبت در `docs/api/README.md` | ⏳ | |
| ۵.۲ | تفسیر «شعبهٔ بدون ساعت کاری = تعریف‌نشده، نه همیشه‌باز» نوشته شد | ⏳ | تسک ۰۳ رویش حساب می‌کند |
| ۵.۳ | `docs/architecture/tenancy.md` جدول طبقه‌بندی به‌روز شد | ⏳ | |
| ۵.۱ | `WorkingHoursTest` — هفت روز، `end<=start`، هم‌پوشانی، `0..1440`، آرایهٔ خالی | ⏳ | |
| ۵.۲ | اتمی بودن: بازهٔ نامعتبر در روز ششم → ۴۲۲ و شش روز قبلی دست‌نخورده | ⏳ | |
| ۵.۳ | `RoomCrudTest` — جفت tenant مشتق، `capacity=0` → ۴۲۲ | ⏳ | |
| ۵.۴ | آدرس/اتاق محیط دیگر → ۴۰۴ (نه ۴۰۳) | ⏳ | |
| ۵.۵ | `BranchAddressFieldsTest` — پیش‌فرض‌ها، `timezone` نامعتبر → ۴۲۲ | ⏳ | |
| ۵.۶ | `phpstan analyse src/Branch` بدون خطا | ⏳ | |
## ۶. بازبینی پایانی
## ۶. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⏳ | |
| ۶.۲ | `bin/phpunit` کامل سبز | ⏳ | |
| ۶.۳ | `--group=slot-mode-frozen` سبز | ⏳ | |
| ۶.۴ | `phpstan` بدون خطای جدید | ⏳ | |
| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
| ۶.۶ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | ⏳ | |
| ۶.۷ | `docs/api/*` به‌روز | ⏳ | |
| ۶.۸ | چک‌لیست UI کامل | ⏳ | |
| ۶.۹ | `nobat724_front` و `clinic-pro-tauri` بررسی شدند | ⏳ | این تسک قرارداد عمومی عوض نمی‌کند |
| ۶.۱۰ | commit، سپس `graphify update .` | ⏳ | |
| ۶.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | ⏳ | |
| ۶.۱ | `docs/api/branch.md` + ثبت در `docs/api/README.md` | ⏳ | JSON واقعی از curl |
| ۶.۲ | «شعبهٔ بدون ساعت کاری = تعریف‌نشده، نه همیشه‌باز» نوشته شد | ⏳ | تسک ۰۳ رویش حساب می‌کند |
| ۶.۳ | «`active` در این فاز بی‌اثر بر اسلات» نوشته شد | ⏳ | |
| ۶.۴ | `docs/api/doctor.md` — دو فیلد جدید در پاسخ ۹ endpoint آدرس | ⏳ | تغییر قرارداد است |
| ۶.۵ | `docs/architecture/tenancy.md` جدول طبقه‌بندی به‌روز شد | ⏳ | |
## ۷. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۷.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⏳ | |
| ۷.۲ | `bin/phpunit` کامل سبز | ⏳ | |
| ۷.۳ | `--group=slot-mode-frozen` سبز | ⏳ | |
| ۷.۴ | `phpstan` بدون خطای جدید (مقایسه با کامیت پیش از تسک) | ⏳ | |
| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
| ۷.۶ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | ⏳ | |
| ۷.۷ | `docs/api/*` به‌روز | ⏳ | |
| ۷.۸ | چک‌لیست UI کامل | ⏳ | |
| ۷.۹ | `nobat724_front` و `clinic-pro-tauri` بررسی شدند | ⏳ | دو فیلد جدید additive است |
| ۷.۱۰ | commit، سپس `graphify update .`، سپس commit جدا | ⏳ | |
| ۷.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | ⏳ | |
@@ -2,65 +2,62 @@
MariaDB 11.8 · Doctrine ORM 3.6 · همهٔ timestamp ها `INT` (Unix)
## `branches`
> ⛔ **جدول `branches` ساخته نمی‌شود.** «شعبه» همان `doctor_addresses` است — دلیل کامل در
> [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md). آنچه در
> نسخهٔ اول این فایل به‌عنوان جدول `branches` و ستون `doctor_addresses.branch_id` آمده بود
> حذف شد.
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | ارجاع خارجی |
| `entity_type` | VARCHAR(10) NOT NULL | `doctor` \| `clinic` |
| `entity_id` | INT NOT NULL | |
| `name` | VARCHAR(150) NOT NULL | |
| `phone` | VARCHAR(20) NULL | |
| `address` | TEXT NULL | |
| `city_id` | INT NULL | FK منطقی به `categories.id` با `bundle='city'` |
| `province_id` | INT NULL | همان الگو با `bundle='state'` |
| `latitude` | DOUBLE NULL | |
| `longitude` | DOUBLE NULL | |
| `timezone` | VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran' | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at` / `updated_at` | INT NOT NULL | |
## تغییر جدول موجود — `doctor_addresses`
ایندکس‌ها:
```sql
KEY idx_branches_tenant (entity_type, entity_id, active)
UNIQUE KEY uniq_branches_uuid (uuid)
ALTER TABLE doctor_addresses
ADD COLUMN active TINYINT(1) NOT NULL DEFAULT 1,
ADD COLUMN timezone VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran';
```
> `entity_type, entity_id` ستون‌های اول‌اند — شرطِ `TenantFilter` وگرنه از ایندکس استفاده نمی‌کند.
هیچ ستونی حذف یا تغییر نوع نمی‌دهد. هر دو `NOT NULL DEFAULT` دارند، پس ردیف‌های موجود
بی‌نیاز از backfill درست می‌شوند و هیچ رفتار فعلی عوض نمی‌شود.
`timezone` از روز اول هست چون مستند بند ۹ می‌گوید ذخیره‌سازی UTC و نمایش محلی؛ امروز همه‌جا
`Asia/Tehran` است ولی افزودن ستون بعداً یعنی backfill روی داده‌های زمان‌دار.
`timezone` از همین حالا اضافه می‌شود چون بند ۹ مستند ذخیره‌سازی UTC با نمایش محلی می‌خواهد؛
افزودنش بعد از اینکه داده‌های زمان‌دار روی شعبه نشستند یعنی backfill پرریسک.
⚠️ `active` در این تسک **فقط ذخیره** می‌شود؛ فیلتر شدنش در محاسبهٔ اسلات کارِ تسک ۰۳ است
(هر تغییری در `SlotCalculatorService` در فاز فعلی ممنوع است — `_shared/red-lines.md`).
## `branch_working_hours`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE CASCADE |
| `day_of_week` | TINYINT NOT NULL | ۰=شنبه … ۶=جمعه |
| `address_id` | INT NOT NULL | FK → `doctor_addresses.id` ON DELETE CASCADE |
| `day_of_week` | TINYINT NOT NULL | ۰=شنبه … ۶=جمعه — همان قرارداد `SlotCalculatorService` |
| `sequence` | TINYINT NOT NULL DEFAULT 0 | بازهٔ چندم آن روز |
| `start_minute` | SMALLINT NOT NULL | ۰..۱۴۴۰ |
| `start_minute` | SMALLINT NOT NULL | ۰..۱۴۴۰ از نیمه‌شب |
| `end_minute` | SMALLINT NOT NULL | > `start_minute` |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
```sql
UNIQUE KEY uniq_branch_day_seq (branch_id, day_of_week, sequence)
KEY idx_bwh_branch_day (branch_id, day_of_week, active)
UNIQUE KEY uniq_bwh_address_day_seq (address_id, day_of_week, sequence)
KEY idx_bwh_address_day (address_id, day_of_week, active)
```
بدون ستون tenant — فرزند aggregate با ریشهٔ `branches` است و uuid از request نمی‌گیرد
(همیشه از راه `/branch/{uuid}/working-hours` لود می‌شود). در `GlobalTables::AGGREGATE_CHILDREN`
با ریشهٔ صریح ثبت شود.
بدون ستون tenant — **فرزند aggregate** با ریشهٔ `DoctorAddress`. هرگز با uuid از request
لود نمی‌شود؛ تنها راه رسیدن به آن `/branch/{addressUuid}/working-hours` است که آدرس را از
`TenantFilter` رد می‌کند. در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح ثبت می‌شود.
دقت: چون `doctor_addresses` خودش `TenantOwnedTrait` ندارد (مالکیتش با `type` +
`doctor_id`/`clinic_id` است)، `TenantFilter` روی خودِ آدرس هم اعمال نمی‌شود.
پس فیلتر محیط برای این endpoint **دستی** است: `DoctorAddressRepository::findForContext()`
که از قبل همین کار را می‌کند و در `clinic/{uuid}/addresses` هم همین‌طور استفاده شده.
## `rooms`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | **از request می‌آید** → پس جفت tenant خودش را دارد |
| `entity_type` / `entity_id` | VARCHAR(10)/INT NOT NULL | از `branch` در سازنده مشتق می‌شود |
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE CASCADE |
| `uuid` | VARCHAR(36) UNIQUE | **از request می‌آید** → پس جفت tenant لازم دارد |
| `entity_type` / `entity_id` | VARCHAR(10)/INT NOT NULL | در سازنده از `DoctorAddress` مشتق می‌شود |
| `address_id` | INT NOT NULL | FK → `doctor_addresses.id` ON DELETE CASCADE |
| `name` | VARCHAR(120) NOT NULL | |
| `room_type` | VARCHAR(60) NULL | متن آزاد، تعریف کلینیک |
| `capacity` | SMALLINT NOT NULL DEFAULT 1 | ظرفیت هم‌زمان |
@@ -70,39 +67,55 @@ KEY idx_bwh_branch_day (branch_id, day_of_week, active)
```sql
KEY idx_rooms_tenant (entity_type, entity_id, active)
KEY idx_rooms_branch (branch_id, active)
KEY idx_rooms_address (address_id, active)
```
## تغییر جدول موجود
`entity_type, entity_id` ستون‌های اولِ ایندکس‌اند — شرط `TenantFilter` وگرنه از ایندکس
استفاده نمی‌کند.
```sql
ALTER TABLE doctor_addresses
ADD COLUMN branch_id INT NULL,
ADD CONSTRAINT fk_doctor_addresses_branch
FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE SET NULL,
ADD KEY idx_doctor_addresses_branch (branch_id);
اشتقاق جفت در سازنده (نه از بدنهٔ request):
```php
$isClinic = $address->getType() === DoctorAddress::TYPE_CLINIC;
$this->assignTenantPair(
$isClinic ? 'clinic' : 'doctor',
$isClinic ? (int) $address->getClinicId() : (int) $address->getDoctor()->getId(),
);
```
هیچ ستونی حذف یا تغییر نوع نمی‌دهد. `location_id` در JSON برنامهٔ هفتگی دست‌نخورده می‌ماند.
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/console app:branch:backfill # dry-run
ddev exec php bin/console app:branch:backfill --force
```
هیچ command backfill لازم نیست — `app:branch:backfill` نسخهٔ اول برای پر کردن `branch_id`
از `doctor_addresses` بود؛ حالا که جدول موازی ساخته نمی‌شود، موضوعش منتفی است.
⚠️ **`db_test` تاریخچهٔ migration جدا دارد** و `migrate` رویش با
`Table 'users' already exists` می‌شکند. ستون‌ها را دستی اضافه کن وگرنه کل تست‌سوئیت با
`Unknown column` قرمز می‌شود:
```bash
ddev mysql -uroot -proot -e "ALTER TABLE db_test.doctor_addresses \
ADD active TINYINT(1) NOT NULL DEFAULT 1, \
ADD timezone VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran';"
```
و بعد از تولید migration، همان `CREATE TABLE` های `branch_working_hours` و `rooms` را هم
روی `db_test` اجرا کن.
## طبقه‌بندی tenant
| جدول | وضعیت | ثبت در |
|---|---|---|
| `branches` | جفت tenant | `TenantOwnedTrait` |
| `doctor_addresses` | از قبل طبقه‌بندی‌شده — دست نمی‌خورد | همان‌جای فعلی |
| `rooms` | جفت tenant | `TenantOwnedTrait` (uuid از request می‌آید) |
| `branch_working_hours` | فرزند aggregate | `GlobalTables::AGGREGATE_CHILDREN` → ریشه `Branch` |
| `branch_working_hours` | فرزند aggregate | `GlobalTables::AGGREGATE_CHILDREN` → ریشه `DoctorAddress` |
بعد از migration:
```bash
ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php
ddev exec php bin/phpunit tests/Shared/TenantLookupInventoryTest.php
```
دومی هم لازم است: repository جدیدی که `findByUuid` دارد باید در `REVIEWED` ثبت شود.
@@ -1,36 +1,59 @@
# نکات پیاده‌سازی — تسک ۰۱
## ۱. چرا شعبه بالای آدرس می‌نشیند، نه جای آن
## ۱. شعبه ساخته نمی‌شود — پیدا می‌شود
سه مصرف‌کنندهٔ زنده به `doctor_addresses.id` وابسته‌اند:
نسخهٔ اول این فایل استدلال می‌کرد «چرا شعبه بالای آدرس می‌نشیند». استدلال درست بود ولی
نتیجه‌اش غلط: اگر آدرس همان مکان فیزیکی است و همه‌جا هم به همان معنا مصرف می‌شود، لایهٔ
بالایی چیزی جز یک جدول دوم برای همان نام و تلفن نیست. سه مصرف‌کنندهٔ زنده‌ای که
`doctor_addresses.id` را می‌خوانند —
1. `WeeklySchedule.setting[day].sessions[].location_id` (JSON)
2. `SlotCalculatorService::buildSessionSlots()` که آن را در هر اسلات کپی می‌کند
3. `AppointmentController::bookingLocations()` که به سایت عمومی `location_uuid` می‌دهد
عوض کردن این قرارداد در build هیچ‌کدام از سه ریپو خطا نمی‌دهد — فقط در runtime آدرس گم می‌شود.
پس `branch_id` روی آدرس اضافه می‌شود و آدرس همان‌جا می‌ماند.
— دلیل اصلی‌اند که «شعبه» همان آدرس است، نه دلیلِ ساختن لایهٔ دوم.
جزئیات کامل: [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md).
## ۲. پزشک مستقل هم شعبه دارد
وسوسه می‌شود که شعبه را فقط برای `entity_type=clinic` بسازیم. نکن. اگر پزشک مستقل شعبه
نداشته باشد، تسک ۰۲ باید دو مسیر کد برای «منبع مال شعبه» و «منبع مال پزشک» داشته باشد و
تسک ۰۶ هر دو را جدا حساب کند. مطب شخصی = شعبه‌ای با `entity_type=doctor`.
خوشبختانه از قبل درست است: `DoctorAddress::forDoctor()` با `type = 'personal'` وجود دارد و
`findForContext()` هم آدرس‌های شخصی و هم کلینیکی را برمی‌گرداند. پس تسک ۰۲ لازم نیست دو
مسیر کد برای «منبع مال شعبه» و «منبع مال پزشک» داشته باشد. مطب شخصی = آدرسی با
`type='personal'`.
## ۳. حذف شعبه
## ۳. `TenantFilter` روی `doctor_addresses` کار نمی‌کند
هرگز `CASCADE` روی حذف شعبه به منابع و نوبت‌ها نده. `DELETE` فقط وقتی مجاز است که:
مهم‌ترین تلهٔ این تسک. `doctor_addresses` ستون `entity_type`/`entity_id` ندارد، پس:
- هیچ `Room` فعالی نداشته باشد، **و**
- هیچ `Resource` فعالی (تسک ۰۲) نداشته باشد، **و**
- هیچ نوبت آیندهٔ فعالی روی منابعش نباشد (تسک ۰۷)
```php
// ❌ آدرس محیط دیگر را هم برمی‌گرداند — filter اینجا تور ایمنی نیست
$address = $this->addresses->findOneBy(['uuid' => $uuid]);
تا آن تسک‌ها نیامده‌اند، فقط شرط اول را چک کن ولی سرویس را طوری بنویس که افزودن دو شرط
بعدی یک خط باشد (لیست `DeletionGuardInterface` و تزریق آرایه‌ای از گاردها).
// ✅ از BranchResolver رد شو
$address = $this->branches->resolve($uuid); // 404 اگر محیط جاری نباشد
```
`active=false` مسیر اصلی است، نه `DELETE`.
`Room` خودش `TenantOwnedTrait` دارد (uuidش از request می‌آید) پس روی آن filter کار می‌کند؛
ولی `BranchWorkingHours` فرزند aggregate است و **هیچ** فیلتری ندارد — تنها محافظش این است
که فقط از راه `BranchResolver` قابل دسترسی باشد. `RequestReachableChildTenantTest` همین را
اجبار می‌کند: هیچ endpointی نباید uuid فرزند را مستقیم بگیرد.
## ۴. ساعت کاری — دقیقه، نه رشته
## ۴. حذف
روی حذف آدرس هیچ دست نمی‌بریم (endpointهایش موجودند). فقط:
- `branch_working_hours.address_id` و `rooms.address_id` هر دو `ON DELETE CASCADE` — حذف
آدرس ساعت و اتاقش را هم می‌برد. این درست است: ساعت کاری بدون مکان معنا ندارد.
- حذف **اتاق**: در این تسک بی‌قید (منبعی هنوز وجود ندارد). سرویس را طوری بنویس که افزودن
گاردهای تسک ۰۲ (منبع فعال) و ۰۷ (نوبت آینده) یک خط باشد — آرایهٔ تزریقی از
`RoomDeletionGuardInterface`، نه زنجیرهٔ `if`.
- `active=false` مسیر اصلی است، نه `DELETE`.
⚠️ CASCADE روی حذف آدرس + وجود نوبت روی اتاق‌های آن = دادهٔ گم‌شده. تا تسک ۰۷ که نوبت به
اتاق وصل می‌شود، این ریسک وجود ندارد؛ آنجا باید گاردِ حذف آدرس اضافه شود. در checklist با
مقصد صریح ثبت شده.
## ۵. ساعت کاری — دقیقه، نه رشته
```php
// ❌ اشتباه: مقایسهٔ رشته‌ای در تسک ۰۶ می‌شکند ("9:00" < "10:00" غلط است)
@@ -44,52 +67,71 @@ private int $startMinute = 540;
- `0 <= start < end <= 1440`
- بازه‌های یک روز نباید هم‌پوشانی داشته باشند (مرتب کن، بعد `prev.end <= next.start`)
- `sequence` را خود سرویس بعد از مرتب‌سازی تخصیص می‌دهد، نه کلاینت
- **اعتبارسنجی کاملِ هر هفت روز قبل از هر `DELETE`** — وگرنه یک بازهٔ نامعتبر در روز ششم،
شش روز درست را هم پاک می‌کند و ۴۲۲ برمی‌گرداند
## ۵. تفسیر «شعبه بدون ساعت کاری»
## ۶. تفسیر «شعبه بدون ساعت کاری»
تصمیم صریح: **تعریف‌نشده، نه همیشه‌باز.** تسک ۰۳ وقتی برای شعبه‌ای ساعتی پیدا نکرد، به
رفتار فعلی برمی‌گردد (برنامهٔ پزشک تنها مرجع است). این باعث می‌شود همهٔ داده‌های موجود
بدون ساعت کاری شعبه دقیقاً مثل امروز کار کنند.
تصمیم صریح: **تعریف‌نشده، نه همیشه‌باز.** تسک ۰۳ وقتی برای آدرسی ساعتی پیدا نکرد، به
رفتار فعلی برمی‌گردد (برنامهٔ پزشک تنها مرجع است). پس همهٔ دادهٔ موجود — که هیچ ساعت کاری
شعبه ندارد — دقیقاً مثل امروز کار می‌کند. این خطِ دفاعیِ «منطق اسلاتی دست نمی‌خورد» است.
این نکته را در `docs/api/branch.md` بنویس، وگرنه اولین کسی که کش را دیباگ می‌کند فکر می‌کند
باگ است.
همین‌طور `active=false` روی آدرس در این تسک **هیچ اثری بر اسلات ندارد**؛ فقط ذخیره می‌شود.
اعمالش در تسک ۰۳ است. اگر همین‌جا اعمال شود، `SlotCalculatorService` عوض می‌شود که در
`_shared/red-lines.md` ممنوع است.
## ۶. edge case ها
هر دو نکته در `docs/api/branch.md` نوشته شود، وگرنه اولین کسی که دیباگ می‌کند فکر می‌کند باگ است.
## ۷. edge case ها
| حالت | رفتار درست |
|---|---|
| شعبه در محیط A، اتاق ساخته‌شده با uuid شعبهٔ محیط B | `404``TenantOwnershipChecker::belongsTo` قبل از هر کاری |
| دو شعبه هم‌نام در یک محیط | مجاز (نام یکتا نیست؛ آدرس فرق دارد) |
| `capacity = 0` | `422` — حداقل ۱ |
| آدرس محیط A، اتاق ساخته‌شده با uuid آدرس محیط B | `404``BranchResolver` قبل از هر کاری |
| دو آدرس هم‌نام در یک محیط | مجاز (نام یکتا نیست) |
| `capacity = 0` یا منفی | `422` — حداقل ۱ |
| ساعت کاری روز جمعه خالی | معتبر — یعنی شعبه جمعه بسته است |
| شعبه‌ای که تنها شعبهٔ محیط است و غیرفعال می‌شود | مجاز، ولی هشدار در UI: «هیچ شعبهٔ فعالی باقی نمی‌ماند» |
| ساعت شبانه‌روزی | `start=0, end=1440` — نه دو ردیف |
| `PUT` با آرایهٔ خالی | همهٔ ساعت‌ها پاک می‌شوند — شعبه کامل بسته |
| ساعت شبانه‌روزی | `start=0, end=1440`یک ردیف، نه دو |
| آدرسی که تنها آدرس فعال محیط است و غیرفعال می‌شود | مجاز، ولی هشدار در UI |
| `timezone` نامعتبر مثل `"Tehran"` | `422` — با `DateTimeZone::listIdentifiers()` چک کن، نه regex |
## ۷. تست
## ۸. تست
```
tests/Branch/BranchCrudTest.php
- ساخت شعبه با نقش مالک کلینیک → 201 و tenant درست
- ساخت با نقش منشیِ بدون محیط انتخاب‌شده → 403
- دیدن شعبهٔ محیط دیگر → 404 (نه 403)
tests/Branch/WorkingHoursTest.php
- هفت روز معتبر → 200 و بازخوانی یکسان
- هفت روز معتبر → 200 و بازخوانی یکسان (کلیدهای 0..6)
- end <= start → 422
- دو بازهٔ هم‌پوشان در یک روز → 422
- بازهٔ شبانه‌روزی 0..1440 → 200
tests/Branch/BranchDeletionTest.php
- حذف شعبهٔ دارای اتاق فعال → 422
- حذف شعبهٔ خالی204
tests/Shared/TenantSchemaCoverageTest.php ← باید سبز بماند
- آرایهٔ خالی → 200 و صفر ردیف
- بازهٔ نامعتبر در روز ششم → 422 و شش روز قبلی دست‌نخورده (اتمی بودن)
- uuid آدرس محیط دیگر404
tests/Branch/RoomCrudTest.php
- ساخت با نقش مالک کلینیک → 201 و جفت tenant مشتق از آدرس
- capacity=0 → 422
- آدرس محیط دیگر → 404
- ویرایش/حذف اتاق محیط دیگر → 404
tests/Branch/BranchAddressFieldsTest.php
- آدرس موجود بدون مقدار → active=true و timezone='Asia/Tehran'
- timezone نامعتبر → 422
tests/Appointment/SlotModeFrozenTest.php ← باید سبز بماند (اسلات دست‌نخورده)
tests/Shared/TenantSchemaCoverageTest.php ← باید سبز بماند
tests/Shared/TenantLookupInventoryTest.php ← repository جدید باید ثبت شود
```
اجرا:
```bash
ddev exec php bin/phpunit tests/Branch
ddev exec php bin/phpunit --group=slot-mode-frozen
ddev exec php vendor/bin/phpstan analyse src/Branch
```
## ۸. مستندات
⚠️ قبل از اجرای تست، ستون‌ها و جدول‌ها را دستی روی `db_test` بساز — رجوع به بخش
Migration در [database.md](database.md). `db_test` تاریخچهٔ migration جدا دارد.
`docs/api/branch.md` بساز (الگو: `docs/api/staff.md`). در `docs/api/README.md` هم اضافه کن.
در `docs/architecture/tenancy.md` جدول طبقه‌بندی را با سه جدول جدید به‌روز کن.
## ۹. مستندات
`docs/api/branch.md` بساز (الگو: `docs/api/staff.md`). در `docs/api/README.md` اضافه کن.
`docs/api/doctor.md` را برای دو فیلد جدید `DoctorAddress::toArray()` به‌روز کن — این فیلدها
در پاسخ ۹ endpoint موجود آدرس ظاهر می‌شوند، پس تغییر قرارداد است.
در `docs/architecture/tenancy.md` جدول طبقه‌بندی را با دو جدول جدید به‌روز کن.
@@ -1,61 +1,97 @@
# تسک ۰۱ — شعبه (Branch) و اتاق (Room)
# تسک ۰۱ — شعبه و اتاق
**فاز:** ۱ (هسته) · **وابستگی:** · **زمان:** ۱۰-۱۲ ساعت
**فاز:** ۱ (هسته) · **وابستگی:** ۰۰ · **زمان:** ۶-۸ ساعت (بازتخمین — رجوع به بخش «تصحیح طرح»)
---
## ⛔ تصحیح طرح (اجرای ۱۴۰۵/۰۵/۰۸)
نسخهٔ اول این تسک یک جدول `branches` تازه می‌خواست و `doctor_addresses.branch_id` را به
آن وصل می‌کرد. **این طرح غلط بود: «شعبه» از قبل وجود دارد و نامش `DoctorAddress` است.**
| `branches` پیشنهادی | واقعیت `doctor_addresses` |
|---|---|
| `name` · `phone` · `address` | ✅ `name` · `telephone` · `address` |
| `city_id` · `province_id` | ✅ **FK به entity `City`/`Province`** — بهتر از ارجاع خام به `categories` که طرح اول می‌خواست |
| `latitude` · `longitude` | ✅ هر دو |
| جفت tenant | ⚠️ `DoctorAddress::forDoctor(Doctor)` / `forClinic(int $clinicId)` + ستون `type` — همان اطلاعات، با شکل دیگر |
| `timezone` | ❌ غایب |
| `active` | ❌ غایب |
| ساعت کاری | ❌ غایب — **واقعاً جدید** |
| اتاق | ❌ غایب — **واقعاً جدید** |
و این‌ها هم از قبل هستند:
- `WeeklySchedule.setting[day].sessions[].location_id``doctor_addresses.id`
- `GET /api/v1/appointment-booking-locations/{doctorUuid}` هر آدرس را «محل نوبت‌دهی» می‌نامد
- `DoctorAddressRepository::findForContext($doctor, $clinicId)` چند آدرس per محیط می‌دهد
- **۹ endpoint CRUD**: `clinic/{uuid}/addresses` (۴ عدد) و `clinic-pro/doctor-address*` (۵ عدد)
- UI ادمین در `ClinicDetailPage`، `ClinicFormPage`، `DoctorDetailPage`، `SettingsPage`
ساختن `branches` بالای این یعنی: دو جدول برای یک مکان فیزیکی، دو منبع حقیقت برای
نام/آدرس/تلفن/مختصات، هر مصرف‌کننده باید تصمیم بگیرد کدام را بخواند، و branchی که
`location_id` هرگز به آن اشاره نمی‌کند. نقض قاعدهٔ پروژه
(«اول بگرد، بعد توسعه بده، در آخر بساز»).
**پس در این تسک هیچ جدول `branches` ساخته نمی‌شود و هیچ endpoint CRUD شعبه اضافه نمی‌شود.**
---
## هدف
سطح سوم مکان را به مدل اضافه کن: امروز `(entity_type, entity_id)` می‌گوید داده مال کدام
محیط است، ولی نمی‌گوید در کدام **ساختمان** و کدام **اتاق**. مستند بند ۴ سه سطح می‌خواهد
و «منابع همیشه مال شعبه‌اند چون فیزیکی‌اند» — بدون شعبه، تسک ۰۲ جایی برای نشستن ندارد.
سه چیزِ واقعاً غایب را اضافه کن تا تسک‌های ۰۲ و ۰۳ جایی برای نشستن داشته باشند:
## وضعیت فعلی
- محل مراجعه امروز `DoctorAddress` است و `location_id` هر شیفت در
`WeeklySchedule.setting[day].sessions[].location_id` به `doctor_addresses.id` اشاره می‌کند
(`SlotCalculatorService::buildSessionSlots()` آن را در هر اسلات کپی می‌کند).
- `Clinic` هیچ فیلد شعبه‌ای ندارد؛ یک آدرس متنی تخت دارد.
- ساعت کاری شعبه وجود ندارد — ساعت کاری فقط روی برنامهٔ پزشک است.
۱. `active` و `timezone` روی `DoctorAddress` — یک شعبهٔ بسته باید بتواند بسته شود، و
بند ۹ مستند ذخیره‌سازی UTC با نمایش محلی می‌خواهد.
۲. **ساعت کاری هفتگی شعبه** — امروز ساعت کاری فقط روی برنامهٔ پزشک است. تسک ۰۳ برای
کسر لایه‌ها به ساعت کاری شعبه نیاز دارد.
۳. **اتاق** — با ظرفیت هم‌زمان. تسک ۰۲ اتاق را به‌عنوان یک `resource_type` منعکس می‌کند.
## دامنه
**هست:** entity های `Branch` و `Room`، ساعت کاری هفتگی شعبه، CRUD پنل ادمین،
پل زدن `DoctorAddress.branch_id` برای اینکه شیفت‌های موجود بدون تغییر به شعبه نگاشت شوند.
**هست:** دو ستون روی `DoctorAddress` · جدول و entity `BranchWorkingHours` · جدول و entity
`Room` · سرویس اعتبارسنجی ساعت · endpoint ساعت کاری و CRUD اتاق · UI ادمین برای هر دو.
**نیست:** استفاده از شعبه در محاسبهٔ اسلات (تسک ۰۳)، اتاق به‌عنوان منبع قابل رزرو (تسک ۰۲).
**نیست:** جدول `branches` (رد شد) · CRUD شعبه (موجود) · استفاده از ساعت شعبه در محاسبهٔ
اسلات (تسک ۰۳) · اتاق به‌عنوان منبع قابل رزرو (تسک ۰۲).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET | `/api/v1/branches` | لیست شعب محیط جاری (paginated) |
| POST | `/api/v1/branch` | ساخت شعبه |
| GET | `/api/v1/branch/{uuid}` | جزئیات + ساعت کاری |
| PATCH | `/api/v1/branch/{uuid}` | ویرایش |
| DELETE | `/api/v1/branch/{uuid}` | حذف (فقط بدون منبع/اتاق فعال) |
| PUT | `/api/v1/branch/{uuid}/working-hours` | ثبت ساعت کاری هفتگی |
| GET | `/api/v1/branch/{uuid}/rooms` | اتاق‌های شعبه |
| POST/PATCH/DELETE | `/api/v1/room[/{uuid}]` | CRUD اتاق |
| GET | `/api/v1/branch/{addressUuid}/working-hours` | ساعت کاری هفتگی یک شعبه |
| PUT | `/api/v1/branch/{addressUuid}/working-hours` | جایگزینی کامل هفت روز |
| GET | `/api/v1/branch/{addressUuid}/rooms` | اتاق‌های شعبه |
| POST | `/api/v1/room` | ساخت اتاق |
| PATCH | `/api/v1/room/{uuid}` | ویرایش |
| DELETE | `/api/v1/room/{uuid}` | حذف (فقط بدون منبع فعال — گارد در تسک ۰۲ تکمیل می‌شود) |
`{addressUuid}` همان uuid رکورد `doctor_addresses` است. «شعبه» و «آدرس» یک چیزند.
## معیار پذیرش
- ✅ موفق: کلینیک با توکن مالک `POST /api/v1/branch` می‌زند → `201` و شعبه با
`entity_type=clinic, entity_id=<id>` ثبت می‌شود. `GET /api/v1/branches` همان را برمی‌گرداند.
- ✅ موفق: `PUT /branch/{uuid}/working-hours` با هفت روز → `200`؛ `GET /branch/{uuid}` همان
ساختار را با کلیدهای `0..6` (۰=شنبه) برمی‌گرداند.
- ❌ خطا: کلینیک B با uuid شعبهٔ کلینیک A → `404` با `ERR_NOT_FOUND_001` (نه ۴۰۳ — طبق
رفتار `TenantFilter`).
- ❌ خطا: `DELETE` شعبه‌ای که اتاق فعال دارد → `422` با پیام فارسی «شعبه دارای اتاق فعال است».
- ⚠️ مرزی: پزشک مستقل (محیط `doctor`) هم می‌تواند شعبه بسازد — «مطب» یک شعبه است.
اولین شعبه از روی `DoctorAddress` موجود ساخته می‌شود، نه دستی.
- ⚠️ مرزی: ساعت کاری با `end_time <= start_time``422`.
- ⚠️ مرزی: شعبه بدون ساعت کاری معتبر است (وراثت: تسک ۰۳ آن را «همیشه باز» تفسیر نمی‌کند،
«تعریف‌نشده» تفسیر می‌کند).
- ✅ موفق: کلینیک `PUT /branch/{uuid}/working-hours` با هفت روز می‌فرستد → `200`؛
`GET` همان ساختار را با کلیدهای `0..6` (۰=شنبه، همان قرارداد `SlotCalculatorService`)
برمی‌گرداند.
- ✅ موفق: `POST /api/v1/room` با `address_uuid` و `capacity: 3``201`، و جفت tenant
اتاق **از آدرس مشتق** می‌شود نه از بدنهٔ درخواست.
- ✅ موفق: `active` پیش‌فرض `true` و `timezone` پیش‌فرض `Asia/Tehran` — هیچ آدرس موجودی
رفتارش عوض نمی‌شود.
- ❌ خطا: آدرس محیط دیگر → `404` (رفتار `TenantFilter`، نه ۴۰۳).
- ❌ خطا: ساعت با `end_minute <= start_minute``422`.
- ❌ خطا: دو بازهٔ هم‌پوشان در یک روز`422`.
- ❌ خطا: `capacity <= 0``422`.
- ⚠️ مرزی: بازهٔ شبانه‌روزی `0..1440``200` (یک ردیف، نه دو).
- ⚠️ مرزی: روز بدون هیچ بازه → معتبر، یعنی شعبه آن روز بسته است.
- ⚠️ مرزی: شعبهٔ **بدون هیچ ساعت کاری** → «تعریف‌نشده»، نه «همیشه‌باز». تسک ۰۳ در این
حالت به رفتار فعلی برمی‌گردد (برنامهٔ پزشک تنها مرجع). این تصمیم باید در
`docs/api/branch.md` نوشته شود.
- ⚠️ مرزی: `PUT` با آرایهٔ خالی → همهٔ ساعت‌های آن شعبه پاک می‌شوند (بستنِ کامل شعبه).
## خروجی
- `src/Branch/` کامل با تست
- `assets/admin/pages/BranchesPage.tsx` + `BranchFormPage.tsx` + `RoomsPage.tsx`
- `src/Branch/` `BranchWorkingHours`، `Room`، سرویس‌ها، کنترلرها
- دو ستون روی `DoctorAddress` + migration
- `assets/admin/pages/BranchWorkingHoursPage.tsx` + `BranchRoomsPage.tsx`
- `docs/api/branch.md`
- migration + دستور `app:branch:backfill` برای ساخت شعبهٔ اولیه از آدرس‌های موجود
- [checklist.md](checklist.md) کامل‌شده
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Branch working hours and rooms, both keyed to doctor_addresses — the existing
* "branch" entity. No parallel branches table is created; see
* docs/new_feture/taskes/_shared/branch-is-doctor-address.md.
*
* Both new doctor_addresses columns are NOT NULL with a default, so existing rows
* become correct without a backfill and no current behaviour changes.
*/
final class Version20260730125038 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add branch working hours and rooms; add active/timezone to doctor_addresses';
}
public function up(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) NOT NULL, entity_id INT NOT NULL, address_id INT NOT NULL, INDEX IDX_E8C43E37F5B7AF75 (address_id), INDEX idx_bwh_address_day (address_id, day_of_week, active), INDEX idx_bwh_tenant (entity_type, entity_id), UNIQUE INDEX uniq_bwh_address_day_seq (address_id, day_of_week, sequence), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE rooms (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(120) NOT NULL, room_type VARCHAR(60) DEFAULT NULL, capacity SMALLINT DEFAULT 1 NOT NULL, floor VARCHAR(20) DEFAULT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, address_id INT NOT NULL, UNIQUE INDEX UNIQ_7CA11A96D17F50A6 (uuid), INDEX IDX_7CA11A96F5B7AF75 (address_id), INDEX idx_rooms_tenant (entity_type, entity_id, active), INDEX idx_rooms_address (address_id, active), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$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 doctor_addresses ADD active TINYINT DEFAULT 1 NOT NULL, ADD timezone VARCHAR(40) DEFAULT \'Asia/Tehran\' NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$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 branch_working_hours');
$this->addSql('DROP TABLE rooms');
$this->addSql('ALTER TABLE doctor_addresses DROP active, DROP timezone');
}
}
+148
View File
@@ -0,0 +1,148 @@
<?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' => $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' => $days,
]);
}
}
+120
View File
@@ -0,0 +1,120 @@
<?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
@@ -0,0 +1,105 @@
<?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
@@ -0,0 +1,120 @@
<?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,
];
}
}
@@ -0,0 +1,70 @@
<?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
@@ -0,0 +1,78 @@
<?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;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Branch\Service;
use App\Auth\Entity\User;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Repository\DoctorAddressRepository;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContextResolver;
use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* تک‌نقطهٔ تبدیل «uuid شعبه در request» به یک {@see DoctorAddress} از محیط جاری.
*
* لازم است چون doctor_addresses جفت (entity_type, entity_id) ندارد و در
* GlobalTables::ENTITIES سراسری اعلام شده، پس TenantFilter رویش کار نمی‌کند:
* findOneBy(['uuid' => …]) آدرس کلینیک دیگری را هم برمی‌گرداند. هر سه کنترلر این
* دامنه از اینجا رد می‌شوند تا این بررسی جایی جا نیفتد.
*/
final class BranchResolver
{
public function __construct(
private readonly DoctorAddressRepository $addresses,
private readonly EntityContextResolver $contexts,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly RequestStack $requestStack,
) {}
/**
* جفت محیطِ این درخواست.
*
* منشی جدا حساب می‌شود چون EntityContextResolver او را مالک هیچ محیطی نمی‌شناسد —
* همان استثنایی که ClinicServiceController::resolveEntity() هم دارد. مجوزش جداگانه
* با denyUnlessGranted سنجیده می‌شود، اینجا فقط «کدام محیط» است.
*
* @return array{0: string, 1: int}
* @throws AppException وقتی محیطی حل نشود
*/
public function pair(User $user): array
{
[$type, $id] = $user->hasRole('ROLE_SECRETARY')
? $this->secretaryAccess->resolveOwnerEntity($user)
: $this->contexts->resolve($user, $this->requestedClinicUuid())->toEntityPair();
if ($id === null) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری انتخاب نشده است', 403);
}
return [$type, (int) $id];
}
/**
* ۴۰۴ می‌دهد نه ۴۰۳ — همان رفتار TenantFilter: وجودِ دادهٔ محیط دیگر لو نمی‌رود.
*
* @throws AppException
*/
public function resolve(User $user, string $addressUuid): DoctorAddress
{
[$entityType, $entityId] = $this->pair($user);
$address = $this->addresses->findByUuidForEntityPair($addressUuid, $entityType, $entityId);
if ($address === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'شعبه یافت نشد', 404);
}
return $address;
}
/** @return DoctorAddress[] شعبه‌های محیط جاری */
public function listForContext(User $user): array
{
[$entityType, $entityId] = $this->pair($user);
return $this->addresses->findForEntityPair($entityType, $entityId);
}
private function requestedClinicUuid(): ?string
{
$request = $this->requestStack->getCurrentRequest();
if ($request === null) {
return null;
}
$fromQuery = $request->query->get('clinic_uuid');
if (is_string($fromQuery) && $fromQuery !== '') {
return $fromQuery;
}
if (!in_array($request->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
return null;
}
$body = json_decode($request->getContent(), true);
return is_array($body) && is_string($body['clinic_uuid'] ?? null) && $body['clinic_uuid'] !== ''
? $body['clinic_uuid']
: null;
}
}
@@ -0,0 +1,20 @@
<?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
@@ -0,0 +1,112 @@
<?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
@@ -0,0 +1,197 @@
<?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;
}
}
+43
View File
@@ -17,6 +17,8 @@ class DoctorAddress
public const TYPE_PERSONAL = 'personal';
public const TYPE_CLINIC = 'clinic';
public const DEFAULT_TIMEZONE = 'Asia/Tehran';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -58,6 +60,12 @@ class DoctorAddress
#[ORM\JoinColumn(name: 'province_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?Province $province = null;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
#[ORM\Column(type: 'string', length: 40, options: ['default' => self::DEFAULT_TIMEZONE])]
private string $timezone = self::DEFAULT_TIMEZONE;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -99,6 +107,25 @@ class DoctorAddress
public function getLongitude(): ?float { return $this->longitude; }
public function getCity(): ?City { return $this->city; }
public function getProvince(): ?Province { return $this->province; }
public function isActive(): bool { return $this->active; }
public function getTimezone(): string { return $this->timezone; }
/** جفت محیط این آدرس — `rooms` و منابع تسک ۰۲ جفتشان را از همین می‌گیرند، نه از request. */
public function tenantEntityType(): string
{
return $this->type === self::TYPE_CLINIC ? 'clinic' : 'doctor';
}
public function tenantEntityId(): int
{
$id = $this->type === self::TYPE_CLINIC ? $this->clinicId : $this->doctor?->getId();
if ($id === null) {
throw new \LogicException('DoctorAddress without an owner cannot derive a tenant pair.');
}
return $id;
}
public function setName(?string $v): self { $this->name = $v; return $this; }
public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; }
@@ -107,6 +134,20 @@ class DoctorAddress
public function setLongitude(?float $v): self { $this->longitude = $v; $this->touch(); return $this; }
public function setCity(?City $v): self { $this->city = $v; $this->touch(); return $this; }
public function setProvince(?Province $v): self { $this->province = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
/** @throws \InvalidArgumentException روی شناسهٔ ناشناختهٔ منطقهٔ زمانی */
public function setTimezone(string $v): self
{
if (!in_array($v, \DateTimeZone::listIdentifiers(), true)) {
throw new \InvalidArgumentException(sprintf('Unknown timezone "%s".', $v));
}
$this->timezone = $v;
$this->touch();
return $this;
}
private function touch(): void { $this->updatedAt = time(); }
@@ -125,6 +166,8 @@ class DoctorAddress
],
'address' => $this->address,
'telephone' => $this->telephone,
'active' => $this->active,
'timezone' => $this->timezone,
'city' => $this->city !== null ? [
'id' => (string) $this->city->getId(),
'name' => $this->city->getName(),
@@ -41,6 +41,65 @@ class DoctorAddressRepository extends ServiceEntityRepository
->getOneOrNullResult();
}
/**
* آدرس‌های یک محیط با جفت (entity_type, entity_id) — همان واژگانی که Room و منابع
* تسک ۰۲ با آن ذخیره می‌شوند.
*
* قرینهٔ findForContext() است ولی Doctor لازم ندارد: در محیط کلینیک، آن متد
* پارامتر doctor را نادیده می‌گیرد و مجبور کردن فراخوان به ساختن یک Doctor
* الکی، همان نوع کدی است که بعداً کسی با getReference() پرش می‌کند.
*
* @return DoctorAddress[]
*/
public function findForEntityPair(string $entityType, int $entityId): array
{
$qb = $this->createQueryBuilder('a');
if ($entityType === 'clinic') {
$qb->where('a.clinicId = :entityId')
->andWhere('a.type = :type')
->setParameter('type', DoctorAddress::TYPE_CLINIC);
} else {
$qb->where('IDENTITY(a.doctor) = :entityId')
->andWhere('a.type = :type')
->setParameter('type', DoctorAddress::TYPE_PERSONAL);
}
return $qb->setParameter('entityId', $entityId)
->orderBy('a.id', 'ASC')
->getQuery()
->getResult();
}
/**
* یک آدرس با uuid، محدود به محیط داده‌شده.
*
* `doctor_addresses` جفت محیط ندارد (عمداً — در GlobalTables::ENTITIES ثبت شده)
* پس TenantFilter رویش اعمال نمی‌شود و `findOneBy(['uuid' => …])` آدرس محیط دیگر
* را هم برمی‌گرداند. هر مسیری که uuid آدرس را از request می‌گیرد باید از این
* متد یا از {@see \App\Branch\Service\BranchResolver} رد شود.
*/
public function findByUuidForEntityPair(string $uuid, string $entityType, int $entityId): ?DoctorAddress
{
$qb = $this->createQueryBuilder('a')
->where('a.uuid = :uuid')
->setParameter('uuid', $uuid);
if ($entityType === 'clinic') {
$qb->andWhere('a.clinicId = :entityId')
->andWhere('a.type = :type')
->setParameter('type', DoctorAddress::TYPE_CLINIC);
} else {
$qb->andWhere('IDENTITY(a.doctor) = :entityId')
->andWhere('a.type = :type')
->setParameter('type', DoctorAddress::TYPE_PERSONAL);
}
return $qb->setParameter('entityId', $entityId)
->getQuery()
->getOneOrNullResult();
}
public function findOneByClinic(int $clinicId): ?DoctorAddress
{
return $this->createQueryBuilder('a')
+144
View File
@@ -0,0 +1,144 @@
<?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
@@ -0,0 +1,50 @@
<?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
@@ -0,0 +1,172 @@
<?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());
}
}
+206
View File
@@ -0,0 +1,206 @@
<?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());
}
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']);
}
}