# نکات پیاده‌سازی — تسک ۰۱ ## ۱. شعبه ساخته نمی‌شود — پیدا می‌شود نسخهٔ اول این فایل استدلال می‌کرد «چرا شعبه بالای آدرس می‌نشیند». استدلال درست بود ولی نتیجه‌اش غلط: اگر آدرس همان مکان فیزیکی است و همه‌جا هم به همان معنا مصرف می‌شود، لایهٔ بالایی چیزی جز یک جدول دوم برای همان نام و تلفن نیست. سه مصرف‌کنندهٔ زنده‌ای که `doctor_addresses.id` را می‌خوانند — 1. `WeeklySchedule.setting[day].sessions[].location_id` (JSON) 2. `SlotCalculatorService::buildSessionSlots()` که آن را در هر اسلات کپی می‌کند 3. `AppointmentController::bookingLocations()` که به سایت عمومی `location_uuid` می‌دهد — دلیل اصلی‌اند که «شعبه» همان آدرس است، نه دلیلِ ساختن لایهٔ دوم. جزئیات کامل: [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md). ## ۲. پزشک مستقل هم شعبه دارد خوشبختانه از قبل درست است: `DoctorAddress::forDoctor()` با `type = 'personal'` وجود دارد و `findForContext()` هم آدرس‌های شخصی و هم کلینیکی را برمی‌گرداند. پس تسک ۰۲ لازم نیست دو مسیر کد برای «منبع مال شعبه» و «منبع مال پزشک» داشته باشد. مطب شخصی = آدرسی با `type='personal'`. ## ۳. `TenantFilter` روی `doctor_addresses` کار نمی‌کند مهم‌ترین تلهٔ این تسک. `doctor_addresses` ستون `entity_type`/`entity_id` ندارد، پس: ```php // ❌ آدرس محیط دیگر را هم برمی‌گرداند — filter اینجا تور ایمنی نیست $address = $this->addresses->findOneBy(['uuid' => $uuid]); // ✅ از BranchResolver رد شو $address = $this->branches->resolve($uuid); // 404 اگر محیط جاری نباشد ``` `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" غلط است) private string $startTime = '09:00'; // ✅ درست private int $startMinute = 540; ``` اعتبارسنجی در `WorkingHoursService`: - `0 <= start < end <= 1440` - بازه‌های یک روز نباید هم‌پوشانی داشته باشند (مرتب کن، بعد `prev.end <= next.start`) - `sequence` را خود سرویس بعد از مرتب‌سازی تخصیص می‌دهد، نه کلاینت - **اعتبارسنجی کاملِ هر هفت روز قبل از هر `DELETE`** — وگرنه یک بازهٔ نامعتبر در روز ششم، شش روز درست را هم پاک می‌کند و ۴۲۲ برمی‌گرداند ## ۶. تفسیر «شعبه بدون ساعت کاری» تصمیم صریح: **تعریف‌نشده، نه همیشه‌باز.** تسک ۰۳ وقتی برای آدرسی ساعتی پیدا نکرد، به رفتار فعلی برمی‌گردد (برنامهٔ پزشک تنها مرجع است). پس همهٔ دادهٔ موجود — که هیچ ساعت کاری شعبه ندارد — دقیقاً مثل امروز کار می‌کند. این خطِ دفاعیِ «منطق اسلاتی دست نمی‌خورد» است. همین‌طور `active=false` روی آدرس در این تسک **هیچ اثری بر اسلات ندارد**؛ فقط ذخیره می‌شود. اعمالش در تسک ۰۳ است. اگر همین‌جا اعمال شود، `SlotCalculatorService` عوض می‌شود که در `_shared/red-lines.md` ممنوع است. هر دو نکته در `docs/api/branch.md` نوشته شود، وگرنه اولین کسی که دیباگ می‌کند فکر می‌کند باگ است. ## ۷. edge case ها | حالت | رفتار درست | |---|---| | آدرس محیط A، اتاق ساخته‌شده با uuid آدرس محیط B | `404` — `BranchResolver` قبل از هر کاری | | دو آدرس هم‌نام در یک محیط | مجاز (نام یکتا نیست) | | `capacity = 0` یا منفی | `422` — حداقل ۱ | | ساعت کاری روز جمعه خالی | معتبر — یعنی شعبه جمعه بسته است | | `PUT` با آرایهٔ خالی | همهٔ ساعت‌ها پاک می‌شوند — شعبه کامل بسته | | ساعت شبانه‌روزی | `start=0, end=1440` — یک ردیف، نه دو | | آدرسی که تنها آدرس فعال محیط است و غیرفعال می‌شود | مجاز، ولی هشدار در UI | | `timezone` نامعتبر مثل `"Tehran"` | `422` — با `DateTimeZone::listIdentifiers()` چک کن، نه regex | ## ۸. تست ``` tests/Branch/WorkingHoursTest.php - هفت روز معتبر → 200 و بازخوانی یکسان (کلیدهای 0..6) - end <= start → 422 - دو بازهٔ هم‌پوشان در یک روز → 422 - بازهٔ شبانه‌روزی 0..1440 → 200 - آرایهٔ خالی → 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/api/doctor.md` را برای دو فیلد جدید `DoctorAddress::toArray()` به‌روز کن — این فیلدها در پاسخ ۹ endpoint موجود آدرس ظاهر می‌شوند، پس تغییر قرارداد است. در `docs/architecture/tenancy.md` جدول طبقه‌بندی را با دو جدول جدید به‌روز کن.