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
@@ -0,0 +1,212 @@
# انتقال برنامه کاری، اصلاح فرم تنظیمات نوبت، فیلدهای عددی لاتین، رفع باگ هزینه ویزیت در مودال، و لاگ/تایم‌لاین لغو نوبت
## پروژه
`clinicpro` (backend Symfony 7.4 + پنل ادمین React 19 داخل Webpack Encore). تک-ریپو، cross-repo نیست.
> تست: پنل ادمین همیشه با `09390039833 / 09390039833`. اجرا داخل ddev (`ddev exec ...`, `https://clinic-pro.ddev.site`).
## زمینه
فیچر «الزامی کردن هزینه ویزیت» قبلاً پیاده شده (پرامپت `require-visit-price-setting.md`): فلگ روی `EntityInsurancePricing.require_visit_price` ذخیره می‌شود و کنترلر ایجاد نوبت با `VisitPriceRequirementResolver` آن را چک می‌کند. اما چند مشکل UX/باگ باقی مانده و همچنین دو تغییر ساختاری (انتقال برنامه کاری و لاگ لغو) لازم است. این پرامپت ۵ تسک مستقل ولی هم‌حوزه را پوشش می‌دهد؛ **هر تسک را جدا پیاده‌سازی، تست و کامیت کن**.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحه `/admin/appointment-settings` (۵۰ خط) — مقصد برنامه کاری، محل دکمه ذخیره |
| `assets/admin/pages/DoctorProfilePage.tsx` | wrapper پروفایل → `<DoctorDetailPage isOwnProfile />` |
| `assets/admin/pages/DoctorDetailPage.tsx` | تعریف `ScheduleSection` (L2058-2089)، رندر آن L2899، `WeeklyScheduleTab` (export L1252) |
| `assets/admin/components/FreeVisitPrice.tsx` | کارت قیمت ویزیت + toggle الزامی + دکمه ذخیره (L66-68) |
| `assets/admin/pages/AppointmentsPage.tsx` | صفحه `/admin/appointments` + `NewAppointmentModal` (L102، رندر L627) — **باگ هزینه ویزیت اینجاست** |
| `assets/admin/pages/AppointmentCreatePage.tsx` | صفحه کامل ثبت نوبت (`/admin/appointments/new`) — مرجع درست هزینه ویزیت (L109-151, 477-488) |
| `assets/admin/components/NewAppointmentDrawer.tsx` | drawer «افزودن نوبت» (L126-140) — همان باگ هزینه ویزیت |
| `assets/admin/components/ui/Input.tsx` | input پایه design-system (`cp-input`) — نقطه تمرکز فیلد عددی سراسری |
| `assets/admin/components/ui/DigitInput.tsx` / `PriceInput.tsx` / `MobileInput.tsx` | فیلدهای عددی موجود (همه `inputMode="numeric"` + `dir="ltr"`) |
| `assets/admin/lib/utils.ts` | `toEnglishDigits` (L106-111)، `sanitizeMobileInput` (L114-116)، `tomanToRial`/`rialToToman` (L5-6) |
| `src/Appointment/Controller/AppointmentController.php` | `updateStatus` (L634-669) و `update` (L677-776) — نقطه لغو نوبت |
| `src/Appointment/Entity/Appointment.php` | ثابت‌های وضعیت (L28-29)، جدول transition (L37-41)، `transitionTo()` (L299-314) |
| `src/Shared/Logging/DbLogger.php` / `AppLog.php` | زیرساخت لاگ موجود (فقط WARNING به بالا persist) |
| `docs/api/appointment.md`, `docs/api/insurance.md` | به‌روزرسانی مستندات (Standing Rule) |
---
## تسک ۱ — انتقال کامل «برنامه کاری» از پروفایل به تنظیمات نوبت‌دهی
### وضعیت فعلی
- `AppointmentSettingsPage.tsx:45` همین الان فقط زیرتب `WeeklyScheduleTab` را دارد (بدون تب‌های «تاریخ‌های خاص» و «تعطیلات»):
```tsx
<WeeklyScheduleTab doctorUuid={uuid} addresses={addresses} />
```
- `DoctorDetailPage.tsx:2899` کل `ScheduleSection` (سه‌تب: `weekly` / `overrides` / `holidays` + هدر «برنامه کاری») را رندر می‌کند و این برای **هم پروفایل و هم نمای ادمینِ جزئیات پزشک** اجرا می‌شود:
```tsx
{uuid && <ScheduleSection doctorUuid={uuid} readOnly={isReadOnly} />}
```
### هدف
برنامه کاری فقط از صفحه تنظیمات نوبت‌دهی مدیریت شود؛ از پروفایل کاملاً حذف شود.
### وظایف
1. در `AppointmentSettingsPage.tsx`، به‌جای `WeeklyScheduleTab` تنها، از **`ScheduleSection` کامل** استفاده کن (تا هر سه تب weekly/overrides/holidays در تنظیمات نوبت‌دهی باشد). `ScheduleSection` را از `DoctorDetailPage` export/import کن (اگر export نیست، `export function ScheduleSection` کن) و با همان props فعلی (`doctorUuid={uuid}`) بده. `addresses` را دیگر لازم نیست جدا بدهی چون `ScheduleSection` خودش `available-locations` را fetch می‌کند (L2061-2067).
2. رندر `ScheduleSection` در پروفایل حذف شود. چون خط L2899 هم پروفایل و هم نمای ادمین را سرو می‌کند، آن را **مشروط** کن که فقط وقتی پروفایلِ خودِ کاربر **نیست** رندر شود — تا نمای ادمینِ جزئیات پزشک دست‌نخورده بماند:
```tsx
{uuid && !isOwnProfile && <ScheduleSection doctorUuid={uuid} readOnly={isReadOnly} />}
```
(نام دقیق prop تشخیص پروفایل را از خود کامپوننت بردار — `isOwnProfile` که `DoctorProfilePage` پاس می‌دهد.)
### نکات
- بعد از انتقال، مطمئن شو دکمه‌های ذخیره داخل تب‌ها (`ذخیره برنامه هفتگی` L1608-1612 و مشابه در overrides/holidays) درست کار می‌کنند — آن‌ها API خودشان را دارند و مستقل از دکمه ذخیره تسک ۲ هستند.
- نمای ادمینِ «جزئیات پزشک» (وقتی ادمین پزشک دیگری را می‌بیند) باید همچنان برنامه کاری را نشان دهد؛ فقط پروفایلِ شخصی نباید.
---
## تسک ۲ — اصلاح چیدمان دکمه ذخیره در صفحه تنظیمات نوبت‌دهی
### وضعیت فعلی
در `AppointmentSettingsPage.tsx` ترتیب فعلی: عنوان → `<FreeVisitPrice/>` (شامل toggle «الزامی کردن هزینه ویزیت» و دکمه ذخیره خودش L66-68) → `ScheduleSection`. دکمه ذخیرهٔ کارت قیمت ویزیت داخل خود کارت است ولی از نظر بصری بعد از toggle در جای مناسبی قرار نمی‌گیرد.
### هدف (بهترین UX انتخاب و پیاده‌سازی شود)
راهکار توصیه‌شده: **دکمه ذخیرهٔ کارت `FreeVisitPrice` بلافاصله زیر فیلد/toggle «الزامی کردن هزینه ویزیت» و در انتهای همان کارت قرار گیرد** (نه شناور بالا). چون منطقاً دکمه ذخیره باید آخرین المان فرمِ آن کارت باشد.
### وظایف
1. در `FreeVisitPrice.tsx` ترتیب داخل کارت را طوری کن که: فیلد قیمت ویزیت آزاد → toggle «الزامی کردن هزینه ویزیت» (L77-93) → **سپس** دکمه ذخیره (L66-68) در انتهای کارت، تراز راست (`marginInlineStart: 'auto'`) با فاصله مناسب از toggle.
2. اگر دکمه ذخیره فعلاً بالای toggle رندر می‌شود، آن را به انتهای JSX کارت منتقل کن.
### نکات
- منطق `save`/state دست‌نخورده بماند؛ فقط ترتیب رندر و استایل جای دکمه.
- الگوی دکمه: `className="btn primary sm"`.
---
## تسک ۳ — اجبار ورودی لاتین در همه فیلدهای عددی سراسری
### وضعیت فعلی
- ابزار موجود: `toEnglishDigits` در `utils.ts:106-111` (فارسی/عربی → لاتین)، و `DigitInput`/`PriceInput`/`MobileInput` که همگی `inputMode="numeric"` + `dir="ltr"` دارند.
- **مشکل**: خیلی از inputها المان خام `<input>` هستند و از این کامپوننت‌ها استفاده نمی‌کنند (مثلاً `NewAppointmentModal` L215-273، `NewAppointmentDrawer` L165-199/303-313). `Input.tsx` پایه design-system است ولی **هیچ** `inputMode`/`lang`/تبدیل رقم ندارد و adoption ناقص است. تبدیل رقم در سه جای تکراری است (`toEnglishDigits`، `PriceInput.toLatinDigits`، regex inline در AppointmentsPage L176-180).
### هدف
هر فیلدی که فقط عدد می‌گیرد، هنگام تایپ رقم لاتین وارد شود (نه فارسی)، بدون شکستن فیلدهای غیرعددی.
### وظایف
1. **`Input.tsx` را ارتقا بده** تا یک prop اختیاری `numeric?: boolean` بگیرد. وقتی `numeric` است:
- `inputMode="numeric"`, `dir="ltr"`, `lang="en"` روی input ست شود.
- در `onChange`، مقدار با `toEnglishDigits` نرمال شود قبل از فراخوانی `onChange` والد (رقم فارسی/عربی تایپ‌شده بلافاصله به لاتین تبدیل شود). از همان `toEnglishDigits` مشترک `utils.ts` استفاده کن — تبدیل‌های تکراری (`PriceInput.toLatinDigits`، regex inline) را با import از `utils.ts` یکدست کن.
2. **حذف تکرار**: `PriceInput.tsx` و `onMobileChange` در `AppointmentsPage.tsx` (L176-180) به‌جای map/regex محلی از `toEnglishDigits` مشترک استفاده کنند.
3. **پوشش inputهای خام عددی**: فیلدهای عددیِ خام موجود در مودال/drawer نوبت و سایر فرم‌ها (کدملی، موبایل، مبالغ، تعداد) که از `Input`/`DigitInput`/`MobileInput`/`PriceInput` استفاده نمی‌کنند را یا به این کامپوننت‌ها مهاجرت بده یا حداقل `inputMode="numeric"` + `dir="ltr"` + نرمال‌سازی `toEnglishDigits` در onChange اضافه کن. حداقل این نقاط: `NewAppointmentModal` (کدملی/موبایل)، `NewAppointmentDrawer`.
### نکات
- فیلدهای متنی (نام، آدرس، توضیحات) نباید عددی شوند — فقط فیلدهایی که «فقط عدد» می‌گیرند.
- `inputMode="numeric"` صفحه‌کلید موبایل را عددی می‌کند؛ `dir="ltr"` + نرمال‌سازی `toEnglishDigits` تضمین می‌کند رقم فارسی paste/تایپ‌شده هم لاتین ذخیره شود. هر دو لازم است.
- تبدیل باید در **onChange** انجام شود نه فقط onBlur، تا کاربر بلافاصله رقم لاتین ببیند.
---
## تسک ۴ — رفع باگ: ثبت نوبت هنگام الزامی بودن هزینه ویزیت (۴۲۲)
### وضعیت فعلی
- backend درست است: `MyAppointmentsController::createAppointment` (L132-135) وقتی `isRequiredForDoctor` و `visit_price_rials <= 0``422 "هزینه ویزیت الزامی است"`.
- **باگ در frontend**: `NewAppointmentModal` (`AppointmentsPage.tsx:102`) — payload آن (L143-152) **اصلاً `visit_price_rials` ندارد**، هیچ فیلد قیمت ویزیت رندر نمی‌کند و تنظیم `insurance-pricing`/`require_visit_price` را نمی‌خواند:
```tsx
mutationFn: () => api.post(createEndpoint, {
doctor_uuid: slot.doctor_uuid,
slot_start: serviceMode ? pick.slot!.start : slot.start,
slot_end: serviceMode ? pick.slot!.end : slot.end,
patient_mobile: mobile,
patient_name: effectiveName,
patient_national_code: effectiveNationalCode,
...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}),
}),
```
- مرجع درست: `AppointmentCreatePage.tsx` که همین را دارد — خواندن تنظیم (L109-114)، state + prefill از `freeVisit` (L116-120)، گیت اعتبارسنجی (L129)، فیلد ورودی (L477-488)، و ارسال شرطی (L149):
```tsx
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
```
- `NewAppointmentDrawer.tsx` (L126-140) هم همین باگ را دارد.
### هدف
مودال (و drawer) ثبت نوبت مثل `AppointmentCreatePage` هزینه ویزیت را بگیرد و ارسال کند تا ۴۲۲ رخ ندهد.
### وظایف
1. در `NewAppointmentModal`:
- تنظیم را بخوان: `useQuery(['insurance-pricing'])``requireVisit` و `freeVisit` (دقیقاً مثل `AppointmentCreatePage.tsx:109-114`). `doctor_uuid` مودال از `slot.doctor_uuid`.
- state `visitPriceToman` با prefill از `freeVisit` (مثل L116-120).
- یک فیلد ورودی «هزینه ویزیت (تومان)» با `<PriceInput>` اضافه کن؛ اگر `requireVisit` است ستاره `*` روی label و پیام خطای «هزینه ویزیت الزامی است» زیر فیلد وقتی `visitPriceToman <= 0`.
- گیت submit: دکمه «ثبت نوبت» (L285) وقتی `requireVisit && visitPriceToman <= 0` غیرفعال شود.
- در payload (L143-152) خط شرطی اضافه کن: `...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {})`.
2. همین اصلاح را در `NewAppointmentDrawer.tsx` (L126-140) اعمال کن.
### نکات
- `visit_price_rials` بر حسب **ریال** ارسال می‌شود؛ ورودی UI تومان است → `tomanToRial()` از `utils.ts`.
- وقتی `requireVisit` غیرفعال است رفتار فعلی حفظ شود (فیلد اختیاری، بدون مقدار → فیلد در payload نیاید).
- فیلد قیمت باید عددی/لاتین باشد (با تسک ۳ سازگار — `PriceInput` این را دارد).
---
## تسک ۵ — ثبت لاگ و رویداد Timeline هنگام لغو نوبت
### وضعیت فعلی (مهم — سیستم Timeline وجود ندارد)
- لغو نوبت از طریق `AppointmentController::updateStatus` (L634-669) با گذار وضعیت به `cancelled_by_doctor` / `cancelled_by_user` انجام می‌شود (و نیز `update` L677-776, transition L752-764). `transitionTo()` (Entity L299-314) فقط status/updatedAt را ست می‌کند، **هیچ لاگ یا reason ندارد**.
- **هیچ فیلد `cancel_reason`** در Entity یا بدنه request وجود ندارد (grep صفر).
- **هیچ سیستم Timeline/ActivityLog/رویدادِ per-appointment** در backend یا پنل ادمین clinicpro وجود ندارد. `TurnsTimeline.tsx` صرفاً نمای روزانهٔ نوبت‌هاست، نه تاریخچهٔ رویدادهای یک نوبت. پس این تسک **اولین** سیستم رویداد نوبت را می‌سازد.
- زیرساخت لاگ موجود: `DbLogger` → جدول `app_log`، اما فقط سطح WARNING به بالا persist می‌شود.
### هدف
هر بار یک نوبت لغو می‌شود: (الف) یک Log ثبت شود، (ب) یک رویداد جدید با عنوان «نوبت لغو شد» شامل زمان لغو، کاربرِ لغوکننده و دلیل لغو (در صورت وجود) در Timeline نوبت نمایش داده شود.
### وظایف
1. **Entity رویداد نوبت (جدید)**`src/Appointment/Entity/AppointmentEvent.php`:
- ستون‌ها: `id`, `uuid`, `appointment_id` (FK/int به نوبت), `type` string (مثل `cancelled`), `title` string («نوبت لغو شد»), `actor_user_id` (nullable int — کاربر لغوکننده), `actor_name` string (nullable — کش نام برای نمایش), `reason` text nullable, `created_at` int (Unix timestamp صحیح — نه DateTime).
- migration لازم است: `ddev exec php bin/console make:migration` سپس `ddev exec php bin/console doctrine:migrations:migrate -n`.
2. **repository جدید** `AppointmentEventRepository` با متد لیستِ رویدادهای یک نوبت به‌صورت **DQL array hydration** (`getArrayResult()`)، مرتب بر `created_at`.
3. **ثبت رویداد در نقطه لغو** — در `AppointmentController::updateStatus` (بعد از `transitionTo`, حدود L656) و نیز مسیر `update` (L760): اگر `$newStatus` یکی از `STATUS_CANCELLED_BY_DOCTOR` / `STATUS_CANCELLED_BY_USER` بود:
- `reason` را از بدنه request بخوان: `$data['cancel_reason'] ?? null` (اختیاری).
- یک `AppointmentEvent` با `type='cancelled'`, `title='نوبت لغو شد'`, `actor_user_id`/`actor_name` از `$user`, `reason`, `created_at=time()` بساز و persist کن.
- همزمان `LoggerInterface` را با فرمت غنی پروژه (الگوی `project-logging`) صدا بزن، سطح `warning` تا در `app_log` هم persist شود:
```php
$this->logger->warning(sprintf(
'Appointment cancelled: uuid=%s status=%s by user=%d(%s) reason=%s',
$appointment->getUuid(), $newStatus, $user->getId(), $user->getName() ?? '-', $reason ?? '-'
));
```
- سرویس لاگ/EntityManager را در constructor کنترلر inject کن (الان هیچ‌کدام inject نشده — L29-39).
4. **خروجی رویدادها در API**: یک endpoint `GET /api/v1/appointment/{uuid}/events` (یا رویدادها را داخل پاسخ جزئیات نوبت `toArray()` اضافه کن) که آرایه رویدادها را برمی‌گرداند: `{ type, title, actor_name, reason, created_at }`. envelope با `$this->success()`.
5. **نمایش Timeline در پنل ادمین**: در نمای جزئیات نوبت (مودال/بخش جزئیات که از `AppointmentsPage`/`TurnsTable` باز می‌شود) یک بخش «تاریخچه/Timeline» اضافه کن که رویدادها را از endpoint بالا می‌خواند و هر رویداد را نشان می‌دهد: عنوان («نوبت لغو شد»)، نامِ لغوکننده، زمان لغو (شمسی با `formatDate`)، و دلیل در صورت وجود. اگر نمای جزئیات نوبت مستقل وجود ندارد، یک بخش timeline ساده در همان مودال/سطر گسترش‌یافته اضافه کن.
### نکات
- تاریخ‌ها Unix timestamp صحیح ذخیره شوند؛ نمایش با `formatDate()` شمسی در فرانت.
- لیست‌های admin طبق قانون پروژه با DQL array hydration.
- `cancel_reason` فیلد اختیاری است — اگر فرانت دلیل نفرستد، رویداد بدون reason ثبت شود ولی همچنان «نوبت لغو شد» ثبت گردد.
- (اختیاری، بهبود) در UIِ لغو نوبت یک ورودی «دلیل لغو» اضافه کن تا `cancel_reason` پر شود؛ اگر خارج از scope است، backend همچنان باید null-safe باشد.
- این ساختار قابل‌گسترش است: در آینده رویدادهای دیگر (ایجاد/تأیید/تغییر) هم می‌توانند از همین `AppointmentEvent` استفاده کنند — ولی در این تسک فقط لغو کافی است.
---
## قوانین عمومی پروژه (برای همه تسک‌ها)
- کنترلرها از `BaseController` ارث می‌برند؛ پاسخ‌ها با `$this->success()` / `$this->error()` / `$this->paginated()`.
- تغییر Entity → migration لازم.
- بعد از تغییر API، فایل مربوط در `docs/api/` همان session به‌روز شود (`docs/api/appointment.md`, `docs/api/insurance.md`).
- Admin frontend: JWT در `localStorage['clinicpro-auth']`؛ paginated → items از `data?.data`, total از `data?.meta?.totalRecords`؛ single → `data?.data`.
- select‌ها: همیشه `SearchableSelect`، نه `<select>` خام.
- رشته‌ها فارسی، تاریخ‌ها شمسی، RTL.
- هر تسک جدا تست و کامیت شود. بعد از تغییر کد، `graphify update .` اجرا شود (بعد از کامیت).
+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.
@@ -0,0 +1,124 @@
# پرکردن خودکار پرونده (Session) هنگام قطعی شدن نوبت: تاریخ/ساعت مراجعه + آیتم‌های هزینه + جمع کل
## پروژه
`clinicpro` (backend Symfony + پنل ادمین React). تک-ریپو.
> تست: پنل ادمین با `09390039833 / 09390039833`. اجرا داخل ddev.
## زمینه
وقتی نوبت به وضعیت **قطعی/تأیید (`confirmed`)** تغییر می‌کند، `AppointmentController` تابع `PatientService::autoCreateOnAppointmentConfirm()` را صدا می‌زند تا برای بیمار یک پرونده (`PatientSession`) بسازد. اما مسیر auto-create فقط `new PatientSession($record, $appointment)` می‌سازد و ذخیره می‌کند — **هیچ داده‌ی هزینه یا زمان مراجعه‌ای ثبت نمی‌شود**. در نتیجه پرونده‌ی ساخته‌شده از نوبت: `session_at = null` (به created_at برمی‌گردد)، `visit_price_rials = 0`، `services_total_rials = 0`، `final_price_rials = 0` و لیست سرویس‌ها خالی است — درحالی‌که خودِ `Appointment` هم `visit_price_rials` و هم `serviceItems` را دارد. این یک باگ است.
مسیر دیگر (ویزارد دستیِ ثبت مراجعه `createSession()`) همه‌ی این‌ها را درست پر می‌کند و **الگوی مرجع** است.
## هدف
مسیر auto-create پرونده از نوبت باید مثل ویزارد، این‌ها را خودکار و **بدون ورود دستی قیمت** پر کند:
1. **تاریخ/ساعت مراجعه** (`session_at`) از زمان واقعی نوبت (`Appointment::getSlotStart()`).
2. **آیتم‌های هزینه‌ی سرویس** به‌صورت تفکیک‌شده: برای هر سرویسِ نوبت یک `SessionService` با قیمت snapshot از خود سرویس (`ServiceItem::getPriceRials()`).
3. **هزینه ویزیت** (`visit_price_rials`) از نوبت (و اگر نوبت مقدار نداشت، از تنظیم «قیمت ویزیت آزاد»).
4. **جمع کل**: `services_total_rials` = مجموع خطوط سرویس؛ `final_price_rials` = `services_total_rials + visit_price_rials`.
5. نمایش در UI پرونده: تاریخ/ساعت مراجعه به‌عنوان اولین اطلاعات + جدول تفکیک‌شده‌ی هزینه‌ها (ویزیت + هر سرویس) + جمع کل.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Patient/Service/PatientService.php` | `autoCreateOnAppointmentConfirm()` (L94-135) و `autoCreateForEntity()` (L119-135) — **محل باگ**؛ `createSession()` (L137-237) الگوی مرجع |
| `src/Patient/Entity/PatientSession.php` | Setterها: `setSessionAt()` (L204)، `setVisitPriceRials()` (L190)، `setServicesTotalRials()` (L193)، `setFinalPriceRials()` (L194)، `addService()` (L121) |
| `src/Patient/Entity/SessionService.php` | خط هزینه؛ constructor قیمت را از `ServiceItem::getPriceRials()` snapshot می‌کند (L44-53)؛ `getLineTotalRials()` (L62) |
| `src/ClinicService/Entity/ServiceItem.php` | `getPriceRials()` (L87) — منبع قیمت سرویس |
| `src/Appointment/Entity/Appointment.php` | `getSlotStart()`، `getVisitPriceRials()` (L251)، `getServiceItems()` (L235، ManyToMany `appointment_service_items`) |
| `src/Insurance/Service/VisitPriceRequirementResolver.php` | resolve تنظیم ویزیت (L22-37) — منبع fallback قیمت ویزیت آزاد |
| `src/Insurance/Entity/EntityInsurancePricing.php` | ردیف free-visit (`insurance_id = null`): `getPatientShareRials()` (L54) = قیمت ویزیت آزاد پیش‌فرض |
| `assets/admin/components/session/DetailsStep.tsx` | نمایش خلاصه پرونده — الان `session_at` و جدول تفکیکی ندارد |
| `docs/api/patient.md`، `docs/api/appointment.md` | به‌روزرسانی مستندات (Standing Rule) |
## وضعیت فعلی (باگ)
`src/Patient/Service/PatientService.php` — auto-create فقط می‌سازد و ذخیره می‌کند، بدون هیچ داده‌ای:
```php
private function autoCreateForEntity(string $entityType, int $entityId, Appointment $appointment, int $createdById): void
{
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
return;
}
$patient = $appointment->getUser();
$record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
if ($record === null) {
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
$this->recordRepo->save($record);
}
$session = new PatientSession($record, $appointment); // ← هیچ‌چیز دیگر ست نمی‌شود
$this->sessionRepo->save($session);
}
```
الگوی مرجع در `createSession()` (چطور باید پر شود) — خطوط کلیدی:
```php
// visit price
$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
// session_at (زمان مراجعه)
if (!empty($data['session_at'])) { $session->setSessionAt((int) $data['session_at']); }
// خطوط سرویس + مجموع
foreach ($data['services'] as $s) {
$item = $this->serviceItemRepo->findByUuid($s['uuid']);
$session->addService(new SessionService($session, $item, $staff, (int)($s['qty'] ?? 1)));
}
$session->setServicesTotalRials($servicesTotal);
$session->setFinalPriceRials($servicesTotal + $visitPrice + ...);
```
## وظایف
### ۱. پرکردن پرونده‌ی auto-create از روی نوبت (backend)
در `autoCreateForEntity()` بعد از ساخت `$session` و **قبل از** `save()`، از `$appointment` پر کن:
```php
$session = new PatientSession($record, $appointment);
// ۱) زمان مراجعه = زمان واقعی نوبت
$session->setSessionAt($appointment->getSlotStart());
// ۲) هزینه ویزیت: از نوبت، fallback به «قیمت ویزیت آزاد» تنظیمات
$visitPrice = $appointment->getVisitPriceRials()
?? $this->resolveFreeVisitPrice($entityType, $entityId);
$session->setVisitPriceRials($visitPrice ?? 0);
// ۳) خطوط سرویس تفکیک‌شده (قیمت snapshot از خود سرویس)
$servicesTotal = 0;
foreach ($appointment->getServiceItems() as $item) {
$line = new SessionService($session, $item, null, 1); // قیمت از ServiceItem::getPriceRials()
$session->addService($line);
$servicesTotal += $line->getLineTotalRials();
}
// ۴) جمع کل
$session->setServicesTotalRials($servicesTotal);
$session->setFinalPriceRials($servicesTotal + ($visitPrice ?? 0));
$this->sessionRepo->save($session);
```
- `resolveFreeVisitPrice($entityType, $entityId)`: یک helper که ردیف free-visit (`insurance_id = null`) را برای این doctor/clinic از `pricingRepo->findOneForInsurance(TYPE_DOCTOR|TYPE_CLINIC, $entityId, null)` می‌خواند و `getPatientShareRials()` را برمی‌گرداند (یا null). از منطق موجود `VisitPriceRequirementResolver` الگو بگیر.
- **توجه به مسیر دوگانه**: `autoCreateOnAppointmentConfirm` این متد را هم برای `doctor` و هم (در صورت وجود) `clinic` صدا می‌زند، پس ممکن است **دو پرونده** ساخته شود (یکی برای پزشک، یکی برای کلینیک). این رفتار فعلی است؛ آن را تغییر نده، فقط هر دو را درست پر کن.
- **قیمت دستی وارد نشود** — همیشه از `ServiceItem::getPriceRials()` و تنظیم ویزیت خوانده شود.
### ۲. نمایش تاریخ/ساعت مراجعه + جدول هزینه تفکیکی در UI پرونده (frontend)
در `assets/admin/components/session/DetailsStep.tsx`:
- **اولین اطلاعات**: «تاریخ و ساعت مراجعه» از `session_at` (شمسی با `formatDateTime`/`formatDate` — Unix timestamp صحیح). اگر `session_at` خالی بود، از `created_at`.
- **جدول هزینه‌ی تفکیک‌شده**: یک ردیف «ویزیت: {formatRial(visit_price_rials)}» وقتی `visit_price_rials > 0`، سپس هر خط سرویس از آرایه‌ی `services` (`service_name` + `line_total_rials`)، و در انتها «جمع کل: {final_price_rials}». از `toArray()` پرونده که `services`، `visit_price_rials`، `services_total_rials`، `final_price_rials`، `session_at` را می‌دهد استفاده کن.
## نکات مهم
- تاریخ‌ها Unix timestamp صحیح (`setSessionAt(int)`), نمایش شمسی با `formatDate`/`formatDateTime`.
- قیمت‌ها ریالی ذخیره؛ نمایش با `formatRial`. تبدیل تومان↔ریال با `tomanToRial`/`rialToToman`.
- تغییری در `Appointment` یا schema لازم نیست (فقط خواندن)؛ **بدون migration** مگر بخواهی ستون audit اضافه کنی (لازم نیست).
- تست: یک نوبت را از `pending` به `confirmed` ببر (`PATCH /api/v1/appointment/{uuid}/status`) و بررسی کن پرونده‌ی ساخته‌شده `session_at`، `visit_price_rials`، خطوط سرویس و `final_price_rials` درست دارد (endpoint `GET /api/v1/patient/{recordUuid}/sessions`).
- بعد از تغییر، `docs/api/patient.md` را اگر خروجی session تغییر معنایی کرد به‌روز کن.
- این پرامپت **پیش‌نیاز منطقی** پرامپت `discount-rules-engine.md` است (تخفیف روی `final_price_rials` اعمال می‌شود که اینجا درست می‌شود).
@@ -4,7 +4,7 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import { formatRial } from '../../lib/utils';
import { formatRial, formatDateTime, tomanToRial } from '../../lib/utils';
import type { DiscountSuggestion } from '../../types';
import type { SessionCardData } from '../SessionServiceCard';
import SearchableSelect from '../ui/SearchableSelect';
@@ -80,7 +80,9 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
const applyDiscount = () => {
if (!discountType || discountValue <= 0) return;
discountMut.mutate({ discount_type: discountType, discount_value: discountValue });
// percent درصد است (بدون تبدیل)؛ fixed مبلغ تومان است → ریال.
const value = discountType === 'fixed' ? tomanToRial(discountValue) : discountValue;
discountMut.mutate({ discount_type: discountType, discount_value: value });
};
const applyRule = (ruleUuid: string) => {
discountMut.mutate({ discount_rule_uuid: ruleUuid });
@@ -91,7 +93,7 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
};
const submitPayment = (method: string) => {
if (amount <= 0) return;
payMut.mutate({ method, amount_rials: amount, paid_at: isoToUnix(paymentDate) });
payMut.mutate({ method, amount_rials: tomanToRial(amount), paid_at: isoToUnix(paymentDate) });
};
const finalPrice = session.final_price_rials ?? 0;