feat: implement cancellation policy, no-show tracking, and waitlist management
- Add implementation notes for cancellation and waitlist features. - Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting. - Establish architecture for domain events and outbox pattern to ensure reliable event publishing. - Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports. - Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
# معماری — تسک ۰۹
|
||||
|
||||
## ساختار فایل
|
||||
|
||||
```
|
||||
src/Policy/
|
||||
├── Entity/
|
||||
│ ├── Policy.php
|
||||
│ └── PolicyVersionLog.php
|
||||
├── Condition/
|
||||
│ ├── ConditionEvaluator.php # ارزیابی شرط
|
||||
│ ├── FieldRegistry.php # فهرست بستهٔ فیلدها
|
||||
│ ├── OperatorRegistry.php # فهرست بستهٔ عملگرها
|
||||
│ └── PolicyContext.php # دادههای در دسترس قانون
|
||||
├── Effect/
|
||||
│ ├── EffectRegistry.php
|
||||
│ └── Combiner.php # جدول ترکیب اثرها (مستند بند ۸)
|
||||
├── Engine/
|
||||
│ ├── PolicyResolver.php # انتخاب قوانین مرتبط + حل تناقض
|
||||
│ ├── SelectionPolicyEngine.php
|
||||
│ ├── EligibilityPolicyEngine.php
|
||||
│ ├── ResourcePolicyEngine.php
|
||||
│ ├── TimingPolicyEngine.php
|
||||
│ ├── SpacingPolicyEngine.php # ← تنها موتوری که SQL تولید میکند
|
||||
│ └── PricingPolicyEngine.php
|
||||
├── Controller/PolicyController.php
|
||||
└── Repository/PolicyRepository.php
|
||||
```
|
||||
|
||||
شش موتور جدا، نه یک `PolicyEngine` بزرگ. هر کدام ورودی و خروجی نوعدار خودش را دارد
|
||||
و در نقطهٔ متفاوتی از زنجیره صدا زده میشود — ادغامشان یعنی یک کلاس با شش دلیل تغییر.
|
||||
|
||||
## `Policy`
|
||||
|
||||
```php
|
||||
class Policy
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const CATEGORIES = ['selection','eligibility','resource','timing','spacing','pricing'];
|
||||
|
||||
private string $name;
|
||||
private string $category;
|
||||
private ?Branch $branch = null; // null = همهٔ شعب محیط
|
||||
private int $priority = 0;
|
||||
private int $version = 1;
|
||||
private ?int $validFrom = null;
|
||||
private ?int $validTo = null;
|
||||
private bool $active = false; // ← پیشفرض غیرفعال، تا آزمایش شود
|
||||
private array $conditions = []; // JSON — فهرست بسته
|
||||
private array $effects = []; // JSON — فهرست بسته
|
||||
private int $specificity = 0; // محاسبهشده، برای حل تناقض
|
||||
}
|
||||
```
|
||||
|
||||
`active = false` پیشفرض عمدی است — مستند بند ۸: «قبل از اینکه یک قانون واقعاً فعال شود،
|
||||
باید بشود آن را روی داده واقعی اجرا کرد». تسک ۱۰ همین را میسازد.
|
||||
|
||||
## شرط — فهرست بسته
|
||||
|
||||
```json
|
||||
{
|
||||
"all": [
|
||||
{ "field": "service.category_path", "op": "contains", "value": "جراحی" },
|
||||
{ "field": "patient.age", "op": "gte", "value": 18 }
|
||||
],
|
||||
"any": [
|
||||
{ "field": "patient.tags", "op": "in", "value": ["vip", "gold"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
فقط `all` و `any` در **یک** سطح. تودرتویی ممنوع — مستند بند ۸: قانون باید قابل تبدیل به
|
||||
کوئری باشد و تودرتویی دلخواه یعنی همان کد دلخواهی که ممنوع شده.
|
||||
|
||||
### `FieldRegistry` — فهرست کامل
|
||||
|
||||
| دامنه | فیلدها |
|
||||
|---|---|
|
||||
| `service` | `id`, `category_path`, `tags`, `session_count` |
|
||||
| `options` | `ids`, `group_ids`, `count` |
|
||||
| `patient` | `age`, `gender`, `tags`, `last_session_at`, `completed_sessions_count` |
|
||||
| `booking` | `at`, `day_of_week`, `branch_id`, `channel` (`online`\|`panel`\|`phone`) |
|
||||
| `plan` | `total_minutes`, `total_price_rials` |
|
||||
|
||||
هر فیلد جدید باید **صریحاً** اینجا اضافه شود. `FieldRegistry` هم schema را برای
|
||||
`GET /policy-schema` تولید میکند و هم استخراج مقدار از `PolicyContext` را انجام میدهد —
|
||||
یک منبع حقیقت، پس فیلدی که در فرم هست و در ارزیابی نیست، ممکن نمیشود.
|
||||
|
||||
### `OperatorRegistry`
|
||||
|
||||
`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `between`, `contains`, `days_since`
|
||||
|
||||
`days_since` عملگر ویژهٔ مستند است («تعداد روز گذشته») و روی فیلدهای زمانی کار میکند.
|
||||
|
||||
## اثر — فهرست بسته per دسته
|
||||
|
||||
| دسته | اثرهای مجاز |
|
||||
|---|---|
|
||||
| `selection` | `require_option`, `forbid_option`, `auto_add_option` |
|
||||
| `eligibility` | `deny` (با پیام), `require_flag` |
|
||||
| `resource` | `add_requirement`, `restrict_requirement` |
|
||||
| `timing` | `min_duration`, `add_duration` |
|
||||
| `spacing` | `min_days_since_last`, `max_days_since_last`, `deny_weekday` |
|
||||
| `pricing` | `discount_percent`, `discount_fixed`, `surcharge_percent`, `surcharge_fixed` |
|
||||
|
||||
اثر خارج از دستهٔ خودش → `422` هنگام ذخیره. این جدول عیناً در `GET /policy-schema` میآید.
|
||||
|
||||
## حل تناقض — `PolicyResolver`
|
||||
|
||||
```php
|
||||
usort($policies, function (Policy $a, Policy $b) {
|
||||
return [$b->getPriority(), $b->getSpecificity(), $a->getCreatedAt()]
|
||||
<=> [$a->getPriority(), $a->getSpecificity(), $b->getCreatedAt()];
|
||||
});
|
||||
```
|
||||
|
||||
سه معیار به ترتیب مستند: اولویت بالاتر → اختصاصیتر → قدیمیتر.
|
||||
|
||||
`specificity` هنگام ذخیره محاسبه و ذخیره میشود (نه در زمان اجرا):
|
||||
|
||||
```
|
||||
+8 branch مشخص
|
||||
+4 service مشخص در شرط
|
||||
+2 service_category مشخص در شرط
|
||||
+1 هر شرط اضافه
|
||||
```
|
||||
|
||||
## ترکیب اثرها — `Combiner` (جدول مستند بند ۸)
|
||||
|
||||
| نوع اثر | قاعده | پیادهسازی |
|
||||
|---|---|---|
|
||||
| `min_duration` | بیشترین برنده | `max()` |
|
||||
| `add_duration` | جمع | `array_sum()` |
|
||||
| `add_requirement` | همه، تکراری حذف | union روی `groupKey` |
|
||||
| `restrict_requirement` | اشتراک محدودیتها | `array_intersect` روی کاندیدها |
|
||||
| `discount_percent` | پشتسرهم به ترتیب اولویت، با سقف | حلقهٔ ضربی + `min($total, $cap)` |
|
||||
| `deny` / `forbid_option` | یکی کافی است | short-circuit |
|
||||
|
||||
`Combiner` یک کلاس خالص و بدون I/O — تست واحد کامل بدون دیتابیس.
|
||||
|
||||
## `SpacingPolicyEngine` — تنها موتور SQL-ساز
|
||||
|
||||
مستند بند ۸: «قوانین مربوط به فاصله زمانی باید قابل تبدیل به کوئری دیتابیس باشند، وگرنه
|
||||
جستجوی وقت آزاد کند میشود».
|
||||
|
||||
```php
|
||||
/** بهجای فیلتر کردن ۹۰ روز اسلات در PHP، یک بازهٔ ممنوعه برمیگرداند. */
|
||||
public function forbiddenRanges(PolicyContext $ctx): array
|
||||
{
|
||||
// قانون: min_days_since_last = 21
|
||||
// آخرین جلسهٔ بیمار برای این سرویس: 1405/05/01
|
||||
// → بازهٔ ممنوعه: [آخرین جلسه, آخرین جلسه + 21 روز)
|
||||
// یک کوئری برای «آخرین جلسه»، بعد محاسبهٔ بازه در PHP
|
||||
}
|
||||
```
|
||||
|
||||
`AvailabilityEngine` این بازهها را **پیش از** تولید کاندیدها به `CandidateGenerator` میدهد
|
||||
تا آن نقطهها هرگز ساخته نشوند — نه اینکه بعد فیلتر شوند.
|
||||
|
||||
## نسخهبندی
|
||||
|
||||
```
|
||||
POST /policy/{uuid}/version { valid_from: …, conditions: …, effects: … }
|
||||
│
|
||||
├─ نسخهٔ فعلی: valid_to = new.valid_from - 1
|
||||
├─ ردیف جدید در policy_version_log با snapshot کامل نسخهٔ قبلی
|
||||
└─ policy.version++ و مقادیر جدید روی همان ردیف
|
||||
```
|
||||
|
||||
قانون **ویرایش نمیشود** — هر تغییر نسخهٔ جدید با تاریخ شروع میسازد. نوبتها
|
||||
`{policy_id, version}` را ذخیره میکنند (`applied_policy_ids` تسک ۰۸)، پس فاکتور دیروز
|
||||
با تغییر امروز خراب نمیشود.
|
||||
|
||||
`policy_version_log` snapshot کامل نگه میدارد نه diff: بازسازی نسخهٔ قدیم باید یک
|
||||
`SELECT` باشد، نه اعمال زنجیرهٔ diff.
|
||||
|
||||
## نقاط اتصال — همه از قبل آمادهاند
|
||||
|
||||
```php
|
||||
// تسک ۰۵ — AppointmentPlanBuilder::build() مرحلهٔ ۷
|
||||
return $this->policies->applyToPlan($withResources);
|
||||
// ↑ ResourcePolicyEngine + TimingPolicyEngine
|
||||
|
||||
// تسک ۰۶ — AvailabilityEngine::search() مرحلهٔ ۶
|
||||
return $this->policies->filterSlots($result, $req);
|
||||
// ↑ SpacingPolicyEngine (بهصورت forbiddenRanges، پیش از تولید کاندید)
|
||||
|
||||
// تسک ۰۷ — BookingService::confirm() مرحلهٔ ۳
|
||||
$this->policies->assertEligibility($appointment);
|
||||
// ↑ EligibilityPolicyEngine
|
||||
|
||||
// تسک ۰۸ — PricingEngine::quote() مرحلهٔ ۳
|
||||
$lines = $this->discounts->apply($lines, $req);
|
||||
// ↑ PricingPolicyEngine + DiscountEngine موجود
|
||||
|
||||
// تسک ۰۴ — ServiceSelectionValidator::validate()
|
||||
$errors = array_merge($errors, $this->policies->validateSelection($ctx, …));
|
||||
// ↑ SelectionPolicyEngine
|
||||
```
|
||||
|
||||
هیچ امضایی عوض نمیشود — این دقیقاً دلیلی است که آن قلابها از روز اول گذاشته شدند.
|
||||
@@ -0,0 +1,126 @@
|
||||
# دیتابیس — تسک ۰۹
|
||||
|
||||
## `policies`
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | |
|
||||
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
|
||||
| `branch_id` | INT NULL | NULL = همهٔ شعب — FK ON DELETE CASCADE |
|
||||
| `name` | VARCHAR(200) NOT NULL | «خدمات جراحی به جراح نیاز دارند» |
|
||||
| `category` | VARCHAR(20) NOT NULL | یکی از شش دسته |
|
||||
| `priority` | SMALLINT NOT NULL DEFAULT 0 | |
|
||||
| `specificity` | SMALLINT NOT NULL DEFAULT 0 | محاسبهشده هنگام ذخیره |
|
||||
| `version` | SMALLINT NOT NULL DEFAULT 1 | |
|
||||
| `valid_from` | INT NULL | |
|
||||
| `valid_to` | INT NULL | |
|
||||
| `active` | TINYINT(1) NOT NULL DEFAULT 0 | **پیشفرض غیرفعال** |
|
||||
| `conditions` | JSON NOT NULL | |
|
||||
| `effects` | JSON NOT NULL | |
|
||||
| `created_at`/`updated_at` | INT NOT NULL | |
|
||||
|
||||
```sql
|
||||
KEY idx_policies_lookup (entity_type, entity_id, category, active, valid_from)
|
||||
KEY idx_policies_branch (branch_id, category, active)
|
||||
```
|
||||
|
||||
`idx_policies_lookup` کوئری داغ است: «قوانین فعال دستهٔ X این محیط که امروز معتبرند».
|
||||
با ۶ دسته و معمولاً < ۵۰ قانون per محیط، این کوئری همیشه ارزان است — و باید بماند.
|
||||
اگر روزی قوانین به هزاران رسیدند، کش per (محیط، دسته) اضافه شود، نه ایندکس پیچیدهتر.
|
||||
|
||||
## `policy_version_log`
|
||||
|
||||
```sql
|
||||
CREATE TABLE policy_version_log (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
policy_id INT NOT NULL,
|
||||
version SMALLINT NOT NULL,
|
||||
snapshot JSON NOT NULL, -- کل قانون در آن نسخه، نه diff
|
||||
valid_from INT NULL,
|
||||
valid_to INT NULL,
|
||||
changed_by INT NULL, -- FK users ON DELETE SET NULL
|
||||
changed_at INT NOT NULL,
|
||||
UNIQUE KEY uniq_policy_version (policy_id, version),
|
||||
KEY idx_pvl_policy (policy_id, version),
|
||||
CONSTRAINT fk_pvl_policy FOREIGN KEY (policy_id) REFERENCES policies(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
`snapshot` کامل است، نه diff: بازسازی نسخهٔ ۳ از یک قانون که الان نسخهٔ ۹ است، باید یک
|
||||
`SELECT` باشد. حجم ناچیز (JSON چند کیلوبایتی × چند ده نسخه).
|
||||
|
||||
## هیچ تغییری در `discount_rules`
|
||||
|
||||
جدول و entity موجود دستنخورده میمانند. `PricingPolicyEngine` **هر دو** را میخواند:
|
||||
|
||||
```php
|
||||
$effects = array_merge(
|
||||
$this->discountEngine->evaluate($ctx), // DiscountRule موجود
|
||||
$this->pricingPolicies->evaluate($ctx), // Policy دستهٔ pricing
|
||||
);
|
||||
$combined = $this->combiner->combine($effects); // ترکیب واحد
|
||||
```
|
||||
|
||||
دلیل عدم مهاجرت در implementation_notes بند ۱.
|
||||
|
||||
## `applied_policy_ids` — تسک ۰۸
|
||||
|
||||
ستون از قبل در `price_snapshots` هست. قرارداد مقدارش اینجا تعیین میشود:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "source": "policy", "id": 12, "version": 3, "name": "تخفیف VIP" },
|
||||
{ "source": "discount_rule", "id": 5, "version": 1, "name": "تخفیف تولد" }
|
||||
]
|
||||
```
|
||||
|
||||
`name` کپی متنی — همان دلیل `price_snapshot_lines.label`: قانون ممکن است حذف شود.
|
||||
|
||||
## ذخیرهٔ قوانین اعمالشده روی خودِ نوبت
|
||||
|
||||
قوانین غیرقیمتی هم باید ثبت شوند (مستند: «هر نوبت فهرست قانونهایی که رویش اعمال شده را
|
||||
ذخیره میکند»)، ولی `price_snapshots` جای قوانین منبع و زمان نیست.
|
||||
|
||||
```sql
|
||||
ALTER TABLE appointments
|
||||
ADD COLUMN applied_policies JSON NULL;
|
||||
```
|
||||
|
||||
همان قرارداد بالا. یک ستون JSON کافی است — هیچ کوئریای روی آن زده نمیشود، فقط برای
|
||||
آدیت و پاسخ به «چرا این نوبت ۷۵ دقیقه شد؟» خوانده میشود.
|
||||
|
||||
## Migration
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
بدون backfill. هیچ قانونی از قبل وجود ندارد و `DiscountRule` ها سر جایشان میمانند.
|
||||
|
||||
## نمونهٔ داده برای تست
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:policy:seed-examples --tenant=clinic:12 --force
|
||||
```
|
||||
|
||||
پنج قانون نمونه (یکی از هر دسته جز `selection`)، همه با `active=false` تا کلینیک اول
|
||||
آزمایششان کند:
|
||||
|
||||
```
|
||||
resource | خدمات جراحی به جراح نیاز دارند
|
||||
timing | حداقل مدت درمان پیچیده یک ساعت است
|
||||
spacing | حداقل ۲۱ روز از جلسهٔ قبلی لیزر
|
||||
eligibility | بیمار زیر ۱۸ سال بدون رضایت والدین نمیشود
|
||||
pricing | بیمار VIP ده درصد تخفیف
|
||||
```
|
||||
|
||||
این نمونهها هم مستندات زندهاند و هم داده تست تسک ۱۰.
|
||||
|
||||
## طبقهبندی tenant
|
||||
|
||||
| جدول | وضعیت |
|
||||
|---|---|
|
||||
| `policies` | جفت tenant |
|
||||
| `policy_version_log` | `AGGREGATE_CHILDREN` → ریشه `Policy` |
|
||||
@@ -0,0 +1,187 @@
|
||||
# نکات پیادهسازی — تسک ۰۹
|
||||
|
||||
## ۱. چرا `DiscountRule` مهاجرت نمیکند
|
||||
|
||||
وسوسهاش زیاد است: `DiscountRule` عملاً یک `Policy` دستهٔ `pricing` است. ولی:
|
||||
|
||||
- `DiscountEngine` روی پرونده (`PatientRecord`) و مراجعه هم اجرا میشود، نه فقط نوبت
|
||||
- `DiscountsPage.tsx` و `docs/api/discount.md` و تستهای موجود روی همان قراردادند
|
||||
- شش نوع تخفیفش (`patient_tag`, `occasion`, `visit_count`, …) دقیقاً همان شرطهای
|
||||
`FieldRegistry` نیستند و نگاشت یکبهیک ندارند
|
||||
|
||||
هزینهٔ مهاجرت بالا و سودش صفر است. `PricingPolicyEngine` هر دو را میخواند و
|
||||
`Combiner` نتیجهشان را یکجا ترکیب میکند. **ولی** یک قاعده لازم است:
|
||||
|
||||
> تخفیف جدید در `DiscountRule` ساخته میشود اگر روی مراجعه هم کار میکند؛ در `Policy`
|
||||
> اگر فقط قیمت نوبت را عوض میکند. این را در `docs/api/policy.md` بنویس.
|
||||
|
||||
## ۲. `FieldRegistry` تنها منبع حقیقت
|
||||
|
||||
```php
|
||||
final class FieldRegistry
|
||||
{
|
||||
private const FIELDS = [
|
||||
'patient.age' => ['type' => 'int', 'ops' => ['eq','gt','gte','lt','lte','between']],
|
||||
'patient.tags'=> ['type' => 'array', 'ops' => ['in','not_in','contains']],
|
||||
// …
|
||||
];
|
||||
|
||||
public function schema(): array; // برای GET /policy-schema
|
||||
public function extract(string $field, PolicyContext $ctx): mixed; // برای ارزیابی
|
||||
public function assertValid(string $field, string $op): void; // برای ذخیره
|
||||
}
|
||||
```
|
||||
|
||||
سه مسئولیت روی یک آرایه. اگر schema و extract جدا باشند، فیلدی در فرم ظاهر میشود که
|
||||
ارزیابی نمیشود — و قانونی که همیشه false است، بدترین باگ این سیستم است چون خطا نمیدهد.
|
||||
|
||||
## ۳. قانون خاموش هرگز نباید بیصدا false باشد
|
||||
|
||||
```php
|
||||
// ❌
|
||||
$value = $ctx->get($field) ?? null;
|
||||
if ($value === null) return false; // قانون بیصدا رد میشود
|
||||
|
||||
// ✅
|
||||
if (!$this->registry->has($field)) {
|
||||
throw new \LogicException("فیلد ناشناخته در قانون: {$field}"); // نباید ممکن باشد؛ ذخیره جلویش را گرفته
|
||||
}
|
||||
$value = $this->registry->extract($field, $ctx);
|
||||
if ($value === self::UNAVAILABLE) {
|
||||
$this->logger->warning('policy_field_unavailable', ['policy' => $id, 'field' => $field]);
|
||||
return false; // با لاگ، نه سکوت
|
||||
}
|
||||
```
|
||||
|
||||
مثال واقعی: قانون روی `patient.last_session_at` برای بیمار جدید. مقدار وجود ندارد و
|
||||
قانون باید رد شود — ولی با لاگ، تا اگر کلینیک گفت «قانونم کار نمیکند» جواب داشته باشیم.
|
||||
|
||||
## ۴. `spacing` — کوئری، نه حلقه
|
||||
|
||||
بدترین اشتباه ممکن در این تسک:
|
||||
|
||||
```php
|
||||
// ❌ فاجعهٔ کارایی — ۹۰ روز × دهها اسلات × یک کوئری
|
||||
foreach ($slots as $slot) {
|
||||
$last = $this->appointmentRepo->findLastSession($patient, $service);
|
||||
if ($slot['start'] - $last < $minDays * 86400) continue;
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// ✅ یک کوئری، بعد بازهٔ ممنوعه
|
||||
$last = $this->appointmentRepo->findLastCompletedAt($patient, $service); // ۱ کوئری
|
||||
if ($last !== null) {
|
||||
$forbidden[] = ['start' => $last, 'end' => $last + $minDays * 86400];
|
||||
}
|
||||
// بازه به CandidateGenerator داده میشود → آن نقطهها ساخته نمیشوند
|
||||
```
|
||||
|
||||
`AvailabilityPerformanceTest` تسک ۰۶ باید **با قوانین فعال** هم سبز بماند. اگر بعد از این
|
||||
تسک قرمز شد، دلیلش همین است.
|
||||
|
||||
## ۵. ترتیب اعمال تخفیف — پشتسرهم
|
||||
|
||||
```php
|
||||
$remaining = $subtotal;
|
||||
foreach ($sortedPolicies as $policy) { // به ترتیب اولویت
|
||||
$amount = intdiv($remaining * $policy->percent(), 100);
|
||||
$remaining -= $amount;
|
||||
$lines[] = PriceLine::discount($policy->getName(), -$amount);
|
||||
}
|
||||
```
|
||||
|
||||
نه جمع درصدها. ۴۰٪ سپس ۱۰٪ = ۴۶٪ کل، نه ۵۰٪. مستند بند ۸ صریح: «به ترتیب اولویت پشت
|
||||
سر هم».
|
||||
|
||||
## ۶. `combinable` و short-circuit
|
||||
|
||||
```php
|
||||
foreach ($sorted as $policy) {
|
||||
if (!$this->conditions->matches($policy, $ctx)) continue;
|
||||
$applied[] = $policy;
|
||||
if (!$policy->isCombinable()) break; // ← اولین غیرترکیبشدنی، پایان
|
||||
}
|
||||
```
|
||||
|
||||
قانون غیرترکیبشدنی با اولویت بالا، بقیه را میبلعد. این همان رفتار `DiscountRule` موجود
|
||||
است و باید یکسان بماند، وگرنه دو دستهٔ تخفیف دو رفتار متفاوت میگیرند.
|
||||
|
||||
اثر `deny` استثناست: **همیشه** short-circuit، مستقل از `combinable`.
|
||||
|
||||
## ۷. نسخهبندی — ویرایش ممنوع
|
||||
|
||||
```php
|
||||
// PolicyController: PATCH وجود ندارد. فقط:
|
||||
POST /policy/{uuid}/version
|
||||
```
|
||||
|
||||
`PATCH` روی محتوای قانون عمداً نیست. تنها چیزهایی که بدون نسخهٔ جدید تغییر میکنند:
|
||||
`active`، `name`. شرط و اثر و اولویت → نسخهٔ جدید.
|
||||
|
||||
اگر کاربر گفت «فقط میخواهم غلط املایی نام را درست کنم» — `name` مجاز است. هر چیزی که
|
||||
روی **محاسبه** اثر دارد، نه.
|
||||
|
||||
## ۸. edge case ها
|
||||
|
||||
| حالت | رفتار درست |
|
||||
|---|---|
|
||||
| هیچ قانونی وجود ندارد | همهچیز مثل قبل — تست سازگاری اجباری |
|
||||
| قانون فعال با `valid_from` آینده | اعمال نمیشود |
|
||||
| دو قانون `deny` | یکی کافی است؛ پیام اولی (بالاترین اولویت) نمایش داده میشود |
|
||||
| قانون `add_requirement` که هیچ منبع واجد شرایطی ندارد | `NoEligibleResourceException` با پیام شامل نام قانون: «قانون X جراح میخواهد ولی جراحی در این شعبه نیست» |
|
||||
| قانون `min_duration` کمتر از مدت فعلی | بیاثر (`max`) |
|
||||
| قانون `spacing` برای بیمار مهمان بدون سابقه | رد نمیکند، اعمال نمیشود |
|
||||
| قانون روی `booking.channel = online` و ثبت از پنل | اعمال نمیشود |
|
||||
| `conditions` خالی (`{}`) | همیشه true — مجاز، ولی در UI هشدار «این قانون روی همهٔ نوبتها اعمال میشود» |
|
||||
| قانون دستهٔ `resource` با اثر `discount_percent` | `422` هنگام ذخیره |
|
||||
| نسخهٔ جدید با `valid_from` گذشته | `422` — نسخه گذشته را عوض نمیکند |
|
||||
| حذف قانونی که در `applied_policy_ids` نوبتهاست | مجاز — `name` کپی شده و فاکتور سالم است |
|
||||
|
||||
سطر ماقبل آخر مهم است: `valid_from` گذشته یعنی بازنویسی تاریخ، که قانون پنجم مستند را
|
||||
نقض میکند.
|
||||
|
||||
## ۹. تست
|
||||
|
||||
```
|
||||
tests/Policy/ConditionEvaluatorTest.php ← واحد، بدون DB
|
||||
- همهٔ عملگرها روی همهٔ نوعها
|
||||
- all/any
|
||||
- فیلد ناموجود → false با لاگ
|
||||
tests/Policy/CombinerTest.php ← واحد
|
||||
- min_duration: max برنده
|
||||
- add_duration: جمع
|
||||
- add_requirement: union بدون تکرار
|
||||
- restrict_requirement: اشتراک
|
||||
- discount: پشتسرهم (۴۰ سپس ۱۰ → ۴۶ کل)
|
||||
- deny: یکی کافی
|
||||
tests/Policy/PolicyResolverTest.php
|
||||
- اولویت > اختصاصیبودن > قدمت (سه سناریوی جدا)
|
||||
- combinable=false short-circuit
|
||||
tests/Policy/SpacingPolicyEngineTest.php
|
||||
- بازهٔ ممنوعه درست
|
||||
- بیمار بدون سابقه → بیاثر
|
||||
- تعداد کوئری ثابت (نه per slot)
|
||||
tests/Policy/PolicyVersioningTest.php ← ⭐ قانون پنجم
|
||||
- نوبت با نسخهٔ ۱ ثبت شد → نسخهٔ ۲ ساخته شد → فاکتور نوبت تغییر نکرد
|
||||
- policy_version_log snapshot کامل دارد
|
||||
- valid_from گذشته → 422
|
||||
tests/Policy/PolicyIntegrationTest.php
|
||||
- resource: نیازمندی اضافه در preview
|
||||
- timing: مدت افزایش مییابد
|
||||
- eligibility: confirm رد میشود با پیام فارسی
|
||||
- pricing: ردیف تخفیف در snapshot
|
||||
tests/Policy/PolicySchemaTest.php
|
||||
- هر فیلد schema قابل extract است (نه فیلد نمایشی بیارزیابی)
|
||||
- اثر خارج از دسته → 422
|
||||
tests/Policy/NoPolicyRegressionTest.php ← ⭐
|
||||
- بدون هیچ قانون: خروجی preview/availability/quote بیتبهبیت مثل تسک ۰۸
|
||||
tests/Appointment/AvailabilityPerformanceTest.php ← باید با قوانین فعال هم سبز باشد
|
||||
```
|
||||
|
||||
## ۱۰. مستندات
|
||||
|
||||
- `docs/api/policy.md` — endpoint ها + فهرست کامل فیلد/عملگر/اثر + قاعدهٔ
|
||||
«`DiscountRule` یا `Policy`؟»
|
||||
- `docs/architecture/policy-engine.md` — جدول حل تناقض، جدول ترکیب، دلیل ممنوعیت کد
|
||||
دلخواه، و دلیل عدم مهاجرت `DiscountRule`
|
||||
@@ -0,0 +1,97 @@
|
||||
# تسک ۰۹ — موتور قوانین ششدستهای
|
||||
|
||||
**فاز:** ۲ (قوانین) · **وابستگی:** ۰۵، ۰۶، ۰۸ · **زمان:** ۲۰-۲۴ ساعت
|
||||
|
||||
---
|
||||
|
||||
## هدف
|
||||
|
||||
مستند بند ۸: شش دستهٔ قانون، هر کدام با نقطهٔ اجرای مشخص، شرط از فهرست بسته،
|
||||
اولویتدار، نسخهبندیشده، و **بدون کد دلخواه**.
|
||||
|
||||
| دسته | کِی اجرا میشود | نقطهٔ اتصال در کد |
|
||||
|---|---|---|
|
||||
| انتخاب (`selection`) | موقع انتخاب آیتم | `ServiceSelectionValidator` (تسک ۰۴) |
|
||||
| صلاحیت بیمار (`eligibility`) | قبل از جستجوی وقت | `AvailabilityEngine::search` ابتدا · `BookingService::confirm` مرحلهٔ ۳ |
|
||||
| منبع (`resource`) | موقع ساخت برنامه | `AppointmentPlanBuilder` مرحلهٔ ۷ (تسک ۰۵) |
|
||||
| زمان (`timing`) | موقع ساخت برنامه | همان |
|
||||
| فاصلهٔ زمانی (`spacing`) | موقع جستجوی وقت | `AvailabilityEngine` مرحلهٔ ۶ |
|
||||
| قیمت (`pricing`) | بعد از نهایی شدن برنامه | `PricingEngine` مرحلهٔ ۳ (تسک ۰۸) |
|
||||
|
||||
همهٔ این قلابها در تسکهای ۰۴ تا ۰۸ از قبل بهصورت no-op گذاشته شدهاند. این تسک
|
||||
پُرشان میکند.
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`DiscountRule` + `DiscountEngine` تنها موتور قانون موجود است و **الگوی درستی** دارد:
|
||||
|
||||
```php
|
||||
// src/Discount/Entity/DiscountRule.php
|
||||
public const TYPES = [TYPE_PATIENT_TAG, TYPE_INVOICE_AMOUNT, TYPE_SPECIFIC_PATIENT,
|
||||
TYPE_OCCASION, TYPE_SERVICE, TYPE_VISIT_COUNT]; // enum بسته ✅
|
||||
private int $priority; // ✅
|
||||
private bool $combinable; // ✅
|
||||
private ?int $validFrom; // ✅
|
||||
private ?int $validTo; // ✅
|
||||
```
|
||||
|
||||
فقط دستهٔ «قیمت» را پوشش میدهد، نسخهبندی ندارد، و محیط آزمایش ندارد.
|
||||
|
||||
## دامنه
|
||||
|
||||
**هست:**
|
||||
- `Policy` — قانون با دسته، شرط، نتیجه، اولویت، بازهٔ اعتبار، نسخه
|
||||
- `PolicyVersionLog` — تاریخچهٔ نسخهها (قانون ویرایش نمیشود، نسخه میگیرد)
|
||||
- `ConditionEvaluator` — ارزیابی شرط از فهرست بسته
|
||||
- شش موتور دسته، هر کدام یک کلاس
|
||||
- حل تناقض: اولویت → اختصاصیبودن → قدمت
|
||||
- ترکیب اثرها طبق جدول مستند بند ۸
|
||||
- ثبت قانونهای اعمالشده روی نوبت (`applied_policy_ids` تسک ۰۸)
|
||||
|
||||
**نیست:** فرم ساخت قانون و محیط آزمایش (تسک ۱۰ — همان تسک UI است).
|
||||
`DiscountRule` موجود **مهاجرت نمیکند**؛ کنار `Policy` دستهٔ `pricing` زندگی میکند
|
||||
(دلیل در implementation_notes).
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/policies` | لیست با فیلتر دسته/وضعیت |
|
||||
| POST | `/api/v1/policy` | ساخت (نسخهٔ ۱) |
|
||||
| GET | `/api/v1/policy/{uuid}` | جزئیات + تاریخچهٔ نسخه |
|
||||
| POST | `/api/v1/policy/{uuid}/version` | نسخهٔ جدید با تاریخ شروع |
|
||||
| POST | `/api/v1/policy/{uuid}/activate` \| `/deactivate` | |
|
||||
| GET | `/api/v1/policy-schema` | فهرست بستهٔ فیلدها، عملگرها و اثرها per دسته |
|
||||
|
||||
`GET /policy-schema` برای تسک ۱۰ حیاتی است: فرم ساخت قانون از همین schema ساخته میشود،
|
||||
نه از کد hard-coded در فرانت.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: قانون «خدمات دسته جراحی به جراح نیاز دارند» (دسته `resource`) →
|
||||
`POST /appointment-plan/preview` برای سرویسی در آن دسته، یک نیازمندی اضافه دارد.
|
||||
- ✅ موفق: قانون «حداقل ۷ روز از جلسهٔ قبلی» (دسته `spacing`) → جستجوی وقت برای بیماری که
|
||||
۳ روز پیش جلسه داشته، روزهای زودتر از روز هفتم را برنمیگرداند.
|
||||
- ✅ موفق: قانون «بیمار زیر ۱۸ سال بدون رضایت والدین نمیشود» (دسته `eligibility`) →
|
||||
`confirm` با `422` و پیام قابل فهم رد میشود.
|
||||
- ✅ موفق: قانون «بیمار VIP ۱۰٪ تخفیف» (دسته `pricing`) → در `price_snapshot_lines` یک
|
||||
ردیف `discount` با نام قانون ظاهر میشود و شناسه+نسخه در `applied_policy_ids` ثبت میشود.
|
||||
- ✅ موفق (**قانون پنجم مستند**): بعد از ثبت نوبت، قانون نسخهٔ ۲ میگیرد →
|
||||
نوبت قبلی همان نسخهٔ ۱ را در `applied_policy_ids` دارد و فاکتورش تغییر نمیکند.
|
||||
- ✅ موفق: دو قانون «حداقل مدت» با مقادیر ۴۵ و ۶۰ دقیقه → **بیشترین** برنده است (۶۰).
|
||||
- ✅ موفق: دو قانون «اضافه کردن زمان» ۱۰ و ۵ دقیقه → **جمع** میشوند (۱۵).
|
||||
- ✅ موفق: یک قانون «ممنوعیت» → کل عملیات رد میشود، حتی اگر ده قانون مجازکننده باشند.
|
||||
- ❌ خطا: شرط با فیلد خارج از فهرست بسته → `422` با نام فیلد مجاز.
|
||||
- ❌ خطا: نتیجه با نوع اثر ناسازگار با دسته → `422`.
|
||||
- ⚠️ مرزی: دو قانون با اولویت مساوی → اختصاصیتر (شعبه بر محیط، سرویس بر دسته) برنده.
|
||||
- ⚠️ مرزی: باز هم مساوی → قانون **قدیمیتر** برنده (مستند بند ۸).
|
||||
- ⚠️ مرزی: قانون با `valid_from` آینده → در محاسبهٔ امروز اعمال نمیشود.
|
||||
- ⚠️ مرزی: هیچ قانونی وجود ندارد → همهچیز مثل قبل کار میکند (تست سازگاری).
|
||||
- ⚠️ مرزی: قانون `spacing` باید **قابل تبدیل به کوئری** باشد؛ ارزیابی per-slot در PHP
|
||||
برای ۹۰ روز غیرقابل قبول است (مستند بند ۸ صریح).
|
||||
|
||||
## خروجی
|
||||
|
||||
- `src/Policy/`
|
||||
- `docs/api/policy.md` + سند معماری `docs/architecture/policy-engine.md`
|
||||
- پر کردن همهٔ قلابهای no-op تسکهای ۰۴ تا ۰۸
|
||||
Reference in New Issue
Block a user