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>
12 KiB
سیستم مدیریت تخفیف عمومی (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 اعمال کند.
هدف / قابلیت
- Entity + CRUD ادمین برای DiscountRule با انواع مختلف.
- Engine که برای یک پرونده، قوانین قابلاعمال را ارزیابی و مبلغ تخفیف هرکدام را محاسبه کند.
- اعمال در صفحه پرداخت با نمایش مبلغ قبل/تخفیف/نهایی + منبع Rule + امکان انتخاب/حذف.
- 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:
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):
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):
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.