feat(policy): rule builder and mandatory dry-run sandbox

Task 09 shipped a powerful API that a non-technical clinic owner could not
safely use. This closes that gap: activation now requires having seen what the
rule actually does.

- PolicySimulator runs a policy against real past appointments and writes
  nothing: evaluation works on facts (never entities), the whole run sits in a
  transaction rolled back and cleared in `finally`, and a test counts rows in
  five sensitive tables before and after
- activate() now demands a simulation of the *same version* — a report for
  version 1 does not unlock version 2
- PolicyTemplateRegistry: six ready-made rules, so the common case never
  touches a raw condition
- Severity from the affected ratio; 0% is a warning too, since a rule that
  changes nothing usually has a condition that never matches
- An empty clinic still succeeds with a warning, otherwise a new clinic could
  never activate anything

Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a
PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a
field that exists only in the schema shows up with no frontend change, and that
operators are filtered per field type.

The schema response now carries per-field metadata (label, type, meaningful
operators) so the form has one source of truth instead of two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 10:44:28 +03:30
co-authored by Claude Opus 5
parent 56bd1b474a
commit bcfa87bfad
27 changed files with 2939 additions and 70 deletions
+136 -2
View File
@@ -41,15 +41,29 @@
**Permission:** `IS_AUTHENTICATED_FULLY`
هر دسته `label` فارسی، فهرست `fields`، همهٔ `operators`، `field_meta` (فراداده per فیلد
شامل **عملگرهای معنادار برای همان نوع**) و `effects` با برچسب و نوع مقدار دارد.
> فرم باید عملگرها را از `field_meta[].operators` بخواند نه از `operators` کلی؛ وگرنه
> کاربر `patient_tags > 5` می‌سازد و `422` می‌گیرد بدون اینکه بفهمد چرا.
### Response `200`
```json
{
"success": true,
"data": {
"selection": {
"label": "انتخاب خدمات",
"fields": ["item_count", "item_uuids", "catalog_category"],
"operators": ["equals", "not_equals", "greater_than", "less_than", "in", "contains"],
"effects": [{ "type": "forbid", "combination": "veto" }]
"field_meta": [
{ "label": "تعداد موارد انتخابی", "type": "int", "key": "item_count",
"operators": ["equals", "not_equals", "greater_than", "less_than"] },
{ "label": "موارد انتخابی", "type": "list", "key": "item_uuids", "operators": ["contains"] },
{ "label": "دستهٔ کاتالوگ", "type": "uuid", "key": "catalog_category",
"operators": ["equals", "not_equals", "in"] }
],
"effects": [{ "label": "ممنوع کن", "value_type": "none", "type": "forbid", "combination": "veto" }]
},
"eligibility": {
"fields": ["patient_age", "patient_gender", "patient_tags", "has_parental_consent", "visit_count"],
@@ -124,7 +138,9 @@
| Field | Type | Required | Description |
|---|---|---|---|
| `category` | string | | یکی از شش دسته |
| `template` | string | | کلید یک الگو از `GET /policy-templates`؛ اگر بیاید، `category`/`condition`/`effects` از الگو ساخته می‌شوند |
| `values` | object | — | مقادیر ورودی‌های همان الگو |
| `category` | string | ✅ (بدون `template`) | یکی از شش دسته |
| `name` | string | ✅ | نام قابل‌فهم؛ در پیام ممنوعیت به کاربر نشان داده می‌شود |
| `condition` | object | — | `{match: "all"\|"any", conditions: [...]}` — خالی یعنی «همیشه» |
| `condition.conditions[].field` | string | ✅ | باید در فهرست فیلدهای همان دسته باشد |
@@ -275,3 +291,121 @@
| `POST /api/v1/appointment-plan/preview` | `total_minutes` با `min_duration_minutes`/`add_duration_minutes` بزرگ می‌شود؛ نقشِ `require_resource` به اولین بخشِ حضور بیمار اضافه می‌شود |
| `POST /api/v1/appointment-hold` | `422` وقتی قانون `eligibility` بیمار را رد کند، پرچم لازم نیامده باشد، یا فاصلهٔ `spacing` رعایت نشده باشد |
| `POST /api/v1/pricing/quote` | تخفیف قوانین `pricing` به تخفیف دستی **اضافه** می‌شود و در `breakdown.sources.applied_policies` با `uuid`، `name` و `version` ثبت می‌شود |
---
## آزمایشگاه قانون (تسک ۱۰)
### GET `/api/v1/policy-templates`
الگوهای آماده. کاربر الگو را انتخاب می‌کند و فقط چند مقدار پر می‌کند؛ `condition` و
`effects` سمت سرور ساخته می‌شوند و از همان اعتبارسنجی عادی رد می‌شوند.
```json
{
"success": true,
"data": [
{
"key": "vip_discount",
"title": "تخفیف بیمار وفادار",
"description": "بیمارانی که بیش از N ویزیت داشته‌اند، درصدی تخفیف بگیرند.",
"category": "pricing",
"inputs": [
{ "key": "visit_count", "type": "int", "label": "بیشتر از چند ویزیت", "min": 1, "max": 100 },
{ "key": "percent", "type": "int", "label": "درصد تخفیف", "min": 1, "max": 100 }
]
}
]
}
```
ساخت قانون با الگو:
```json
POST /api/v1/policy
{ "name": "تخفیف مشتری وفادار", "template": "vip_discount", "values": { "visit_count": 3, "percent": 15 } }
```
الگوهای موجود: `min_days_between_sessions` · `complex_min_duration` ·
`extra_time_for_many_items` · `surgery_needs_surgeon` · `minor_needs_consent` ·
`vip_discount`.
### POST `/api/v1/policy/{uuid}/simulate`
اجرای قانون روی نوبت‌های واقعیِ گذشته، **بدون نوشتن هیچ چیز** جز خودِ نتیجه.
| Field | Type | Required | Description |
|---|---|---|---|
| `sample_size` | int | — | پیش‌فرض ۵۰، سقف ۲۰۰ |
نمونه به دامنهٔ خود قانون محدود می‌شود (شعبه/سرویس/دسته)، وگرنه «۰٪ تحت تأثیر» فقط
یعنی نمونه اشتباه بوده.
#### Response `201`
```json
{
"success": true,
"data": {
"uuid": "…",
"policy_uuid": "…",
"policy_version": 1,
"sample_size": 4,
"affected_count": 3,
"affected_percent": 75,
"severity": "high",
"created_at": 1785480121,
"rows": [
{
"appointment_uuid": "…",
"patient_name": "ز. احمدی",
"slot_start": 1785000000,
"before": "2,000,000 ریال",
"after": "1,500,000 ریال",
"reason": "500,000 ریال تخفیف"
}
],
"warning": null
}
}
```
| شدت | نسبت تحت تأثیر | معنی |
|---|---|---|
| `none` | ۰٪ | **هشدار** — شرط احتمالاً هرگز برقرار نمی‌شود |
| `low` | ۱–۲۰٪ | اثر محدود |
| `medium` | ۲۱–۶۰٪ | بخش قابل‌توجه |
| `high` | > ۶۰٪ | بیشتر نوبت‌ها؛ احتمالاً اشتباه نوشته شده |
محیطی که هیچ نوبت گذشته‌ای ندارد `201` می‌گیرد با `sample_size: 0`, `severity: "none"` و
`warning: "داده‌ای برای آزمایش نیست"` — وگرنه کلینیک تازه هرگز نمی‌توانست قانونی فعال کند.
### GET `/api/v1/policy/{uuid}/simulations`
ده اجرای آخر، جدیدترین اول.
### شرط تازهٔ `activate`
`POST /api/v1/policy/{uuid}/activate` حالا یک اجرای آزمایشیِ **همین نسخه** لازم دارد:
```json
{
"success": false,
"data": null,
"errors": [{
"code": "ERR_VALIDATION_001",
"message": "ابتدا قانون را آزمایش کنید و نتیجه را ببینید",
"field": "simulation"
}]
}
```
آزمایش نسخهٔ ۱ اجازهٔ فعال‌سازی نسخهٔ ۲ را نمی‌دهد.
### صفحه‌های پنل
| مسیر | صفحه |
|---|---|
| `/admin/policies` | فهرست قوانین |
| `/admin/policies/new` | ساخت با الگو یا حالت پیشرفته |
| `/admin/policies/{uuid}/simulate` | گزارش آزمایش + دکمهٔ فعال‌سازی |
+35 -1
View File
@@ -100,6 +100,39 @@
---
## چرا آزمایش اجباری است
بند ۱۷ مستند، ریسک دوم: «کاربر غیرفنی نمی‌تواند قانون درست تعریف کند → قانون‌های اشتباه،
رفتار عجیب». موتور قانون بدون آزمایشگاه یک API قدرتمند است که هیچ‌کس نمی‌تواند درست از
آن استفاده کند.
پس `activate` یک شرط دارد: یک اجرای آزمایشیِ **همین نسخه** باید ثبت شده باشد. آزمایش
قانون را روی نوبت‌های واقعیِ گذشته اجرا می‌کند و می‌گوید چند نوبت تغییر می‌کردند و دقیقاً
چه تغییری. عددِ «۷۵٪ نوبت‌ها رد می‌شدند» چیزی است که کاربر غیرفنی هم می‌فهمد.
نسخه‌محور بودن شرط عمدی است: کاربری که گزارش را دید و بعد متن قانون را عوض کرد، دیگر
گزارشی از قانونِ فعلی ندارد.
### هیچ چیز ثبت نمی‌شود — سه لایه
۱. ارزیابی روی **حقایق** انجام می‌شود نه روی entity؛ هیچ entity ای تغییر نمی‌کند.
۲. کل اجرا در تراکنشی است که در `finally` همیشه `rollback` و `clear` می‌شود. `clear`
اختیاری نیست: entity های لمس‌شده در identity map می‌مانند و اولین `flush` بعدی در
همان request ثبتشان می‌کند — باگی که پیدا کردنش روزها می‌برد.
۳. `PolicySimulationTest::testSimulationWritesNothingButItsOwnRun` تعداد ردیف جدول‌های
حساس را قبل و بعد می‌شمارد.
خودِ `PolicySimulationRun` **بعد** از این بلوک و در تراکنش خودش ثبت می‌شود.
### دو حالتِ مرزی که عمداً موفق‌اند
- **محیط بدون نوبت گذشته** → گزارش خالی با `warning`. اگر خطا بود، کلینیک تازه هرگز
نمی‌توانست قانونی فعال کند.
- **قانونی که هیچ نوبتی را تغییر نمی‌دهد** → موفق ولی با شدت `none`، که خودش هشدار
است: شرط احتمالاً هرگز برقرار نمی‌شود.
---
## تصمیم‌های ثبت‌شده و انحراف‌ها
| موضوع | تصمیم | دلیل |
@@ -107,5 +140,6 @@
| یک `PolicyResolver` به‌جای شش موتور جدا | یک resolver + یک نقطهٔ اجرا در هر سرویس مقصد | شش کلاس با همان بدنه فقط تکرار بود؛ تفاوت واقعی در حقایق است که هر نقطه خودش می‌سازد |
| `spacing` در لحظهٔ رزرو موقت، نه در تولید کاندید | رد کردن هنگام `hold` | نگه داشتن تعداد کوئریِ `AvailabilityEngine` ثابت؛ **هزینه‌اش** این است که اسلات نمایش داده می‌شود و بعد رد؛ بستنِ آن در تولید کاندید به تسک ۱۳ موکول شد |
| `specificity` هنگام اجرا حساب می‌شود | متد `Policy::specificity()` | ستون ذخیره‌شده باید با تغییر دامنه هم‌زمان به‌روز بماند؛ محاسبهٔ درجا سه مقایسهٔ صحیح است |
| `appointments.applied_policies` ساخته نشد | فعلاً `PriceSnapshot.sources.applied_policies` | نوبت‌های بدون فاکتور هنوز ردپای قانون ندارند — تسک ۱۰ |
| `appointments.applied_policies` ساخته نشد | فعلاً `PriceSnapshot.sources.applied_policies` | نوبت‌های بدون فاکتور هنوز ردپای قانون ندارند — تسک ۱۴ (رویدادها) |
| یک `PolicyResolver::evaluateOne()` به‌جای `evaluateIsolated()` روی شش موتور | همان resolver، بدون رقابت و ترکیب | شش موتور جدایی وجود ندارد که متد بگیرد؛ رفتار همان است |
| عملگر `days_since` اضافه نشد | `min_days_between` مستقیم فاصله را می‌سنجد | تنها مصرفش همان دستهٔ `spacing` بود؛ عملگری که یک مصرف دارد، اثر است نه عملگر |
@@ -1,6 +1,6 @@
# چک‌لیست — تسک ۱۰ (فرم ساخت قانون و محیط آزمایش)
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:**
**وضعیت کلی:** ✅ تمام‌شده با انحراف‌های ثبت‌شده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
@@ -11,92 +11,95 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | شبیه‌سازی **هیچ ردیفی** نمی‌نویسد (جز `policy_simulation_runs`) | | ⭐⭐ با شمارش ردیف اثبات شود |
| ۰.۳ | نوبت‌های واقعی بیماران در شبیه‌سازی تغییر نکردند | | |
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | شبیه‌سازی **هیچ ردیفی** نمی‌نویسد (جز `policy_simulation_runs`) | | ⭐⭐ `testSimulationWritesNothingButItsOwnRun` پنج جدول را قبل/بعد می‌شمارد |
| ۰.۳ | نوبت‌های واقعی بیماران تغییر نکردند | | ارزیابی روی حقایق است، نه entity |
## ۱. بک‌اند — شبیه‌ساز
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `PolicySimulator` · `SimulationSampler` · `PolicySimulationRun` | | |
| ۱.۲ | سه لایهٔ تضمین: DTO · تراکنش با rollback در `finally` · تست شمارش | ⏳ | ⭐ |
| ۱.۳ | `$this->em->clear()` بعد از rollback | | ⭐ وگرنه entity کثیف در identity map |
| ۱.۴ | `PolicySimulationRun` **بعد از** rollback و در تراکنش جدا ثبت می‌شود | | |
| ۱.۵ | `evaluateIsolated()` — فقط همان قانون، بدون `Resolver` و `Combiner` | ⏳ | |
| ۱.۶ | فیلتر شعبه و سرویس از **خودِ شرط قانون** استخراج می‌شود | ⏳ | وگرنه «۰٪ تحت تأثیر» گمراه‌کننده |
| ۱.۷ | سقف نمونه ۵۰؛ درخواست بیشتر → ۴۲۲ | | |
| ۱.۸ | `PolicyTemplateRegistry` با پنج الگو | | |
| ۱.۹ | `activate` شرط `simulate` **همان نسخه** را می‌سنجد | | ⭐ نسخهٔ ۱ اجازهٔ نسخهٔ ۲ نمی‌دهد |
| ۱.۱۰ | محیط بدون نوبت → `simulate` خالی موفق، `activate` مجاز | | ⭐ کلینیک جدید قفل نشود |
| ۱.۱۱ | چهار سطح شدت با آستانه‌های مستند | | |
| ۱.۱۲ | دو endpoint | | |
| ۱.۱۳ | `app:policy:prune-simulations` — آخرین اجرا per (policy, version) هرگز حذف نمی‌شود | | `activate` به آن وابسته است |
| ۱.۱ | `PolicySimulator` · `SimulationSampler` · `PolicySimulationRun` | | به‌علاوهٔ `SimulationFacts` |
| ۱.۲ | سه لایهٔ تضمین | ✅ | ⭐ حقایق (نه entity) · تراکنش با rollback در `finally` · تست شمارش |
| ۱.۳ | `$this->em->clear()` بعد از rollback | | ⭐ قانون بعد از `clear` دوباره خوانده می‌شود |
| ۱.۴ | ثبت نتیجه **بعد از** rollback و در تراکنش جدا | | |
| ۱.۵ | ارزیابی جدا — فقط همان قانون | ⚠️ | `PolicyResolver::evaluateOne()` به‌جای `evaluateIsolated()` روی شش موتور؛ شش موتوری وجود ندارد که متد بگیرد (انحراف تسک ۰۹) |
| ۱.۶ | فیلتر شعبه/سرویس/دسته از دامنهٔ قانون | ⚠️ | از **دامنهٔ** قانون استخراج می‌شود، نه از داخل `condition`؛ شرط‌ها فیلدِ id ندارند که به کوئری تبدیل شوند |
| ۱.۷ | سقف نمونه ۵۰؛ درخواست بیشتر → ۴۲۲ | | `testSampleSizeAboveTheCapIsRejected` |
| ۱.۸ | `PolicyTemplateRegistry` با پنج الگو | | شش الگو |
| ۱.۹ | `activate` شرط `simulate` **همان نسخه** | | ⭐ `testSimulationOfTheOldVersionDoesNotUnlockTheNewOne` |
| ۱.۱۰ | محیط بدون نوبت → آزمایش خالی موفق، فعال‌سازی مجاز | | ⭐ `warning: داده‌ای برای آزمایش نیست` |
| ۱.۱۱ | چهار سطح شدت با آستانه‌های مستند | | `PolicySimulationRun::severityFor()` — ۰٪ · ≤۲۰٪ · ≤۶۰٪ · >۶۰٪ |
| ۱.۱۲ | دو endpoint | | سه تا: `policy-templates` · `simulate` · `simulations` |
| ۱.۱۳ | `app:policy:prune-simulations` — آخرین اجرا per (قانون، نسخه) حذف نمی‌شود | | `--days` و `--dry-run`؛ SQL با `MAX(id) GROUP BY policy_id, policy_version` |
## ۲. دیتابیس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | `policy_simulation_runs` با جفت tenant | | |
| ۲.۲ | `report` سقف ۵۰ ردیف | | |
| ۲.۳ | `TenantSchemaCoverageTest` سبز | | |
| ۲.۱ | `policy_simulation_runs` با جفت tenant | | `Version20260731065427` + دو ایندکس |
| ۲.۲ | `report` سقف ۵۰ ردیف | | از سقف نمونه می‌آید: بیش از ۵۰ نوبت اصلاً خوانده نمی‌شود |
| ۲.۳ | `TenantSchemaCoverageTest` سبز | | |
## ۳. UI
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `PoliciesPage` · `PolicyFormPage` · `PolicySimulationPage` | | |
| ۳.۲ | `PolicyConditionBuilder` **از `GET /policy-schema`** ساخته می‌شود | ⏳ | ⭐ هیچ فیلد hard-code |
| ۳.۳ | عملگرها per فیلد **فیلتر** می‌شوند، نه همه | | ⭐ وگرنه ۴۲۲ بی‌توضیح |
| ۳.۴ | نوع ورودی مقدار از `schema.fields[f].type` | ⏳ | |
| ۳.۵ | همهٔ select ها `SearchableSelect`؛ هیچ `<select>` بومی | ⏳ | |
| ۳.۶ | انتخاب الگو → فرم کوتاه مقدارها (مسیر ۹۰٪ کاربران) | ⏳ | |
| ۳.۷ | ستون «وضعیت فعلی با این قانون» در گزارش | | ⭐ تنها چیزی که کاربر غیرفنی می‌فهمد |
| ۳.۸ | درصد تحت تأثیر + سطح شدت با رنگ توکن‌محور | | |
| ۳.۹ | شدت `none` هم هشدار می‌دهد، با متن دو‌حالتی | ⏳ | |
| ۳.۱۰ | شدت `high`متن «مطمئنید؟» روی دکمهٔ فعال‌سازی | | |
| ۳.۱۱ | `ConfirmDialog` موجود برای فعال‌سازی | | نه مودال دست‌ساز |
| ۳.۱۲ | `DataTable` برای لیست قوانین با فیلتر دسته/وضعیت در URL | | |
| ۳.۱۳ | `backTo`/`BackButton` روی هر سه صفحه | | |
| ۳.۱۴ | هیچ رنگ/شعاع hard-code — رنگ‌های شدت هم از توکن وضعیت | | `--warning` `--danger` `--success` |
| ۳.۱۵ | دارک‌مود و حالت فشرده | ⏳ | |
| ۳.۱۶ | RTL و موبایل — جدول گزارش اسکرول افقی داخلی | | |
| ۳.۱۷ | تاریخ‌ها شمسی | | |
| ۳.۱۸ | همهٔ رشته‌ها فارسی | | |
| ۳.۱۹ | نمایش تاریخچهٔ نسخه‌ها با diff | ⏳ | |
| ۳.۱ | `PoliciesPage` · `PolicyFormPage` · `PolicySimulationPage` | | + ورودی «قوانین» در منوی تنظیمات |
| ۳.۲ | `PolicyConditionBuilder` از `GET /policy-schema` | ✅ | ⭐ تست با فیلد ساختگی: بدون تغییر فرانت در UI ظاهر می‌شود |
| ۳.۳ | عملگرها per فیلد فیلتر می‌شوند | | ⭐ `field_meta[].operators` — تست جداگانه |
| ۳.۴ | نوع ورودی مقدار از schema | ✅ | int · enum · bool · list |
| ۳.۵ | همهٔ selectها `SearchableSelect` | ✅ | هیچ `<select>` بومی |
| ۳.۶ | انتخاب الگو → فرم کوتاه مقدارها | ✅ | حالت پیش‌فرض صفحه همین است |
| ۳.۷ | ستون «وضعیت فعلی با این قانون» | | ⭐ |
| ۳.۸ | درصد + شدت با رنگ توکن‌محور | | کلاس‌های `badge green/amber/red` موجود |
| ۳.۹ | شدت `none` هشدار می‌دهد | ✅ | «احتمالاً شرطش هرگز برقرار نمی‌شود» |
| ۳.۱۰ | شدت `high`تأیید دوم | | |
| ۳.۱۱ | `ConfirmDialog` موجود | | نه `window.confirm` |
| ۳.۱۲ | `DataTable` + فیلتر دسته در URL | | `useUrlState` |
| ۳.۱۳ | `backTo` روی هر سه صفحه | | |
| ۳.۱۴ | هیچ رنگ/شعاع hard-code | | فقط `var(--…)` |
| ۳.۱۵ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌های موجود استفاده شده؛ بازبینی چشمی انجام نشد |
| ۳.۱۶ | RTL و موبایل — اسکرول افقی جدول | | `overflow-x: auto` دور جدول گزارش |
| ۳.۱۷ | تاریخ‌ها شمسی | | `formatDate` |
| ۳.۱۸ | همهٔ رشته‌ها فارسی | | |
| ۳.۱۹ | تاریخچهٔ نسخه‌ها با diff | ⚠️ | فهرست نسخه‌ها با اثرهای هر نسخه نمایش داده می‌شود؛ diff بصری واقعی نیست |
## ۴. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `PolicySimulatorTest` شمارش ردیف قبل/بعد | | ⭐⭐ |
| ۴.۲ | `PolicySimulatorTest` — استثنا در `evaluateIsolated` → rollback + clear | ⏳ | |
| ۴.۳ | `SimulationSamplerTest` — استخراج فیلتر، فقط confirmed/completed، سقف ۵۰ | | |
| ۴.۴ | `PolicyActivationGuardTest` — چهار حالت | | ⭐ |
| ۴.۵ | `PolicyTemplateTest` — هر الگو قانون معتبر تولید می‌کند (dataProvider) | ⏳ | ⭐ |
| ۴.۶ | `SeverityTest` — چهار آستانه | ⏳ | |
| ۴.۷ | `PolicyFormPage.test.tsx` فیلد ساختگی از mock schema در UI ظاهر می‌شود | | ⭐ |
| ۴.۸ | `PolicyFormPage.test.tsx` عملگر نامعتبر برای نوع نمایش داده نمی‌شود | | |
| ۴.۱ | شمارش ردیف قبل/بعد | | ⭐⭐ |
| ۴.۲ | استثنا → rollback + clear | ⚠️ | `finally` تضمینش می‌کند ولی تست تزریق استثنا نوشته نشد |
| ۴.۳ | نمونه‌گیری — فقط confirmed/completed، سقف | | سقف تست شد؛ فیلتر وضعیت غیرمستقیم (نوبت‌های نمونه completed اند) |
| ۴.۴ | دروازهٔ فعال‌سازی | | ⭐ بدون آزمایش، نسخهٔ قدیمی، نسخهٔ درست، محیط خالی |
| ۴.۵ | هر الگو قانون معتبر می‌سازد | ⚠️ | یک الگو کامل تست شد (`vip_discount`) + ورودی ناقص؛ dataProvider شش‌تایی نوشته نشد |
| ۴.۶ | چهار آستانهٔ شدت | ⚠️ | `high` و `none` تست شدند؛ `low`/`medium` نه |
| ۴.۷ | فیلد ساختگی از mock schema در UI | | ⭐ `PolicyFormPage.test.tsx` |
| ۴.۸ | عملگر نامعتبر نمایش داده نمی‌شود | | |
| ۴.۹ | صفحهٔ آزمایش — قفل فعال‌سازی روی نسخهٔ قدیمی | ✅ | `PolicySimulationPage.test.tsx` |
**اجرا:** `ddev exec php bin/phpunit tests/Policy` → ۳۰ تست · `npx vitest run` → ۶۲۸ تست.
## ۵. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `docs/api/policy.md``simulate`، `policy-templates`، شرط جدید `activate` | | |
| ۵.۲ | `docs/architecture/policy-engine.md` بخش «چرا آزمایش اجباری است» | | ارجاع به ریسک دوم مستند |
| ۵.۱ | `docs/api/policy.md``simulate`، `policy-templates`، شرط تازهٔ `activate` | | JSON واقعی |
| ۵.۲ | `policy-engine.md` بخش «چرا آزمایش اجباری است» | | + سه لایهٔ تضمین و دو حالت مرزی |
## ۶. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | |
| ۶.۲ | `bin/phpunit` کامل سبز | | |
| ۶.۳ | `--group=slot-mode-frozen` سبز | | |
| ۶.۴ | `phpstan` بدون خطای جدید | | |
| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
| ۶.۶ | تست‌های tenant سبز | | |
| ۶.۷ | `docs/api/*` به‌روز | | |
| ۶.۸ | چک‌لیست UI کامل | | |
| ۶.۹ | دو کلاینت دیگر بررسی شدند | | این تسک قرارداد عمومی عوض نمی‌کند |
| ۶.۱۰ | commit، سپس `graphify update .` | | |
| ۶.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | ⏳ | |
| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | ۷ مورد ⚠️ همه با دلیل |
| ۶.۲ | `bin/phpunit` کامل سبز | | ۱۲۵۰ تست |
| ۶.۳ | `--group=slot-mode-frozen` سبز | | |
| ۶.۴ | `phpstan` بدون خطای جدید | | ۱۴ = baseline |
| ۶.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۲۸ تست؛ **`yarn test` داخل ddev اجرا نمی‌شود** (باینری esbuild برای darwin نصب شده) — روی هاست اجرا شد |
| ۶.۶ | تست‌های tenant سبز | | |
| ۶.۷ | `docs/api/*` به‌روز | | |
| ۶.۸ | چک‌لیست UI کامل | | جز ۳.۱۵ و ۳.۱۹ |
| ۶.۹ | دو کلاینت دیگر بررسی شدند | | این تسک هیچ قرارداد عمومی‌ای عوض نکرد؛ همهٔ اندپوینت‌ها پنل‌محورند |
| ۶.۱۰ | commit، سپس `graphify update .` | | دو کامیت جدا |
| ۶.۱۱ | موارد به‌تعویق با دلیل | ✅ | ۳.۱۵ (بازبینی چشمی) · ۳.۱۹ (diff بصری) · ۴.۲/۴.۵/۴.۶ (پوشش تست) — همه در همین فایل ثبت‌اند |