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 .` | ✅ | دو کامیت جدا |
|
||||
| ۸.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ✅ | تسک ۱۰: ۱.۱۰، ۱.۱۴، ۳.۳، ۳.۶، ۵.۲، ۶.۷، ۸.۹ · تسک ۱۳: ۱.۱۲، ۶.۴ |
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* The six-category policy engine of section 8: conditions drawn from a closed list,
|
||||
* priority-ordered, versioned, and with no arbitrary code.
|
||||
*
|
||||
* Policies are versioned rather than edited in place. An appointment booked yesterday
|
||||
* references the version that actually applied to it; rewriting a policy would make
|
||||
* that reference point at text the appointment never saw.
|
||||
*/
|
||||
final class Version20260731061814 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add the six-category policy engine with version history';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE policies (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, category VARCHAR(20) NOT NULL, name VARCHAR(200) NOT NULL, condition_json JSON NOT NULL, effects JSON NOT NULL, priority SMALLINT DEFAULT 0 NOT NULL, valid_from INT DEFAULT NULL, valid_to INT DEFAULT NULL, version SMALLINT DEFAULT 1 NOT NULL, active TINYINT DEFAULT 0 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, address_id INT DEFAULT NULL, service_item_id INT DEFAULT NULL, catalog_category_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_E08BBFCED17F50A6 (uuid), INDEX IDX_E08BBFCEF5B7AF75 (address_id), INDEX IDX_E08BBFCEDDEB00C2 (service_item_id), INDEX IDX_E08BBFCE3F2BC4C (catalog_category_id), INDEX idx_policy_tenant_category (entity_type, entity_id, category, active), INDEX idx_policy_validity (valid_from, valid_to), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('CREATE TABLE policy_version_logs (id INT AUTO_INCREMENT NOT NULL, version SMALLINT NOT NULL, snapshot JSON NOT NULL, created_at INT NOT NULL, policy_id INT NOT NULL, INDEX IDX_B27F258B2D29E3C6 (policy_id), UNIQUE INDEX uniq_policy_version (policy_id, version), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE policies ADD CONSTRAINT FK_E08BBFCEF5B7AF75 FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE policies ADD CONSTRAINT FK_E08BBFCEDDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE policies ADD CONSTRAINT FK_E08BBFCE3F2BC4C FOREIGN KEY (catalog_category_id) REFERENCES service_catalog_categories (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE policy_version_logs ADD CONSTRAINT FK_B27F258B2D29E3C6 FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE policies DROP FOREIGN KEY FK_E08BBFCEF5B7AF75');
|
||||
$this->addSql('ALTER TABLE policies DROP FOREIGN KEY FK_E08BBFCEDDEB00C2');
|
||||
$this->addSql('ALTER TABLE policies DROP FOREIGN KEY FK_E08BBFCE3F2BC4C');
|
||||
$this->addSql('ALTER TABLE policy_version_logs DROP FOREIGN KEY FK_B27F258B2D29E3C6');
|
||||
$this->addSql('DROP TABLE policies');
|
||||
$this->addSql('DROP TABLE policy_version_logs');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Auth\Repository\UserRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Pricing\Entity\PriceSnapshot;
|
||||
use App\Pricing\Service\PriceSnapshotService;
|
||||
use App\Policy\Service\BookingPolicyGuard;
|
||||
use App\Pricing\Service\PricingEngine;
|
||||
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
|
||||
use App\Auth\Entity\User;
|
||||
@@ -50,10 +51,35 @@ class BookingController extends BaseController
|
||||
private readonly PricingEngine $pricing,
|
||||
private readonly PriceSnapshotService $snapshots,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly BookingPolicyGuard $guard,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* پرچمهایی که فقط در همین درخواست وجود دارند و جایی ذخیره نمیشوند
|
||||
* (مثل رضایت والدین که اپراتور همان لحظه میگیرد).
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requestFlags(array $data): array
|
||||
{
|
||||
$flags = [];
|
||||
|
||||
foreach (['has_parental_consent'] as $flag) {
|
||||
if (isset($data[$flag])) {
|
||||
$flags[$flag] = (bool) $data[$flag];
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($data['patient_gender'] ?? null)) {
|
||||
$flags['patient_gender'] = $data['patient_gender'];
|
||||
}
|
||||
|
||||
return $flags;
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-hold', name: 'appointment_hold_create', methods: ['POST'])]
|
||||
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
@@ -94,6 +120,10 @@ class BookingController extends BaseController
|
||||
is_string($data['patient_gender'] ?? null) ? $data['patient_gender'] : null,
|
||||
);
|
||||
|
||||
// قوانین وابسته به بیمار پیش از گرفتن صندلی اجرا میشوند، نه هنگام ثبت نهایی.
|
||||
$this->guard->assertEligible($user, $service, $selected, $address, $this->requestFlags($data));
|
||||
$this->guard->assertSpacing($user, $service, $address, (int) $data['start']);
|
||||
|
||||
$assignment = $this->resolveAssignment($user, $data['assignment']);
|
||||
$this->assertAssignmentCoversPlan($plan, $assignment);
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ use App\Resource\Entity\ResourceType;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Resource\Repository\ResourceTypeRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Policy\Service\PolicyResolver;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
@@ -27,6 +30,7 @@ use App\Shared\Exception\AppException;
|
||||
final class AppointmentPlanBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyResolver $policies,
|
||||
private readonly SegmentTemplateRepository $templates,
|
||||
private readonly ClinicResourceRepository $resources,
|
||||
private readonly ResourceTypeRepository $types,
|
||||
@@ -49,7 +53,10 @@ final class AppointmentPlanBuilder
|
||||
// سرویسی که الگوی بخش ندارد، همان رفتار امروز را میگیرد: یک بخش پیوسته که
|
||||
// پزشک را میگیرد. بدون این، هر سرویس موجود بیبرنامه میشد.
|
||||
if ($templates === []) {
|
||||
return $this->singleSegmentPlan($service, $address, $itemMinutes, $patientGender);
|
||||
$segments = $this->singleSegment($service, $address, $itemMinutes);
|
||||
$offset = $itemMinutes;
|
||||
|
||||
return $this->finish($service, $selectedItems, $address, $segments, $offset);
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
@@ -82,16 +89,99 @@ final class AppointmentPlanBuilder
|
||||
$offset += $duration;
|
||||
}
|
||||
|
||||
if ($offset > SegmentTemplate::MAX_TOTAL_MINUTES) {
|
||||
return $this->finish($service, $selectedItems, $address, $segments, $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* پایان مشترک هر دو مسیر — با الگو و بیالگو.
|
||||
*
|
||||
* قوانین باید روی سرویسِ بیالگو هم اجرا شوند: «حداقل ۶۰ دقیقه برای این دسته»
|
||||
* ربطی به این ندارد که کلینیک برای آن سرویس بخش تعریف کرده باشد یا نه.
|
||||
*
|
||||
* @param ServiceItem[] $selectedItems
|
||||
* @param list<PlannedSegment> $segments
|
||||
*/
|
||||
private function finish(
|
||||
ServiceItem $service,
|
||||
array $selectedItems,
|
||||
DoctorAddress $address,
|
||||
array $segments,
|
||||
int $total,
|
||||
): AppointmentPlan {
|
||||
// ── قوانین دستهٔ «زمان» ────────────────────────────────────────────
|
||||
// اثرها روی **مجموع** نوبت اعمال میشوند نه روی یک بخش: «حداقل ۶۰ دقیقه»
|
||||
// یعنی کل جلسه، و کوتاه کردنِ یک بخش برای رسیدن به آن معنا ندارد.
|
||||
$total = $this->applyTimingPolicies($service, $selectedItems, $address, $segments, $total);
|
||||
|
||||
// ── قوانین دستهٔ «منبع» ─────────────────────────────────────────────
|
||||
$segments = $this->applyResourcePolicies($service, $selectedItems, $address, $segments);
|
||||
|
||||
if ($total > SegmentTemplate::MAX_TOTAL_MINUTES) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('مجموع مدت بخشها (%d دقیقه) از سقف %d دقیقه بیشتر است', $offset, SegmentTemplate::MAX_TOTAL_MINUTES),
|
||||
sprintf('مجموع مدت بخشها (%d دقیقه) از سقف %d دقیقه بیشتر است', $total, SegmentTemplate::MAX_TOTAL_MINUTES),
|
||||
422,
|
||||
'segments',
|
||||
);
|
||||
}
|
||||
|
||||
return new AppointmentPlan($segments, $offset);
|
||||
return new AppointmentPlan($segments, $total);
|
||||
}
|
||||
|
||||
/**
|
||||
* قوانین «زمان»: حداقل مدت (بیشترین برنده) و افزودن مدت (جمع).
|
||||
*
|
||||
* @param ServiceItem[] $selectedItems
|
||||
* @param list<PlannedSegment> $segments بهصورت ارجاع تغییر میکند
|
||||
*/
|
||||
private function applyTimingPolicies(
|
||||
ServiceItem $service,
|
||||
array $selectedItems,
|
||||
DoctorAddress $address,
|
||||
array &$segments,
|
||||
int $total,
|
||||
): int {
|
||||
$outcome = $this->policies->resolve(
|
||||
Policy::CATEGORY_TIMING,
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
[
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'item_count' => count($selectedItems),
|
||||
'catalog_category' => $service->getCatalogCategory()?->getUuid(),
|
||||
],
|
||||
$address,
|
||||
$service,
|
||||
);
|
||||
|
||||
if ($outcome->effects === []) {
|
||||
return $total;
|
||||
}
|
||||
|
||||
$extra = (int) $outcome->effect(PolicySchema::EFFECT_ADD_DURATION, 0);
|
||||
$minimum = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DURATION, 0);
|
||||
$target = max($total + $extra, $minimum);
|
||||
|
||||
if ($target === $total || $segments === []) {
|
||||
return $total;
|
||||
}
|
||||
|
||||
// مدت اضافه به **آخرین** بخش میرود: آفست بخشهای قبلی نباید عوض شود، وگرنه
|
||||
// برنامهای که کاربر تأیید کرده زیر پایش جابهجا میشود.
|
||||
$last = $segments[count($segments) - 1];
|
||||
$grown = $last->durationMinutes + ($target - $total);
|
||||
|
||||
$segments[count($segments) - 1] = new PlannedSegment(
|
||||
sequence: $last->sequence,
|
||||
name: $last->name,
|
||||
offsetMinutes: $last->offsetMinutes,
|
||||
durationMinutes: $grown,
|
||||
patientPresent: $last->patientPresent,
|
||||
mergeable: $last->mergeable,
|
||||
requirements: $last->requirements,
|
||||
);
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +216,148 @@ final class AppointmentPlanBuilder
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* قوانین «منبع»: نقشی که قانون لازم میداند، اگر الگو نداشته باشد، اضافه میشود.
|
||||
*
|
||||
* نقشِ اضافهشده به **اولین بخشی که بیمار حاضر است** میچسبد، نه به همهٔ بخشها:
|
||||
* «سرپرست لازم است» یعنی سرپرست در جلسه حضور داشته باشد، نه اینکه تمام مدتِ
|
||||
* آمادهسازی هم اشغال شود.
|
||||
*
|
||||
* ممنوعیت هم اینجا خوانده میشود: قانونی که میگوید این ترکیب در این شعبه انجام
|
||||
* نمیشود، پیش از رسیدن به موتور دسترسپذیری جلوی کار را میگیرد.
|
||||
*
|
||||
* @param ServiceItem[] $selectedItems
|
||||
* @param list<PlannedSegment> $segments
|
||||
* @return list<PlannedSegment>
|
||||
*/
|
||||
private function applyResourcePolicies(
|
||||
ServiceItem $service,
|
||||
array $selectedItems,
|
||||
DoctorAddress $address,
|
||||
array $segments,
|
||||
): array {
|
||||
if ($segments === []) {
|
||||
return $segments;
|
||||
}
|
||||
|
||||
$outcome = $this->policies->resolve(
|
||||
Policy::CATEGORY_RESOURCE,
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
[
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'catalog_category' => $service->getCatalogCategory()?->getUuid(),
|
||||
'item_count' => count($selectedItems),
|
||||
],
|
||||
$address,
|
||||
$service,
|
||||
);
|
||||
|
||||
if ($outcome->isForbidden()) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
implode(' ', $outcome->forbidReasons),
|
||||
422,
|
||||
'service_uuid',
|
||||
);
|
||||
}
|
||||
|
||||
$required = (array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_RESOURCE, []);
|
||||
|
||||
if ($required === []) {
|
||||
return $segments;
|
||||
}
|
||||
|
||||
$present = [];
|
||||
foreach ($segments as $segment) {
|
||||
foreach ($segment->requirements as $requirement) {
|
||||
$present[$requirement->role] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$targetIndex = $this->firstPatientPresentIndex($segments);
|
||||
$extra = [];
|
||||
|
||||
foreach ($required as $code) {
|
||||
if (!is_string($code) || isset($present[$code])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extra[] = $this->requirementForRole($code, $address);
|
||||
}
|
||||
|
||||
if ($extra === []) {
|
||||
return $segments;
|
||||
}
|
||||
|
||||
$target = $segments[$targetIndex];
|
||||
|
||||
$segments[$targetIndex] = new PlannedSegment(
|
||||
sequence: $target->sequence,
|
||||
name: $target->name,
|
||||
offsetMinutes: $target->offsetMinutes,
|
||||
durationMinutes: $target->durationMinutes,
|
||||
patientPresent: $target->patientPresent,
|
||||
mergeable: $target->mergeable,
|
||||
requirements: [...$target->requirements, ...$extra],
|
||||
);
|
||||
|
||||
return array_values($segments);
|
||||
}
|
||||
|
||||
/** @param list<PlannedSegment> $segments */
|
||||
private function firstPatientPresentIndex(array $segments): int
|
||||
{
|
||||
foreach ($segments as $index => $segment) {
|
||||
if ($segment->patientPresent) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* قانونی که نقشِ ناشناخته یا بیمنبع میخواهد **خطاست، نه بیاثر**: در سکوت رد
|
||||
* کردنش یعنی کلینیک فکر کند قانونش اجرا میشود در حالی که هیچوقت نشده.
|
||||
*/
|
||||
private function requirementForRole(string $code, DoctorAddress $address): PlannedRequirement
|
||||
{
|
||||
$type = $this->types->findByCode($address->tenantEntityType(), $address->tenantEntityId(), $code);
|
||||
|
||||
if ($type === null) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('قانون منبعی نقش «%s» را لازم دارد که در این محیط تعریف نشده است', $code),
|
||||
422,
|
||||
'requirements',
|
||||
);
|
||||
}
|
||||
|
||||
$eligible = array_values($this->resources->findEligible($address, $type, []));
|
||||
|
||||
if ($eligible === []) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE,
|
||||
sprintf('هیچ %s در شعبهٔ «%s» موجود نیست', $type->getName(), $address->getName() ?? '—'),
|
||||
422,
|
||||
'requirements',
|
||||
);
|
||||
}
|
||||
|
||||
return new PlannedRequirement(
|
||||
role: $type->getCode(),
|
||||
roleName: $type->getName(),
|
||||
count: 1,
|
||||
occupancy: SegmentRequirement::OCCUPANCY_EXCLUSIVE,
|
||||
constraints: [],
|
||||
eligible: $eligible,
|
||||
skillName: null,
|
||||
setupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getSetupMinutes()),
|
||||
cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return list<PlannedRequirement> */
|
||||
private function planRequirements(
|
||||
SegmentTemplate $template,
|
||||
@@ -219,12 +451,12 @@ final class AppointmentPlanBuilder
|
||||
/**
|
||||
* رفتار امروز، بیانشده به زبان برنامه: یک بخش پیوسته که پزشک را میگیرد.
|
||||
*/
|
||||
private function singleSegmentPlan(
|
||||
/** @return list<PlannedSegment> */
|
||||
private function singleSegment(
|
||||
ServiceItem $service,
|
||||
DoctorAddress $address,
|
||||
int $minutes,
|
||||
?string $patientGender,
|
||||
): AppointmentPlan {
|
||||
): array {
|
||||
if ($minutes <= 0) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
@@ -255,18 +487,15 @@ final class AppointmentPlanBuilder
|
||||
cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()),
|
||||
)];
|
||||
|
||||
return new AppointmentPlan(
|
||||
[new PlannedSegment(
|
||||
sequence: 1,
|
||||
name: $service->getName(),
|
||||
offsetMinutes: 0,
|
||||
durationMinutes: $minutes,
|
||||
patientPresent: true,
|
||||
mergeable: false,
|
||||
requirements: $requirements,
|
||||
)],
|
||||
$minutes,
|
||||
);
|
||||
return [new PlannedSegment(
|
||||
sequence: 1,
|
||||
name: $service->getName(),
|
||||
offsetMinutes: 0,
|
||||
durationMinutes: $minutes,
|
||||
patientPresent: true,
|
||||
mergeable: false,
|
||||
requirements: $requirements,
|
||||
)];
|
||||
}
|
||||
|
||||
/** @param ClinicResource[] $resources */
|
||||
|
||||
@@ -9,6 +9,8 @@ use App\ClinicService\Entity\ServiceItemRelation;
|
||||
use App\ClinicService\Repository\ItemGroupMemberRepository;
|
||||
use App\ClinicService\Repository\ServiceItemRelationRepository;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\PolicyResolver;
|
||||
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
|
||||
|
||||
/**
|
||||
@@ -28,6 +30,7 @@ final class ServiceSelectionValidator
|
||||
private readonly ServiceItemRelationRepository $relations,
|
||||
private readonly ServiceBranchOverrideRepository $overrides,
|
||||
private readonly DurationCalculator $durations,
|
||||
private readonly PolicyResolver $policies,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -40,6 +43,7 @@ final class ServiceSelectionValidator
|
||||
$errors = [
|
||||
...$this->groupErrors($selected, $groups),
|
||||
...$this->relationErrors($selected),
|
||||
...$this->policyErrors($selected, $address),
|
||||
];
|
||||
|
||||
$overrides = $address === null
|
||||
@@ -58,6 +62,53 @@ final class ServiceSelectionValidator
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ممنوعیتهای دستهٔ «انتخاب» — لایهای روی گروه و رابطه، نه جایگزینشان.
|
||||
*
|
||||
* گروه و رابطه ساختار ثابتِ کاتالوگاند؛ قانون چیزی است که کلینیک بدون دست زدن
|
||||
* به کاتالوگ روشن و خاموش میکند. بدون شعبه اجرا نمیشود چون محیط از آدرس
|
||||
* میآید و بیآن هیچ محیطی برای جستوجو نیست.
|
||||
*
|
||||
* @param ServiceItem[] $selected
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function policyErrors(array $selected, ?DoctorAddress $address): array
|
||||
{
|
||||
if ($address === null || $selected === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$facts = [
|
||||
'item_count' => count($selected),
|
||||
'item_uuids' => array_map(static fn (ServiceItem $i): string => $i->getUuid(), $selected),
|
||||
];
|
||||
|
||||
$errors = [];
|
||||
|
||||
// هر آیتم جداگانه حل میشود: قانونی که دامنهاش یک سرویس خاص است فقط وقتی
|
||||
// معنا دارد که همان سرویس در انتخاب باشد، و پیام خطا باید بگوید کدام.
|
||||
foreach ($selected as $item) {
|
||||
$outcome = $this->policies->resolve(
|
||||
Policy::CATEGORY_SELECTION,
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
$facts + ['catalog_category' => $item->getCatalogCategory()?->getUuid()],
|
||||
$address,
|
||||
$item,
|
||||
);
|
||||
|
||||
foreach ($outcome->forbidReasons as $reason) {
|
||||
$errors[] = [
|
||||
'code' => 'policy_forbidden',
|
||||
'items' => [$item->getUuid()],
|
||||
'message' => $reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ServiceItem[] $selected
|
||||
* @param ItemGroup[] $groups
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\ClinicService\Repository\CatalogCategoryRepository;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Entity\PolicyVersionLog;
|
||||
use App\Policy\Repository\PolicyRepository;
|
||||
use App\Policy\Repository\PolicyVersionLogRepository;
|
||||
use App\Policy\Service\ConditionEvaluator;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Policy')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class PolicyController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyRepository $policies,
|
||||
private readonly PolicyVersionLogRepository $versions,
|
||||
private readonly ConditionEvaluator $evaluator,
|
||||
private readonly PolicySchema $schema,
|
||||
private readonly ServiceItemRepository $items,
|
||||
private readonly CatalogCategoryRepository $categories,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* فهرست بستهٔ فیلدها، عملگرها و اثرها.
|
||||
*
|
||||
* فرم ساخت قانون در پنل از **همین** ساخته میشود، نه از فهرستی که در فرانت دوباره
|
||||
* نوشته شده باشد — دو فهرست یعنی دو حقیقت و یکی از آنها همیشه قدیمی است.
|
||||
*/
|
||||
#[Route('/api/v1/policy-schema', name: 'policy_schema', methods: ['GET'])]
|
||||
public function schema(): JsonResponse
|
||||
{
|
||||
return $this->success($this->schema->describe());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/policies', name: 'policy_index', methods: ['GET'])]
|
||||
public function index(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
$category = $request->query->get('category');
|
||||
|
||||
if (is_string($category) && $category !== '' && !in_array($category, Policy::CATEGORIES, true)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دستهٔ قانون نامعتبر است', 422, 'category');
|
||||
}
|
||||
|
||||
return $this->success(array_map(
|
||||
static fn (Policy $p): array => $p->toArray(),
|
||||
$this->policies->findForPair($entityType, $entityId, is_string($category) && $category !== '' ? $category : null),
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/policy', name: 'policy_create', methods: ['POST'])]
|
||||
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data) || !is_string($data['category'] ?? null) || !in_array($data['category'], Policy::CATEGORIES, true)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دستهٔ قانون نامعتبر است', 422, 'category');
|
||||
}
|
||||
|
||||
if (!is_string($data['name'] ?? null) || trim($data['name']) === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام قانون الزامی است', 422, 'name');
|
||||
}
|
||||
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
$policy = new Policy($entityType, $entityId, $data['category'], trim($data['name']));
|
||||
$this->apply($user, $policy, $data);
|
||||
|
||||
$this->em->persist($policy);
|
||||
$this->em->flush();
|
||||
|
||||
$this->em->persist(new PolicyVersionLog($policy, $policy->getVersion(), $policy->toArray()));
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($policy->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/policy/{uuid}', name: 'policy_show', methods: ['GET'])]
|
||||
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$policy = $this->requirePolicy($user, $uuid);
|
||||
|
||||
return $this->success($policy->toArray() + [
|
||||
'versions' => array_map(
|
||||
static fn (PolicyVersionLog $l): array => $l->toArray(),
|
||||
$this->versions->findForPolicy($policy),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* نسخهٔ جدید — قانون **ویرایش نمیشود**.
|
||||
*
|
||||
* نوبتی که دیروز ثبت شده نسخهٔ قبلی را در فاکتورش نگه داشته؛ بازنویسی درجا یعنی
|
||||
* آن ارجاع به متنی اشاره کند که هرگز روی آن نوبت اعمال نشده بود.
|
||||
*/
|
||||
#[Route('/api/v1/policy/{uuid}/version', name: 'policy_version', methods: ['POST'])]
|
||||
public function version(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$policy = $this->requirePolicy($user, $uuid);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
$this->apply($user, $policy, $data);
|
||||
$policy->bumpVersion();
|
||||
|
||||
$this->em->persist(new PolicyVersionLog($policy, $policy->getVersion(), $policy->toArray()));
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($policy->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/policy/{uuid}/activate', name: 'policy_activate', methods: ['POST'])]
|
||||
public function activate(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$policy = $this->requirePolicy($user, $uuid)->setActive(true);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($policy->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/policy/{uuid}/deactivate', name: 'policy_deactivate', methods: ['POST'])]
|
||||
public function deactivate(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$policy = $this->requirePolicy($user, $uuid)->setActive(false);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($policy->toArray());
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function apply(User $user, Policy $policy, array $data): void
|
||||
{
|
||||
if (is_string($data['name'] ?? null) && trim($data['name']) !== '') {
|
||||
$policy->setName(trim($data['name']));
|
||||
}
|
||||
|
||||
if (is_array($data['condition'] ?? null)) {
|
||||
// اعتبارسنجی در **زمان ساخت**: قانونی که موقع رزرو بیمار بترکد، بدترین
|
||||
// جای ممکن برای شکستن است.
|
||||
$this->evaluator->assertValid($policy->getCategory(), $data['condition']);
|
||||
$policy->setCondition($data['condition']);
|
||||
}
|
||||
|
||||
if (is_array($data['effects'] ?? null)) {
|
||||
$this->evaluator->assertEffectsValid($policy->getCategory(), $data['effects']);
|
||||
$policy->setEffects(array_values($data['effects']));
|
||||
}
|
||||
|
||||
if (is_numeric($data['priority'] ?? null)) {
|
||||
$policy->setPriority((int) $data['priority']);
|
||||
}
|
||||
|
||||
if (array_key_exists('valid_from', $data) || array_key_exists('valid_to', $data)) {
|
||||
try {
|
||||
$policy->setValidity(
|
||||
is_numeric($data['valid_from'] ?? null) ? (int) $data['valid_from'] : null,
|
||||
is_numeric($data['valid_to'] ?? null) ? (int) $data['valid_to'] : null,
|
||||
);
|
||||
} catch (\InvalidArgumentException) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'پایان اعتبار باید بعد از شروع آن باشد', 422, 'valid_to');
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('address_uuid', $data)) {
|
||||
$policy->setAddress(is_string($data['address_uuid']) ? $this->branches->resolve($user, $data['address_uuid']) : null);
|
||||
}
|
||||
|
||||
if (array_key_exists('service_uuid', $data)) {
|
||||
$policy->setServiceItem(is_string($data['service_uuid']) ? $this->requireItem($user, $data['service_uuid']) : null);
|
||||
}
|
||||
|
||||
if (array_key_exists('catalog_category_uuid', $data)) {
|
||||
$policy->setCatalogCategory(
|
||||
is_string($data['catalog_category_uuid']) ? $this->requireCategory($user, $data['catalog_category_uuid']) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function requirePolicy(User $user, string $uuid): Policy
|
||||
{
|
||||
$policy = $this->policies->findByUuid($uuid);
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($policy === null || !$this->ownership->belongsToPair($entityType, $entityId, $policy)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'قانون یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $policy;
|
||||
}
|
||||
|
||||
private function requireItem(User $user, string $uuid): \App\ClinicService\Entity\ServiceItem
|
||||
{
|
||||
$item = $this->items->findByUuid($uuid);
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($item === null
|
||||
|| $item->getSection()->getEntityType() !== $entityType
|
||||
|| $item->getSection()->getEntityId() !== $entityId
|
||||
) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
private function requireCategory(User $user, string $uuid): \App\ClinicService\Entity\CatalogCategory
|
||||
{
|
||||
$category = $this->categories->findByUuid($uuid);
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($category === null || !$this->ownership->belongsToPair($entityType, $entityId, $category)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'دسته یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Entity;
|
||||
|
||||
use App\ClinicService\Entity\CatalogCategory;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Policy\Repository\PolicyRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* یک قانون از شش دستهٔ بند ۸ مستند.
|
||||
*
|
||||
* سه چیز عمداً **نیست**: کد دلخواه، شرط آزاد، و ویرایش درجا.
|
||||
*
|
||||
* - شرط فقط از فهرست بستهٔ {@see \App\Policy\Service\PolicySchema} میآید. قانونی که
|
||||
* بتواند هر عبارتی را ارزیابی کند، دیگر قابل تحلیل ایستا نیست و دستهٔ `spacing`
|
||||
* هرگز به کوئری تبدیل نمیشود.
|
||||
* - قانون **ویرایش نمیشود، نسخه میگیرد**. نوبتی که دیروز ثبت شده باید همان نسخهای
|
||||
* را که رویش اعمال شده نگه دارد (قانون پنجم مستند).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: PolicyRepository::class)]
|
||||
#[ORM\Table(name: 'policies')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'category', 'active'], name: 'idx_policy_tenant_category')]
|
||||
#[ORM\Index(columns: ['valid_from', 'valid_to'], name: 'idx_policy_validity')]
|
||||
class Policy
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const CATEGORY_SELECTION = 'selection';
|
||||
public const CATEGORY_ELIGIBILITY = 'eligibility';
|
||||
public const CATEGORY_RESOURCE = 'resource';
|
||||
public const CATEGORY_TIMING = 'timing';
|
||||
public const CATEGORY_SPACING = 'spacing';
|
||||
public const CATEGORY_PRICING = 'pricing';
|
||||
|
||||
public const CATEGORIES = [
|
||||
self::CATEGORY_SELECTION,
|
||||
self::CATEGORY_ELIGIBILITY,
|
||||
self::CATEGORY_RESOURCE,
|
||||
self::CATEGORY_TIMING,
|
||||
self::CATEGORY_SPACING,
|
||||
self::CATEGORY_PRICING,
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $category;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 200)]
|
||||
private string $name;
|
||||
|
||||
/**
|
||||
* نام ستون `condition` نیست چون در MariaDB کلمهٔ کلیدی است و هر INSERT را
|
||||
* میشکند؛ نام فیلد در API همان `condition` میماند.
|
||||
*
|
||||
* @var array{match?: string, conditions?: list<array<string, mixed>>}
|
||||
*/
|
||||
#[ORM\Column(name: 'condition_json', type: 'json')]
|
||||
private array $condition = [];
|
||||
|
||||
/** @var list<array<string, mixed>> */
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $effects = [];
|
||||
|
||||
/** بزرگتر یعنی مهمتر. اولین معیار حل تناقض. */
|
||||
#[ORM\Column(type: 'smallint', options: ['default' => 0])]
|
||||
private int $priority = 0;
|
||||
|
||||
// ── دامنه: هرچه باریکتر، در تساویِ اولویت برندهتر ──────────────────────
|
||||
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
|
||||
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?DoctorAddress $address = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?ServiceItem $serviceItem = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: CatalogCategory::class)]
|
||||
#[ORM\JoinColumn(name: 'catalog_category_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?CatalogCategory $catalogCategory = null;
|
||||
|
||||
#[ORM\Column(name: 'valid_from', type: 'integer', nullable: true)]
|
||||
private ?int $validFrom = null;
|
||||
|
||||
#[ORM\Column(name: 'valid_to', type: 'integer', nullable: true)]
|
||||
private ?int $validTo = null;
|
||||
|
||||
#[ORM\Column(type: 'smallint', options: ['default' => 1])]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
private bool $active = false;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $entityType, int $entityId, string $category, string $name)
|
||||
{
|
||||
if (!in_array($category, self::CATEGORIES, true)) {
|
||||
throw new \InvalidArgumentException(sprintf('Unknown policy category "%s".', $category));
|
||||
}
|
||||
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->category = $category;
|
||||
$this->name = $name;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
|
||||
$this->assignTenantPair($entityType, $entityId);
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getCategory(): string { return $this->category; }
|
||||
public function getName(): string { return $this->name; }
|
||||
public function getCondition(): array { return $this->condition; }
|
||||
public function getEffects(): array { return $this->effects; }
|
||||
public function getPriority(): int { return $this->priority; }
|
||||
public function getVersion(): int { return $this->version; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getValidFrom(): ?int { return $this->validFrom; }
|
||||
public function getValidTo(): ?int { return $this->validTo; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
public function getAddress(): ?DoctorAddress { return $this->address; }
|
||||
public function getServiceItem(): ?ServiceItem { return $this->serviceItem; }
|
||||
public function getCatalogCategory(): ?CatalogCategory { return $this->catalogCategory; }
|
||||
|
||||
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
|
||||
public function setPriority(int $v): self { $this->priority = $v; $this->touch(); return $this; }
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
public function setAddress(?DoctorAddress $v): self { $this->address = $v; $this->touch(); return $this; }
|
||||
public function setServiceItem(?ServiceItem $v): self { $this->serviceItem = $v; $this->touch(); return $this; }
|
||||
public function setCatalogCategory(?CatalogCategory $v): self { $this->catalogCategory = $v; $this->touch(); return $this; }
|
||||
|
||||
/** @param array<string, mixed> $condition */
|
||||
public function setCondition(array $condition): self { $this->condition = $condition; $this->touch(); return $this; }
|
||||
|
||||
/** @param list<array<string, mixed>> $effects */
|
||||
public function setEffects(array $effects): self { $this->effects = $effects; $this->touch(); return $this; }
|
||||
|
||||
public function setValidity(?int $from, ?int $to): self
|
||||
{
|
||||
if ($from !== null && $to !== null && $to <= $from) {
|
||||
throw new \InvalidArgumentException('Policy validity end must be after its start.');
|
||||
}
|
||||
|
||||
$this->validFrom = $from;
|
||||
$this->validTo = $to;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function bumpVersion(): self { $this->version++; $this->touch(); return $this; }
|
||||
|
||||
public function appliesAt(int $at): bool
|
||||
{
|
||||
return $this->active
|
||||
&& ($this->validFrom === null || $at >= $this->validFrom)
|
||||
&& ($this->validTo === null || $at < $this->validTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* هرچه باریکتر، بزرگتر. در تساویِ اولویت، اختصاصیتر برنده است — «این سرویس» باید
|
||||
* بتواند «همهٔ سرویسها» را کنار بزند، وگرنه استثنا غیرقابل بیان میشود.
|
||||
*/
|
||||
public function specificity(): int
|
||||
{
|
||||
return ($this->address !== null ? 4 : 0)
|
||||
+ ($this->serviceItem !== null ? 2 : 0)
|
||||
+ ($this->catalogCategory !== null ? 1 : 0);
|
||||
}
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'category' => $this->category,
|
||||
'name' => $this->name,
|
||||
'condition' => (object) $this->condition,
|
||||
'effects' => $this->effects,
|
||||
'priority' => $this->priority,
|
||||
'version' => $this->version,
|
||||
'active' => $this->active,
|
||||
'valid_from' => $this->validFrom,
|
||||
'valid_to' => $this->validTo,
|
||||
'address_uuid' => $this->address?->getUuid(),
|
||||
'service_uuid' => $this->serviceItem?->getUuid(),
|
||||
'catalog_category_uuid' => $this->catalogCategory?->getUuid(),
|
||||
'specificity' => $this->specificity(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Entity;
|
||||
|
||||
use App\Policy\Repository\PolicyVersionLogRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* عکس هر نسخهٔ یک قانون.
|
||||
*
|
||||
* نوبتی که دیروز ثبت شده، نسخهای را در `applied_policies` نگه میدارد که ممکن است
|
||||
* امروز دیگر متن فعلی قانون نباشد. بدون این جدول، «چرا آن نوبت این قیمت را گرفت؟»
|
||||
* سه ماه بعد بیجواب میماند.
|
||||
*
|
||||
* فرزند aggregate با ریشهٔ {@see Policy}؛ فقط نوشته میشود و هرگز ویرایش نمیشود.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: PolicyVersionLogRepository::class)]
|
||||
#[ORM\Table(name: 'policy_version_logs')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_policy_version', columns: ['policy_id', 'version'])]
|
||||
class PolicyVersionLog
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Policy::class)]
|
||||
#[ORM\JoinColumn(name: 'policy_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Policy $policy;
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $version;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $snapshot;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(Policy $policy, int $version, array $snapshot)
|
||||
{
|
||||
$this->policy = $policy;
|
||||
$this->version = $version;
|
||||
$this->snapshot = $snapshot;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getPolicy(): Policy { return $this->policy; }
|
||||
public function getVersion(): int { return $this->version; }
|
||||
public function getSnapshot(): array { return $this->snapshot; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'version' => $this->version,
|
||||
'snapshot' => $this->snapshot,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Repository;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Policy>
|
||||
*/
|
||||
class PolicyRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Policy::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?Policy
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* قانونهای فعالِ یک دسته — یک کوئری per دسته، نه per قانون.
|
||||
*
|
||||
* @return Policy[]
|
||||
*/
|
||||
public function findForCategory(string $entityType, int $entityId, string $category): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->addSelect('a', 's', 'c')
|
||||
->leftJoin('p.address', 'a')
|
||||
->leftJoin('p.serviceItem', 's')
|
||||
->leftJoin('p.catalogCategory', 'c')
|
||||
->where('p.entityType = :type')
|
||||
->andWhere('p.entityId = :id')
|
||||
->andWhere('p.category = :category')
|
||||
->andWhere('p.active = true')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->setParameter('category', $category)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @return Policy[] */
|
||||
public function findForPair(string $entityType, int $entityId, ?string $category = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('p')
|
||||
->where('p.entityType = :type')
|
||||
->andWhere('p.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->orderBy('p.priority', 'DESC')
|
||||
->addOrderBy('p.createdAt', 'ASC');
|
||||
|
||||
if ($category !== null) {
|
||||
$qb->andWhere('p.category = :category')->setParameter('category', $category);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Repository;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Entity\PolicyVersionLog;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<PolicyVersionLog>
|
||||
*/
|
||||
class PolicyVersionLogRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PolicyVersionLog::class);
|
||||
}
|
||||
|
||||
/** @return PolicyVersionLog[] */
|
||||
public function findForPolicy(Policy $policy): array
|
||||
{
|
||||
return $this->createQueryBuilder('l')
|
||||
->where('l.policy = :policy')
|
||||
->setParameter('policy', $policy)
|
||||
->orderBy('l.version', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\ValueObject\PolicyOutcome;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\UserProfile\Entity\UserProfile;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* دو دستهٔ قانون که به **بیمار** وابستهاند، نه به سرویس: «صلاحیت» و «فاصله».
|
||||
*
|
||||
* جای اجرایشان لحظهٔ رزرو موقت است نه ثبت نهایی: کاربری که ده دقیقه صندلی گرفته و
|
||||
* بعد میشنود «شما واجد شرایط نیستید» هم وقت خودش را تلف کرده هم صندلی را.
|
||||
*
|
||||
* حقایق از پروفایل و تاریخچهٔ بیمار خوانده میشوند نه از بدنهٔ درخواست — با یک
|
||||
* استثنا: `has_parental_consent` چیزی است که اپراتور همان لحظه تأیید میکند و
|
||||
* جایی برای ذخیره ندارد.
|
||||
*/
|
||||
final class BookingPolicyGuard
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyResolver $policies,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param ServiceItem[] $items
|
||||
* @param array<string, mixed> $extraFacts حقایقی که فقط در همین درخواست وجود دارند
|
||||
*
|
||||
* @throws AppException ۴۲۲ اگر قانونی این بیمار را ممنوع کند
|
||||
*/
|
||||
public function assertEligible(
|
||||
User $patient,
|
||||
ServiceItem $service,
|
||||
array $items,
|
||||
DoctorAddress $address,
|
||||
array $extraFacts = [],
|
||||
?int $at = null,
|
||||
): PolicyOutcome {
|
||||
$at = $at ?? time();
|
||||
|
||||
$outcome = $this->policies->resolve(
|
||||
Policy::CATEGORY_ELIGIBILITY,
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
$this->patientFacts($patient, $address, $at) + $extraFacts + ['item_count' => count($items) + 1],
|
||||
$address,
|
||||
$service,
|
||||
$at,
|
||||
);
|
||||
|
||||
if ($outcome->isForbidden()) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
implode(' ', $outcome->forbidReasons),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// `require_flag` ممنوعیت نیست، شرط است: تا وقتی اپراتور آن پرچم را نفرستاده
|
||||
// درخواست ناقص است، و بعد از فرستادنش قانون راضی است.
|
||||
$missing = array_values(array_filter(
|
||||
(array) $outcome->effect(PolicySchema::EFFECT_REQUIRE_FLAG, []),
|
||||
static fn (string $flag): bool => empty($extraFacts[$flag]),
|
||||
));
|
||||
|
||||
if ($missing !== []) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_002,
|
||||
sprintf('برای این نوبت تأیید %s الزامی است', implode('، ', $missing)),
|
||||
422,
|
||||
$missing[0],
|
||||
);
|
||||
}
|
||||
|
||||
return $outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* حداقل فاصله تا نوبت قبلیِ **همان دسته** — بند «فاصلهٔ بین جلسات».
|
||||
*
|
||||
* مبنا نوبت قبلی است نه نوبت بعدی: قانون میگوید بعد از هر جلسه چقدر باید صبر
|
||||
* کرد، پس رزرو آیندهای که هنوز انجام نشده معیار نیست.
|
||||
*
|
||||
* @throws AppException ۴۲۲ اگر فاصله کافی نباشد
|
||||
*/
|
||||
public function assertSpacing(
|
||||
User $patient,
|
||||
ServiceItem $service,
|
||||
DoctorAddress $address,
|
||||
int $startsAt,
|
||||
?int $at = null,
|
||||
): void {
|
||||
$at = $at ?? time();
|
||||
|
||||
$outcome = $this->policies->resolve(
|
||||
Policy::CATEGORY_SPACING,
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
[
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'catalog_category' => $service->getCatalogCategory()?->getUuid(),
|
||||
],
|
||||
$address,
|
||||
$service,
|
||||
$at,
|
||||
);
|
||||
|
||||
$minDays = (int) $outcome->effect(PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 0);
|
||||
|
||||
if ($minDays <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$last = $this->lastAppointmentAt($patient, $service, $startsAt);
|
||||
|
||||
if ($last === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$gapDays = (int) floor(($startsAt - $last) / 86400);
|
||||
|
||||
if ($gapDays < $minDays) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('بین دو جلسهٔ این خدمت باید حداقل %d روز فاصله باشد', $minDays),
|
||||
422,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function patientFacts(User $patient, DoctorAddress $address, int $at): array
|
||||
{
|
||||
$profile = $this->em->getRepository(UserProfile::class)->findOneBy(['user' => $patient]);
|
||||
|
||||
return [
|
||||
'patient_age' => $this->ageOf($profile?->getDateOfBirth(), $at),
|
||||
'patient_gender' => $profile?->getGender(),
|
||||
'patient_tags' => [],
|
||||
'visit_count' => $this->visitCount($patient, $address),
|
||||
];
|
||||
}
|
||||
|
||||
/** سن با سال میانگین گریگوری حساب میشود؛ اختلافش با شمسی در مرز سن صفر است. */
|
||||
private function ageOf(?int $dateOfBirth, int $at): ?int
|
||||
{
|
||||
if ($dateOfBirth === null || $dateOfBirth <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) floor(($at - $dateOfBirth) / 31556952);
|
||||
}
|
||||
|
||||
private function visitCount(User $patient, DoctorAddress $address): int
|
||||
{
|
||||
return (int) $this->em->createQueryBuilder()
|
||||
->select('COUNT(a.id)')
|
||||
->from(Appointment::class, 'a')
|
||||
->where('a.user = :user')
|
||||
->andWhere('a.status = :status')
|
||||
->setParameter('user', $patient)
|
||||
->setParameter('status', Appointment::STATUS_COMPLETED)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* آخرین نوبتِ گذشتهٔ بیمار از همان دستهٔ کاتالوگ — یا از همان سرویس اگر دسته ندارد.
|
||||
*/
|
||||
private function lastAppointmentAt(User $patient, ServiceItem $service, int $before): ?int
|
||||
{
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select('MAX(a.slotStart)')
|
||||
->from(Appointment::class, 'a')
|
||||
->join('a.serviceItem', 'si')
|
||||
->where('a.user = :user')
|
||||
->andWhere('a.slotStart < :before')
|
||||
->andWhere('a.status NOT IN (:dead)')
|
||||
->setParameter('user', $patient)
|
||||
->setParameter('before', $before)
|
||||
->setParameter('dead', [
|
||||
Appointment::STATUS_CANCELLED_BY_DOCTOR,
|
||||
Appointment::STATUS_CANCELLED_BY_USER,
|
||||
Appointment::STATUS_EXPIRED,
|
||||
]);
|
||||
|
||||
$category = $service->getCatalogCategory();
|
||||
|
||||
if ($category !== null) {
|
||||
$qb->andWhere('si.catalogCategory = :category')->setParameter('category', $category);
|
||||
} else {
|
||||
$qb->andWhere('si = :service')->setParameter('service', $service);
|
||||
}
|
||||
|
||||
$result = $qb->getQuery()->getSingleScalarResult();
|
||||
|
||||
return $result === null ? null : (int) $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Service;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* ارزیابی شرط یک قانون در برابر «حقایق» یک درخواست.
|
||||
*
|
||||
* هیچ کد دلخواهی اجرا نمیشود: فیلد باید در فهرست بستهٔ {@see PolicySchema} باشد و
|
||||
* عملگر از شش عملگر ثابت. شرطی که فیلد ناشناخته دارد **در زمان ساخت** رد میشود، نه
|
||||
* در زمان اجرا — قانونی که موقع رزرو بیمار بترکد، بدترین جای ممکن برای شکستن است.
|
||||
*/
|
||||
final class ConditionEvaluator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicySchema $schema,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $facts
|
||||
*/
|
||||
public function matches(Policy $policy, array $facts): bool
|
||||
{
|
||||
$condition = $policy->getCondition();
|
||||
$conditions = $condition['conditions'] ?? [];
|
||||
|
||||
// شرط خالی یعنی «همیشه» — قانونِ بیقید و شرط کاملاً معتبر است.
|
||||
if ($conditions === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$mode = ($condition['match'] ?? 'all') === 'any' ? 'any' : 'all';
|
||||
|
||||
foreach ($conditions as $clause) {
|
||||
$result = $this->evaluateClause($clause, $facts);
|
||||
|
||||
if ($mode === 'any' && $result) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($mode === 'all' && !$result) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $mode === 'all';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $facts */
|
||||
private function evaluateClause(mixed $clause, array $facts): bool
|
||||
{
|
||||
if (!is_array($clause) || !is_string($clause['field'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$field = $clause['field'];
|
||||
$operator = $clause['operator'] ?? PolicySchema::OP_EQUALS;
|
||||
$expected = $clause['value'] ?? null;
|
||||
|
||||
// فیلدی که در حقایق این درخواست نیست، شرط را **رد** میکند نه اینکه نادیده
|
||||
// بگیرد: قانون «سن زیر ۱۸» وقتی سن نامشخص است نباید بیصدا صادق شود.
|
||||
if (!array_key_exists($field, $facts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->compare($facts[$field], $operator, $expected);
|
||||
}
|
||||
|
||||
private function compare(mixed $actual, string $operator, mixed $expected): bool
|
||||
{
|
||||
return match ($operator) {
|
||||
PolicySchema::OP_EQUALS => $this->looselyEqual($actual, $expected),
|
||||
PolicySchema::OP_NOT_EQUALS => !$this->looselyEqual($actual, $expected),
|
||||
PolicySchema::OP_GREATER_THAN => is_numeric($actual) && is_numeric($expected) && $actual > $expected,
|
||||
PolicySchema::OP_LESS_THAN => is_numeric($actual) && is_numeric($expected) && $actual < $expected,
|
||||
PolicySchema::OP_IN => is_array($expected) && in_array($actual, $expected, false),
|
||||
PolicySchema::OP_CONTAINS => is_array($actual) && in_array($expected, $actual, false),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function looselyEqual(mixed $a, mixed $b): bool
|
||||
{
|
||||
// مقایسهٔ سست عمدی است: مقدارِ آمده از JSON ممکن است "18" باشد و حقیقت 18.
|
||||
// مقایسهٔ سخت اینجا فقط باگهای خاموش میساخت.
|
||||
return is_scalar($a) && is_scalar($b) ? $a == $b : $a === $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* اعتبارسنجی ساختار شرط در **زمان ساخت**.
|
||||
*
|
||||
* @param array<string, mixed> $condition
|
||||
* @throws AppException
|
||||
*/
|
||||
public function assertValid(string $category, array $condition): void
|
||||
{
|
||||
// کلید ناشناس در ریشهٔ شرط **خطاست**: `{"all": [...]}` بهجای
|
||||
// `{"match": "all", "conditions": [...]}` شرطی خالی میسازد که همیشه صادق
|
||||
// است — یعنی قانون روی همهچیز اجرا میشود بیآنکه کسی بفهمد.
|
||||
$unknown = array_diff(array_keys($condition), ['match', 'conditions']);
|
||||
|
||||
if ($unknown !== []) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('کلید «%s» در شرط شناخته نمیشود؛ ساختار درست {match, conditions} است', (string) reset($unknown)),
|
||||
422,
|
||||
'condition',
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($condition['match']) && !in_array($condition['match'], ['all', 'any'], true)) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مقدار match باید all یا any باشد', 422, 'condition');
|
||||
}
|
||||
|
||||
if (isset($condition['conditions']) && !is_array($condition['conditions'])) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'conditions باید فهرست باشد', 422, 'condition');
|
||||
}
|
||||
|
||||
foreach (($condition['conditions'] ?? []) as $clause) {
|
||||
if (!is_array($clause) || !is_string($clause['field'] ?? null)) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'هر شرط باید فیلد داشته باشد', 422, 'condition');
|
||||
}
|
||||
|
||||
if (!$this->schema->allowsField($category, $clause['field'])) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf(
|
||||
'فیلد «%s» برای دستهٔ «%s» مجاز نیست. مجازها: %s',
|
||||
$clause['field'],
|
||||
$category,
|
||||
implode('، ', PolicySchema::FIELDS[$category] ?? []),
|
||||
),
|
||||
422,
|
||||
'condition',
|
||||
);
|
||||
}
|
||||
|
||||
$operator = $clause['operator'] ?? PolicySchema::OP_EQUALS;
|
||||
|
||||
if (!in_array($operator, PolicySchema::OPERATORS, true)) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('عملگر «%s» شناخته نمیشود', is_string($operator) ? $operator : '—'),
|
||||
422,
|
||||
'condition',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ورودی مستقیم از JSON کاربر میآید، پس نوعش `mixed` است نه آرایهٔ ساختاریافته —
|
||||
* اعتبارسنجی همینجا همان چیزی است که ساختار را تضمین میکند.
|
||||
*
|
||||
* @param list<mixed> $effects
|
||||
* @throws AppException
|
||||
*/
|
||||
public function assertEffectsValid(string $category, array $effects): void
|
||||
{
|
||||
if ($effects === []) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'قانون باید حداقل یک اثر داشته باشد', 422, 'effects');
|
||||
}
|
||||
|
||||
foreach ($effects as $effect) {
|
||||
if (!is_array($effect) || !is_string($effect['type'] ?? null)) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'هر اثر باید نوع داشته باشد', 422, 'effects');
|
||||
}
|
||||
|
||||
if (!$this->schema->allowsEffect($category, $effect['type'])) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf(
|
||||
'اثر «%s» با دستهٔ «%s» سازگار نیست. مجازها: %s',
|
||||
$effect['type'],
|
||||
$category,
|
||||
implode('، ', PolicySchema::EFFECTS[$category] ?? []),
|
||||
),
|
||||
422,
|
||||
'effects',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Service;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Repository\PolicyRepository;
|
||||
use App\Policy\ValueObject\PolicyOutcome;
|
||||
|
||||
/**
|
||||
* انتخاب قانونهای مرتبط، حل تناقض، و ترکیب اثرها.
|
||||
*
|
||||
* ## ترتیب حل تناقض (بند ۸ مستند)
|
||||
*
|
||||
* ۱. **اولویت** بزرگتر
|
||||
* ۲. در تساوی: **اختصاصیتر** (شعبه بر محیط، سرویس بر دسته)
|
||||
* ۳. باز هم تساوی: قانون **قدیمیتر**
|
||||
*
|
||||
* قاعدهٔ سوم عمداً «قدیمیتر» است نه «تازهتر»: قانونی که مدتهاست کار میکند رفتار
|
||||
* جاافتادهٔ کلینیک است و قانون تازهای که تصادفاً هماولویت شده نباید بیصدا عوضش کند.
|
||||
*
|
||||
* ## ترکیب اثرها
|
||||
*
|
||||
* از جدول {@see PolicySchema::COMBINATION} میآید — `veto`، `max`، `sum`، `union`.
|
||||
* یک `forbid` کل عملیات را رد میکند حتی اگر ده قانون مجازکننده باشند؛ ممنوعیت رأی
|
||||
* اکثریت نیست.
|
||||
*/
|
||||
final class PolicyResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyRepository $policies,
|
||||
private readonly ConditionEvaluator $evaluator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $facts
|
||||
*/
|
||||
public function resolve(
|
||||
string $category,
|
||||
string $entityType,
|
||||
int $entityId,
|
||||
array $facts,
|
||||
?DoctorAddress $address = null,
|
||||
?ServiceItem $service = null,
|
||||
?int $at = null,
|
||||
): PolicyOutcome {
|
||||
$at = $at ?? time();
|
||||
$candidates = $this->policies->findForCategory($entityType, $entityId, $category);
|
||||
|
||||
$matched = [];
|
||||
|
||||
foreach ($candidates as $policy) {
|
||||
if (!$policy->appliesAt($at) || !$this->inScope($policy, $address, $service)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->evaluator->matches($policy, $facts)) {
|
||||
$matched[] = $policy;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matched === []) {
|
||||
return new PolicyOutcome();
|
||||
}
|
||||
|
||||
usort($matched, $this->comparator(...));
|
||||
|
||||
return $this->combine($matched);
|
||||
}
|
||||
|
||||
/**
|
||||
* قانونی که دامنهاش با این درخواست نمیخواند اصلاً کاندید نیست.
|
||||
*
|
||||
* دامنهٔ تهی یعنی «همه» — قانون سطح محیط روی همهچیز اعمال میشود.
|
||||
*/
|
||||
private function inScope(Policy $policy, ?DoctorAddress $address, ?ServiceItem $service): bool
|
||||
{
|
||||
if ($policy->getAddress() !== null && $policy->getAddress()->getId() !== $address?->getId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($policy->getServiceItem() !== null && $policy->getServiceItem()->getId() !== $service?->getId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($policy->getCatalogCategory() !== null
|
||||
&& $policy->getCatalogCategory()->getId() !== $service?->getCatalogCategory()?->getId()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function comparator(Policy $a, Policy $b): int
|
||||
{
|
||||
return [$b->getPriority(), $b->specificity(), $a->getCreatedAt()]
|
||||
<=> [$a->getPriority(), $a->specificity(), $b->getCreatedAt()];
|
||||
}
|
||||
|
||||
/** @param Policy[] $policies به ترتیب برندهترین */
|
||||
private function combine(array $policies): PolicyOutcome
|
||||
{
|
||||
$effects = [];
|
||||
$applied = [];
|
||||
$forbids = [];
|
||||
|
||||
foreach ($policies as $policy) {
|
||||
$contributed = false;
|
||||
|
||||
foreach ($policy->getEffects() as $effect) {
|
||||
$type = $effect['type'] ?? null;
|
||||
|
||||
if (!is_string($type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contributed = true;
|
||||
$mode = PolicySchema::COMBINATION[$type] ?? 'max';
|
||||
$value = $effect['value'] ?? true;
|
||||
|
||||
if ($mode === 'veto') {
|
||||
// متن دلخواه با کلید `reason` میآید؛ نبودنش خطا نیست چون نام
|
||||
// خودِ قانون همیشه یک توضیح قابلفهم است.
|
||||
$reason = $effect['reason'] ?? null;
|
||||
$forbids[] = is_string($reason) && trim($reason) !== ''
|
||||
? $reason
|
||||
: sprintf('قانون «%s» این عملیات را مجاز نمیداند', $policy->getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
$effects[$type] = match ($mode) {
|
||||
'sum' => (float) ($effects[$type] ?? 0) + (float) $value,
|
||||
'union' => array_values(array_unique([...($effects[$type] ?? []), ...(array) $value])),
|
||||
default => max($effects[$type] ?? $value, $value), // max
|
||||
};
|
||||
}
|
||||
|
||||
if ($contributed) {
|
||||
$applied[] = [
|
||||
'uuid' => $policy->getUuid(),
|
||||
'name' => $policy->getName(),
|
||||
'version' => $policy->getVersion(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// جمعها به عدد صحیح برمیگردند: دقیقه و ریال هر دو صحیحاند.
|
||||
foreach ($effects as $type => $value) {
|
||||
if (is_float($value)) {
|
||||
$effects[$type] = $value == (int) $value ? (int) $value : $value;
|
||||
}
|
||||
}
|
||||
|
||||
return new PolicyOutcome($effects, $applied, $forbids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\Service;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
|
||||
/**
|
||||
* فهرست **بستهٔ** فیلدها، عملگرها و اثرها per دسته.
|
||||
*
|
||||
* بند ۸ مستند صریح است: شرط از فهرست بسته میآید و کد دلخواه وجود ندارد. دو دلیل:
|
||||
*
|
||||
* ۱. قانونی که هر عبارتی را بتواند ارزیابی کند، قابل تحلیل ایستا نیست — و دستهٔ
|
||||
* `spacing` باید به **کوئری** تبدیل شود، نه اینکه per اسلات در PHP اجرا شود
|
||||
* (برای ۹۰ روز غیرقابل قبول است).
|
||||
* ۲. فرم ساخت قانون در پنل از همین schema ساخته میشود، نه از فهرستی که در فرانت
|
||||
* دوباره نوشته شده باشد. دو فهرست یعنی دو حقیقت.
|
||||
*/
|
||||
final class PolicySchema
|
||||
{
|
||||
public const OP_EQUALS = 'equals';
|
||||
public const OP_NOT_EQUALS = 'not_equals';
|
||||
public const OP_GREATER_THAN = 'greater_than';
|
||||
public const OP_LESS_THAN = 'less_than';
|
||||
public const OP_IN = 'in';
|
||||
public const OP_CONTAINS = 'contains';
|
||||
|
||||
public const OPERATORS = [
|
||||
self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_GREATER_THAN,
|
||||
self::OP_LESS_THAN, self::OP_IN, self::OP_CONTAINS,
|
||||
];
|
||||
|
||||
// ── اثرها ───────────────────────────────────────────────────────────────
|
||||
public const EFFECT_FORBID = 'forbid';
|
||||
public const EFFECT_REQUIRE_RESOURCE = 'require_resource';
|
||||
public const EFFECT_MIN_DURATION = 'min_duration_minutes';
|
||||
public const EFFECT_ADD_DURATION = 'add_duration_minutes';
|
||||
public const EFFECT_MIN_DAYS_BETWEEN = 'min_days_between';
|
||||
public const EFFECT_DISCOUNT_PERCENT = 'discount_percent';
|
||||
public const EFFECT_DISCOUNT_RIALS = 'discount_rials';
|
||||
public const EFFECT_REQUIRE_FLAG = 'require_flag';
|
||||
|
||||
/** فیلدهای مجاز per دسته. */
|
||||
public const FIELDS = [
|
||||
Policy::CATEGORY_SELECTION => ['item_count', 'item_uuids', 'catalog_category'],
|
||||
Policy::CATEGORY_ELIGIBILITY => ['patient_age', 'patient_gender', 'patient_tags', 'has_parental_consent', 'visit_count'],
|
||||
Policy::CATEGORY_RESOURCE => ['catalog_category', 'service_uuid', 'item_count'],
|
||||
Policy::CATEGORY_TIMING => ['catalog_category', 'service_uuid', 'item_count', 'patient_age'],
|
||||
Policy::CATEGORY_SPACING => ['catalog_category', 'service_uuid'],
|
||||
Policy::CATEGORY_PRICING => ['patient_tags', 'visit_count', 'item_count', 'subtotal_rials'],
|
||||
];
|
||||
|
||||
/** اثرهای مجاز per دسته — اثر ناسازگار با دسته پذیرفته نمیشود. */
|
||||
public const EFFECTS = [
|
||||
Policy::CATEGORY_SELECTION => [self::EFFECT_FORBID],
|
||||
Policy::CATEGORY_ELIGIBILITY => [self::EFFECT_FORBID, self::EFFECT_REQUIRE_FLAG],
|
||||
Policy::CATEGORY_RESOURCE => [self::EFFECT_REQUIRE_RESOURCE, self::EFFECT_FORBID],
|
||||
Policy::CATEGORY_TIMING => [self::EFFECT_MIN_DURATION, self::EFFECT_ADD_DURATION],
|
||||
Policy::CATEGORY_SPACING => [self::EFFECT_MIN_DAYS_BETWEEN],
|
||||
Policy::CATEGORY_PRICING => [self::EFFECT_DISCOUNT_PERCENT, self::EFFECT_DISCOUNT_RIALS],
|
||||
];
|
||||
|
||||
/**
|
||||
* چگونه چند اثرِ همنوع با هم ترکیب میشوند — جدول بند ۸.
|
||||
*
|
||||
* `forbid` هیچوقت ترکیب نمیشود: یک ممنوعیت کل عملیات را رد میکند، حتی اگر ده
|
||||
* قانون مجازکننده باشند.
|
||||
*/
|
||||
public const COMBINATION = [
|
||||
self::EFFECT_FORBID => 'veto',
|
||||
self::EFFECT_REQUIRE_RESOURCE => 'union',
|
||||
self::EFFECT_REQUIRE_FLAG => 'union',
|
||||
self::EFFECT_MIN_DURATION => 'max',
|
||||
self::EFFECT_MIN_DAYS_BETWEEN => 'max',
|
||||
self::EFFECT_ADD_DURATION => 'sum',
|
||||
self::EFFECT_DISCOUNT_PERCENT => 'sum',
|
||||
self::EFFECT_DISCOUNT_RIALS => 'sum',
|
||||
];
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function describe(): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach (Policy::CATEGORIES as $category) {
|
||||
$out[$category] = [
|
||||
'fields' => self::FIELDS[$category],
|
||||
'operators' => self::OPERATORS,
|
||||
'effects' => array_map(
|
||||
static fn (string $effect): array => [
|
||||
'type' => $effect,
|
||||
'combination' => self::COMBINATION[$effect],
|
||||
],
|
||||
self::EFFECTS[$category],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function allowsField(string $category, string $field): bool
|
||||
{
|
||||
return in_array($field, self::FIELDS[$category] ?? [], true);
|
||||
}
|
||||
|
||||
public function allowsEffect(string $category, string $effect): bool
|
||||
{
|
||||
return in_array($effect, self::EFFECTS[$category] ?? [], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policy\ValueObject;
|
||||
|
||||
/**
|
||||
* نتیجهٔ اعمال یک دسته قانون: اثرهای ترکیبشده + ردِ قانونهایی که اعمال شدند.
|
||||
*
|
||||
* `appliedPolicies` شناسه **و نسخه** را نگه میدارد. فقط شناسه کافی نیست: قانون فردا
|
||||
* نسخهٔ ۲ میگیرد و آنوقت «چرا این نوبت این قیمت را گرفت؟» جواب اشتباه میدهد.
|
||||
*/
|
||||
final readonly class PolicyOutcome
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $effects نوعِ اثر => مقدار ترکیبشده
|
||||
* @param list<array<string, mixed>> $appliedPolicies
|
||||
* @param list<string> $forbidReasons پیامهای انسانیِ ممنوعیت
|
||||
*/
|
||||
public function __construct(
|
||||
public array $effects = [],
|
||||
public array $appliedPolicies = [],
|
||||
public array $forbidReasons = [],
|
||||
) {}
|
||||
|
||||
public function isForbidden(): bool
|
||||
{
|
||||
return $this->forbidReasons !== [];
|
||||
}
|
||||
|
||||
public function effect(string $type, mixed $default = null): mixed
|
||||
{
|
||||
return $this->effects[$type] ?? $default;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'effects' => (object) $this->effects,
|
||||
'applied_policies' => $this->appliedPolicies,
|
||||
'forbidden' => $this->isForbidden(),
|
||||
'forbid_reasons' => $this->forbidReasons,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Pricing\Repository\PriceListItemRepository;
|
||||
use App\Pricing\Repository\PriceListRepository;
|
||||
use App\Pricing\ValueObject\PriceQuote;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\PolicyResolver;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use App\Representation\Service\JalaliDateService;
|
||||
|
||||
/**
|
||||
@@ -33,6 +36,7 @@ use App\Representation\Service\JalaliDateService;
|
||||
final class PricingEngine
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyResolver $policies,
|
||||
private readonly PriceListRepository $priceLists,
|
||||
private readonly PriceListItemRepository $priceListItems,
|
||||
private readonly ServiceBranchOverrideRepository $overrides,
|
||||
@@ -73,6 +77,10 @@ final class PricingEngine
|
||||
$subtotal = $base + $itemsTotal;
|
||||
|
||||
// ── تخفیف ─────────────────────────────────────────────────────────────
|
||||
// قوانین دستهٔ «قیمت» کنار سیاست دستیِ درخواست مینشینند، نه بهجایش: تخفیفی
|
||||
// که اپراتور دستی میدهد و تخفیفی که قانون میدهد هر دو واقعیاند.
|
||||
$policy = $this->mergePolicyDiscounts($service, $items, $address, $at, $subtotal, $policy, $sources);
|
||||
|
||||
[$discount, $discounts] = $this->discountFor($subtotal, $policy);
|
||||
|
||||
// تخفیف بیشتر از مبلغ، مبلغ را **صفر** میکند نه منفی: بدهی منفی یعنی کلینیک
|
||||
@@ -116,6 +124,58 @@ final class PricingEngine
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* اثر قوانین «قیمت» را به سیاست درخواست اضافه میکند.
|
||||
*
|
||||
* شناسه و **نسخهٔ** هر قانون در `sources` ثبت میشود تا فاکتور بتواند سه ماه بعد
|
||||
* بگوید کدام نسخه رویش اعمال شده بود.
|
||||
*
|
||||
* @param ServiceItem[] $items
|
||||
* @param array<string, mixed> $policy
|
||||
* @param array<string, mixed> $sources
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mergePolicyDiscounts(
|
||||
ServiceItem $service,
|
||||
array $items,
|
||||
DoctorAddress $address,
|
||||
int $at,
|
||||
int $subtotal,
|
||||
array $policy,
|
||||
array &$sources,
|
||||
): array {
|
||||
$outcome = $this->policies->resolve(
|
||||
Policy::CATEGORY_PRICING,
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
[
|
||||
'item_count' => count($items),
|
||||
'subtotal_rials' => $subtotal,
|
||||
'patient_tags' => $policy['patient_tags'] ?? [],
|
||||
'visit_count' => $policy['visit_count'] ?? 0,
|
||||
],
|
||||
$address,
|
||||
$service,
|
||||
$at,
|
||||
);
|
||||
|
||||
if ($outcome->appliedPolicies === []) {
|
||||
return $policy;
|
||||
}
|
||||
|
||||
$sources['applied_policies'] = $outcome->appliedPolicies;
|
||||
|
||||
$policy['discount_percent'] = (float) ($policy['discount_percent'] ?? 0)
|
||||
+ (float) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_PERCENT, 0);
|
||||
|
||||
$policy['discount_rials'] = (int) ($policy['discount_rials'] ?? 0)
|
||||
+ (int) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_RIALS, 0);
|
||||
|
||||
$policy['discount_label'] ??= $outcome->appliedPolicies[0]['name'];
|
||||
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $sources
|
||||
*/
|
||||
|
||||
@@ -110,6 +110,7 @@ final class GlobalTables
|
||||
\App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class,
|
||||
\App\ClinicService\Entity\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class,
|
||||
\App\Pricing\Entity\PriceListItem::class => \App\Pricing\Entity\PriceList::class,
|
||||
\App\Policy\Entity\PolicyVersionLog::class => \App\Policy\Entity\Policy::class,
|
||||
|
||||
\App\Billing\Entity\ClaimItem::class => \App\Billing\Entity\Claim::class,
|
||||
\App\Billing\Entity\ClaimStatusLog::class => \App\Billing\Entity\Claim::class,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
|
||||
/**
|
||||
* خروجی واقعیِ اندپوینتهای قانون برای `docs/api/policy.md`.
|
||||
*
|
||||
* جدا از تستهای رفتاری است و در گروه `docs` میماند تا در اجرای عادی نویز نسازد.
|
||||
*/
|
||||
#[Group('docs')]
|
||||
class DocsCaptureTest extends ApiTestCase
|
||||
{
|
||||
public function testCapture(): void
|
||||
{
|
||||
// بهصورت پیشفرض رد میشود: کارش تولید خروجی برای مستندات است، نه ادعای رفتار.
|
||||
if (getenv('POLICY_DOCS') !== '1') {
|
||||
self::markTestSkipped('برای تولید خروجی مستندات: POLICY_DOCS=1');
|
||||
}
|
||||
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($user);
|
||||
$clinic->setName('کلینیک نمونه');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی');
|
||||
$this->em->persist($section);
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setName('شعبهٔ مرکزی');
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
$service = new ServiceItem($section, 'لیزر صورت');
|
||||
$service->setSoloDurationMinutes(20);
|
||||
$service->setPriceRials(1_000_000);
|
||||
$this->em->persist($service);
|
||||
$this->em->flush();
|
||||
|
||||
$dump = function (string $label, mixed $body): void {
|
||||
fwrite(
|
||||
STDERR,
|
||||
sprintf("\n===%s %d===\n%s\n", $label, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)),
|
||||
);
|
||||
};
|
||||
|
||||
$dump('SCHEMA', $this->authJson('GET', '/api/v1/policy-schema', $user));
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'حداقل یک ساعت برای لیزر',
|
||||
'priority' => 10,
|
||||
'condition' => ['match' => 'all', 'conditions' => [
|
||||
['field' => 'item_count', 'operator' => 'greater_than', 'value' => 1],
|
||||
]],
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 60]],
|
||||
]);
|
||||
$dump('CREATE', $created);
|
||||
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$dump('ACTIVATE', $this->authJson('POST', "/api/v1/policy/$uuid/activate", $user));
|
||||
|
||||
$dump('VERSION', $this->authJson('POST', "/api/v1/policy/$uuid/version", $user, [
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 90]],
|
||||
]));
|
||||
|
||||
$dump('SHOW', $this->authJson('GET', "/api/v1/policy/$uuid", $user));
|
||||
$dump('INDEX', $this->authJson('GET', '/api/v1/policies?category=timing', $user));
|
||||
|
||||
$dump('BAD_FIELD', $this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون نامعتبر',
|
||||
'condition' => ['match' => 'all', 'conditions' => [
|
||||
['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 10],
|
||||
]],
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
|
||||
]));
|
||||
|
||||
$dump('BAD_EFFECT', $this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'اثر نامعتبر',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]));
|
||||
|
||||
$dump('DEACTIVATE', $this->authJson('POST', "/api/v1/policy/$uuid/deactivate", $user));
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* بدون هیچ قانونی، خروجیها باید **دقیقاً** همان تسک ۰۸ باشند.
|
||||
*
|
||||
* موتور قوانین یک لایهٔ افزودنی است نه بازنویسی: کلینیکی که هیچ قانونی ننوشته نباید
|
||||
* هیچ تفاوتی حس کند. این تست همان تضمین را میگیرد — و چون همهٔ قلابها در مسیر داغ
|
||||
* نشستهاند، شکستنش یعنی یک اثرِ پیشفرضِ ناخواسته وارد محاسبه شده.
|
||||
*/
|
||||
class NoPolicyRegressionTest extends ApiTestCase
|
||||
{
|
||||
public function testWithoutAnyPolicyThePlanAndQuoteAreUnchanged(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($user);
|
||||
$clinic->setName('کلینیک بیقانون');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی');
|
||||
$this->em->persist($section);
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setName('شعبهٔ مرکزی');
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
$service = new ServiceItem($section, 'لیزر');
|
||||
$service->setSoloDurationMinutes(20);
|
||||
$service->setPriceRials(1_000_000);
|
||||
$this->em->persist($service);
|
||||
$this->em->flush();
|
||||
|
||||
$plan = $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(20, $plan['data']['total_minutes']);
|
||||
self::assertSame([0], array_column($plan['data']['segments'], 'offset_minutes'));
|
||||
|
||||
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(1_000_000, $quote['data']['base_rials']);
|
||||
self::assertSame(0, $quote['data']['discount_rials']);
|
||||
self::assertSame(1_000_000, $quote['data']['final_rials']);
|
||||
|
||||
// نبودِ کلید مهمتر از صفر بودن مقدار است: کلیدِ خالی هم یعنی موتور چیزی
|
||||
// اعمال کرده که نباید میکرد.
|
||||
self::assertArrayNotHasKey('applied_policies', $quote['data']['breakdown']['sources']);
|
||||
|
||||
$selection = $this->authJson('POST', '/api/v1/service-selection/validate', $user, [
|
||||
'item_uuids' => [$service->getUuid()],
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertTrue($selection['data']['valid']);
|
||||
self::assertSame([], $selection['data']['errors']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* موتور قوانین ششدستهای — بند ۸ مستند.
|
||||
*
|
||||
* تأکید تستها روی سه چیز است که خرابیشان بیصداست: ترکیب اثرها (max/sum/veto)،
|
||||
* ترتیب حل تناقض (اولویت ← اختصاصیبودن ← قدمت)، و نسخهپذیری (قانون ویرایش
|
||||
* نمیشود، نسخهٔ تازه میگیرد).
|
||||
*/
|
||||
class PolicyEngineTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress} */
|
||||
private function clinicWithBranch(): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($user);
|
||||
$clinic->setName('کلینیک قوانین');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی');
|
||||
$this->em->persist($section);
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setName('شعبهٔ مرکزی');
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $section, $address];
|
||||
}
|
||||
|
||||
private function service(ServiceSection $section, string $name, int $solo = 20, int $price = 1_000_000): ServiceItem
|
||||
{
|
||||
$item = new ServiceItem($section, $name);
|
||||
$item->setSoloDurationMinutes($solo);
|
||||
$item->setPriceRials($price);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* قانون تازه **پیشنویس** است؛ تا فعال نشود اجرا نمیشود.
|
||||
*
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
private function policy(User $user, array $body, bool $activate = true): array
|
||||
{
|
||||
$created = $this->authJson('POST', '/api/v1/policy', $user, $body);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
if (!$activate) {
|
||||
return $created['data'];
|
||||
}
|
||||
|
||||
$active = $this->authJson('POST', "/api/v1/policy/{$created['data']['uuid']}/activate", $user);
|
||||
self::assertSame(200, $this->responseCode(), json_encode($active, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $active['data'];
|
||||
}
|
||||
|
||||
/** پیشنویس ماندنِ قانون تازه عمدی است: نوشتن قانون نباید یعنی اجرای آن. */
|
||||
public function testANewPolicyIsADraftUntilActivated(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'خدمت پیشنویس', 20);
|
||||
|
||||
$draft = $this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون پیشنویس',
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 30]],
|
||||
], activate: false);
|
||||
|
||||
self::assertFalse($draft['active']);
|
||||
self::assertSame(20, $this->preview($user, $service, $address)['data']['total_minutes']);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $extra */
|
||||
private function preview(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array
|
||||
{
|
||||
return $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, $extra + [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $extra */
|
||||
private function quote(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array
|
||||
{
|
||||
return $this->authJson('POST', '/api/v1/pricing/quote', $user, $extra + [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── شِما ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testSchemaIsAClosedListPerCategory(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$body = $this->authJson('GET', '/api/v1/policy-schema', $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$schema = $body['data'];
|
||||
|
||||
self::assertArrayHasKey('timing', $schema);
|
||||
self::assertContains('equals', $schema['timing']['operators']);
|
||||
|
||||
self::assertSame(
|
||||
['min_duration_minutes', 'add_duration_minutes'],
|
||||
array_column($schema['timing']['effects'], 'type'),
|
||||
);
|
||||
self::assertSame(
|
||||
['max', 'sum'],
|
||||
array_column($schema['timing']['effects'], 'combination'),
|
||||
);
|
||||
|
||||
// فیلد قیمتی در دستهٔ زمان جایی ندارد — همین بستهبودن نکتهٔ اصلی شِماست.
|
||||
self::assertNotContains('subtotal_rials', $schema['timing']['fields']);
|
||||
}
|
||||
|
||||
public function testFieldOutsideTheCategoryIsRejectedAtCreateTime(): void
|
||||
{
|
||||
[$user] = $this->clinicWithBranch();
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون بیربط',
|
||||
'condition' => ['match' => 'all', 'conditions' => [['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 10]]],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
public function testEffectOutsideTheCategoryIsRejectedAtCreateTime(): void
|
||||
{
|
||||
[$user] = $this->clinicWithBranch();
|
||||
|
||||
$this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'تخفیف در دستهٔ زمان',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── ترکیب اثرها ─────────────────────────────────────────────────────────
|
||||
|
||||
/** «حداقل مدت» با max ترکیب میشود: سختگیرترین قانون برنده است. */
|
||||
public function testMinDurationTakesTheLargestNotTheLast(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر', 20);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'حداقل ۴۵ دقیقه',
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 45]],
|
||||
]);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'حداقل ۶۰ دقیقه',
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 60]],
|
||||
]);
|
||||
|
||||
$plan = $this->preview($user, $service, $address);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(60, $plan['data']['total_minutes']);
|
||||
}
|
||||
|
||||
/** «افزودن مدت» با sum ترکیب میشود — دو قانون ۱۰ دقیقهای یعنی ۲۰ دقیقه. */
|
||||
public function testAddDurationSumsAcrossPolicies(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'پاکسازی', 20);
|
||||
|
||||
foreach (['ضدعفونی اضافه', 'آمادهسازی اضافه'] as $name) {
|
||||
$this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => $name,
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 10]],
|
||||
]);
|
||||
}
|
||||
|
||||
$plan = $this->preview($user, $service, $address);
|
||||
|
||||
self::assertSame(40, $plan['data']['total_minutes']);
|
||||
}
|
||||
|
||||
/** یک ممنوعیت کافی است؛ ممنوعیت رأی اکثریت نیست. */
|
||||
public function testOneForbidVetoesTheSelection(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'بوتاکس', 20);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'selection',
|
||||
'name' => 'این خدمت فعلاً ارائه نمیشود',
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'effects' => [['type' => 'forbid', 'reason' => 'این خدمت موقتاً متوقف است']],
|
||||
]);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/service-selection/validate', $user, [
|
||||
'item_uuids' => [$service->getUuid()],
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertFalse($body['data']['valid']);
|
||||
self::assertSame('policy_forbidden', $body['data']['errors'][0]['code']);
|
||||
self::assertSame('این خدمت موقتاً متوقف است', $body['data']['errors'][0]['message']);
|
||||
}
|
||||
|
||||
// ── شرطها ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testConditionThatDoesNotMatchLeavesThePlanAlone(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'مشاوره', 20);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'فقط برای انتخابهای پرتعداد',
|
||||
'condition' => ['match' => 'all', 'conditions' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 3]]],
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 30]],
|
||||
]);
|
||||
|
||||
$plan = $this->preview($user, $service, $address);
|
||||
|
||||
self::assertSame(20, $plan['data']['total_minutes']);
|
||||
}
|
||||
|
||||
/** حقیقتِ غایب یعنی شرط **برقرار نیست** — نه اینکه بیصدا رد شود. */
|
||||
public function testMissingFactFailsTheClauseInsteadOfPassingIt(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر بدن', 20);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'وابسته به سن',
|
||||
'condition' => ['match' => 'all', 'conditions' => [['field' => 'patient_age', 'operator' => 'less_than', 'value' => 18]]],
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 15]],
|
||||
]);
|
||||
|
||||
// پیشنمایش برنامه سن بیمار را نمیفرستد.
|
||||
$plan = $this->preview($user, $service, $address);
|
||||
|
||||
self::assertSame(20, $plan['data']['total_minutes']);
|
||||
}
|
||||
|
||||
public function testExpiredPolicyIsIgnored(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'میکرونیدلینگ', 20);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'کمپین نوروز',
|
||||
'valid_from' => time() - 86400 * 30,
|
||||
'valid_to' => time() - 86400,
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 25]],
|
||||
]);
|
||||
|
||||
$plan = $this->preview($user, $service, $address);
|
||||
|
||||
self::assertSame(20, $plan['data']['total_minutes']);
|
||||
}
|
||||
|
||||
public function testDeactivatedPolicyIsIgnored(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'هیدرافیشیال', 20);
|
||||
|
||||
$policy = $this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون خاموششدنی',
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 20]],
|
||||
]);
|
||||
|
||||
self::assertSame(40, $this->preview($user, $service, $address)['data']['total_minutes']);
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/deactivate", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
self::assertSame(20, $this->preview($user, $service, $address)['data']['total_minutes']);
|
||||
}
|
||||
|
||||
// ── ترتیب و اختصاصیبودن ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* در تساوی اولویت، قانونِ اختصاصیتر اول مینشیند — همان که برچسبش روی فاکتور
|
||||
* میرود.
|
||||
*/
|
||||
public function testMoreSpecificPolicyIsRankedFirstOnEqualPriority(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'فیلر', 20, 2_000_000);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف عمومی محیط',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 5]],
|
||||
]);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف همین سرویس',
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]);
|
||||
|
||||
$quote = $this->quote($user, $service, $address);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$applied = $quote['data']['breakdown']['sources']['applied_policies'];
|
||||
|
||||
self::assertSame('تخفیف همین سرویس', $applied[0]['name']);
|
||||
// درصدها جمع میشوند: ۵٪ + ۱۰٪ روی ۲٬۰۰۰٬۰۰۰
|
||||
self::assertSame(300_000, $quote['data']['discount_rials']);
|
||||
}
|
||||
|
||||
public function testHigherPriorityBeatsSpecificity(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'مزوتراپی', 20, 1_000_000);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'قانون محیطی با اولویت بالا',
|
||||
'priority' => 100,
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 5]],
|
||||
]);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'قانون سرویسی با اولویت پایین',
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'priority' => 1,
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 5]],
|
||||
]);
|
||||
|
||||
$applied = $this->quote($user, $service, $address)['data']['breakdown']['sources']['applied_policies'];
|
||||
|
||||
self::assertSame('قانون محیطی با اولویت بالا', $applied[0]['name']);
|
||||
}
|
||||
|
||||
// ── نسخه ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* قانون **ویرایش نمیشود**: تغییر یعنی نسخهٔ تازه، و شمارهٔ نسخه در فاکتور ثبت
|
||||
* میشود تا سه ماه بعد بشود گفت کدام متن اعمال شده بود (قانون پنجم مستند).
|
||||
*/
|
||||
public function testEditingAPolicyCreatesANewVersionAndTheQuoteRecordsIt(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر صورت', 20, 1_000_000);
|
||||
|
||||
$policy = $this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف پاییز',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]);
|
||||
|
||||
self::assertSame(1, $policy['version']);
|
||||
|
||||
$first = $this->quote($user, $service, $address);
|
||||
self::assertSame(100_000, $first['data']['discount_rials']);
|
||||
self::assertSame(1, $first['data']['breakdown']['sources']['applied_policies'][0]['version']);
|
||||
|
||||
$updated = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 20]],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($updated, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(2, $updated['data']['version']);
|
||||
|
||||
$second = $this->quote($user, $service, $address);
|
||||
self::assertSame(200_000, $second['data']['discount_rials']);
|
||||
self::assertSame(2, $second['data']['breakdown']['sources']['applied_policies'][0]['version']);
|
||||
|
||||
// هر دو نسخه در تاریخچه میمانند.
|
||||
$show = $this->authJson('GET', "/api/v1/policy/{$policy['uuid']}", $user);
|
||||
self::assertSame([1, 2], array_column($show['data']['versions'], 'version'));
|
||||
}
|
||||
|
||||
// ── جداسازی محیط ────────────────────────────────────────────────────────
|
||||
|
||||
public function testPolicyOfAnotherClinicIsNeitherVisibleNorApplied(): void
|
||||
{
|
||||
[$owner, , ] = $this->clinicWithBranch();
|
||||
[$other, $section, $address] = $this->clinicWithBranch();
|
||||
|
||||
$service = $this->service($section, 'خدمت کلینیک دوم', 20, 1_000_000);
|
||||
|
||||
$foreign = $this->policy($owner, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف کلینیک اول',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 50]],
|
||||
]);
|
||||
|
||||
$this->authJson('GET', "/api/v1/policy/{$foreign['uuid']}", $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
|
||||
$quote = $this->quote($other, $service, $address);
|
||||
|
||||
self::assertSame(0, $quote['data']['discount_rials']);
|
||||
self::assertArrayNotHasKey('applied_policies', $quote['data']['breakdown']['sources']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user