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,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) کاملشده
|
||||
|
||||
Reference in New Issue
Block a user