Files
clinicpro/.claude/prompt/discount-rules-engine.md
T
hamedandClaude Fable 5 476e219165 fix(admin): convert toman to rial when recording session payment/discount
PaymentStep sent the toman amount straight through as amount_rials (and the
fixed discount value as discount_value), so a 500,000 toman payment was stored
as 5,000,000... no — as 500,000 rial (10x too small). Apply tomanToRial before
sending the payment amount and the fixed-discount value; percent discount and
rule-based discount are unaffected. Verified stored value is now correct rial.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:38:10 +03:30

150 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# سیستم مدیریت تخفیف عمومی (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');
// <div className="seg"> ... <button onClick={() => setTab('plans')}>پلن‌ها</button> ...
{tab === 'plans' && <PlansTab />}
{tab === 'report' && <ReportTab />}
```
## وظایف
### ۱. 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'` گسترش بده، یک `<button>` به `.seg` اضافه کن، و `<DiscountTab />` جدید بساز — جدول قوانین + مودال ساخت/ویرایش (TanStack Query + RHF + Zod + `Modal`/`ConfirmDialog`)، با فرم پویا بر اساس `type` (نمایش فیلد target مربوطه). selectها با `SearchableSelect` (نه `<select>` خام).
### ۶. UI پرداخت (frontend)
در `PaymentStep.tsx`: علاوه بر تخفیف دستی، `GET /session/{uuid}/discount-suggestions` را بخوان و پیشنهادها را نشان بده. اپراتور بتواند یکی را انتخاب (→ `PATCH session { discount_rule_uuid }`) یا حذف کند. نمایش: **مبلغ قبل از تخفیف** (`final_price_rials`**مبلغ تخفیف** (`discount_rials`**مبلغ نهایی** (`final - discount`)، و **منبع Rule** (`applied_discount_rule_label`).
## نکات مهم
- کنترلرها از `BaseController`؛ پاسخ‌ها `$this->success()`/`$this->paginated()`/`$this->error()`. لیست ادمین با array hydration.
- تاریخ‌ها Unix timestamp صحیح؛ قیمت‌ها ریالی (UI تومان → `tomanToRial`).
- تخفیف هرگز از `final_price_rials - paid_total` بیشتر نشود (منطق سقفِ فعلی `applyDiscount` را نگه‌دار/گسترش بده).
- **audit برای گزارش‌گیری**: `applied_discount_rule_id/label` روی session کافی است تا بعداً در گزارش‌های مالی join/گزارش شود؛ در `toArray()` پرونده expose شوند.
- Domain جدید `src/Discount/` طبق ساختار domain-driven پروژه (Controller/Entity/Repository/Service).
- Entity جدید + ستون‌های جدید → **migration لازم** (`doctrine:migrations:diff` سپس `migrate`؛ خطوط drift نامرتبط را از migration پاک کن).
- مستندات: فایل جدید `docs/api/discount.md` + به‌روزرسانی `docs/api/patient.md` برای `discount_rule_uuid` و فیلدهای audit.
- این فیچر بزرگ است — طبق run-prompt هر وظیفه (۱..۶) جدا پیاده، تست و کامیت شود؛ Backend اول (Entity→migration→repo→engine→controller)، سپس frontend.