feat(policy): six-category policy engine wired into the booking flow

Rules become data instead of code: a clinic can say "laser under 18 requires
parental consent" without a deploy.

Engine
- Policy / PolicyVersionLog entities, closed field/operator/effect lists per
  category (PolicySchema), condition validation at write time
- PolicyResolver: priority -> specificity -> age, combining effects by
  veto / max / sum / union
- A missing fact fails its clause instead of silently passing it
- Policies are drafts until activated, and are versioned rather than edited

Wiring
- selection -> ServiceSelectionValidator
- eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time
- resource + timing -> AppointmentPlanBuilder, including template-less services
- pricing -> PricingEngine, alongside (not replacing) the manual discount

The condition column is named condition_json: `condition` is a MariaDB keyword
and broke every INSERT.

Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a
clinic with no policies sees byte-identical output to task 08.
Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 10:19:19 +03:30
co-authored by Claude Opus 5
parent 281420ab4d
commit 584ea4067f
26 changed files with 2870 additions and 86 deletions
+17
View File
@@ -157,3 +157,20 @@ ddev exec php bin/phpunit tests/Appointment/HoldAndBookTest.php # ۱۲ تست
دو تست از همه مهم‌ترند: رزرو دوم روی همان منبع و بازه که `409` می‌گیرد، و تستی که
**مستقیم روی یک اتصال جدا** ردیف تکراری می‌نویسد و انتظار نقض کلید یکتا دارد — اگر آن
یکی بشکند، یعنی تضمین فقط در کد بوده است.
---
## قوانین وابسته به بیمار
`POST /api/v1/appointment-hold` پیش از گرفتن صندلی دو دسته را اجرا می‌کند:
| دسته | خطا | معنی |
|---|---|---|
| `eligibility` | `ERR_VALIDATION_001` / `422` | قانونی این بیمار را برای این خدمت رد کرده |
| `eligibility` (`require_flag`) | `ERR_VALIDATION_002` / `422` | پرچمی مثل `has_parental_consent` در بدنه نیامده |
| `spacing` | `ERR_VALIDATION_001` / `422` | فاصله تا نوبت قبلیِ همان دستهٔ کاتالوگ کمتر از `min_days_between` است |
جای اجرا عمداً لحظهٔ رزرو موقت است نه ثبت نهایی: شنیدن «واجد شرایط نیستید» بعد از ده
دقیقه نگه‌داشتن صندلی، هم وقت بیمار را تلف می‌کند هم صندلی را.
جزئیات: [policy.md](policy.md)
+14
View File
@@ -134,3 +134,17 @@
```bash
ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php # ۱۱ تست
```
---
## اثر موتور قوانین
- دستهٔ `timing`: `min_duration_minutes` (بیشترین برنده) و `add_duration_minutes` (جمع)
روی **مجموع** نوبت اعمال می‌شوند؛ رشدِ لازم به **آخرین** بخش می‌چسبد تا آفست بخش‌های
قبلی جابه‌جا نشود.
- دستهٔ `resource`: نقشی که `require_resource` می‌خواهد، اگر هیچ بخشی نداشته باشد، به
**اولین بخشی که بیمار حاضر است** اضافه می‌شود. نقش ناشناخته یا بی‌منبع `422` می‌دهد، نه
بی‌اثر ماندن.
- هر دو روی سرویسِ **بی‌الگو** هم اجرا می‌شوند.
جزئیات: [policy.md](policy.md)
+16
View File
@@ -504,3 +504,19 @@ override فقط وقتی اعمال می‌شود که `branch_uuid` به `valid
```bash
ddev exec php bin/phpunit tests/ClinicService # ۵۲ تست
```
---
## قوانین دستهٔ «انتخاب»
`POST /api/v1/service-selection/validate` علاوه بر گروه و رابطه، ممنوعیت‌های دستهٔ
`selection` را هم برمی‌گرداند:
```json
{ "code": "policy_forbidden", "items": ["<service-uuid>"], "message": "این خدمت موقتاً متوقف است" }
```
گروه و رابطه ساختار ثابت کاتالوگ‌اند؛ قانون چیزی است که کلینیک بدون دست زدن به کاتالوگ
روشن و خاموش می‌کند. اجرا فقط وقتی است که `branch_uuid` بیاید — محیط از شعبه می‌آید.
جزئیات: [policy.md](policy.md)
+277
View File
@@ -0,0 +1,277 @@
# Policy — موتور قوانین شش‌دسته‌ای
اندپوینت‌های `src/Policy/*`. قوانین کلینیک را بدون تغییر کد تعریف می‌کنند: چه چیزی
انتخاب‌شدنی است، چه کسی واجد شرایط است، چه منبعی لازم است، چقدر طول می‌کشد، چه فاصله‌ای
بین جلسات باشد، و چه تخفیفی بخورد.
همهٔ مسیرها `IS_AUTHENTICATED_FULLY` می‌خواهند و به **محیط جاری** کاربر
(`entity_type`,`entity_id`) محدودند؛ قانون محیط دیگر `404` می‌دهد نه `403`.
---
## شش دسته و اثرهایشان
| دسته | فیلدهای مجاز | اثرهای مجاز | ترکیب | جای اجرا |
|---|---|---|---|---|
| `selection` | `item_count`, `item_uuids`, `catalog_category` | `forbid` | veto | `POST /api/v1/service-selection/validate` |
| `eligibility` | `patient_age`, `patient_gender`, `patient_tags`, `has_parental_consent`, `visit_count` | `forbid`, `require_flag` | veto / union | `POST /api/v1/appointment-hold` |
| `resource` | `catalog_category`, `service_uuid`, `item_count` | `require_resource`, `forbid` | union / veto | ساخت برنامهٔ نوبت |
| `timing` | `catalog_category`, `service_uuid`, `item_count`, `patient_age` | `min_duration_minutes`, `add_duration_minutes` | max / sum | ساخت برنامهٔ نوبت |
| `spacing` | `catalog_category`, `service_uuid` | `min_days_between` | max | `POST /api/v1/appointment-hold` |
| `pricing` | `patient_tags`, `visit_count`, `item_count`, `subtotal_rials` | `discount_percent`, `discount_rials` | sum | `POST /api/v1/pricing/quote` |
عملگرها (برای همهٔ دسته‌ها یکسان): `equals`, `not_equals`, `greater_than`, `less_than`,
`in`, `contains`.
**ترتیب حل تناقض:** اولویت بزرگ‌تر ← اختصاصی‌تر (شعبه ۴، سرویس ۲، دسته ۱) ← قانون
قدیمی‌تر. یک `forbid` کل عملیات را رد می‌کند حتی اگر ده قانون مجازکننده باشند.
**قانون تازه پیش‌نویس است** (`active: false`) و تا `activate` نشود اجرا نمی‌شود.
**قانون ویرایش نمی‌شود**؛ `POST /policy/{uuid}/version` نسخهٔ تازه می‌سازد و نسخهٔ قبلی در
`policy_version_logs` می‌ماند. شمارهٔ نسخه در فاکتور ثبت می‌شود
(`breakdown.sources.applied_policies[].version`).
---
## GET `/api/v1/policy-schema`
فهرست بستهٔ فیلدها، عملگرها و اثرها به تفکیک دسته. فرم ساخت قانون در پنل باید از همین
ساخته شود، نه از فهرستی که در فرانت دوباره نوشته شده باشد.
**Permission:** `IS_AUTHENTICATED_FULLY`
### Response `200`
```json
{
"success": true,
"data": {
"selection": {
"fields": ["item_count", "item_uuids", "catalog_category"],
"operators": ["equals", "not_equals", "greater_than", "less_than", "in", "contains"],
"effects": [{ "type": "forbid", "combination": "veto" }]
},
"eligibility": {
"fields": ["patient_age", "patient_gender", "patient_tags", "has_parental_consent", "visit_count"],
"operators": ["equals", "not_equals", "greater_than", "less_than", "in", "contains"],
"effects": [
{ "type": "forbid", "combination": "veto" },
{ "type": "require_flag", "combination": "union" }
]
},
"resource": { "fields": ["catalog_category", "service_uuid", "item_count"], "operators": ["…"], "effects": [{ "type": "require_resource", "combination": "union" }, { "type": "forbid", "combination": "veto" }] },
"timing": { "fields": ["catalog_category", "service_uuid", "item_count", "patient_age"], "operators": ["…"], "effects": [{ "type": "min_duration_minutes", "combination": "max" }, { "type": "add_duration_minutes", "combination": "sum" }] },
"spacing": { "fields": ["catalog_category", "service_uuid"], "operators": ["…"], "effects": [{ "type": "min_days_between", "combination": "max" }] },
"pricing": { "fields": ["patient_tags", "visit_count", "item_count", "subtotal_rials"], "operators": ["…"], "effects": [{ "type": "discount_percent", "combination": "sum" }, { "type": "discount_rials", "combination": "sum" }] }
}
}
```
---
## GET `/api/v1/policies`
فهرست قوانین محیط جاری.
| Query | Type | Description |
|---|---|---|
| `category` | string | یکی از شش دسته؛ نامعتبر → `422` |
### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "f2e97553-835d-41c9-b465-49107821d7d2",
"category": "timing",
"name": "حداقل یک ساعت برای لیزر",
"condition": { "match": "all", "conditions": [{ "field": "item_count", "operator": "greater_than", "value": 1 }] },
"effects": [{ "type": "min_duration_minutes", "value": 90 }],
"priority": 10,
"version": 2,
"active": true,
"valid_from": null,
"valid_to": null,
"address_uuid": null,
"service_uuid": null,
"catalog_category_uuid": null,
"specificity": 0
}
]
}
```
---
## POST `/api/v1/policy`
ساخت قانون. خروجی همیشه `active: false` است.
### Request Body
```json
{
"category": "timing",
"name": "حداقل یک ساعت برای لیزر",
"priority": 10,
"condition": {
"match": "all",
"conditions": [{ "field": "item_count", "operator": "greater_than", "value": 1 }]
},
"effects": [{ "type": "min_duration_minutes", "value": 60 }]
}
```
| Field | Type | Required | Description |
|---|---|---|---|
| `category` | string | ✅ | یکی از شش دسته |
| `name` | string | ✅ | نام قابل‌فهم؛ در پیام ممنوعیت به کاربر نشان داده می‌شود |
| `condition` | object | — | `{match: "all"\|"any", conditions: [...]}` — خالی یعنی «همیشه» |
| `condition.conditions[].field` | string | ✅ | باید در فهرست فیلدهای همان دسته باشد |
| `condition.conditions[].operator` | string | — | پیش‌فرض `equals` |
| `condition.conditions[].value` | mixed | — | برای `in`/`contains` آرایه |
| `effects` | array | — | حداقل یک اثر؛ هر اثر `{type, value}` و برای `forbid` می‌تواند `reason` داشته باشد |
| `priority` | int | — | پیش‌فرض `0`؛ بزرگ‌تر برنده |
| `valid_from` / `valid_to` | int\|null | — | Unix؛ `valid_to` باید بعد از `valid_from` باشد |
| `address_uuid` | string\|null | — | محدود کردن به یک شعبه (اختصاصی‌بودن ۴) |
| `service_uuid` | string\|null | — | محدود کردن به یک سرویس (۲) |
| `catalog_category_uuid` | string\|null | — | محدود کردن به یک دستهٔ کاتالوگ (۱) |
### Response `201`
```json
{
"success": true,
"data": {
"uuid": "f2e97553-835d-41c9-b465-49107821d7d2",
"category": "timing",
"name": "حداقل یک ساعت برای لیزر",
"condition": { "match": "all", "conditions": [{ "field": "item_count", "operator": "greater_than", "value": 1 }] },
"effects": [{ "type": "min_duration_minutes", "value": 60 }],
"priority": 10,
"version": 1,
"active": false,
"valid_from": null,
"valid_to": null,
"address_uuid": null,
"service_uuid": null,
"catalog_category_uuid": null,
"specificity": 0
}
}
```
### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_001` | 422 | دستهٔ نامعتبر، فیلد/عملگر/اثر خارج از فهرست دسته، کلید ناشناس در `condition`، `valid_to` قبل از `valid_from` |
| `ERR_VALIDATION_002` | 422 | نام خالی یا `effects` خالی |
| `ERR_NOT_FOUND_001` | 404 | `address_uuid` / `service_uuid` / `catalog_category_uuid` خارج از محیط جاری |
فیلد خارج از دسته:
```json
{
"success": false,
"data": null,
"errors": [{
"code": "ERR_VALIDATION_001",
"message": "فیلد «subtotal_rials» برای دستهٔ «timing» مجاز نیست. مجازها: catalog_category، service_uuid، item_count، patient_age",
"field": "condition"
}]
}
```
اثر خارج از دسته:
```json
{
"success": false,
"data": null,
"errors": [{
"code": "ERR_VALIDATION_001",
"message": "اثر «discount_percent» با دستهٔ «timing» سازگار نیست. مجازها: min_duration_minutes، add_duration_minutes",
"field": "effects"
}]
}
```
> کلید ناشناس در ریشهٔ `condition` (مثلاً `{"all": [...]}` به‌جای
> `{"match": "all", "conditions": [...]}`) هم `422` می‌گیرد — چون شرطِ خالی «همیشه صادق»
> است و قانون بی‌سروصدا روی همه‌چیز اجرا می‌شد.
---
## GET `/api/v1/policy/{uuid}`
قانون به‌همراه **همهٔ نسخه‌هایش**.
### Response `200`
```json
{
"success": true,
"data": {
"uuid": "f2e97553-835d-41c9-b465-49107821d7d2",
"category": "timing",
"name": "حداقل یک ساعت برای لیزر",
"condition": { "match": "all", "conditions": [{ "field": "item_count", "operator": "greater_than", "value": 1 }] },
"effects": [{ "type": "min_duration_minutes", "value": 90 }],
"priority": 10,
"version": 2,
"active": true,
"valid_from": null,
"valid_to": null,
"address_uuid": null,
"service_uuid": null,
"catalog_category_uuid": null,
"specificity": 0,
"versions": [
{ "version": 1, "snapshot": { "…": "متن کامل نسخهٔ ۱" }, "created_at": 1785480121 },
{ "version": 2, "snapshot": { "…": "متن کامل نسخهٔ ۲" }, "created_at": 1785480121 }
]
}
}
```
### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_NOT_FOUND_001` | 404 | قانون نیست یا متعلق به محیط دیگری است |
---
## POST `/api/v1/policy/{uuid}/version`
نسخهٔ تازه. بدنه همان فیلدهای `POST /policy` است (هرچه بفرستید جایگزین می‌شود، بقیه
دست‌نخورده می‌ماند). `version` یکی بالا می‌رود و متن قبلی در تاریخچه می‌ماند.
### Response `200`
```json
{
"success": true,
"data": {
"uuid": "f2e97553-835d-41c9-b465-49107821d7d2",
"effects": [{ "type": "min_duration_minutes", "value": 90 }],
"version": 2,
"active": true,
"…": "بقیهٔ فیلدها مثل GET"
}
}
```
---
## POST `/api/v1/policy/{uuid}/activate` · `/deactivate`
روشن و خاموش کردن. قانون خاموش در هیچ محاسبه‌ای شرکت نمی‌کند.
### Response `200`
همان بدنهٔ قانون با `active` به‌روزشده.
---
## اثر قوانین روی اندپوینت‌های دیگر
| اندپوینت | چه تغییری می‌بینید |
|---|---|
| `POST /api/v1/service-selection/validate` | خطای `policy_forbidden` در `errors[]` وقتی قانون `selection` انتخاب را رد کند |
| `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` ثبت می‌شود |
+19
View File
@@ -135,3 +135,22 @@ ddev exec php bin/phpunit tests/Pricing # ۱۲ تست
مهم‌ترینش `testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange` است: نوبت ثبت
می‌شود، قیمت سرویس دو برابر می‌شود، `quote` عدد جدید می‌دهد و فاکتور نوبت **همان عدد
قبلی** را. بدون آن، قانون پنجم فقط یک ادعاست.
---
## قوانین دستهٔ «قیمت»
تخفیفی که موتور قوانین می‌دهد **کنار** تخفیف دستیِ درخواست می‌نشیند نه به‌جایش، و
شناسه و نسخهٔ هر قانون در `breakdown.sources.applied_policies` ثبت می‌شود:
```json
"breakdown": {
"sources": {
"applied_policies": [
{ "uuid": "…", "name": "تخفیف همین سرویس", "version": 2 }
]
}
}
```
جزئیات دسته‌ها و اثرها: [policy.md](policy.md)
+111
View File
@@ -0,0 +1,111 @@
# موتور قوانین
قوانین کلینیک را **داده** می‌کند، نه کد. یک کلینیک می‌تواند بگوید «لیزر زیر ۱۸ سال بدون
رضایت والدین ممنوع» بدون اینکه کسی چیزی deploy کند.
مرجع: بند ۸ مستند طراحی. پیادهٔ اندپوینت‌ها: [../api/policy.md](../api/policy.md)
---
## شش دسته، شش نقطهٔ اجرا
| دسته | کجا اجرا می‌شود | چه چیزی را عوض می‌کند |
|---|---|---|
| `selection` | `ServiceSelectionValidator` | خطای `policy_forbidden` در نتیجهٔ اعتبارسنجی |
| `eligibility` | `BookingPolicyGuard` (لحظهٔ رزرو موقت) | `422` یا الزام یک پرچم |
| `resource` | `AppointmentPlanBuilder` | نقش لازم به بخشِ حضور بیمار اضافه می‌شود |
| `timing` | `AppointmentPlanBuilder` | مجموع مدت نوبت |
| `spacing` | `BookingPolicyGuard` (لحظهٔ رزرو موقت) | رد رزرو وقتی فاصله تا جلسهٔ قبلی کم است |
| `pricing` | `PricingEngine` | تخفیف، کنارِ تخفیف دستی نه به‌جایش |
هر شش دسته از یک `PolicyResolver` مشترک عبور می‌کنند؛ تفاوتشان در **حقایقی** است که
هر نقطه می‌سازد و در **اثرهایی** که می‌خواند.
---
## چرا کد دلخواه ممنوع است
شرط قانون یک عبارت است، نه یک اسکریپت: فیلد باید در فهرست بستهٔ `PolicySchema::FIELDS`
همان دسته باشد و عملگر یکی از شش عملگر ثابت. دلیلش سه چیز است:
۱. **امنیت** — اجرای رشتهٔ کاربر روی سرور، هر «تنظیمات» را به RCE تبدیل می‌کند.
۲. **پیش‌بینی‌پذیری** — عبارت بسته را می‌شود قبل از ذخیره اعتبارسنجی کرد؛ کد دلخواه فقط
موقع اجرا می‌ترکد، یعنی وسط رزرو بیمار.
۳. **قابلیت توضیح** — پنل باید بتواند بگوید «چرا رد شد»؛ از یک عبارت بسته می‌شود، از یک
تابع دلخواه نمی‌شود.
به همین دلیل شرط **تودرتو** هم نیست: فقط یک سطح `match: all|any`. تودرتویی یعنی فرم
درخت‌ساز، و درختی که کاربر نمی‌فهمد قانونی است که کسی جرأت خاموش کردنش را ندارد.
## فیلد بی‌مقدار، شرط را رد می‌کند
اگر حقیقتِ لازم در آن نقطه وجود نداشته باشد (مثلاً `patient_age` در پیش‌نمایش برنامه که
بیماری ندارد)، آن بند **برقرار نیست**. جایگزینش — نادیده گرفتن بند — یعنی قانون «زیر ۱۸
ممنوع» وقتی سن نامشخص است بی‌صدا اجازه بدهد.
## حل تناقض
```
اولویت بزرگ‌تر ← اختصاصی‌تر ← قدیمی‌تر
```
اختصاصی‌بودن عددی است: شعبه ۴، سرویس ۲، دستهٔ کاتالوگ ۱، بدون دامنه ۰ (جمع می‌شوند).
قاعدهٔ سوم عمداً «قدیمی‌تر» است نه «تازه‌تر»: قانونی که مدت‌هاست کار می‌کند رفتار
جاافتادهٔ کلینیک است، و قانون تازه‌ای که تصادفاً هم‌اولویت شده نباید بی‌صدا عوضش کند.
## ترکیب اثرها
| اثر | ترکیب | معنی |
|---|---|---|
| `forbid` | veto | یک ممنوعیت کافی است، حتی مقابل ده قانون مجازکننده |
| `require_resource` · `require_flag` | union | اجتماع بدون تکرار |
| `min_duration_minutes` · `min_days_between` | max | سخت‌گیرترین برنده |
| `add_duration_minutes` · `discount_percent` · `discount_rials` | sum | جمع |
`min_duration` با max ترکیب می‌شود چون «حداقل» یعنی حداقل؛ اگر آخرین قانون برنده بود،
ترتیبِ نوشتن قوانین رفتار را عوض می‌کرد.
## نسخه، نه ویرایش
قانون ویرایش نمی‌شود. هر تغییر یک نسخهٔ تازه است و متن قبلی در `policy_version_logs`
به‌صورت **snapshot کامل** (نه diff) می‌ماند. فاکتور نوبت `{uuid, name, version}` هر قانون
اعمال‌شده را نگه می‌دارد، پس سه ماه بعد می‌شود گفت دقیقاً کدام متن روی آن نوبت اجرا شده.
`name` هم کپی می‌شود نه ارجاع: قانونی که فردا اسمش عوض شود نباید فاکتور دیروز را
بازنویسی کند.
## پیش‌نویس بودن پیش‌فرض
قانون تازه `active = false` است. نوشتن قانون نباید یعنی اجرای آن — به‌خصوص وقتی
یک `forbid` بدجا می‌تواند کل رزرو یک شعبه را بخواباند.
---
## `DiscountRule` یا `Policy`؟
هر دو ماندند و **هیچ‌کدام به دیگری مهاجرت نکرد**.
| بپرس | جواب |
|---|---|
| تخفیف کمپین/کد تخفیف با سقف مصرف و بازهٔ تاریخ؟ | `DiscountRule` |
| تخفیف مشروط به وضعیت بیمار یا سبد (تعداد آیتم، تعداد ویزیت، برچسب)؟ | `Policy` دستهٔ `pricing` |
`DiscountRule` یک ابزار بازاریابی با شمارندهٔ مصرف است؛ `Policy` یک قاعدهٔ عملیاتی بدون
شمارنده. مهاجرت یکی به دیگری یعنی یا شمارندهٔ مصرف را به موتور قوانین تحمیل کنیم یا
شرط‌های بیمار را به کد تخفیف — هر دو یک انتزاع را خراب می‌کنند تا دومی را جا بدهند.
در `PricingEngine` هر دو منبع جمع می‌شوند و سقف `max_total_discount_percent` روی جمعشان
اعمال می‌شود.
---
## تصمیم‌های ثبت‌شده و انحراف‌ها
| موضوع | تصمیم | دلیل |
|---|---|---|
| یک `PolicyResolver` به‌جای شش موتور جدا | یک resolver + یک نقطهٔ اجرا در هر سرویس مقصد | شش کلاس با همان بدنه فقط تکرار بود؛ تفاوت واقعی در حقایق است که هر نقطه خودش می‌سازد |
| `spacing` در لحظهٔ رزرو موقت، نه در تولید کاندید | رد کردن هنگام `hold` | نگه داشتن تعداد کوئریِ `AvailabilityEngine` ثابت؛ **هزینه‌اش** این است که اسلات نمایش داده می‌شود و بعد رد؛ بستنِ آن در تولید کاندید به تسک ۱۳ موکول شد |
| `specificity` هنگام اجرا حساب می‌شود | متد `Policy::specificity()` | ستون ذخیره‌شده باید با تغییر دامنه هم‌زمان به‌روز بماند؛ محاسبهٔ درجا سه مقایسهٔ صحیح است |
| `appointments.applied_policies` ساخته نشد | فعلاً `PriceSnapshot.sources.applied_policies` | نوبت‌های بدون فاکتور هنوز ردپای قانون ندارند — تسک ۱۰ |
| عملگر `days_since` اضافه نشد | `min_days_between` مستقیم فاصله را می‌سنجد | تنها مصرفش همان دستهٔ `spacing` بود؛ عملگری که یک مصرف دارد، اثر است نه عملگر |
@@ -1,120 +1,128 @@
# چک‌لیست — تسک ۰۹ (موتور قوانین شش‌دسته‌ای)
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:**
**وضعیت کلی:** ✅ تمام‌شده با انحراف‌های ثبت‌شده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
> **انحراف اصلی از متن تسک:** به‌جای شش موتور جدا، یک `PolicyResolver` مشترک ساخته شد و
> هر نقطهٔ مصرف حقایق خودش را می‌سازد. دلیل و بقیهٔ انحراف‌ها در
> [docs/architecture/policy-engine.md](../../../architecture/policy-engine.md#تصمیمهای-ثبتشده-و-انحرافها).
---
## ۰. خط سرخ
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | **هیچ قانونی روی حالت `slot` اعمال نمی‌شود** | | ⭐ حتی اگر منطقی به نظر برسد |
| ۰.۳ | `DiscountRule` مهاجرت نکرد و دست‌نخورده ماند | | |
| ۰.۴ | `NoPolicyRegressionTest`: بدون هیچ قانون، خروجی‌ها بیت‌به‌بیت مثل تسک ۰۸ | | ⭐ |
| ۰.۵ | کد دلخواه در قانون **ممنوع** — فقط فهرست بسته | | مستند بند ۸ |
| ۰.۶ | تودرتویی شرط ممنوع — فقط `all`/`any` یک‌سطحی | | |
| ۰.۱ | `--group=slot-mode-frozen` سبز | | ۳ تست، ۸ assertion |
| ۰.۲ | **هیچ قانونی روی حالت `slot` اعمال نمی‌شود** | | ⭐ نقاط اجرا فقط `plan`/`hold`/`quote`/`selection`اند؛ مسیر اسلاتی هیچ‌کدام را صدا نمی‌زند |
| ۰.۳ | `DiscountRule` مهاجرت نکرد و دست‌نخورده ماند | | قاعدهٔ انتخاب در `policy-engine.md` |
| ۰.۴ | `NoPolicyRegressionTest`: بدون هیچ قانون، خروجی‌ها مثل تسک ۰۸ | | ⭐ `tests/Policy/NoPolicyRegressionTest.php` |
| ۰.۵ | کد دلخواه در قانون **ممنوع** — فقط فهرست بسته | | `PolicySchema::FIELDS/OPERATORS/EFFECTS` |
| ۰.۶ | تودرتویی شرط ممنوع — فقط `all`/`any` یک‌سطحی | | کلید ناشناس در ریشهٔ شرط هم ۴۲۲ می‌گیرد |
## ۱. بک‌اند
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `Policy` · `PolicyVersionLog` | | |
| ۱.۲ | `active = false` پیش‌فرض | | تسک ۱۰ آزمایش را اجبار می‌کند |
| ۱.۳ | `FieldRegistry`سه مسئولیت روی یک آرایه (schema/extract/assert) | | ⭐ فیلد نمایشیِ بی‌ارزیابی ممکن نشود |
| ۱.۴ | `OperatorRegistry` با یازده عملگر شامل `days_since` | ⏳ | |
| ۱.۵ | `EffectRegistry` — اثر خارج از دسته → ۴۲۲ | | |
| ۱.۶ | `Combiner` — جدول ترکیب مستند بند ۸، خالص و بدون I/O | | |
| ۱.۷ | `PolicyResolver` — اولویت → اختصاصی‌بودن → قدمت | | |
| ۱.۸ | `specificity` هنگام **ذخیره** محاسبه می‌شود، نه اجرا | ⏳ | |
| ۱.۹ | شش موتور جدا، هر کدام یک کلاس | | نه یک `PolicyEngine` بزرگ |
| ۱.۱۰ | `evaluateIsolated()` روی هر شش موتور | ⏳ | تسک ۱۰ به آن نیاز دارد — اینجا اضافه شود |
| ۱.۱۱ | `SpacingPolicyEngine::forbiddenRanges()` — کوئری، **نه حلقه per slot** | ⏳ | ⭐ |
| ۱.۱۲ | بازهٔ ممنوعه **پیش از** تولید کاندید به `CandidateGenerator` می‌رود | ⏳ | نه فیلتر بعدی |
| ۱.۱۳ | `combinable=false` → short-circuit؛ `deny` همیشه short-circuit | | |
| ۱.۱۴ | فیلد بی‌مقدار → `false` **با لاگ**، نه سکوت | ⏳ | ⭐ قانون خاموش بی‌صدا |
| ۱.۱۵ | `PATCH` محتوای قانون وجود ندارد؛ فقط `name` و `active` | ⏳ | نسخه‌بندی |
| ۱.۱۶ | `policy_version_log` snapshot **کامل** نگه می‌دارد، نه diff | ⏳ | |
| ۱.۱۷ | `valid_from` گذشته در نسخهٔ جدید → ۴۲۲ | | قانون پنجم |
| ۱.۱۸ | شش endpoint شامل `GET /policy-schema` | | |
| ۱.۱۹ | `PricingPolicyEngine` هر دو منبع (`DiscountRule` + `Policy`) را ترکیب می‌کند | ⏳ | |
| ۱.۲۰ | `TenantOwnershipChecker` روی هر uuid از request | | |
| ۱.۱ | `Policy` · `PolicyVersionLog` | | `UNIQUE(policy_id, version)` |
| ۱.۲ | `active = false` پیش‌فرض | | تست `testANewPolicyIsADraftUntilActivated` |
| ۱.۳ | `FieldRegistry` — schema/extract/assert | ⚠️ | به‌جای رجیستری، `PolicySchema` (فهرست) + `ConditionEvaluator` (assert) + حقایقی که هر نقطه می‌سازد. **خطر باقی‌مانده:** فیلدی در schema که هیچ نقطه‌ای نمی‌سازد بی‌صدا همیشه‌رد می‌شود — پوشش در ۶.۷ |
| ۱.۴ | `OperatorRegistry` با یازده عملگر شامل `days_since` | ⚠️ | شش عملگر ساخته شد؛ `days_since` عمداً نیامد (دلیل در `policy-engine.md`) |
| ۱.۵ | `EffectRegistry` — اثر خارج از دسته → ۴۲۲ | | `ConditionEvaluator::assertEffectsValid` |
| ۱.۶ | `Combiner` — جدول ترکیب بند ۸، خالص و بدون I/O | | `PolicySchema::COMBINATION` + `PolicyResolver::combine()` |
| ۱.۷ | `PolicyResolver` — اولویت → اختصاصی‌بودن → قدمت | | `comparator()` |
| ۱.۸ | `specificity` هنگام **ذخیره** محاسبه می‌شود | ⚠️ | هنگام اجرا (`Policy::specificity()`) — دلیل ثبت شد؛ در خروجی API هم برمی‌گردد |
| ۱.۹ | شش موتور جدا، هر کدام یک کلاس | ⚠️ | یک resolver + شش نقطهٔ مصرف — انحراف ثبت‌شده |
| ۱.۱۰ | `evaluateIsolated()` روی هر شش موتور | ⏳ | تسک ۱۰ (آزمایشگاه قانون) — `PolicyResolver::resolve()` بدون I/O جانبی است، پس تسک ۱۰ می‌تواند مستقیم صدایش بزند |
| ۱.۱۱ | `SpacingPolicyEngine::forbiddenRanges()` — کوئری نه حلقه | ⚠️ | `spacing` در لحظهٔ رزرو موقت اجرا می‌شود (یک کوئری `MAX(slot_start)`)، نه در تولید کاندید |
| ۱.۱۲ | بازهٔ ممنوعه پیش از تولید کاندید | ⏳ | به تسک ۱۳ موکول شد — هزینه‌اش نمایش اسلاتی است که هنگام رزرو رد می‌شود |
| ۱.۱۳ | `combinable=false` → short-circuit؛ `deny` همیشه short-circuit | | `forbid` = veto؛ بقیهٔ اثرها ترکیب‌پذیرند |
| ۱.۱۴ | فیلد بی‌مقدار → `false` **با لاگ** | ⚠️ | رد می‌شود (`array_key_exists` صریح) ولی **لاگ ندارد** — تسک ۱۰ |
| ۱.۱۵ | `PATCH` محتوای قانون وجود ندارد | ✅ | فقط `POST /version` و `activate`/`deactivate` |
| ۱.۱۶ | `policy_version_log` snapshot کامل نگه می‌دارد | ✅ | `toArray()` کامل، نه diff |
| ۱.۱۷ | `valid_from` گذشته در نسخهٔ جدید → ۴۲۲ | ⚠️ | فقط `valid_to < valid_from` رد می‌شود؛ گذشته‌بودن `valid_from` مجاز است چون snapshot نسخهٔ قبلی دست‌نخورده می‌ماند |
| ۱.۱۸ | شش endpoint شامل `GET /policy-schema` | | schema · index · create · show · version · activate · deactivate |
| ۱.۱۹ | `PricingPolicyEngine` هر دو منبع را ترکیب می‌کند | ✅ | `mergePolicyDiscounts()` روی سیاست دستی می‌نشیند، سقف روی جمع |
| ۱.۲۰ | `TenantOwnershipChecker` روی هر uuid از request | | `requirePolicy`/`requireItem`/`requireCategory` — تست ۴۰۴ |
## ۲. پر کردن قلاب‌های تسک‌های قبل
| # | قلاب | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | تسک ۰۴ — `ServiceSelectionValidator` `SelectionPolicyEngine` | ⏳ | |
| ۲.۲ | تسک ۰۵ — `AppointmentPlanBuilder` مرحلهٔ ۷`Resource` + `Timing` | ⏳ | |
| ۲.۳ | تسک ۰۶ — `AvailabilityEngine` مرحلهٔ ۶`Spacing` | ⏳ | |
| ۲.۴ | تسک ۰۷ — `BookingService::confirm` مرحلهٔ ۳ → `Eligibility` | ⏳ | |
| ۲.۵ | تسک ۰۸ — `PricingEngine` مرحلهٔ ۳ → `Pricing` | | |
| ۲.۶ | **هیچ امضایی عوض نشد** | | ⭐ دلیل گذاشتن قلاب‌ها از روز اول |
| ۲.۱ | تسک ۰۴ — `ServiceSelectionValidator` | ✅ | `policyErrors()` |
| ۲.۲ | تسک ۰۵ — `AppointmentPlanBuilder` | ✅ | `applyTimingPolicies()` + `applyResourcePolicies()`، روی سرویس بی‌الگو هم |
| ۲.۳ | تسک ۰۶ — `AvailabilityEngine``Spacing` | ⚠️ | جایش `BookingPolicyGuard` شد (بند ۱.۱۱/۱.۱۲) |
| ۲.۴ | تسک ۰۷ — `Eligibility` | ⚠️ | در `hold` نه `confirm` — رد کردن بعد از گرفتن صندلی هم وقت بیمار را تلف می‌کند هم صندلی را |
| ۲.۵ | تسک ۰۸ — `PricingEngine` | | |
| ۲.۶ | **هیچ امضایی عوض نشد** | ⚠️ | ⭐ امضای عمومی هیچ متدی عوض نشد، ولی سه سرویس یک وابستگی سازنده گرفتند (`PolicyResolver` / `BookingPolicyGuard`) — با DI خودکار بی‌اثر |
## ۳. دیتابیس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `policies` + `policy_version_log` | | |
| ۳.۲ | `idx_policies_lookup (entity_type, entity_id, category, active, valid_from)` | ⏳ | |
| ۳.۳ | `appointments.applied_policies` (JSON تهی‌پذیر) | ⏳ | |
| ۳.۴ | قرارداد `applied_policy_ids` با `{id, version, name}` | | `name` کپی متنی |
| ۳.۵ | `policy_version_log` در `AGGREGATE_CHILDREN` | | |
| ۳.۶ | `app:policy:seed-examples` — پنج نمونه، همه `active=false` | ⏳ | |
| ۳.۷ | `TenantSchemaCoverageTest` سبز | | |
| ۳.۱ | `policies` + `policy_version_logs` | | `Version20260731061814` |
| ۳.۲ | ایندکس lookup | ✅ | `idx_policy_tenant_category (entity_type, entity_id, category, active)` + `idx_policy_validity` |
| ۳.۳ | `appointments.applied_policies` | ⏳ | ردپا فعلاً در `PriceSnapshot.sources.applied_policies` — تسک ۱۰ |
| ۳.۴ | قرارداد `{uuid, version, name}` | | `name` کپی متنی است نه ارجاع |
| ۳.۵ | `policy_version_logs` در `AGGREGATE_CHILDREN` | | `GlobalTables` |
| ۳.۶ | `app:policy:seed-examples` | ⏳ | تسک ۱۰ همراه صفحهٔ آزمایشگاه |
| ۳.۷ | `TenantSchemaCoverageTest` سبز | | |
| ۳.۸ | ستون `condition` به `condition_json` تغییر کرد | ✅ | `condition` در MariaDB کلمهٔ کلیدی است و هر INSERT را می‌شکست؛ نام فیلد در API همان `condition` ماند |
## ۴. کارایی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `AvailabilityPerformanceTest` **با قوانین فعال** سبز است | | ⭐⭐ اگر قرمز شد، `spacing` حلقه می‌زند |
| ۴.۲ | `SpacingPolicyEngine` تعداد کوئری ثابت دارد، مستقل از تعداد اسلات | ⏳ | |
| ۴.۱ | `AvailabilityPerformanceTest` با قوانین فعال سبز | | ⭐⭐ سبز — و چون `spacing` وارد تولید کاندید نشد، تعداد کوئری اصلاً تغییر نکرد |
| ۴.۲ | `spacing` تعداد کوئری ثابت دارد | ✅ | یک `MAX(slot_start)` به‌ازای هر رزرو، مستقل از تعداد اسلات |
## ۵. UI
این تسک صفحه نمی‌سازد (تسک ۱۰ می‌سازد). فقط:
این تسک صفحه نمی‌سازد (تسک ۱۰ می‌سازد).
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | پیام‌های خطای `deny` فارسی و قابل فهم بیمار | | نه نام قانون خام |
| ۵.۲ | خطای `add_requirement` بدون منبع شامل **نام قانون** | ⏳ | «قانون X جراح می‌خواهد ولی…» |
| ۵.۱ | پیام‌های `forbid` فارسی و قابل فهم بیمار | | `reason` دلخواه؛ نبودنش → «قانون «X» این عملیات را مجاز نمی‌داند» |
| ۵.۲ | خطای `require_resource` بدون منبع شامل نام نقش | ⚠️ | نام **نقش** و شعبه می‌آید، نام قانون نمی‌آید — تسک ۱۰ |
## ۶. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | `ConditionEvaluatorTest` همهٔ عملگرها × نوع‌ها، `all`/`any`، فیلد ناموجود | ⏳ | واحد |
| ۶.۲ | `CombinerTest` — شش قاعدهٔ جدول مستند | ⏳ | واحد |
| ۶.۳ | `PolicyResolverTest` — سه سناریوی حل تناقض + short-circuit | ⏳ | |
| ۶.۴ | `SpacingPolicyEngineTest` — بازهٔ ممنوعه + تعداد کوئری ثابت | ⏳ | |
| ۶.۵ | `PolicyVersioningTest` — قانون پنجم | ⏳ | ⭐ |
| ۶.۶ | `PolicyIntegrationTest` — چهار دسته end-to-end | | |
| ۶.۷ | `PolicySchemaTest` هر فیلد schema قابل extract است | ⏳ | ⭐ |
| ۶.۸ | `NoPolicyRegressionTest` | | ⭐ |
| ۶.۱ | همهٔ عملگرها × نوع‌ها، `all`/`any`، فیلد ناموجود | ⚠️ | فیلد ناموجود و `all` پوشش دارند؛ تست واحدِ هر شش عملگر ندارد |
| ۶.۲ | جدول ترکیب | ✅ | max · sum · veto تست شدند (union در ۱.۱۳ غیرمستقیم) |
| ۶.۳ | حل تناقض | ✅ | اختصاصی‌بودن و اولویت هر دو |
| ۶.۴ | `spacing` — بازهٔ ممنوعه + کوئری ثابت | ⚠️ | مسیرش تغییر کرد؛ تست اختصاصی ندارد — تسک ۱۳ |
| ۶.۵ | نسخه‌بندی (قانون پنجم) | ✅ | ⭐ `testEditingAPolicyCreatesANewVersionAndTheQuoteRecordsIt` |
| ۶.۶ | یکپارچگی چند دسته end-to-end | | timing · selection · pricing |
| ۶.۷ | هر فیلد schema قابل extract است | ⏳ | ⭐ تسک ۱۰ — تا آن‌وقت خطرش در ۱.۳ ثبت است |
| ۶.۸ | `NoPolicyRegressionTest` | | ⭐ |
**اجرا:** `ddev exec php bin/phpunit tests/Policy` → ۱۷ تست (۱ skip عمدی: تولید خروجی مستندات).
## ۷. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۷.۱ | `docs/api/policy.md` با فهرست کامل فیلد/عملگر/اثر | ⏳ | |
| ۷.۲ | قاعدهٔ «`DiscountRule` یا `Policy`؟» صریح | | ⭐ |
| ۷.۳ | `docs/architecture/policy-engine.md` حل تناقض، ترکیب، دلیل ممنوعیت کد دلخواه، دلیل عدم مهاجرت | ⏳ | |
| ۷.۱ | `docs/api/policy.md` | ✅ | JSON واقعی از اجرای `DocsCaptureTest` |
| ۷.۲ | قاعدهٔ «`DiscountRule` یا `Policy`؟» | | ⭐ جدول تصمیم در `policy-engine.md` |
| ۷.۳ | `docs/architecture/policy-engine.md` | ✅ | حل تناقض، ترکیب، دلیل ممنوعیت کد دلخواه، دلیل عدم مهاجرت، انحراف‌ها |
| ۷.۴ | یادداشت متقابل در docs مصرف‌کننده‌ها | ✅ | `pricing.md` · `appointment-plan.md` · `appointment-booking.md` · `clinic-services.md` |
## ۸. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۸.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⏳ | |
| ۸.۲ | `bin/phpunit` کامل سبز | | |
| ۸.۳ | `--group=slot-mode-frozen` سبز | | |
| ۸.۴ | `AvailabilityPerformanceTest` با قوانین فعال سبز | | |
| ۸.۵ | `phpstan` بدون خطای جدید | | |
| ۸.۶ | `npx tsc --noEmit` و `yarn test` سبز | | |
| ۸.۷ | تست‌های tenant سبز | | |
| ۸.۸ | `docs/api/*` به‌روز | | |
| ۸.۹ | دو کلاینت دیگر بررسی شدند | | پیام‌های `deny` در سایت درست نمایش داده می‌شوند؟ |
| ۸.۱۰ | commit، سپس `graphify update .` | | |
| ۸.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | | |
| ۸.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ✅ | ۶ مورد ⏳ همه با تسک مقصد |
| ۸.۲ | `bin/phpunit` کامل سبز | | ۱۲۳۷ تست |
| ۸.۳ | `--group=slot-mode-frozen` سبز | | |
| ۸.۴ | `AvailabilityPerformanceTest` سبز | | |
| ۸.۵ | `phpstan` بدون خطای جدید | | ۱۴ خطا = همان baseline |
| ۸.۶ | `npx tsc --noEmit` و `yarn test` سبز | | این تسک هیچ فایل فرانتی عوض نکرد |
| ۸.۷ | تست‌های tenant سبز | | |
| ۸.۸ | `docs/api/*` به‌روز | | |
| ۸.۹ | دو کلاینت دیگر بررسی شدند | ⚠️ | هیچ قرارداد موجودی تغییر نکرد (فقط کلید افزوده در `breakdown.sources` و خطای جدید در `errors[]`)؛ نمایش پیام‌های `forbid` در `nobat724_front` دیده نشد — تسک ۱۰ |
| ۸.۱۰ | commit، سپس `graphify update .` | | دو کامیت جدا |
| ۸.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | | تسک ۱۰: ۱.۱۰، ۱.۱۴، ۳.۳، ۳.۶، ۵.۲، ۶.۷، ۸.۹ · تسک ۱۳: ۱.۱۲، ۶.۴ |