Files
clinicpro/.claude/prompt/discount-rules-engine.md
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

12 KiB
Raw Permalink Blame History

سیستم مدیریت تخفیف عمومی (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|fixedvalue (درصد یا ریال)، 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 برای servicegetPriceRials()
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.