feat: implement cancellation policy, no-show tracking, and waitlist management

- Add implementation notes for cancellation and waitlist features.
- Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting.
- Establish architecture for domain events and outbox pattern to ensure reliable event publishing.
- Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports.
- Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements.
This commit is contained in:
hamed
2026-07-30 11:43:58 +03:30
parent 1d338503c8
commit 021d0eb6b2
62 changed files with 8098 additions and 0 deletions
@@ -0,0 +1,125 @@
# معماری — تسک ۰۱
## ساختار فایل
```
src/Branch/
├── Controller/
│ ├── BranchController.php # CRUD شعبه + ساعت کاری
│ └── RoomController.php # CRUD اتاق
├── Entity/
│ ├── Branch.php
│ ├── BranchWorkingHours.php
│ └── Room.php
├── Repository/
│ ├── BranchRepository.php
│ ├── BranchWorkingHoursRepository.php
│ └── RoomRepository.php
├── Service/
│ ├── BranchService.php # ساخت/ویرایش/حذف + قواعد حذف
│ └── WorkingHoursService.php # اعتبارسنجی و ذخیرهٔ هفت روز
└── Command/
└── BackfillBranchCommand.php # app:branch:backfill
assets/admin/pages/
├── BranchesPage.tsx
├── BranchFormPage.tsx # شامل تب ساعت کاری
└── RoomsPage.tsx
```
## لایه‌بندی
`BranchController` نازک است: اعتبارسنجی ورودی + `EntityContextResolver` + صدا زدن سرویس.
همهٔ قواعد (حذف امن، یکتایی نام در محیط، نرمال‌سازی ساعت) در `BranchService` و
`WorkingHoursService`.
```php
final class BranchService
{
public function __construct(
private readonly BranchRepository $branches,
private readonly RoomRepository $rooms,
private readonly EntityManagerInterface $em,
) {}
public function create(EntityContext $ctx, BranchInput $input): Branch
{
$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);
}
// ...
}
}
```
## رابطهٔ 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` برای اجرا.
## ساعت کاری شعبه
مثل `WeeklySchedule` یک JSON نیست — جدول جداست، چون تسک ۰۳ باید بتواند
`WHERE branch_id = ? AND day = ?` بزند بدون خواندن و 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'])]
class BranchWorkingHours
{
private int $dayOfWeek; // 0=شنبه … 6=جمعه — همان قرارداد SlotCalculatorService
private int $startMinute; // دقیقه از نیمه‌شب، 0..1440
private int $endMinute;
private int $sequence; // چند بازه در روز (صبح/عصر)
}
```
`startMinute`/`endMinute` به‌جای رشتهٔ `"08:30"` ذخیره می‌شوند تا مقایسه و تقاطع در تسک ۰۶
حسابی باشد نه رشته‌ای. تبدیل به `H:i` فقط در `toArray()`.
## اتاق
```php
class Room
{
use TenantOwnedTrait;
private Branch $branch;
private string $name;
private ?string $roomType = null; // متن آزاد — نوعِ اتاق را کلینیک تعریف می‌کند
private int $capacity = 1; // چند بیمار هم‌زمان (اتاق تزریق سه‌تخته = 3)
private bool $active = true;
}
```
`capacity` از همین‌جا شروع می‌شود چون مستند بند ۶ صریح می‌گوید سه تخت = **یک منبع با
ظرفیت سه**، نه سه منبع. تسک ۰۲ همین معنا را روی `Resource` تکرار می‌کند و اتاق را
به‌عنوان یک `Resource` با `resource_type=room` منعکس می‌کند.
## پنل ادمین
- `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`
@@ -0,0 +1,108 @@
# دیتابیس — تسک ۰۱
MariaDB 11.8 · Doctrine ORM 3.6 · همهٔ timestamp ها `INT` (Unix)
## `branches`
| ستون | نوع | توضیح |
|---|---|---|
| `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 | |
ایندکس‌ها:
```sql
KEY idx_branches_tenant (entity_type, entity_id, active)
UNIQUE KEY uniq_branches_uuid (uuid)
```
> `entity_type, entity_id` ستون‌های اول‌اند — شرطِ `TenantFilter` وگرنه از ایندکس استفاده نمی‌کند.
`timezone` از روز اول هست چون مستند بند ۹ می‌گوید ذخیره‌سازی UTC و نمایش محلی؛ امروز همه‌جا
`Asia/Tehran` است ولی افزودن ستون بعداً یعنی backfill روی داده‌های زمان‌دار.
## `branch_working_hours`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE CASCADE |
| `day_of_week` | TINYINT NOT NULL | ۰=شنبه … ۶=جمعه |
| `sequence` | TINYINT NOT NULL DEFAULT 0 | بازهٔ چندم آن روز |
| `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)
```
بدون ستون tenant — فرزند aggregate با ریشهٔ `branches` است و uuid از request نمی‌گیرد
(همیشه از راه `/branch/{uuid}/working-hours` لود می‌شود). در `GlobalTables::AGGREGATE_CHILDREN`
با ریشهٔ صریح ثبت شود.
## `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 |
| `name` | VARCHAR(120) NOT NULL | |
| `room_type` | VARCHAR(60) NULL | متن آزاد، تعریف کلینیک |
| `capacity` | SMALLINT NOT NULL DEFAULT 1 | ظرفیت هم‌زمان |
| `floor` | VARCHAR(20) NULL | ویژگی آزاد — مستند بند ۶ |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at` / `updated_at` | INT NOT NULL | |
```sql
KEY idx_rooms_tenant (entity_type, entity_id, active)
KEY idx_rooms_branch (branch_id, active)
```
## تغییر جدول موجود
```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);
```
هیچ ستونی حذف یا تغییر نوع نمی‌دهد. `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
```
## طبقه‌بندی tenant
| جدول | وضعیت | ثبت در |
|---|---|---|
| `branches` | جفت tenant | `TenantOwnedTrait` |
| `rooms` | جفت tenant | `TenantOwnedTrait` (uuid از request می‌آید) |
| `branch_working_hours` | فرزند aggregate | `GlobalTables::AGGREGATE_CHILDREN` → ریشه `Branch` |
بعد از migration:
```bash
ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php
```
@@ -0,0 +1,95 @@
# نکات پیاده‌سازی — تسک ۰۱
## ۱. چرا شعبه بالای آدرس می‌نشیند، نه جای آن
سه مصرف‌کنندهٔ زنده به `doctor_addresses.id` وابسته‌اند:
1. `WeeklySchedule.setting[day].sessions[].location_id` (JSON)
2. `SlotCalculatorService::buildSessionSlots()` که آن را در هر اسلات کپی می‌کند
3. `AppointmentController::bookingLocations()` که به سایت عمومی `location_uuid` می‌دهد
عوض کردن این قرارداد در build هیچ‌کدام از سه ریپو خطا نمی‌دهد — فقط در runtime آدرس گم می‌شود.
پس `branch_id` روی آدرس اضافه می‌شود و آدرس همان‌جا می‌ماند.
## ۲. پزشک مستقل هم شعبه دارد
وسوسه می‌شود که شعبه را فقط برای `entity_type=clinic` بسازیم. نکن. اگر پزشک مستقل شعبه
نداشته باشد، تسک ۰۲ باید دو مسیر کد برای «منبع مال شعبه» و «منبع مال پزشک» داشته باشد و
تسک ۰۶ هر دو را جدا حساب کند. مطب شخصی = شعبه‌ای با `entity_type=doctor`.
## ۳. حذف شعبه
هرگز `CASCADE` روی حذف شعبه به منابع و نوبت‌ها نده. `DELETE` فقط وقتی مجاز است که:
- هیچ `Room` فعالی نداشته باشد، **و**
- هیچ `Resource` فعالی (تسک ۰۲) نداشته باشد، **و**
- هیچ نوبت آیندهٔ فعالی روی منابعش نباشد (تسک ۰۷)
تا آن تسک‌ها نیامده‌اند، فقط شرط اول را چک کن ولی سرویس را طوری بنویس که افزودن دو شرط
بعدی یک خط باشد (لیست `DeletionGuardInterface` و تزریق آرایه‌ای از گاردها).
`active=false` مسیر اصلی است، نه `DELETE`.
## ۴. ساعت کاری — دقیقه، نه رشته
```php
// ❌ اشتباه: مقایسهٔ رشته‌ای در تسک ۰۶ می‌شکند ("9:00" < "10:00" غلط است)
private string $startTime = '09:00';
// ✅ درست
private int $startMinute = 540;
```
اعتبارسنجی در `WorkingHoursService`:
- `0 <= start < end <= 1440`
- بازه‌های یک روز نباید هم‌پوشانی داشته باشند (مرتب کن، بعد `prev.end <= next.start`)
- `sequence` را خود سرویس بعد از مرتب‌سازی تخصیص می‌دهد، نه کلاینت
## ۵. تفسیر «شعبه بدون ساعت کاری»
تصمیم صریح: **تعریف‌نشده، نه همیشه‌باز.** تسک ۰۳ وقتی برای شعبه‌ای ساعتی پیدا نکرد، به
رفتار فعلی برمی‌گردد (برنامهٔ پزشک تنها مرجع است). این باعث می‌شود همهٔ داده‌های موجود
بدون ساعت کاری شعبه دقیقاً مثل امروز کار کنند.
این نکته را در `docs/api/branch.md` بنویس، وگرنه اولین کسی که کش را دیباگ می‌کند فکر می‌کند
باگ است.
## ۶. edge case ها
| حالت | رفتار درست |
|---|---|
| شعبه در محیط A، اتاق ساخته‌شده با uuid شعبهٔ محیط B | `404``TenantOwnershipChecker::belongsTo` قبل از هر کاری |
| دو شعبه هم‌نام در یک محیط | مجاز (نام یکتا نیست؛ آدرس فرق دارد) |
| `capacity = 0` | `422` — حداقل ۱ |
| ساعت کاری روز جمعه خالی | معتبر — یعنی شعبه جمعه بسته است |
| شعبه‌ای که تنها شعبهٔ محیط است و غیرفعال می‌شود | مجاز، ولی هشدار در UI: «هیچ شعبهٔ فعالی باقی نمی‌ماند» |
| ساعت شبانه‌روزی | `start=0, end=1440` — نه دو ردیف |
## ۷. تست
```
tests/Branch/BranchCrudTest.php
- ساخت شعبه با نقش مالک کلینیک → 201 و tenant درست
- ساخت با نقش منشیِ بدون محیط انتخاب‌شده → 403
- دیدن شعبهٔ محیط دیگر → 404 (نه 403)
tests/Branch/WorkingHoursTest.php
- هفت روز معتبر → 200 و بازخوانی یکسان
- end <= start → 422
- دو بازهٔ هم‌پوشان در یک روز → 422
- بازهٔ شبانه‌روزی 0..1440 → 200
tests/Branch/BranchDeletionTest.php
- حذف شعبهٔ دارای اتاق فعال → 422
- حذف شعبهٔ خالی → 204
tests/Shared/TenantSchemaCoverageTest.php ← باید سبز بماند
```
اجرا:
```bash
ddev exec php bin/phpunit tests/Branch
ddev exec php vendor/bin/phpstan analyse src/Branch
```
## ۸. مستندات
`docs/api/branch.md` بساز (الگو: `docs/api/staff.md`). در `docs/api/README.md` هم اضافه کن.
در `docs/architecture/tenancy.md` جدول طبقه‌بندی را با سه جدول جدید به‌روز کن.
@@ -0,0 +1,61 @@
# تسک ۰۱ — شعبه (Branch) و اتاق (Room)
**فاز:** ۱ (هسته) · **وابستگی:** — · **زمان:** ۱۰-۱۲ ساعت
---
## هدف
سطح سوم مکان را به مدل اضافه کن: امروز `(entity_type, entity_id)` می‌گوید داده مال کدام
محیط است، ولی نمی‌گوید در کدام **ساختمان** و کدام **اتاق**. مستند بند ۴ سه سطح می‌خواهد
و «منابع همیشه مال شعبه‌اند چون فیزیکی‌اند» — بدون شعبه، تسک ۰۲ جایی برای نشستن ندارد.
## وضعیت فعلی
- محل مراجعه امروز `DoctorAddress` است و `location_id` هر شیفت در
`WeeklySchedule.setting[day].sessions[].location_id` به `doctor_addresses.id` اشاره می‌کند
(`SlotCalculatorService::buildSessionSlots()` آن را در هر اسلات کپی می‌کند).
- `Clinic` هیچ فیلد شعبه‌ای ندارد؛ یک آدرس متنی تخت دارد.
- ساعت کاری شعبه وجود ندارد — ساعت کاری فقط روی برنامهٔ پزشک است.
## دامنه
**هست:** entity های `Branch` و `Room`، ساعت کاری هفتگی شعبه، CRUD پنل ادمین،
پل زدن `DoctorAddress.branch_id` برای اینکه شیفت‌های موجود بدون تغییر به شعبه نگاشت شوند.
**نیست:** استفاده از شعبه در محاسبهٔ اسلات (تسک ۰۳)، اتاق به‌عنوان منبع قابل رزرو (تسک ۰۲).
## 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 اتاق |
## معیار پذیرش
- ✅ موفق: کلینیک با توکن مالک `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`.
- ⚠️ مرزی: شعبه بدون ساعت کاری معتبر است (وراثت: تسک ۰۳ آن را «همیشه باز» تفسیر نمی‌کند،
«تعریف‌نشده» تفسیر می‌کند).
## خروجی
- `src/Branch/` کامل با تست
- `assets/admin/pages/BranchesPage.tsx` + `BranchFormPage.tsx` + `RoomsPage.tsx`
- `docs/api/branch.md`
- migration + دستور `app:branch:backfill` برای ساخت شعبهٔ اولیه از آدرس‌های موجود