Files
clinicpro/docs/new_feture/taskes/task-01-branch-room/task.md
T
hamedandClaude Opus 5 eebb363b9f 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>
2026-07-30 16:28:04 +03:30

98 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# تسک ۰۱ — شعبه و اتاق
**فاز:** ۱ (هسته) · **وابستگی:** ۰۰ · **زمان:** ۶-۸ ساعت (بازتخمین — رجوع به بخش «تصحیح طرح»)
---
## ⛔ تصحیح طرح (اجرای ۱۴۰۵/۰۵/۰۸)
نسخهٔ اول این تسک یک جدول `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) کامل‌شده