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>
98 lines
6.1 KiB
Markdown
98 lines
6.1 KiB
Markdown
# تسک ۰۱ — شعبه و اتاق
|
||
|
||
**فاز:** ۱ (هسته) · **وابستگی:** ۰۰ · **زمان:** ۶-۸ ساعت (بازتخمین — رجوع به بخش «تصحیح طرح»)
|
||
|
||
---
|
||
|
||
## ⛔ تصحیح طرح (اجرای ۱۴۰۵/۰۵/۰۸)
|
||
|
||
نسخهٔ اول این تسک یک جدول `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 شعبه اضافه نمیشود.**
|
||
|
||
---
|
||
|
||
## هدف
|
||
|
||
سه چیزِ واقعاً غایب را اضافه کن تا تسکهای ۰۲ و ۰۳ جایی برای نشستن داشته باشند:
|
||
|
||
۱. `active` و `timezone` روی `DoctorAddress` — یک شعبهٔ بسته باید بتواند بسته شود، و
|
||
بند ۹ مستند ذخیرهسازی UTC با نمایش محلی میخواهد.
|
||
۲. **ساعت کاری هفتگی شعبه** — امروز ساعت کاری فقط روی برنامهٔ پزشک است. تسک ۰۳ برای
|
||
کسر لایهها به ساعت کاری شعبه نیاز دارد.
|
||
۳. **اتاق** — با ظرفیت همزمان. تسک ۰۲ اتاق را بهعنوان یک `resource_type` منعکس میکند.
|
||
|
||
## دامنه
|
||
|
||
**هست:** دو ستون روی `DoctorAddress` · جدول و entity `BranchWorkingHours` · جدول و entity
|
||
`Room` · سرویس اعتبارسنجی ساعت · endpoint ساعت کاری و CRUD اتاق · UI ادمین برای هر دو.
|
||
|
||
**نیست:** جدول `branches` (رد شد) · CRUD شعبه (موجود) · استفاده از ساعت شعبه در محاسبهٔ
|
||
اسلات (تسک ۰۳) · اتاق بهعنوان منبع قابل رزرو (تسک ۰۲).
|
||
|
||
## Endpoint ها
|
||
|
||
| متد | مسیر | توضیح |
|
||
|---|---|---|
|
||
| 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` است. «شعبه» و «آدرس» یک چیزند.
|
||
|
||
## معیار پذیرش
|
||
|
||
- ✅ موفق: کلینیک `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/` — `BranchWorkingHours`، `Room`، سرویسها، کنترلرها
|
||
- دو ستون روی `DoctorAddress` + migration
|
||
- `assets/admin/pages/BranchWorkingHoursPage.tsx` + `BranchRoomsPage.tsx`
|
||
- `docs/api/branch.md`
|
||
- [checklist.md](checklist.md) کاملشده
|