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>
153 lines
7.9 KiB
Markdown
153 lines
7.9 KiB
Markdown
# معماری — تسک ۰۱
|
|
|
|
> ⛔ نسخهٔ اول این فایل entity `Branch`، `BranchController` (CRUD شعبه)، `BranchService` و
|
|
> `BackfillBranchCommand` داشت. همه حذف شدند: «شعبه» = `DoctorAddress` و CRUDش از قبل
|
|
> وجود دارد. دلیل: [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md).
|
|
|
|
## ساختار فایل
|
|
|
|
```
|
|
src/Branch/
|
|
├── Controller/
|
|
│ ├── BranchWorkingHoursController.php # GET/PUT ساعت کاری یک آدرس
|
|
│ └── RoomController.php # CRUD اتاق
|
|
├── Entity/
|
|
│ ├── BranchWorkingHours.php # فرزند aggregate — بدون جفت tenant
|
|
│ └── Room.php # TenantOwnedTrait
|
|
├── Repository/
|
|
│ ├── BranchWorkingHoursRepository.php
|
|
│ └── RoomRepository.php
|
|
└── Service/
|
|
├── WorkingHoursService.php # اعتبارسنجی + جایگزینی هفت روز
|
|
├── RoomService.php # ساخت/ویرایش/حذف + قواعد حذف
|
|
└── BranchResolver.php # uuid آدرس → DoctorAddress در محیط جاری
|
|
|
|
src/Doctor/Entity/DoctorAddress.php # + active + timezone
|
|
|
|
assets/admin/pages/
|
|
├── BranchesPage.tsx # لیست شعبههای محیط جاری + دو اکشن
|
|
├── BranchWorkingHoursPage.tsx
|
|
└── BranchRoomsPage.tsx
|
|
```
|
|
|
|
دامنهٔ جدید `Branch` است نه `Doctor`، چون `BranchWorkingHours` و `Room` مفاهیم مکاناند و
|
|
تسکهای ۰۲/۰۳ منابع را هم روی همین دامنه میسازند. `DoctorAddress` سرِ جایش در `Doctor`
|
|
میماند — جابهجا کردنش namespace را میشکند بدون هیچ سودی.
|
|
|
|
## `BranchResolver` — چرا لازم است
|
|
|
|
`doctor_addresses` **ستون `entity_type`/`entity_id` ندارد**، پس `TenantFilter` رویش اعمال
|
|
نمیشود. یعنی `findOneBy(['uuid' => $uuid])` آدرس محیط دیگر را هم برمیگرداند. هر endpoint
|
|
جدیدی که با uuid آدرس شروع میشود باید محیط را **دستی** بررسی کند — همان کاری که
|
|
`clinic/{uuid}/addresses` با `findByUuidAndClinic()` میکند.
|
|
|
|
یک نقطهٔ متمرکز بهجای تکرار در سه کنترلر:
|
|
|
|
```php
|
|
final class BranchResolver
|
|
{
|
|
public function __construct(
|
|
private readonly DoctorAddressRepository $addresses,
|
|
private readonly EntityContextResolver $context,
|
|
) {}
|
|
|
|
/** @throws AppException 404 وقتی آدرس در محیط جاری نیست */
|
|
public function resolve(string $addressUuid): DoctorAddress
|
|
{
|
|
$address = $this->addresses->findOneBy(['uuid' => $addressUuid]);
|
|
if ($address === null || !$this->belongsToCurrentContext($address)) {
|
|
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'شعبه یافت نشد', 404);
|
|
}
|
|
return $address;
|
|
}
|
|
}
|
|
```
|
|
|
|
**۴۰۴ نه ۴۰۳** — همان رفتار `TenantFilter`: وجود دادهٔ محیط دیگر لو نمیرود.
|
|
|
|
## ساعت کاری شعبه
|
|
|
|
جدول جداست نه JSON مثل `WeeklySchedule`، چون تسک ۰۳ باید
|
|
`WHERE address_id = ? AND day_of_week = ?` بزند بدون decode کردن JSON برای هر روز از ۹۰ روز.
|
|
|
|
```php
|
|
#[ORM\Entity]
|
|
#[ORM\Table(name: 'branch_working_hours')]
|
|
#[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 bool $active = true;
|
|
}
|
|
```
|
|
|
|
`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`.
|
|
|
|
## اتاق
|
|
|
|
```php
|
|
class Room
|
|
{
|
|
use TenantOwnedTrait;
|
|
private DoctorAddress $address;
|
|
private string $name;
|
|
private ?string $roomType = null; // متن آزاد — نوع اتاق را کلینیک تعریف میکند
|
|
private int $capacity = 1; // چند بیمار همزمان (اتاق تزریق سهتخته = 3)
|
|
private ?string $floor = null;
|
|
private bool $active = true;
|
|
}
|
|
```
|
|
|
|
جفت tenant در **سازنده از آدرس مشتق** میشود، نه از بدنهٔ request — پس هیچ نقطهٔ ساختی
|
|
نمیتواند فراموشش کند. `capacity` از روز اول هست چون مستند بند ۶ صریح میگوید سه تخت =
|
|
**یک منبع با ظرفیت سه**، نه سه منبع؛ تسک ۰۲ همین معنا را روی `Resource` تکرار میکند و
|
|
اتاق را بهعنوان `resource_type=room` منعکس میکند.
|
|
|
|
حذف اتاق در این تسک فقط `active` را چک میکند (اتاق فعال قابل حذف است، منبع هنوز وجود
|
|
ندارد). گاردِ «اتاقی که منبع فعال دارد حذف نشود» در تسک ۰۲ اضافه میشود — آنجاست که
|
|
`Resource.room_id` به وجود میآید. این را در checklist بهعنوان ⏳ با مقصد صریح ثبت کن.
|
|
|
|
## پنل ادمین
|
|
|
|
سه صفحهٔ جدید، همه با الگوهای موجود (`_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` کنار «تنظیمات نوبتدهی».
|