From 584ea4067f07887a3794030bb38a0ece2ea092aa Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 31 Jul 2026 10:19:19 +0330 Subject: [PATCH] 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) --- docs/api/appointment-booking.md | 17 + docs/api/appointment-plan.md | 14 + docs/api/clinic-services.md | 16 + docs/api/policy.md | 277 ++++++++++++ docs/api/pricing.md | 19 + docs/architecture/policy-engine.md | 111 +++++ .../taskes/task-09-policy-engine/checklist.md | 142 +++--- migrations/Version20260731061814.php | 44 ++ .../Booking/Controller/BookingController.php | 30 ++ .../Plan/Service/AppointmentPlanBuilder.php | 267 ++++++++++- .../Service/ServiceSelectionValidator.php | 51 +++ src/Policy/Controller/PolicyController.php | 242 ++++++++++ src/Policy/Entity/Policy.php | 209 +++++++++ src/Policy/Entity/PolicyVersionLog.php | 61 +++ src/Policy/Repository/PolicyRepository.php | 64 +++ .../Repository/PolicyVersionLogRepository.php | 30 ++ src/Policy/Service/BookingPolicyGuard.php | 209 +++++++++ src/Policy/Service/ConditionEvaluator.php | 187 ++++++++ src/Policy/Service/PolicyResolver.php | 158 +++++++ src/Policy/Service/PolicySchema.php | 110 +++++ src/Policy/ValueObject/PolicyOutcome.php | 43 ++ src/Pricing/Service/PricingEngine.php | 60 +++ src/Shared/Tenant/GlobalTables.php | 1 + tests/Policy/DocsCaptureTest.php | 97 ++++ tests/Policy/NoPolicyRegressionTest.php | 72 +++ tests/Policy/PolicyEngineTest.php | 425 ++++++++++++++++++ 26 files changed, 2870 insertions(+), 86 deletions(-) create mode 100644 docs/api/policy.md create mode 100644 docs/architecture/policy-engine.md create mode 100644 migrations/Version20260731061814.php create mode 100644 src/Policy/Controller/PolicyController.php create mode 100644 src/Policy/Entity/Policy.php create mode 100644 src/Policy/Entity/PolicyVersionLog.php create mode 100644 src/Policy/Repository/PolicyRepository.php create mode 100644 src/Policy/Repository/PolicyVersionLogRepository.php create mode 100644 src/Policy/Service/BookingPolicyGuard.php create mode 100644 src/Policy/Service/ConditionEvaluator.php create mode 100644 src/Policy/Service/PolicyResolver.php create mode 100644 src/Policy/Service/PolicySchema.php create mode 100644 src/Policy/ValueObject/PolicyOutcome.php create mode 100644 tests/Policy/DocsCaptureTest.php create mode 100644 tests/Policy/NoPolicyRegressionTest.php create mode 100644 tests/Policy/PolicyEngineTest.php diff --git a/docs/api/appointment-booking.md b/docs/api/appointment-booking.md index 1691a617..3cbb6749 100644 --- a/docs/api/appointment-booking.md +++ b/docs/api/appointment-booking.md @@ -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) diff --git a/docs/api/appointment-plan.md b/docs/api/appointment-plan.md index 80eafe12..2856b886 100644 --- a/docs/api/appointment-plan.md +++ b/docs/api/appointment-plan.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) diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md index a45f935e..acbcb71d 100644 --- a/docs/api/clinic-services.md +++ b/docs/api/clinic-services.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": [""], "message": "این خدمت موقتاً متوقف است" } +``` + +گروه و رابطه ساختار ثابت کاتالوگ‌اند؛ قانون چیزی است که کلینیک بدون دست زدن به کاتالوگ +روشن و خاموش می‌کند. اجرا فقط وقتی است که `branch_uuid` بیاید — محیط از شعبه می‌آید. + +جزئیات: [policy.md](policy.md) diff --git a/docs/api/policy.md b/docs/api/policy.md new file mode 100644 index 00000000..3c0545b7 --- /dev/null +++ b/docs/api/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` ثبت می‌شود | diff --git a/docs/api/pricing.md b/docs/api/pricing.md index 2e160a82..6360a3c7 100644 --- a/docs/api/pricing.md +++ b/docs/api/pricing.md @@ -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) diff --git a/docs/architecture/policy-engine.md b/docs/architecture/policy-engine.md new file mode 100644 index 00000000..26ca3150 --- /dev/null +++ b/docs/architecture/policy-engine.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` بود؛ عملگری که یک مصرف دارد، اثر است نه عملگر | diff --git a/docs/new_feture/taskes/task-09-policy-engine/checklist.md b/docs/new_feture/taskes/task-09-policy-engine/checklist.md index b133c14d..524b4dc1 100644 --- a/docs/new_feture/taskes/task-09-policy-engine/checklist.md +++ b/docs/new_feture/taskes/task-09-policy-engine/checklist.md @@ -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 .` | ✅ | دو کامیت جدا | +| ۸.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | ✅ | تسک ۱۰: ۱.۱۰، ۱.۱۴، ۳.۳، ۳.۶، ۵.۲، ۶.۷، ۸.۹ · تسک ۱۳: ۱.۱۲، ۶.۴ | diff --git a/migrations/Version20260731061814.php b/migrations/Version20260731061814.php new file mode 100644 index 00000000..11f93580 --- /dev/null +++ b/migrations/Version20260731061814.php @@ -0,0 +1,44 @@ +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'); + } +} diff --git a/src/Appointment/Booking/Controller/BookingController.php b/src/Appointment/Booking/Controller/BookingController.php index d3434be7..11046798 100644 --- a/src/Appointment/Booking/Controller/BookingController.php +++ b/src/Appointment/Booking/Controller/BookingController.php @@ -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 $data + * @return array + */ + 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); diff --git a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php index b31bd822..e499aa49 100644 --- a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php +++ b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php @@ -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 $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 $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 $segments + * @return list + */ + 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 $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 */ private function planRequirements( SegmentTemplate $template, @@ -219,12 +451,12 @@ final class AppointmentPlanBuilder /** * رفتار امروز، بیان‌شده به زبان برنامه: یک بخش پیوسته که پزشک را می‌گیرد. */ - private function singleSegmentPlan( + /** @return list */ + 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 */ diff --git a/src/ClinicService/Service/ServiceSelectionValidator.php b/src/ClinicService/Service/ServiceSelectionValidator.php index b92ead61..f8f7cff5 100644 --- a/src/ClinicService/Service/ServiceSelectionValidator.php +++ b/src/ClinicService/Service/ServiceSelectionValidator.php @@ -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> + */ + 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 diff --git a/src/Policy/Controller/PolicyController.php b/src/Policy/Controller/PolicyController.php new file mode 100644 index 00000000..abf832da --- /dev/null +++ b/src/Policy/Controller/PolicyController.php @@ -0,0 +1,242 @@ +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 $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; + } +} diff --git a/src/Policy/Entity/Policy.php b/src/Policy/Entity/Policy.php new file mode 100644 index 00000000..7d24ac16 --- /dev/null +++ b/src/Policy/Entity/Policy.php @@ -0,0 +1,209 @@ +>} + */ + #[ORM\Column(name: 'condition_json', type: 'json')] + private array $condition = []; + + /** @var list> */ + #[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 $condition */ + public function setCondition(array $condition): self { $this->condition = $condition; $this->touch(); return $this; } + + /** @param list> $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(), + ]; + } +} diff --git a/src/Policy/Entity/PolicyVersionLog.php b/src/Policy/Entity/PolicyVersionLog.php new file mode 100644 index 00000000..6982cc72 --- /dev/null +++ b/src/Policy/Entity/PolicyVersionLog.php @@ -0,0 +1,61 @@ +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, + ]; + } +} diff --git a/src/Policy/Repository/PolicyRepository.php b/src/Policy/Repository/PolicyRepository.php new file mode 100644 index 00000000..a48eadb9 --- /dev/null +++ b/src/Policy/Repository/PolicyRepository.php @@ -0,0 +1,64 @@ + + */ +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(); + } +} diff --git a/src/Policy/Repository/PolicyVersionLogRepository.php b/src/Policy/Repository/PolicyVersionLogRepository.php new file mode 100644 index 00000000..4f6e5f67 --- /dev/null +++ b/src/Policy/Repository/PolicyVersionLogRepository.php @@ -0,0 +1,30 @@ + + */ +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(); + } +} diff --git a/src/Policy/Service/BookingPolicyGuard.php b/src/Policy/Service/BookingPolicyGuard.php new file mode 100644 index 00000000..f1153624 --- /dev/null +++ b/src/Policy/Service/BookingPolicyGuard.php @@ -0,0 +1,209 @@ + $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 + */ + 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; + } +} diff --git a/src/Policy/Service/ConditionEvaluator.php b/src/Policy/Service/ConditionEvaluator.php new file mode 100644 index 00000000..fa9b0c88 --- /dev/null +++ b/src/Policy/Service/ConditionEvaluator.php @@ -0,0 +1,187 @@ + $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 $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 $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 $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', + ); + } + } + } +} diff --git a/src/Policy/Service/PolicyResolver.php b/src/Policy/Service/PolicyResolver.php new file mode 100644 index 00000000..115661fb --- /dev/null +++ b/src/Policy/Service/PolicyResolver.php @@ -0,0 +1,158 @@ + $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); + } +} diff --git a/src/Policy/Service/PolicySchema.php b/src/Policy/Service/PolicySchema.php new file mode 100644 index 00000000..266a00c9 --- /dev/null +++ b/src/Policy/Service/PolicySchema.php @@ -0,0 +1,110 @@ + ['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 */ + 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); + } +} diff --git a/src/Policy/ValueObject/PolicyOutcome.php b/src/Policy/ValueObject/PolicyOutcome.php new file mode 100644 index 00000000..925126d7 --- /dev/null +++ b/src/Policy/ValueObject/PolicyOutcome.php @@ -0,0 +1,43 @@ + $effects نوعِ اثر => مقدار ترکیب‌شده + * @param list> $appliedPolicies + * @param list $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, + ]; + } +} diff --git a/src/Pricing/Service/PricingEngine.php b/src/Pricing/Service/PricingEngine.php index 6bbbe8ff..b727cb9a 100644 --- a/src/Pricing/Service/PricingEngine.php +++ b/src/Pricing/Service/PricingEngine.php @@ -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 $policy + * @param array $sources + * @return array + */ + 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 $sources */ diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index c5ea23a6..371a90e0 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -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, diff --git a/tests/Policy/DocsCaptureTest.php b/tests/Policy/DocsCaptureTest.php new file mode 100644 index 00000000..ea02878f --- /dev/null +++ b/tests/Policy/DocsCaptureTest.php @@ -0,0 +1,97 @@ +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); + } +} diff --git a/tests/Policy/NoPolicyRegressionTest.php b/tests/Policy/NoPolicyRegressionTest.php new file mode 100644 index 00000000..c6c801ec --- /dev/null +++ b/tests/Policy/NoPolicyRegressionTest.php @@ -0,0 +1,72 @@ +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']); + } +} diff --git a/tests/Policy/PolicyEngineTest.php b/tests/Policy/PolicyEngineTest.php new file mode 100644 index 00000000..9e591c2b --- /dev/null +++ b/tests/Policy/PolicyEngineTest.php @@ -0,0 +1,425 @@ +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 $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 $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 $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']); + } +}