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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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` ثبت میشود |
|
||||
@@ -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)
|
||||
|
||||
@@ -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 .` | ✅ | دو کامیت جدا |
|
||||
| ۸.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ✅ | تسک ۱۰: ۱.۱۰، ۱.۱۴، ۳.۳، ۳.۶، ۵.۲، ۶.۷، ۸.۹ · تسک ۱۳: ۱.۱۲، ۶.۴ |
|
||||
|
||||
Reference in New Issue
Block a user