- 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.
188 lines
9.3 KiB
Markdown
188 lines
9.3 KiB
Markdown
# نکات پیادهسازی — تسک ۰۹
|
||
|
||
## ۱. چرا `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`
|