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,161 @@
# معماری — تسک ۱۰
## ساختار فایل
```
src/Policy/
├── Simulation/
│ ├── PolicySimulator.php # اجرای dry-run
│ ├── SimulationSampler.php # انتخاب نمونهٔ نوبت‌های واقعی
│ └── Dto/{SimulationReport, SimulationRow}.php
├── Entity/PolicySimulationRun.php
├── Template/PolicyTemplateRegistry.php
└── Controller/PolicySimulationController.php
assets/admin/
├── pages/PoliciesPage.tsx
├── pages/PolicyFormPage.tsx
├── pages/PolicySimulationPage.tsx
└── components/PolicyConditionBuilder.tsx # از schema ساخته می‌شود
```
## `PolicySimulator` — dry-run واقعی
```php
public function simulate(Policy $policy, int $sampleSize = 50): SimulationReport
{
$sample = $this->sampler->recentAppointments($policy, $sampleSize);
$rows = [];
foreach ($sample as $appointment) {
$ctx = PolicyContext::fromAppointment($appointment);
$before = $this->snapshotOf($appointment); // وضعیت واقعی ثبت‌شده
$after = $this->engineFor($policy->getCategory())
->evaluateIsolated($policy, $ctx); // فقط همین قانون
if ($before->equals($after)) continue; // بی‌تأثیر
$rows[] = new SimulationRow($appointment, $before, $after);
}
return new SimulationReport($policy, count($sample), $rows);
}
```
### تضمین «هیچ چیزی ثبت نمی‌شود»
سه لایه، نه یکی:
1. `evaluateIsolated()` روی DTO کار می‌کند، نه entity — هیچ entity ای تغییر نمی‌کند
2. کل شبیه‌سازی داخل تراکنشی اجرا می‌شود که **همیشه** rollback می‌شود:
```php
$this->em->beginTransaction();
try { $report = $this->runInternal($policy, $size); }
finally { $this->em->rollback(); $this->em->clear(); }
```
3. تست تعداد ردیف‌های جدول‌های حساس را قبل و بعد مقایسه می‌کند
لایهٔ ۲ حتی اگر کسی روزی سهواً یک `flush` اضافه کرد، جلویش را می‌گیرد. `em->clear()`
اجباری است، وگرنه entity های کثیف در identity map می‌مانند و درخواست بعدی همان request
آن‌ها را flush می‌کند.
`PolicySimulationRun` **بعد از** rollback و در یک تراکنش جدا ثبت می‌شود.
## `evaluateIsolated` — چرا فقط همین قانون
شبیه‌سازی باید بگوید «**این** قانون چه تغییری می‌دهد»، نه «نتیجهٔ نهایی با همهٔ قوانین چه
می‌شود». دومی مفید است ولی سؤال کاربر نیست: کاربر دارد یک قانون می‌سازد و می‌خواهد اثر
همان را ببیند.
هر یک از شش موتور تسک ۰۹ باید یک متد `evaluateIsolated(Policy, PolicyContext)` داشته باشد
که بدون `PolicyResolver` و بدون `Combiner` فقط همان قانون را ارزیابی کند.
## `PolicyTemplateRegistry` — الگوهای آماده
```php
private const TEMPLATES = [
'min_days_between_sessions' => [
'title' => 'حداقل فاصله بین جلسات',
'category' => 'spacing',
'inputs' => [
['key' => 'service_uuid', 'type' => 'service_select', 'label' => 'سرویس'],
['key' => 'days', 'type' => 'int', 'label' => 'حداقل روز', 'min' => 1, 'max' => 365],
],
'build' => /* callable که conditions و effects را می‌سازد */,
],
'surgery_needs_surgeon' => […], // resource
'vip_discount' => […], // pricing
'minor_needs_consent' => […], // eligibility
'complex_min_duration' => […], // timing
];
```
کاربر ۹۰٪ موارد از الگو استفاده می‌کند و هرگز شرط خام نمی‌نویسد. حالت پیشرفته
(`PolicyConditionBuilder`) برای بقیه است.
## فرم از schema، نه hard-code
```tsx
// PolicyConditionBuilder.tsx
const { data: schema } = useQuery({ queryKey: ['policy-schema'], queryFn: … });
// هر شرط: [فیلد ▾] [عملگر ▾] [مقدار]
// - فیلدها از schema.fields
// - عملگرهای مجاز از schema.fields[field].ops ← فیلتر می‌شود، نه همه
// - نوع ورودی مقدار از schema.fields[field].type
// int → عدد · array → SearchableSelect چندانتخابی · enum → SearchableSelect
```
اگر عملگرها را فیلتر نکنی، کاربر `patient.tags > 5` می‌سازد و `422` می‌گیرد بدون فهمیدن چرا.
هرگز `<select>` بومی — `SearchableSelect` طبق قاعدهٔ پروژه.
## گزارش شبیه‌سازی — UI
```
PolicySimulationPage.tsx
┌─────────────────────────────────────────────────────┐
│ آزمایش قانون: حداقل ۲۱ روز فاصله بین جلسات لیزر │
│ │
│ نمونه: ۵۰ نوبت اخیر · تحت تأثیر: ۷ نوبت (۱۴٪) │
│ ⚠️ شدت: متوسط │
├─────────────────────────────────────────────────────┤
│ بیمار تاریخ نوبت وضعیت فعلی → با این قانون │
│ ز. احمدی ۱۴۰۵/۰۴/۱۲ مجاز → رد می‌شد │
│ م. کریمی ۱۴۰۵/۰۴/۱۵ مجاز → رد می‌شد │
│ … │
└─────────────────────────────────────────────────────┘
[بازگشت به ویرایش] [فعال‌سازی قانون]
```
ستون «وضعیت فعلی → با این قانون» تنها چیزی است که کاربر غیرفنی می‌فهمد. درصد و شدت هم
لازم است: قانونی که ۹۸٪ نوبت‌ها را رد می‌کند تقریباً همیشه اشتباه نوشته شده.
سطح شدت:
| تحت تأثیر | شدت | رنگ |
|---|---|---|
| ۰٪ | `none` | خاکستری + هشدار «این قانون روی هیچ نوبتی اثر نداشت» |
| ۱-۲۰٪ | `low` | سبز |
| ۲۱-۶۰٪ | `medium` | نارنجی |
| > ۶۰٪ | `high` | قرمز + متن «مطمئنید؟» روی دکمهٔ فعال‌سازی |
شدت `none` هم هشدار است: یعنی شرط احتمالاً هرگز true نمی‌شود.
## `activate` با شرط آزمایش
```php
// PolicyService::activate()
$run = $this->simulationRepo->latestFor($policy);
if ($run === null || $run->getPolicyVersion() !== $policy->getVersion()) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'ابتدا قانون را آزمایش کنید و نتیجه را ببینید',
422
);
}
```
`getPolicyVersion() !== $policy->getVersion()` مهم است: آزمایش نسخهٔ ۱ اجازهٔ فعال‌سازی
نسخهٔ ۲ را نمی‌دهد.
@@ -0,0 +1,71 @@
# دیتابیس — تسک ۱۰
## `policy_simulation_runs`
تنها جدول جدید این تسک — و تنها چیزی که شبیه‌سازی می‌نویسد.
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `policy_id` | INT NOT NULL | FK → `policies.id` ON DELETE CASCADE |
| `policy_version` | SMALLINT NOT NULL | نسخهٔ آزمایش‌شده |
| `sample_size` | SMALLINT NOT NULL | تعداد نوبت نمونه |
| `affected_count` | SMALLINT NOT NULL | تعداد تحت تأثیر |
| `severity` | VARCHAR(10) NOT NULL | `none`\|`low`\|`medium`\|`high` |
| `report` | JSON NOT NULL | ردیف‌های تفصیلی (حداکثر ۵۰) |
| `run_by` | INT NULL | FK → `users.id` ON DELETE SET NULL |
| `created_at` | INT NOT NULL | |
```sql
KEY idx_psr_policy (policy_id, policy_version, created_at)
KEY idx_psr_tenant (entity_type, entity_id, created_at)
```
`report` سقف حجم دارد: ۵۰ ردیف × چند فیلد ≈ چند کیلوبایت. بیشتر ذخیره نکن — گزارش
تفصیلی‌تر با اجرای دوباره به دست می‌آید.
## هیچ تغییری در جدول‌های دیگر
`policies.active` از قبل هست. شرط آزمایش در سطح سرویس اعمال می‌شود، نه schema.
## نمونه‌گیری — `SimulationSampler`
```sql
-- نوبت‌های واقعی، مرتبط با دامنهٔ قانون، جدیدترین اول
SELECT a.* FROM appointments a
WHERE a.entity_type = :type AND a.entity_id = :id
AND a.status IN ('confirmed','completed')
AND (:branchId IS NULL OR a.branch_id = :branchId)
AND (:serviceId IS NULL OR a.service_item_id = :serviceId)
ORDER BY a.slot_start DESC
LIMIT 50
```
فیلتر `service_item_id` از خودِ شرط قانون استخراج می‌شود (اگر قانون سرویس مشخصی را
هدف گرفته). بدون آن، شبیه‌سازی قانون لیزر روی ۵۰ نوبت دندانپزشکی اجرا می‌شود و
«۰٪ تحت تأثیر» می‌دهد — که گمراه‌کننده است.
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
## پاکسازی
اجراهای آزمایشی قدیمی ارزشی ندارند:
```bash
ddev exec php bin/console app:policy:prune-simulations --older-than=30d --force
```
آخرین اجرا per (policy, version) **هرگز** حذف نمی‌شود — چون شرط `activate` به آن وابسته است.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `policy_simulation_runs` | جفت tenant |
@@ -0,0 +1,130 @@
# نکات پیاده‌سازی — تسک ۱۰
## ۱. rollback اجباری، سه لایه
```php
public function simulate(Policy $policy, int $size): SimulationReport
{
$this->em->beginTransaction();
try {
return $this->runInternal($policy, $size);
} finally {
$this->em->rollback(); // ← حتی اگر استثنا پرت شود
$this->em->clear(); // ← identity map پاک شود
}
}
```
`finally` نه `catch`: اگر شبیه‌سازی استثنا داد، هنوز باید rollback شود.
`clear()` بدون آن، entity های تغییرکردهٔ درون تراکنش در حافظه می‌مانند و اولین `flush`
در ادامهٔ همان request آن‌ها را ثبت می‌کند — یک باگ که پیدا کردنش روزها می‌برد.
ثبت `PolicySimulationRun` **بعد** از این بلوک و در تراکنش خودش.
## ۲. تست «هیچ چیزی ننوشت» — با شمارش، نه با اعتماد
```php
$before = $this->countRows(['appointments','price_snapshots','resource_occupancy',
'resource_occupancy_slot','appointment_segments']);
$this->simulator->simulate($policy, 50);
$after = $this->countRows([...]);
self::assertSame($before, $after, 'شبیه‌سازی نباید هیچ ردیفی بنویسد');
```
این تست ارزشمندترین تست این تسک است. هر بار که کسی `PolicySimulator` را تغییر دهد،
همین تست جلوی فاجعه را می‌گیرد.
## ۳. `evaluateIsolated` روی همهٔ شش موتور
اضافه کردن این متد به شش موتور تسک ۰۹، تغییر اینترفیس است. پس **در تسک ۰۹ اضافه شود**،
نه اینجا — وگرنه شش کلاس دوباره ویرایش می‌شوند.
اگر تسک ۰۹ تمام شده و این متد نیست، اضافه‌اش کن ولی به‌عنوان یک متد در همان اینترفیس
موجود، نه یک اینترفیس جدید.
## ۴. فرم از schema — بدون استثنا
```tsx
// ❌ اولین وسوسه
const FIELDS = ['patient.age', 'patient.tags', 'service.category_path'];
// ✅
const { data: schema } = useQuery({ queryKey: ['policy-schema'], staleTime: 300_000 });
```
اگر فیلدها را در فرانت hard-code کنی، هر فیلد جدید در `FieldRegistry` نیاز به تغییر
فرانت دارد و بعد از دو ماه دو فهرست ناهمگام داریم. `staleTime` بلند اشکالی ندارد —
schema تقریباً هرگز عوض نمی‌شود.
## ۵. شدت `none` هم هشدار است
قانونی که روی هیچ نوبتی اثر نداشت، دو حالت دارد:
- شرطش هرگز true نمی‌شود (اشتباه نوشته شده)
- نمونهٔ ۵۰ نوبتی آن حالت را نداشت (شاید درست است)
پیام باید هر دو را بگوید:
> «این قانون روی هیچ‌کدام از ۵۰ نوبت نمونه اثر نداشت. یا شرط آن هرگز برقرار نمی‌شود،
> یا این حالت در نوبت‌های اخیر پیش نیامده. فعال‌سازی مجاز است.»
فعال‌سازی را نبند — کلینیک جدید هیچ نوبتی ندارد و باید بتواند قانون بسازد.
## ۶. الگوها باید واقعاً کار کنند
هر الگو در `PolicyTemplateRegistry` باید یک تست داشته باشد که آن را می‌سازد،
شبیه‌سازی می‌کند و فعال می‌کند. الگویی که `conditions` نامعتبر تولید کند، بدترین حالت است:
کاربر فرم آماده را پر می‌کند و `422` می‌گیرد.
```php
// tests/Policy/PolicyTemplateTest.php
/** @dataProvider templates */
public function testTemplateProducesValidPolicy(string $key): void
{
$policy = $this->registry->build($key, $this->sampleInputs($key));
$this->validator->assertValid($policy); // همان اعتبارسنجی POST /policy
}
```
## ۷. edge case ها
| حالت | رفتار درست |
|---|---|
| محیط بدون هیچ نوبت | گزارش خالی، `severity=none`، `activate` مجاز |
| قانون `deny` که همه را رد می‌کند | `severity=high`، فعال‌سازی با تأیید دوباره |
| `simulate` نسخهٔ ۱، بعد نسخهٔ ۲ ساخته شد | `activate` نسخهٔ ۲ → `422` |
| `simulate` دو بار پشت‌سرهم | آخری معیار است؛ قبلی می‌ماند |
| قانون فعال که دوباره `simulate` می‌شود | مجاز — کاربر می‌خواهد اثرش را ببیند |
| نوبت نمونه‌ای که سرویسش حذف شده | از نمونه حذف شود، در `sample_size` نیاید |
| قانون `pricing` روی نوبتی بدون snapshot | آن ردیف رد شود با علت `no_snapshot` |
| `sample_size` بزرگ‌تر از ۵۰ | سقف ۵۰ — درخواست بیشتر `422` |
## ۸. تست
```
tests/Policy/PolicySimulatorTest.php ← ⭐
- هیچ ردیفی نوشته نمی‌شود (شمارش قبل/بعد)
- رفتار درست وقتی evaluateIsolated استثنا می‌دهد (rollback + clear)
- گزارش فقط نوبت‌های تحت تأثیر را دارد
tests/Policy/SimulationSamplerTest.php
- فیلتر شعبه و سرویس از شرط قانون استخراج می‌شود
- فقط confirmed/completed
- سقف ۵۰
tests/Policy/PolicyActivationGuardTest.php ← ⭐
- activate بدون simulate → 422
- activate با simulate نسخهٔ قبلی → 422
- activate با simulate نسخهٔ جاری → 200
- محیط بدون نوبت: simulate خالی → activate مجاز
tests/Policy/PolicyTemplateTest.php
- هر الگو قانون معتبر تولید می‌کند (dataProvider روی همهٔ الگوها)
tests/Policy/SeverityTest.php
- ۰٪ → none · ۱۰٪ → low · ۴۰٪ → medium · ۸۰٪ → high
assets/admin/pages/PolicyFormPage.test.tsx
- فیلدها از schema می‌آیند (mock schema با فیلد ساختگی → در UI ظاهر شود)
- عملگرهای نامعتبر برای نوع فیلد نمایش داده نمی‌شوند
```
## ۹. مستندات
`docs/api/policy.md` را با `simulate` و `policy-templates` و شرط جدید `activate`
به‌روز کن. در `docs/architecture/policy-engine.md` یک بخش «چرا آزمایش اجباری است»
اضافه کن با ارجاع به ریسک دوم مستند بند ۱۷.
@@ -0,0 +1,61 @@
# تسک ۱۰ — فرم ساخت قانون و محیط آزمایش
**فاز:** ۲ (قوانین) · **وابستگی:** ۰۹ · **زمان:** ۱۰-۱۲ ساعت
---
## هدف
مستند بند ۱۷، ریسک دوم: «کاربر غیرفنی نمی‌تواند قانون درست تعریف کند → قانون‌های اشتباه،
رفتار عجیب». راه‌حل مستند: **فرم آماده، الگوهای از پیش تعریف‌شده، آزمایش اجباری قبل از
فعال شدن.**
بدون این تسک، تسک ۰۹ یک API قدرتمند است که هیچ‌کس نمی‌تواند از آن استفادهٔ درست کند.
## دامنه
**هست:**
- فرم ساخت قانون که از `GET /api/v1/policy-schema` ساخته می‌شود (نه hard-code در فرانت)
- الگوهای آماده (`policy templates`) — کاربر الگو را انتخاب و مقدار پر می‌کند
- محیط آزمایش (`dry-run`): اجرای قانون روی داده واقعی بدون ثبت هیچ چیز
- **آزمایش اجباری**: `activate` تا وقتی یک اجرای آزمایشی موفق ثبت نشده، رد می‌شود
- نمایش تاریخچهٔ نسخه‌ها با diff
**نیست:** موتور قانون (تسک ۰۹).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| POST | `/api/v1/policy/{uuid}/simulate` | اجرای آزمایشی روی نوبت‌های واقعی گذشته |
| GET | `/api/v1/policy-templates` | الگوهای آماده |
`POST /policy/{uuid}/activate` (تسک ۰۹) یک شرط جدید می‌گیرد: وجود یک `simulate` موفق
برای نسخهٔ جاری.
## معیار پذیرش
- ✅ موفق: کاربر الگوی «حداقل فاصله بین جلسات» را انتخاب می‌کند، سرویس و تعداد روز را
پر می‌کند، `simulate` می‌زند → گزارشی از ۵۰ نوبت اخیر: چند تا تحت تأثیر قرار می‌گرفتند و
دقیقاً چه تغییری می‌کردند.
- ✅ موفق: `simulate` هیچ ردیفی در دیتابیس نمی‌نویسد (به‌جز `policy_simulation_runs`).
تست باید تعداد ردیف‌های `appointments`, `price_snapshots`, `resource_occupancy` را
قبل و بعد مقایسه کند.
- ✅ موفق: بعد از `simulate` موفق، `activate` کار می‌کند.
- ✅ موفق: فرم ساخت قانون بدون هیچ تغییر کد فرانت، فیلد جدیدی که به `FieldRegistry`
اضافه شود را نشان می‌دهد.
- ❌ خطا: `activate` بدون `simulate``422` با پیام «ابتدا قانون را آزمایش کنید».
- ❌ خطا: `activate` بعد از تغییر محتوای قانون (نسخهٔ جدید) → `simulate` قبلی معتبر نیست
`422`.
- ⚠️ مرزی: محیطی که هیچ نوبت گذشته‌ای ندارد → `simulate` با گزارش خالی و
`warning: 'داده‌ای برای آزمایش نیست'` موفق شود (وگرنه کلینیک جدید هرگز نمی‌تواند
قانون فعال کند).
- ⚠️ مرزی: قانون `deny` که همهٔ ۵۰ نوبت را رد می‌کند → `simulate` موفق ولی با
`severity: 'high'` و پیام «این قانون همهٔ نوبت‌های نمونه را رد می‌کند».
- ⚠️ مرزی: `simulate` روی قانون دستهٔ `pricing` → تفاوت مبلغ per نوبت نمایش داده شود.
## خروجی
- `src/Policy/Simulation/`
- `assets/admin/pages/PoliciesPage.tsx` + `PolicyFormPage.tsx` + `PolicySimulationPage.tsx`
- `docs/api/policy.md` به‌روزرسانی