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
@@ -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 بصری) · ۴.۲/۴.۵/۴.۶ (پوشش تست) — همه در همین فایل ثبت‌اند |