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>
This commit is contained in:
hamed
2026-07-17 14:38:10 +03:30
co-authored by Claude Fable 5
parent ee69ac96be
commit 476e219165
4 changed files with 490 additions and 3 deletions
+149
View File
@@ -0,0 +1,149 @@
# سیستم مدیریت تخفیف عمومی (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.