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:
@@ -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` کنار «تنظیمات نوبتدهی».
|
||||
|
||||
Reference in New Issue
Block a user