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:
@@ -0,0 +1,82 @@
|
||||
# «شعبه» جدول تازهای نیست — `doctor_addresses` است
|
||||
|
||||
**این سند بر همهٔ تسکهایی که `branches` یا `branch_id` میگویند حاکم است.**
|
||||
نسخهٔ اول تسک ۰۱ یک جدول `branches` طراحی کرده بود؛ در اجرا معلوم شد آن موجودیت از قبل
|
||||
وجود دارد. تسک ۰۱ اصلاح شد و جدول ساخته **نشد**.
|
||||
|
||||
هر جا در تسکهای ۰۲، ۰۴، ۰۷، ۰۸، ۰۹، ۱۰، ۱۳ نوشته شده `branch_id INT NOT NULL FK →
|
||||
branches(id)`، بخوانید:
|
||||
|
||||
```sql
|
||||
address_id INT NOT NULL -- FK → doctor_addresses(id)
|
||||
```
|
||||
|
||||
و هر جا `Branch $branch` نوشته شده، بخوانید `DoctorAddress $address`.
|
||||
|
||||
---
|
||||
|
||||
## چرا
|
||||
|
||||
`App\Doctor\Entity\DoctorAddress` تمام چیزی است که یک شعبه لازم دارد:
|
||||
|
||||
| نیاز شعبه | در `DoctorAddress` |
|
||||
|---|---|
|
||||
| نام | `name` |
|
||||
| آدرس | `address` |
|
||||
| تلفن | `telephone` |
|
||||
| مختصات | `latitude` / `longitude` |
|
||||
| شهر و استان | FK به entity `City` / `Province` |
|
||||
| مالک (محیط) | `forDoctor(Doctor)` یا `forClinic(int $clinicId)` + ستون `type` |
|
||||
| فعال/غیرفعال | `active` — **تسک ۰۱ اضافه کرد** |
|
||||
| منطقهٔ زمانی | `timezone` — **تسک ۰۱ اضافه کرد** |
|
||||
|
||||
و از قبل در کل سیستم به همین معنا مصرف میشود:
|
||||
|
||||
- `WeeklySchedule.setting[day].sessions[].location_id` → `doctor_addresses.id`
|
||||
- `SlotCalculatorService::buildSessionSlots()` آن را در هر اسلات کپی میکند
|
||||
- `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`
|
||||
|
||||
ساختن جدول موازی یعنی دو منبع حقیقت برای نام/آدرس/تلفن/مختصات یک مکان فیزیکی، و شعبهای
|
||||
که `location_id` هرگز به آن اشاره نمیکند — یعنی decorative. قاعدهٔ #۸ پروژه:
|
||||
«API/جدول جدید فقط وقتی هیچ موجودی — حتی با توسعه — کافی نباشد.»
|
||||
|
||||
---
|
||||
|
||||
## پیامد برای تسکهای بعدی
|
||||
|
||||
| تسک | چه چیزی عوض میشود |
|
||||
|---|---|
|
||||
| ۰۲ منابع | `clinic_resources.address_id` → `doctor_addresses(id)`. `ResourcePool` هم همین. «منابع مال شعبهاند» = مال یک آدرساند |
|
||||
| ۰۳ تقویم منبع | ساعت کاری شعبه از `branch_working_hours` که به `doctor_addresses.id` کلید میخورد |
|
||||
| ۰۴ کاتالوگ | `service_branch_overrides.address_id` |
|
||||
| ۰۷ رزرو | `appointments.address_id` **از قبل وجود دارد** (`Appointment::$addressId`) — ستون جدید لازم نیست |
|
||||
| ۰۸ قیمت | `price_lists.address_id` |
|
||||
| ۰۹/۱۰ قوانین | `policies.address_id` (اختصاصیبودن per شعبه) |
|
||||
| ۱۳ لغو/انتظار | `waitlist_entries.address_id` |
|
||||
|
||||
⚠️ نکتهٔ تسک ۰۷: `Appointment` از قبل `address_id` دارد (ستون `addressId`, تهیپذیر) و
|
||||
`SlotCalculatorService::resolveSlotLocationId()` پرش میکند. پس آنجا هم ستون تازه لازم نیست.
|
||||
|
||||
---
|
||||
|
||||
## جفت tenant اتاق و منابع
|
||||
|
||||
`DoctorAddress` ستونهای `entity_type`/`entity_id` ندارد؛ مالکیتش با `type` +
|
||||
`doctor_id`/`clinic_id` بیان میشود. موجودیتهای جدیدی که به آدرس کلید میخورند و
|
||||
`TenantOwnedTrait` دارند، جفتشان را در **سازنده از آدرس مشتق** میکنند:
|
||||
|
||||
```php
|
||||
// App\Branch\Entity\Room::__construct()
|
||||
$this->assignTenantPair(
|
||||
$address->getType() === DoctorAddress::TYPE_CLINIC ? 'clinic' : 'doctor',
|
||||
$address->getType() === DoctorAddress::TYPE_CLINIC
|
||||
? (int) $address->getClinicId()
|
||||
: (int) $address->getDoctor()->getId(),
|
||||
);
|
||||
```
|
||||
|
||||
همان قاعدهٔ `docs/architecture/tenancy.md`: جفت در سازنده از ریشه مشتق میشود، نه از
|
||||
ورودی درخواست — پس هیچ نقطهٔ ساختی نمیتواند فراموشش کند و write-once میماند.
|
||||
@@ -1,101 +1,111 @@
|
||||
# معماری — تسک ۰۱
|
||||
|
||||
> ⛔ نسخهٔ اول این فایل entity `Branch`، `BranchController` (CRUD شعبه)، `BranchService` و
|
||||
> `BackfillBranchCommand` داشت. همه حذف شدند: «شعبه» = `DoctorAddress` و CRUDش از قبل
|
||||
> وجود دارد. دلیل: [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md).
|
||||
|
||||
## ساختار فایل
|
||||
|
||||
```
|
||||
src/Branch/
|
||||
├── Controller/
|
||||
│ ├── BranchController.php # CRUD شعبه + ساعت کاری
|
||||
│ └── RoomController.php # CRUD اتاق
|
||||
│ ├── BranchWorkingHoursController.php # GET/PUT ساعت کاری یک آدرس
|
||||
│ └── RoomController.php # CRUD اتاق
|
||||
├── Entity/
|
||||
│ ├── Branch.php
|
||||
│ ├── BranchWorkingHours.php
|
||||
│ └── Room.php
|
||||
│ ├── BranchWorkingHours.php # فرزند aggregate — بدون جفت tenant
|
||||
│ └── Room.php # TenantOwnedTrait
|
||||
├── Repository/
|
||||
│ ├── BranchRepository.php
|
||||
│ ├── BranchWorkingHoursRepository.php
|
||||
│ └── RoomRepository.php
|
||||
├── Service/
|
||||
│ ├── BranchService.php # ساخت/ویرایش/حذف + قواعد حذف
|
||||
│ └── WorkingHoursService.php # اعتبارسنجی و ذخیرهٔ هفت روز
|
||||
└── Command/
|
||||
└── BackfillBranchCommand.php # app:branch:backfill
|
||||
└── Service/
|
||||
├── WorkingHoursService.php # اعتبارسنجی + جایگزینی هفت روز
|
||||
├── RoomService.php # ساخت/ویرایش/حذف + قواعد حذف
|
||||
└── BranchResolver.php # uuid آدرس → DoctorAddress در محیط جاری
|
||||
|
||||
src/Doctor/Entity/DoctorAddress.php # + active + timezone
|
||||
|
||||
assets/admin/pages/
|
||||
├── BranchesPage.tsx
|
||||
├── BranchFormPage.tsx # شامل تب ساعت کاری
|
||||
└── RoomsPage.tsx
|
||||
├── BranchesPage.tsx # لیست شعبههای محیط جاری + دو اکشن
|
||||
├── BranchWorkingHoursPage.tsx
|
||||
└── BranchRoomsPage.tsx
|
||||
```
|
||||
|
||||
## لایهبندی
|
||||
دامنهٔ جدید `Branch` است نه `Doctor`، چون `BranchWorkingHours` و `Room` مفاهیم مکاناند و
|
||||
تسکهای ۰۲/۰۳ منابع را هم روی همین دامنه میسازند. `DoctorAddress` سرِ جایش در `Doctor`
|
||||
میماند — جابهجا کردنش namespace را میشکند بدون هیچ سودی.
|
||||
|
||||
`BranchController` نازک است: اعتبارسنجی ورودی + `EntityContextResolver` + صدا زدن سرویس.
|
||||
همهٔ قواعد (حذف امن، یکتایی نام در محیط، نرمالسازی ساعت) در `BranchService` و
|
||||
`WorkingHoursService`.
|
||||
## `BranchResolver` — چرا لازم است
|
||||
|
||||
`doctor_addresses` **ستون `entity_type`/`entity_id` ندارد**، پس `TenantFilter` رویش اعمال
|
||||
نمیشود. یعنی `findOneBy(['uuid' => $uuid])` آدرس محیط دیگر را هم برمیگرداند. هر endpoint
|
||||
جدیدی که با uuid آدرس شروع میشود باید محیط را **دستی** بررسی کند — همان کاری که
|
||||
`clinic/{uuid}/addresses` با `findByUuidAndClinic()` میکند.
|
||||
|
||||
یک نقطهٔ متمرکز بهجای تکرار در سه کنترلر:
|
||||
|
||||
```php
|
||||
final class BranchService
|
||||
final class BranchResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BranchRepository $branches,
|
||||
private readonly RoomRepository $rooms,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly DoctorAddressRepository $addresses,
|
||||
private readonly EntityContextResolver $context,
|
||||
) {}
|
||||
|
||||
public function create(EntityContext $ctx, BranchInput $input): Branch
|
||||
/** @throws AppException 404 وقتی آدرس در محیط جاری نیست */
|
||||
public function resolve(string $addressUuid): DoctorAddress
|
||||
{
|
||||
$branch = new Branch($input->name);
|
||||
$branch->assignTenant($ctx); // ← اجباری، وگرنه flush میشکند
|
||||
// ...
|
||||
}
|
||||
|
||||
/** حذف فقط وقتی هیچ اتاق یا منبعِ فعالی به شعبه وصل نیست. */
|
||||
public function delete(Branch $branch): void
|
||||
{
|
||||
if ($this->rooms->countActiveByBranch($branch) > 0) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شعبه دارای اتاق فعال است', 422);
|
||||
$address = $this->addresses->findOneBy(['uuid' => $addressUuid]);
|
||||
if ($address === null || !$this->belongsToCurrentContext($address)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'شعبه یافت نشد', 404);
|
||||
}
|
||||
// ...
|
||||
return $address;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## رابطهٔ Branch با DoctorAddress
|
||||
|
||||
`DoctorAddress` حذف نمیشود. یک ستون `branch_id` تهیپذیر میگیرد:
|
||||
|
||||
```
|
||||
DoctorAddress.branch_id ──▶ branches.id (nullable, ON DELETE SET NULL)
|
||||
```
|
||||
|
||||
دلیل: `location_id` در JSON برنامهٔ هفتگی به `doctor_addresses.id` اشاره دارد و در
|
||||
`SlotCalculatorService` و `AppointmentController::bookingLocations()` و سایت عمومی مصرف میشود.
|
||||
تغییر آن قرارداد یعنی شکستن سه کلاینت. پس شعبه یک **لایهٔ بالاتر** مینشیند و آدرس به آن
|
||||
لینک میشود، نه برعکس.
|
||||
|
||||
`BackfillBranchCommand` برای هر محیطی که آدرس دارد یک شعبه با نام آدرس میسازد و
|
||||
`branch_id` را پر میکند. dry-run پیشفرض، `--force` برای اجرا.
|
||||
**۴۰۴ نه ۴۰۳** — همان رفتار `TenantFilter`: وجود دادهٔ محیط دیگر لو نمیرود.
|
||||
|
||||
## ساعت کاری شعبه
|
||||
|
||||
مثل `WeeklySchedule` یک JSON نیست — جدول جداست، چون تسک ۰۳ باید بتواند
|
||||
`WHERE branch_id = ? AND day = ?` بزند بدون خواندن و decode کردن JSON برای هر روز از ۹۰ روز.
|
||||
جدول جداست نه JSON مثل `WeeklySchedule`، چون تسک ۰۳ باید
|
||||
`WHERE address_id = ? AND day_of_week = ?` بزند بدون decode کردن JSON برای هر روز از ۹۰ روز.
|
||||
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'branch_working_hours')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_branch_day_seq', columns: ['branch_id', 'day_of_week', 'sequence'])]
|
||||
#[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 int $sequence; // بازهٔ چندم آن روز (صبح/عصر)
|
||||
private bool $active = true;
|
||||
}
|
||||
```
|
||||
|
||||
`startMinute`/`endMinute` بهجای رشتهٔ `"08:30"` ذخیره میشوند تا مقایسه و تقاطع در تسک ۰۶
|
||||
حسابی باشد نه رشتهای. تبدیل به `H:i` فقط در `toArray()`.
|
||||
`startMinute`/`endMinute` عدد است نه رشتهٔ `"08:30"`، تا تقاطع در تسک ۰۶ حسابی باشد نه
|
||||
رشتهای. تبدیل به `H:i` فقط در `toArray()`.
|
||||
|
||||
### `WorkingHoursService` — جایگزینی کامل، نه تفاضلی
|
||||
|
||||
```php
|
||||
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`.
|
||||
|
||||
## اتاق
|
||||
|
||||
@@ -103,23 +113,40 @@ class BranchWorkingHours
|
||||
class Room
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
private Branch $branch;
|
||||
private DoctorAddress $address;
|
||||
private string $name;
|
||||
private ?string $roomType = null; // متن آزاد — نوعِ اتاق را کلینیک تعریف میکند
|
||||
private ?string $roomType = null; // متن آزاد — نوع اتاق را کلینیک تعریف میکند
|
||||
private int $capacity = 1; // چند بیمار همزمان (اتاق تزریق سهتخته = 3)
|
||||
private ?string $floor = null;
|
||||
private bool $active = true;
|
||||
}
|
||||
```
|
||||
|
||||
`capacity` از همینجا شروع میشود چون مستند بند ۶ صریح میگوید سه تخت = **یک منبع با
|
||||
ظرفیت سه**، نه سه منبع. تسک ۰۲ همین معنا را روی `Resource` تکرار میکند و اتاق را
|
||||
بهعنوان یک `Resource` با `resource_type=room` منعکس میکند.
|
||||
جفت tenant در **سازنده از آدرس مشتق** میشود، نه از بدنهٔ request — پس هیچ نقطهٔ ساختی
|
||||
نمیتواند فراموشش کند. `capacity` از روز اول هست چون مستند بند ۶ صریح میگوید سه تخت =
|
||||
**یک منبع با ظرفیت سه**، نه سه منبع؛ تسک ۰۲ همین معنا را روی `Resource` تکرار میکند و
|
||||
اتاق را بهعنوان `resource_type=room` منعکس میکند.
|
||||
|
||||
حذف اتاق در این تسک فقط `active` را چک میکند (اتاق فعال قابل حذف است، منبع هنوز وجود
|
||||
ندارد). گاردِ «اتاقی که منبع فعال دارد حذف نشود» در تسک ۰۲ اضافه میشود — آنجاست که
|
||||
`Resource.room_id` به وجود میآید. این را در checklist بهعنوان ⏳ با مقصد صریح ثبت کن.
|
||||
|
||||
## پنل ادمین
|
||||
|
||||
- `BranchesPage.tsx` — `DataTable` + `PageHeader` با `backTo`، وضعیت لیست در URL با `useUrlState`
|
||||
- `BranchFormPage.tsx` — دو تب: مشخصات / ساعت کاری. `SearchableSelect` برای شهر
|
||||
(هرگز `<select>` بومی)
|
||||
- `RoomsPage.tsx` — زیرصفحهٔ شعبه، `<BackButton fallback="/admin/branches" />`
|
||||
- مسیرها در `App.tsx`: `/admin/branches`, `/admin/branches/new`, `/admin/branches/:uuid`,
|
||||
`/admin/branches/:uuid/rooms`
|
||||
سه صفحهٔ جدید، همه با الگوهای موجود (`_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` کنار «تنظیمات نوبتدهی».
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# چکلیست — تسک ۰۱ (شعبه و اتاق)
|
||||
|
||||
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** —
|
||||
**وضعیت کلی:** 🔄 در حال انجام · **آخرین بازبینی:** —
|
||||
|
||||
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
||||
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
|
||||
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) ·
|
||||
[branch-is-doctor-address.md](../_shared/branch-is-doctor-address.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -13,80 +14,97 @@
|
||||
|---|---|---|---|
|
||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۰.۲ | `SlotCalculatorService` دستنخورده | ⏳ | این تسک به آن کاری ندارد |
|
||||
| ۰.۳ | `location_id` در JSON برنامهٔ هفتگی دستنخورده | ⏳ | شعبه **بالای** آدرس مینشیند |
|
||||
| ۰.۴ | `DoctorAddress` هیچ ستونی حذف/تغییر نداد | ⏳ | فقط `branch_id` تهیپذیر اضافه شد |
|
||||
| ۰.۳ | `location_id` در JSON برنامهٔ هفتگی دستنخورده | ⏳ | شعبه = همان `doctor_addresses.id` |
|
||||
| ۰.۴ | `DoctorAddress` هیچ ستونی حذف/تغییر نداد | ⏳ | فقط `active` و `timezone` با `DEFAULT` |
|
||||
| ۰.۵ | `active=false` هیچ اثری بر محاسبهٔ اسلات ندارد | ⏳ | اعمالش تسک ۰۳ است |
|
||||
|
||||
## ۱. بکاند
|
||||
## ۱. طرح
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۱.۱ | `Branch` + `BranchWorkingHours` + `Room` entity | ⏳ | |
|
||||
| ۱.۲ | `BranchService` با گاردهای حذف قابل توسعه (`DeletionGuardInterface`) | ⏳ | تسک ۰۲ و ۰۷ گارد اضافه میکنند |
|
||||
| ۱.۳ | `WorkingHoursService` — اعتبارسنجی و `sequence` سمت سرور | ⏳ | |
|
||||
| ۱.۴ | ساعت با `start_minute`/`end_minute` عددی، نه رشتهٔ `"09:00"` | ⏳ | |
|
||||
| ۱.۵ | هشت endpoint ساخته شد | ⏳ | |
|
||||
| ۱.۶ | پزشک مستقل هم شعبه دارد (مطب = شعبه) | ⏳ | نه فقط `entity_type=clinic` |
|
||||
| ۱.۷ | `app:branch:backfill` — dry-run پیشفرض، idempotent | ⏳ | |
|
||||
| ۱.۸ | کنترلر نازک · `BaseController` · `success/paginated/error` | ⏳ | |
|
||||
| ۱.۹ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | |
|
||||
| ۱.۱ | ⛔ جدول `branches` **ساخته نشد** — دلیل مکتوب | ✅ | `_shared/branch-is-doctor-address.md` |
|
||||
| ۱.۲ | `task.md` · `architecture.md` · `database.md` · `implementation_notes.md` تصحیح شد | ✅ | |
|
||||
| ۱.۳ | ارجاعهای `branch_id` در تسکهای ۰۲/۰۴/۰۷/۰۸/۰۹/۱۰/۱۳ با سند حاکم پوشش داده شد | ✅ | ۲۴ ارجاع — یک سند واحد در `_shared` بهجای ویرایش ۲۴ نقطه |
|
||||
|
||||
## ۲. دیتابیس
|
||||
## ۲. بکاند
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۲.۱ | `branches` · `branch_working_hours` · `rooms` | ⏳ | |
|
||||
| ۲.۲ | `entity_type, entity_id` ستون **اول** ایندکسهای لیست | ⏳ | |
|
||||
| ۲.۳ | `timezone` روی شعبه از روز اول | ⏳ | افزودن بعدی = backfill زماندار |
|
||||
| ۲.۴ | `rooms.capacity` — ظرفیت همزمان | ⏳ | اتاق سهتخته = یک ردیف با ۳ |
|
||||
| ۲.۵ | `branch_working_hours` در `GlobalTables::AGGREGATE_CHILDREN` | ⏳ | |
|
||||
| ۲.۶ | `TenantSchemaCoverageTest` سبز | ⏳ | |
|
||||
| ۲.۱ | `DoctorAddress` += `active` + `timezone` | ⏳ | |
|
||||
| ۲.۲ | `timezone` با `DateTimeZone::listIdentifiers()` اعتبارسنجی میشود، نه regex | ⏳ | |
|
||||
| ۲.۳ | `BranchWorkingHours` entity (فرزند aggregate) | ⏳ | |
|
||||
| ۲.۴ | `Room` entity با `TenantOwnedTrait` و جفت مشتق از آدرس در سازنده | ⏳ | نه از بدنهٔ request |
|
||||
| ۲.۵ | `BranchResolver` — تکنقطهٔ uuid آدرس → محیط جاری، ۴۰۴ نه ۴۰۳ | ⏳ | `TenantFilter` روی `doctor_addresses` کار نمیکند |
|
||||
| ۲.۶ | `WorkingHoursService` — اعتبارسنجی کامل **قبل از** حذف (اتمی) | ⏳ | |
|
||||
| ۲.۷ | ساعت با `start_minute`/`end_minute` عددی، نه رشتهٔ `"09:00"` | ⏳ | |
|
||||
| ۲.۸ | `sequence` سمت سرور تخصیص مییابد، نه کلاینت | ⏳ | |
|
||||
| ۲.۹ | `RoomService` با گارد حذف قابل توسعه (آرایهٔ تزریقی، نه زنجیرهٔ `if`) | ⏳ | تسک ۰۲ و ۰۷ گارد اضافه میکنند |
|
||||
| ۲.۱۰ | شش endpoint ساخته شد | ⏳ | صفر endpoint CRUD شعبه — موجود است |
|
||||
| ۲.۱۱ | پزشک مستقل هم شعبه دارد | ⏳ | `type='personal'` از قبل کار میکند |
|
||||
| ۲.۱۲ | کنترلر نازک · `BaseController` · `success/paginated/error` | ⏳ | |
|
||||
|
||||
## ۳. UI
|
||||
## ۳. دیتابیس
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۳.۱ | `BranchesPage` · `BranchFormPage` · `RoomsPage` | ⏳ | |
|
||||
| ۳.۲ | `DataTable` با skeleton و empty state فارسی | ⏳ | |
|
||||
| ۳.۳ | `PageHeader` با `backTo` روی زیرصفحهها | ⏳ | |
|
||||
| ۳.۴ | شهر/استان با `SearchableSelect` — هیچ `<select>` بومی | ⏳ | |
|
||||
| ۳.۵ | وضعیت لیست در URL با `useUrlState` | ⏳ | |
|
||||
| ۳.۶ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن | ⏳ | |
|
||||
| ۳.۷ | دارکمود و حالت فشرده بررسی شد | ⏳ | |
|
||||
| ۳.۸ | RTL و موبایل بررسی شد | ⏳ | |
|
||||
| ۳.۹ | همهٔ رشتهها فارسی از i18n | ⏳ | |
|
||||
| ۳.۱۰ | هشدار UI: «هیچ شعبهٔ فعالی باقی نمیماند» | ⏳ | |
|
||||
| ۳.۱۱ | مسیرها در `App.tsx` | ⏳ | |
|
||||
| ۳.۱ | `branch_working_hours` · `rooms` | ⏳ | |
|
||||
| ۳.۲ | `entity_type, entity_id` ستون **اول** ایندکس `rooms` | ⏳ | |
|
||||
| ۳.۳ | `timezone` روی آدرس از روز اول | ⏳ | افزودن بعدی = backfill زماندار |
|
||||
| ۳.۴ | `rooms.capacity` — ظرفیت همزمان | ⏳ | اتاق سهتخته = یک ردیف با ۳ |
|
||||
| ۳.۵ | `branch_working_hours` در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح | ⏳ | |
|
||||
| ۳.۶ | ستونها و جدولها روی `db_test` هم ساخته شد | ⏳ | تاریخچهٔ migration جدا |
|
||||
| ۳.۷ | `TenantSchemaCoverageTest` سبز | ⏳ | |
|
||||
| ۳.۸ | `TenantLookupInventoryTest` سبز — repository جدید ثبت شد | ⏳ | |
|
||||
|
||||
## ۴. تست
|
||||
## ۴. UI
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | `BranchCrudTest` — نقشها، ۴۰۴ نه ۴۰۳ برای محیط دیگر | ⏳ | |
|
||||
| ۴.۲ | `WorkingHoursTest` — `end<=start`، همپوشانی، شبانهروزی `0..1440` | ⏳ | |
|
||||
| ۴.۳ | `BranchDeletionTest` — شعبهٔ دارای اتاق فعال → ۴۲۲ | ⏳ | |
|
||||
| ۴.۴ | `capacity=0` → ۴۲۲ | ⏳ | |
|
||||
| ۴.۵ | `phpstan analyse src/Branch` بدون خطا | ⏳ | |
|
||||
| ۴.۱ | `BranchesPage` · `BranchWorkingHoursPage` · `BranchRoomsPage` | ⏳ | |
|
||||
| ۴.۲ | `DataTable` با skeleton و empty state فارسی | ⏳ | |
|
||||
| ۴.۳ | `PageHeader` با `backTo` روی زیرصفحهها | ⏳ | |
|
||||
| ۴.۴ | هر `select` با `SearchableSelect` — هیچ `<select>` بومی | ⏳ | |
|
||||
| ۴.۵ | وضعیت لیست در URL با `useUrlState` | ⏳ | |
|
||||
| ۴.۶ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن | ⏳ | |
|
||||
| ۴.۷ | دارکمود و حالت فشرده بررسی شد | ⏳ | |
|
||||
| ۴.۸ | RTL و موبایل بررسی شد | ⏳ | |
|
||||
| ۴.۹ | هشدار UI: «هیچ شعبهٔ فعالی باقی نمیماند» | ⏳ | |
|
||||
| ۴.۱۰ | مسیرها در `App.tsx` + ورودی در `SettingsMenuPage` | ⏳ | |
|
||||
| ۴.۱۱ | مجوز موجود `appointment_settings` استفاده شد، نه مجوز تازه | ⏳ | |
|
||||
|
||||
## ۵. مستندات
|
||||
## ۵. تست
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۵.۱ | `docs/api/branch.md` + ثبت در `docs/api/README.md` | ⏳ | |
|
||||
| ۵.۲ | تفسیر «شعبهٔ بدون ساعت کاری = تعریفنشده، نه همیشهباز» نوشته شد | ⏳ | تسک ۰۳ رویش حساب میکند |
|
||||
| ۵.۳ | `docs/architecture/tenancy.md` جدول طبقهبندی بهروز شد | ⏳ | |
|
||||
| ۵.۱ | `WorkingHoursTest` — هفت روز، `end<=start`، همپوشانی، `0..1440`، آرایهٔ خالی | ⏳ | |
|
||||
| ۵.۲ | اتمی بودن: بازهٔ نامعتبر در روز ششم → ۴۲۲ و شش روز قبلی دستنخورده | ⏳ | |
|
||||
| ۵.۳ | `RoomCrudTest` — جفت tenant مشتق، `capacity=0` → ۴۲۲ | ⏳ | |
|
||||
| ۵.۴ | آدرس/اتاق محیط دیگر → ۴۰۴ (نه ۴۰۳) | ⏳ | |
|
||||
| ۵.۵ | `BranchAddressFieldsTest` — پیشفرضها، `timezone` نامعتبر → ۴۲۲ | ⏳ | |
|
||||
| ۵.۶ | `phpstan analyse src/Branch` بدون خطا | ⏳ | |
|
||||
|
||||
## ۶. بازبینی پایانی
|
||||
## ۶. مستندات
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۶.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ⏳ | |
|
||||
| ۶.۲ | `bin/phpunit` کامل سبز | ⏳ | |
|
||||
| ۶.۳ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۶.۴ | `phpstan` بدون خطای جدید | ⏳ | |
|
||||
| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
|
||||
| ۶.۶ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | ⏳ | |
|
||||
| ۶.۷ | `docs/api/*` بهروز | ⏳ | |
|
||||
| ۶.۸ | چکلیست UI کامل | ⏳ | |
|
||||
| ۶.۹ | `nobat724_front` و `clinic-pro-tauri` بررسی شدند | ⏳ | این تسک قرارداد عمومی عوض نمیکند |
|
||||
| ۶.۱۰ | commit، سپس `graphify update .` | ⏳ | |
|
||||
| ۶.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ⏳ | |
|
||||
| ۶.۱ | `docs/api/branch.md` + ثبت در `docs/api/README.md` | ⏳ | JSON واقعی از curl |
|
||||
| ۶.۲ | «شعبهٔ بدون ساعت کاری = تعریفنشده، نه همیشهباز» نوشته شد | ⏳ | تسک ۰۳ رویش حساب میکند |
|
||||
| ۶.۳ | «`active` در این فاز بیاثر بر اسلات» نوشته شد | ⏳ | |
|
||||
| ۶.۴ | `docs/api/doctor.md` — دو فیلد جدید در پاسخ ۹ endpoint آدرس | ⏳ | تغییر قرارداد است |
|
||||
| ۶.۵ | `docs/architecture/tenancy.md` جدول طبقهبندی بهروز شد | ⏳ | |
|
||||
|
||||
## ۷. بازبینی پایانی
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۷.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ⏳ | |
|
||||
| ۷.۲ | `bin/phpunit` کامل سبز | ⏳ | |
|
||||
| ۷.۳ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۷.۴ | `phpstan` بدون خطای جدید (مقایسه با کامیت پیش از تسک) | ⏳ | |
|
||||
| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
|
||||
| ۷.۶ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | ⏳ | |
|
||||
| ۷.۷ | `docs/api/*` بهروز | ⏳ | |
|
||||
| ۷.۸ | چکلیست UI کامل | ⏳ | |
|
||||
| ۷.۹ | `nobat724_front` و `clinic-pro-tauri` بررسی شدند | ⏳ | دو فیلد جدید additive است |
|
||||
| ۷.۱۰ | commit، سپس `graphify update .`، سپس commit جدا | ⏳ | |
|
||||
| ۷.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ⏳ | |
|
||||
|
||||
@@ -2,65 +2,62 @@
|
||||
|
||||
MariaDB 11.8 · Doctrine ORM 3.6 · همهٔ timestamp ها `INT` (Unix)
|
||||
|
||||
## `branches`
|
||||
> ⛔ **جدول `branches` ساخته نمیشود.** «شعبه» همان `doctor_addresses` است — دلیل کامل در
|
||||
> [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md). آنچه در
|
||||
> نسخهٔ اول این فایل بهعنوان جدول `branches` و ستون `doctor_addresses.branch_id` آمده بود
|
||||
> حذف شد.
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | ارجاع خارجی |
|
||||
| `entity_type` | VARCHAR(10) NOT NULL | `doctor` \| `clinic` |
|
||||
| `entity_id` | INT NOT NULL | |
|
||||
| `name` | VARCHAR(150) NOT NULL | |
|
||||
| `phone` | VARCHAR(20) NULL | |
|
||||
| `address` | TEXT NULL | |
|
||||
| `city_id` | INT NULL | FK منطقی به `categories.id` با `bundle='city'` |
|
||||
| `province_id` | INT NULL | همان الگو با `bundle='state'` |
|
||||
| `latitude` | DOUBLE NULL | |
|
||||
| `longitude` | DOUBLE NULL | |
|
||||
| `timezone` | VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran' | |
|
||||
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
|
||||
| `created_at` / `updated_at` | INT NOT NULL | |
|
||||
## تغییر جدول موجود — `doctor_addresses`
|
||||
|
||||
ایندکسها:
|
||||
```sql
|
||||
KEY idx_branches_tenant (entity_type, entity_id, active)
|
||||
UNIQUE KEY uniq_branches_uuid (uuid)
|
||||
ALTER TABLE doctor_addresses
|
||||
ADD COLUMN active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
ADD COLUMN timezone VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran';
|
||||
```
|
||||
|
||||
> `entity_type, entity_id` ستونهای اولاند — شرطِ `TenantFilter` وگرنه از ایندکس استفاده نمیکند.
|
||||
هیچ ستونی حذف یا تغییر نوع نمیدهد. هر دو `NOT NULL DEFAULT` دارند، پس ردیفهای موجود
|
||||
بینیاز از backfill درست میشوند و هیچ رفتار فعلی عوض نمیشود.
|
||||
|
||||
`timezone` از روز اول هست چون مستند بند ۹ میگوید ذخیرهسازی UTC و نمایش محلی؛ امروز همهجا
|
||||
`Asia/Tehran` است ولی افزودن ستون بعداً یعنی backfill روی دادههای زماندار.
|
||||
`timezone` از همین حالا اضافه میشود چون بند ۹ مستند ذخیرهسازی UTC با نمایش محلی میخواهد؛
|
||||
افزودنش بعد از اینکه دادههای زماندار روی شعبه نشستند یعنی backfill پرریسک.
|
||||
|
||||
⚠️ `active` در این تسک **فقط ذخیره** میشود؛ فیلتر شدنش در محاسبهٔ اسلات کارِ تسک ۰۳ است
|
||||
(هر تغییری در `SlotCalculatorService` در فاز فعلی ممنوع است — `_shared/red-lines.md`).
|
||||
|
||||
## `branch_working_hours`
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE CASCADE |
|
||||
| `day_of_week` | TINYINT NOT NULL | ۰=شنبه … ۶=جمعه |
|
||||
| `address_id` | INT NOT NULL | FK → `doctor_addresses.id` ON DELETE CASCADE |
|
||||
| `day_of_week` | TINYINT NOT NULL | ۰=شنبه … ۶=جمعه — همان قرارداد `SlotCalculatorService` |
|
||||
| `sequence` | TINYINT NOT NULL DEFAULT 0 | بازهٔ چندم آن روز |
|
||||
| `start_minute` | SMALLINT NOT NULL | ۰..۱۴۴۰ |
|
||||
| `start_minute` | SMALLINT NOT NULL | ۰..۱۴۴۰ از نیمهشب |
|
||||
| `end_minute` | SMALLINT NOT NULL | > `start_minute` |
|
||||
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
|
||||
|
||||
```sql
|
||||
UNIQUE KEY uniq_branch_day_seq (branch_id, day_of_week, sequence)
|
||||
KEY idx_bwh_branch_day (branch_id, day_of_week, active)
|
||||
UNIQUE KEY uniq_bwh_address_day_seq (address_id, day_of_week, sequence)
|
||||
KEY idx_bwh_address_day (address_id, day_of_week, active)
|
||||
```
|
||||
|
||||
بدون ستون tenant — فرزند aggregate با ریشهٔ `branches` است و uuid از request نمیگیرد
|
||||
(همیشه از راه `/branch/{uuid}/working-hours` لود میشود). در `GlobalTables::AGGREGATE_CHILDREN`
|
||||
با ریشهٔ صریح ثبت شود.
|
||||
بدون ستون tenant — **فرزند aggregate** با ریشهٔ `DoctorAddress`. هرگز با uuid از request
|
||||
لود نمیشود؛ تنها راه رسیدن به آن `/branch/{addressUuid}/working-hours` است که آدرس را از
|
||||
`TenantFilter` رد میکند. در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح ثبت میشود.
|
||||
|
||||
دقت: چون `doctor_addresses` خودش `TenantOwnedTrait` ندارد (مالکیتش با `type` +
|
||||
`doctor_id`/`clinic_id` است)، `TenantFilter` روی خودِ آدرس هم اعمال نمیشود.
|
||||
پس فیلتر محیط برای این endpoint **دستی** است: `DoctorAddressRepository::findForContext()`
|
||||
که از قبل همین کار را میکند و در `clinic/{uuid}/addresses` هم همینطور استفاده شده.
|
||||
|
||||
## `rooms`
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | **از request میآید** → پس جفت tenant خودش را دارد |
|
||||
| `entity_type` / `entity_id` | VARCHAR(10)/INT NOT NULL | از `branch` در سازنده مشتق میشود |
|
||||
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE CASCADE |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | **از request میآید** → پس جفت tenant لازم دارد |
|
||||
| `entity_type` / `entity_id` | VARCHAR(10)/INT NOT NULL | در سازنده از `DoctorAddress` مشتق میشود |
|
||||
| `address_id` | INT NOT NULL | FK → `doctor_addresses.id` ON DELETE CASCADE |
|
||||
| `name` | VARCHAR(120) NOT NULL | |
|
||||
| `room_type` | VARCHAR(60) NULL | متن آزاد، تعریف کلینیک |
|
||||
| `capacity` | SMALLINT NOT NULL DEFAULT 1 | ظرفیت همزمان |
|
||||
@@ -70,39 +67,55 @@ KEY idx_bwh_branch_day (branch_id, day_of_week, active)
|
||||
|
||||
```sql
|
||||
KEY idx_rooms_tenant (entity_type, entity_id, active)
|
||||
KEY idx_rooms_branch (branch_id, active)
|
||||
KEY idx_rooms_address (address_id, active)
|
||||
```
|
||||
|
||||
## تغییر جدول موجود
|
||||
`entity_type, entity_id` ستونهای اولِ ایندکساند — شرط `TenantFilter` وگرنه از ایندکس
|
||||
استفاده نمیکند.
|
||||
|
||||
```sql
|
||||
ALTER TABLE doctor_addresses
|
||||
ADD COLUMN branch_id INT NULL,
|
||||
ADD CONSTRAINT fk_doctor_addresses_branch
|
||||
FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE SET NULL,
|
||||
ADD KEY idx_doctor_addresses_branch (branch_id);
|
||||
اشتقاق جفت در سازنده (نه از بدنهٔ request):
|
||||
|
||||
```php
|
||||
$isClinic = $address->getType() === DoctorAddress::TYPE_CLINIC;
|
||||
$this->assignTenantPair(
|
||||
$isClinic ? 'clinic' : 'doctor',
|
||||
$isClinic ? (int) $address->getClinicId() : (int) $address->getDoctor()->getId(),
|
||||
);
|
||||
```
|
||||
|
||||
هیچ ستونی حذف یا تغییر نوع نمیدهد. `location_id` در JSON برنامهٔ هفتگی دستنخورده میماند.
|
||||
|
||||
## Migration
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
ddev exec php bin/console app:branch:backfill # dry-run
|
||||
ddev exec php bin/console app:branch:backfill --force
|
||||
```
|
||||
|
||||
هیچ command backfill لازم نیست — `app:branch:backfill` نسخهٔ اول برای پر کردن `branch_id`
|
||||
از `doctor_addresses` بود؛ حالا که جدول موازی ساخته نمیشود، موضوعش منتفی است.
|
||||
|
||||
⚠️ **`db_test` تاریخچهٔ migration جدا دارد** و `migrate` رویش با
|
||||
`Table 'users' already exists` میشکند. ستونها را دستی اضافه کن وگرنه کل تستسوئیت با
|
||||
`Unknown column` قرمز میشود:
|
||||
|
||||
```bash
|
||||
ddev mysql -uroot -proot -e "ALTER TABLE db_test.doctor_addresses \
|
||||
ADD active TINYINT(1) NOT NULL DEFAULT 1, \
|
||||
ADD timezone VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran';"
|
||||
```
|
||||
و بعد از تولید migration، همان `CREATE TABLE` های `branch_working_hours` و `rooms` را هم
|
||||
روی `db_test` اجرا کن.
|
||||
|
||||
## طبقهبندی tenant
|
||||
|
||||
| جدول | وضعیت | ثبت در |
|
||||
|---|---|---|
|
||||
| `branches` | جفت tenant | `TenantOwnedTrait` |
|
||||
| `doctor_addresses` | از قبل طبقهبندیشده — دست نمیخورد | همانجای فعلی |
|
||||
| `rooms` | جفت tenant | `TenantOwnedTrait` (uuid از request میآید) |
|
||||
| `branch_working_hours` | فرزند aggregate | `GlobalTables::AGGREGATE_CHILDREN` → ریشه `Branch` |
|
||||
| `branch_working_hours` | فرزند aggregate | `GlobalTables::AGGREGATE_CHILDREN` → ریشه `DoctorAddress` |
|
||||
|
||||
بعد از migration:
|
||||
```bash
|
||||
ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php
|
||||
ddev exec php bin/phpunit tests/Shared/TenantLookupInventoryTest.php
|
||||
```
|
||||
دومی هم لازم است: repository جدیدی که `findByUuid` دارد باید در `REVIEWED` ثبت شود.
|
||||
|
||||
@@ -1,36 +1,59 @@
|
||||
# نکات پیادهسازی — تسک ۰۱
|
||||
|
||||
## ۱. چرا شعبه بالای آدرس مینشیند، نه جای آن
|
||||
## ۱. شعبه ساخته نمیشود — پیدا میشود
|
||||
|
||||
سه مصرفکنندهٔ زنده به `doctor_addresses.id` وابستهاند:
|
||||
نسخهٔ اول این فایل استدلال میکرد «چرا شعبه بالای آدرس مینشیند». استدلال درست بود ولی
|
||||
نتیجهاش غلط: اگر آدرس همان مکان فیزیکی است و همهجا هم به همان معنا مصرف میشود، لایهٔ
|
||||
بالایی چیزی جز یک جدول دوم برای همان نام و تلفن نیست. سه مصرفکنندهٔ زندهای که
|
||||
`doctor_addresses.id` را میخوانند —
|
||||
|
||||
1. `WeeklySchedule.setting[day].sessions[].location_id` (JSON)
|
||||
2. `SlotCalculatorService::buildSessionSlots()` که آن را در هر اسلات کپی میکند
|
||||
3. `AppointmentController::bookingLocations()` که به سایت عمومی `location_uuid` میدهد
|
||||
|
||||
عوض کردن این قرارداد در build هیچکدام از سه ریپو خطا نمیدهد — فقط در runtime آدرس گم میشود.
|
||||
پس `branch_id` روی آدرس اضافه میشود و آدرس همانجا میماند.
|
||||
— دلیل اصلیاند که «شعبه» همان آدرس است، نه دلیلِ ساختن لایهٔ دوم.
|
||||
جزئیات کامل: [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md).
|
||||
|
||||
## ۲. پزشک مستقل هم شعبه دارد
|
||||
|
||||
وسوسه میشود که شعبه را فقط برای `entity_type=clinic` بسازیم. نکن. اگر پزشک مستقل شعبه
|
||||
نداشته باشد، تسک ۰۲ باید دو مسیر کد برای «منبع مال شعبه» و «منبع مال پزشک» داشته باشد و
|
||||
تسک ۰۶ هر دو را جدا حساب کند. مطب شخصی = شعبهای با `entity_type=doctor`.
|
||||
خوشبختانه از قبل درست است: `DoctorAddress::forDoctor()` با `type = 'personal'` وجود دارد و
|
||||
`findForContext()` هم آدرسهای شخصی و هم کلینیکی را برمیگرداند. پس تسک ۰۲ لازم نیست دو
|
||||
مسیر کد برای «منبع مال شعبه» و «منبع مال پزشک» داشته باشد. مطب شخصی = آدرسی با
|
||||
`type='personal'`.
|
||||
|
||||
## ۳. حذف شعبه
|
||||
## ۳. `TenantFilter` روی `doctor_addresses` کار نمیکند
|
||||
|
||||
هرگز `CASCADE` روی حذف شعبه به منابع و نوبتها نده. `DELETE` فقط وقتی مجاز است که:
|
||||
مهمترین تلهٔ این تسک. `doctor_addresses` ستون `entity_type`/`entity_id` ندارد، پس:
|
||||
|
||||
- هیچ `Room` فعالی نداشته باشد، **و**
|
||||
- هیچ `Resource` فعالی (تسک ۰۲) نداشته باشد، **و**
|
||||
- هیچ نوبت آیندهٔ فعالی روی منابعش نباشد (تسک ۰۷)
|
||||
```php
|
||||
// ❌ آدرس محیط دیگر را هم برمیگرداند — filter اینجا تور ایمنی نیست
|
||||
$address = $this->addresses->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
تا آن تسکها نیامدهاند، فقط شرط اول را چک کن ولی سرویس را طوری بنویس که افزودن دو شرط
|
||||
بعدی یک خط باشد (لیست `DeletionGuardInterface` و تزریق آرایهای از گاردها).
|
||||
// ✅ از BranchResolver رد شو
|
||||
$address = $this->branches->resolve($uuid); // 404 اگر محیط جاری نباشد
|
||||
```
|
||||
|
||||
`active=false` مسیر اصلی است، نه `DELETE`.
|
||||
`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" غلط است)
|
||||
@@ -44,52 +67,71 @@ private int $startMinute = 540;
|
||||
- `0 <= start < end <= 1440`
|
||||
- بازههای یک روز نباید همپوشانی داشته باشند (مرتب کن، بعد `prev.end <= next.start`)
|
||||
- `sequence` را خود سرویس بعد از مرتبسازی تخصیص میدهد، نه کلاینت
|
||||
- **اعتبارسنجی کاملِ هر هفت روز قبل از هر `DELETE`** — وگرنه یک بازهٔ نامعتبر در روز ششم،
|
||||
شش روز درست را هم پاک میکند و ۴۲۲ برمیگرداند
|
||||
|
||||
## ۵. تفسیر «شعبه بدون ساعت کاری»
|
||||
## ۶. تفسیر «شعبه بدون ساعت کاری»
|
||||
|
||||
تصمیم صریح: **تعریفنشده، نه همیشهباز.** تسک ۰۳ وقتی برای شعبهای ساعتی پیدا نکرد، به
|
||||
رفتار فعلی برمیگردد (برنامهٔ پزشک تنها مرجع است). این باعث میشود همهٔ دادههای موجود
|
||||
بدون ساعت کاری شعبه دقیقاً مثل امروز کار کنند.
|
||||
تصمیم صریح: **تعریفنشده، نه همیشهباز.** تسک ۰۳ وقتی برای آدرسی ساعتی پیدا نکرد، به
|
||||
رفتار فعلی برمیگردد (برنامهٔ پزشک تنها مرجع است). پس همهٔ دادهٔ موجود — که هیچ ساعت کاری
|
||||
شعبه ندارد — دقیقاً مثل امروز کار میکند. این خطِ دفاعیِ «منطق اسلاتی دست نمیخورد» است.
|
||||
|
||||
این نکته را در `docs/api/branch.md` بنویس، وگرنه اولین کسی که کش را دیباگ میکند فکر میکند
|
||||
باگ است.
|
||||
همینطور `active=false` روی آدرس در این تسک **هیچ اثری بر اسلات ندارد**؛ فقط ذخیره میشود.
|
||||
اعمالش در تسک ۰۳ است. اگر همینجا اعمال شود، `SlotCalculatorService` عوض میشود که در
|
||||
`_shared/red-lines.md` ممنوع است.
|
||||
|
||||
## ۶. edge case ها
|
||||
هر دو نکته در `docs/api/branch.md` نوشته شود، وگرنه اولین کسی که دیباگ میکند فکر میکند باگ است.
|
||||
|
||||
## ۷. edge case ها
|
||||
|
||||
| حالت | رفتار درست |
|
||||
|---|---|
|
||||
| شعبه در محیط A، اتاق ساختهشده با uuid شعبهٔ محیط B | `404` — `TenantOwnershipChecker::belongsTo` قبل از هر کاری |
|
||||
| دو شعبه همنام در یک محیط | مجاز (نام یکتا نیست؛ آدرس فرق دارد) |
|
||||
| `capacity = 0` | `422` — حداقل ۱ |
|
||||
| آدرس محیط A، اتاق ساختهشده با uuid آدرس محیط B | `404` — `BranchResolver` قبل از هر کاری |
|
||||
| دو آدرس همنام در یک محیط | مجاز (نام یکتا نیست) |
|
||||
| `capacity = 0` یا منفی | `422` — حداقل ۱ |
|
||||
| ساعت کاری روز جمعه خالی | معتبر — یعنی شعبه جمعه بسته است |
|
||||
| شعبهای که تنها شعبهٔ محیط است و غیرفعال میشود | مجاز، ولی هشدار در UI: «هیچ شعبهٔ فعالی باقی نمیماند» |
|
||||
| ساعت شبانهروزی | `start=0, end=1440` — نه دو ردیف |
|
||||
| `PUT` با آرایهٔ خالی | همهٔ ساعتها پاک میشوند — شعبه کامل بسته |
|
||||
| ساعت شبانهروزی | `start=0, end=1440` — یک ردیف، نه دو |
|
||||
| آدرسی که تنها آدرس فعال محیط است و غیرفعال میشود | مجاز، ولی هشدار در UI |
|
||||
| `timezone` نامعتبر مثل `"Tehran"` | `422` — با `DateTimeZone::listIdentifiers()` چک کن، نه regex |
|
||||
|
||||
## ۷. تست
|
||||
## ۸. تست
|
||||
|
||||
```
|
||||
tests/Branch/BranchCrudTest.php
|
||||
- ساخت شعبه با نقش مالک کلینیک → 201 و tenant درست
|
||||
- ساخت با نقش منشیِ بدون محیط انتخابشده → 403
|
||||
- دیدن شعبهٔ محیط دیگر → 404 (نه 403)
|
||||
tests/Branch/WorkingHoursTest.php
|
||||
- هفت روز معتبر → 200 و بازخوانی یکسان
|
||||
- هفت روز معتبر → 200 و بازخوانی یکسان (کلیدهای 0..6)
|
||||
- end <= start → 422
|
||||
- دو بازهٔ همپوشان در یک روز → 422
|
||||
- بازهٔ شبانهروزی 0..1440 → 200
|
||||
tests/Branch/BranchDeletionTest.php
|
||||
- حذف شعبهٔ دارای اتاق فعال → 422
|
||||
- حذف شعبهٔ خالی → 204
|
||||
tests/Shared/TenantSchemaCoverageTest.php ← باید سبز بماند
|
||||
- آرایهٔ خالی → 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/architecture/tenancy.md` جدول طبقهبندی را با سه جدول جدید بهروز کن.
|
||||
## ۹. مستندات
|
||||
|
||||
`docs/api/branch.md` بساز (الگو: `docs/api/staff.md`). در `docs/api/README.md` اضافه کن.
|
||||
`docs/api/doctor.md` را برای دو فیلد جدید `DoctorAddress::toArray()` بهروز کن — این فیلدها
|
||||
در پاسخ ۹ endpoint موجود آدرس ظاهر میشوند، پس تغییر قرارداد است.
|
||||
در `docs/architecture/tenancy.md` جدول طبقهبندی را با دو جدول جدید بهروز کن.
|
||||
|
||||
@@ -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