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:
@@ -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` برای ساخت شعبهٔ اولیه از آدرسهای موجود
|
||||
Reference in New Issue
Block a user