# سیستم مدیریت تخفیف عمومی (Discount Rules Engine) + اعمال در پرداخت پرونده ## پروژه `clinicpro` (backend Symfony + پنل ادمین React). تک-ریپو. > پیش‌نیاز منطقی: `session-autofill-on-appointment-confirm.md` (تخفیف روی `final_price_rials` اعمال می‌شود). > تست: پنل ادمین با `09390039833 / 09390039833`. اجرا داخل ddev. ## زمینه الان تخفیف فقط **تسویه‌ی دستی** است: در صفحه پرداخت پرونده، اپراتور `percent` یا `fixed` با مقدار آزاد وارد می‌کند؛ روی `PatientSession.discount_type/discount_value/discount_rials` ذخیره می‌شود (`PATCH /api/v1/session/{uuid}` → `PatientService::applyDiscount()`). هیچ **قانون تخفیف** تعریف‌شده‌ای وجود ندارد، هیچ محاسبه‌ی خودکاری بر اساس تگ/سرویس/مبلغ/… نیست، و هیچ ردی از «کدام قانون» اعمال شده ذخیره نمی‌شود (audit gap). هدف: یک سیستم **تخفیف عمومی (Generic Discount Rules)** که در `/admin/subscription` مدیریت شود و هنگام پرداخت پرونده، تخفیف‌های قابل‌اعمال را خودکار محاسبه، به اپراتور پیشنهاد، و پس از انتخاب با ثبت منبعِ Rule اعمال کند. ## هدف / قابلیت 1. Entity + CRUD ادمین برای **DiscountRule** با انواع مختلف. 2. **Engine** که برای یک پرونده، قوانین قابل‌اعمال را ارزیابی و مبلغ تخفیف هرکدام را محاسبه کند. 3. اعمال در صفحه پرداخت با نمایش مبلغ قبل/تخفیف/نهایی + منبع Rule + امکان انتخاب/حذف. 4. **Audit**: ثبت اینکه کدام Rule اعمال شده، در پرونده و سوابق مالی، برای گزارش‌گیری. ## انواع قوانین (rule types) | نوع | `type` | هدف (`target_*`) | نمونه | |-----|--------|------------------|-------| | تگ بیمار | `patient_tag` | `target_tag_id` (TenantTag) | VIP، پرسنل، خانواده پزشک، خیریه | | مبلغ فاکتور | `invoice_amount` | `min_amount_rials` (آستانه) | بالای ۲م → ۱۰٪ | | بیمار خاص | `specific_patient` | `target_record_id` (PatientRecord) + بازه‌ی زمانی اختیاری | بیمار A همیشه ۳۰٪ | | مناسبتی | `occasion` | `valid_from`/`valid_to` (+ زیرنوع تولد) | تولد بیمار، کمپین، بازه | | سرویس | `service` | `target_service_item_id` (ServiceItem) | لیزر ۲۰٪ | | تعداد مراجعات | `visit_count` | `min_visit_count` | بعد از مراجعه ۵م → ۱۰٪ | هر Rule مشترکاً دارد: `discount_type` (`percent`|`fixed`)، `value` (درصد یا ریال)، `priority` (int، بزرگ‌تر = مهم‌تر)، `combinable` (bool)، `active` (bool)، `valid_from`/`valid_to` (nullable int unix — برای موقت/مناسبتی)، و مالکیت scope (`owner_type` doctor|clinic + `owner_id`) هم‌سو با بقیه‌ی داده‌های per-tenant. ## اولویت / ترکیب‌پذیری - قوانین قابل‌اعمال بر اساس `priority` نزولی مرتب شوند. - پیش‌فرض: **فقط یک تخفیف** (بالاترین priority) اعمال می‌شود. - اگر Rule `combinable = true` باشد، می‌تواند با سایر combinableها جمع شود (جمع مبلغ ریالی، با سقفِ `final_price_rials`). - اپراتور می‌تواند به‌جای پیشنهاد خودکار، دستی یکی را انتخاب یا حذف کند. ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Discount/Entity/DiscountRule.php` (جدید) | Entity قانون تخفیف | | `src/Discount/Repository/DiscountRuleRepository.php` (جدید) | کوئری‌ها (array hydration برای لیست ادمین) | | `src/Discount/Service/DiscountEngine.php` (جدید) | ارزیابی قوانین برای یک `PatientSession` → لیست پیشنهادها | | `src/Discount/Controller/DiscountController.php` (جدید) | CRUD ادمین + endpoint محاسبه برای یک session | | `src/Patient/Entity/PatientSession.php` | افزودن ستون‌های audit `applied_discount_rule_id` (nullable) + `applied_discount_rule_label` (nullable) — `discount_type/value/rials` و `setDiscount()` (L196-203) موجودند | | `src/Patient/Service/PatientService.php` | `applyDiscount()` (L244-283) — گسترش برای پذیرش/ثبت `rule` | | `src/Patient/Controller/PatientController.php` | `updateSession()` (L1034-1073) — عبور `discount_rule_uuid` | | `src/Patient/Entity/PatientRecord.php` | `getTags()` (ManyToMany `patient_record_tags` → TenantTag) — برای `patient_tag` | | `src/Tag/Entity/TenantTag.php` | برچسب بیمار (per-tenant، `name`/`color`) | | `src/ClinicService/Entity/ServiceItem.php` | برای `service` — `getPriceRials()` | | `assets/admin/pages/AdminSubscriptionPage.tsx` | صفحه‌ی تب‌دار (`.seg`) — افزودن تب «مدیریت تخفیف‌ها» | | `assets/admin/components/session/PaymentStep.tsx` | UI پرداخت — نمایش/انتخاب تخفیف‌های پیشنهادی | | `docs/api/*.md` | مستندات (فایل جدید `docs/api/discount.md` + به‌روزرسانی `patient.md`) | ## وضعیت فعلی `src/Patient/Service/PatientService.php` — تخفیف دستی، بدون منبع Rule: ```php public function applyDiscount(PatientSession $session, ?string $type, int $value): void { $final = $session->getFinalPriceRials(); if ($type === 'percent') { if ($value > 100) throw new AppException(/* ... */ 'discount_value'); $rials = (int) round($final * $value / 100); } else { if ($value > $final) throw new AppException(/* ... */ 'discount_value'); $rials = $value; } if ($rials > $final - $session->getPaidTotalRials()) throw new AppException(/* ... */); $session->setDiscount($type, $value, $rials); // ← فقط type/value/rials؛ بدون rule } ``` `assets/admin/components/session/PaymentStep.tsx` — تخفیف فقط `percent`/`fixed` با مقدار آزاد (L98-140): ```tsx const applyDiscount = () => { if (!discountType || !discountValue) return; discountMut.mutate({ discount_type: discountType, discount_value: Number(discountValue) }); }; // discountMut → PATCH /api/v1/session/{sessionUuid} ``` `assets/admin/pages/AdminSubscriptionPage.tsx` — تب‌دار با `.seg` (L437-453): ```tsx const [tab, setTab] = useState<'plans' | 'report'>('plans'); //
... ... {tab === 'plans' && } {tab === 'report' && } ``` ## وظایف ### ۱. Entity + migration — `DiscountRule` `src/Discount/Entity/DiscountRule.php` با ستون‌ها: `id`, `uuid`, `owner_type` (doctor|clinic), `owner_id` (int), `name` (string), `type` (یکی از انواع بالا), `discount_type` (percent|fixed), `value` (int), `priority` (int, default 0), `combinable` (bool, default false), `active` (bool, default true), `valid_from`/`valid_to` (int nullable), و فیلدهای target اختیاری: `target_tag_id`, `target_record_id`, `target_service_item_id`, `min_amount_rials`, `min_visit_count` (همه nullable int)، `created_at`/`updated_at`. constant array برای انواع. `toArray()`. migration لازم. همچنین دو ستون audit روی `PatientSession`: `applied_discount_rule_id` (int nullable) + `applied_discount_rule_label` (string nullable) — migration جدا یا همان. ### ۲. Repository + Engine `DiscountRuleRepository`: `findActiveForOwner($ownerType, $ownerId)` و لیست array hydration برای ادمین. `DiscountEngine::evaluate(PatientSession $session): array` — برای هر Rule فعالِ owner: - `patient_tag`: اگر `$session->getRecord()->getTags()` شامل `target_tag_id` باشد. - `invoice_amount`: اگر `final_price_rials >= min_amount_rials`. - `specific_patient`: اگر `record_id == target_record_id` و در بازه‌ی زمانی (`valid_from/to`). - `occasion`: اگر now در بازه؛ زیرنوع تولد → مقایسه با تاریخ تولد بیمار. - `service`: اگر یکی از `session->getServices()` سرویسِ `target_service_item_id` باشد (تخفیف روی همان خط). - `visit_count`: اگر تعداد پرونده‌های قبلی بیمار `>= min_visit_count`. خروجی: آرایه‌ای از `{ rule_uuid, rule_name, type, discount_type, value, discount_rials, combinable, priority }` مرتب بر priority نزولی. مبلغ ریالی هر پیشنهاد با سقف `final_price_rials` و باقی‌مانده محاسبه شود. ### ۳. Controller — CRUD ادمین + محاسبه `src/Discount/Controller/DiscountController.php` (extends `BaseController`): - `GET/POST/PATCH/DELETE /api/v1/admin/discount-rules[/{uuid}]` — CRUD، `#[IsGranted]` مثل بقیه‌ی adminها، scope به owner جاری. - `GET /api/v1/session/{uuid}/discount-suggestions` — خروجی `DiscountEngine::evaluate()` برای آن پرونده. ### ۴. اعمال تخفیف با ثبت منبع (backend) `PatientService::applyDiscount()` را گسترش بده تا `?DiscountRule $rule = null` بگیرد و هنگام ست، `applied_discount_rule_id` + `applied_discount_rule_label` را روی session بنویسد. در `updateSession()` (`PATCH /api/v1/session/{uuid}`) اگر `discount_rule_uuid` آمد، Rule را resolve و مقدار/نوع را از خود Rule بگیر (نه ورودی دستی) و pass کن؛ مسیر دستیِ فعلی (`discount_type`/`discount_value` بدون rule) حفظ شود. ### ۵. تب «مدیریت تخفیف‌ها» در subscription (frontend) در `AdminSubscriptionPage.tsx`: union تب را به `'plans' | 'report' | 'discounts'` گسترش بده، یک `