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,141 @@
# دیتابیس — تسک ۱۳
## `cancellation_policies`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `service_item_id` | INT NULL | NULL = پیش‌فرض محیط — FK ON DELETE CASCADE |
| `free_window_hours` | SMALLINT NOT NULL DEFAULT 24 | |
| `penalty_mode` | VARCHAR(10) NOT NULL DEFAULT 'none' | `none`\|`percent`\|`fixed` |
| `penalty_value` | INT NOT NULL DEFAULT 0 | درصد ۰..۱۰۰ یا ریال |
| `deposit_refundable` | TINYINT(1) NOT NULL DEFAULT 0 | پس از پنجرهٔ رایگان |
| `credit_refundable` | TINYINT(1) NOT NULL DEFAULT 1 | اعتبار پکیج |
| `no_show_threshold` | SMALLINT NOT NULL DEFAULT 3 | |
| `risk_tag_uuid` | VARCHAR(36) NULL | ارجاع به `tenant_tags.uuid` — بدون FK، الگوی موجود پروژه |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
UNIQUE KEY uniq_cancel_policy_scope (entity_type, entity_id, service_item_id)
KEY idx_cancel_policies_tenant (entity_type, entity_id, active)
```
`risk_tag_uuid` بدون FK — همان الگوی `DiscountRule.target_tag_uuid` موجود.
## `no_show_records`
```sql
CREATE TABLE no_show_records (
id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
patient_record_id INT NOT NULL,
appointment_id INT NOT NULL,
recorded_at INT NOT NULL,
recorded_by INT NULL,
UNIQUE KEY uniq_no_show_appointment (appointment_id), -- یک بار per نوبت
KEY idx_no_show_patient (patient_record_id, recorded_at), -- کوئری شمارش ۱۲ ماه
KEY idx_no_show_tenant (entity_type, entity_id, recorded_at),
CONSTRAINT fk_ns_patient FOREIGN KEY (patient_record_id) REFERENCES patient_records(id) ON DELETE CASCADE,
CONSTRAINT fk_ns_appt FOREIGN KEY (appointment_id) REFERENCES appointments(id) ON DELETE CASCADE
);
```
جدول جدا و نه یک ستون شمارنده روی بیمار — همان استدلال دفتر اعتبار تسک ۱۱:
شمارنده، «چه زمانی و کدام نوبت» را از دست می‌دهد و پنجرهٔ ۱۲ ماهه غیرقابل محاسبه می‌شود.
`uniq_no_show_appointment`: تغییر وضعیت به `no_show` ممکن است دوبار اتفاق بیفتد
(idempotency)؛ رکورد دوم ثبت نشود.
## `waitlist_entries`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `patient_record_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `service_item_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `branch_id` | INT NULL | FK ON DELETE CASCADE |
| `desired_from` | INT NOT NULL | |
| `desired_to` | INT NOT NULL | |
| `preferred_day_parts` | JSON NULL | `["morning","evening"]` |
| `priority` | SMALLINT NOT NULL DEFAULT 0 | |
| `status` | VARCHAR(12) NOT NULL DEFAULT 'waiting' | `waiting`\|`notified`\|`converted`\|`expired` |
| `notified_at` | INT NULL | آخرین اطلاع |
| `notify_count` | SMALLINT NOT NULL DEFAULT 0 | سقف برای جلوگیری از اسپم |
| `converted_appointment_id` | INT NULL | FK ON DELETE SET NULL |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_waitlist_match (service_item_id, branch_id, status, desired_from, desired_to)
KEY idx_waitlist_tenant (entity_type, entity_id, status, created_at)
KEY idx_waitlist_patient (patient_record_id, status)
```
`idx_waitlist_match` کوئری داغ است: «چه کسانی منتظر این سرویس در این بازه‌اند؟»
```sql
SELECT * FROM waitlist_entries
WHERE service_item_id = ? AND (branch_id = ? OR branch_id IS NULL)
AND status = 'waiting'
AND desired_from <= :freedEnd AND desired_to >= :freedStart
ORDER BY priority DESC, created_at ASC
LIMIT 10
```
`preferred_day_parts` در PHP فیلتر می‌شود (JSON قابل ایندکس مطمئن نیست و نتیجه ≤ ۱۰ ردیف است).
## هیچ تغییری در `appointments`
وضعیت‌های `cancelled_by_user`, `cancelled_by_doctor`, `no_show` از قبل هستند.
`deposit_amount_rials` هم.
## جریمه در دفتر مالی موجود
جدول جدید ندارد. `WalletTransaction` موجود استفاده می‌شود:
```php
new WalletTransaction(
user: $appt->getUser(),
amountRials: -$penalty,
kind: 'cancellation_penalty', // ← مقدار جدید در enum موجود
reference: $appt->getUuid(),
);
$tx->setRecordedEntity($appt->getEntityType(), $appt->getEntityId()); // per-محیط، طبق tenancy.md
```
`setRecordedEntity` اجباری است، وگرنه جریمه در دفتر همهٔ محیط‌ها دیده می‌شود
(`docs/architecture/tenancy.md`، بخش کیف پول).
## 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:cancellation:seed-default-policy --force
```
`seed-default-policy` برای هر محیط یک سیاست پیش‌فرض محافظه‌کار می‌سازد:
`free_window_hours=24, penalty_mode=none, deposit_refundable=true, credit_refundable=true`.
**پیش‌فرض بدون جریمه** عمدی است: فعال شدن ناگهانی جریمه روی بیماران موجود، شکایت است.
کلینیک خودش باید فعالش کند.
## پاکسازی
```bash
ddev exec php bin/console app:waitlist:expire # روزانه
```
ورودی‌هایی که `desired_to` گذشته → `status='expired'`.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `cancellation_policies`, `no_show_records`, `waitlist_entries` | جفت tenant |