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>
7.9 KiB
معماری — تسک ۰۱
⛔ نسخهٔ اول این فایل entity
Branch،BranchController(CRUD شعبه)،BranchServiceوBackfillBranchCommandداشت. همه حذف شدند: «شعبه» =DoctorAddressو CRUDش از قبل وجود دارد. دلیل:_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() میکند.
یک نقطهٔ متمرکز بهجای تکرار در سه کنترلر:
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 برای هر روز از ۹۰ روز.
#[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 — جایگزینی کامل، نه تفاضلی
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.
اتاق
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 کنار «تنظیمات نوبتدهی».