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>
150 lines
12 KiB
Markdown
150 lines
12 KiB
Markdown
# سیستم مدیریت تخفیف عمومی (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.
|