diff --git a/.claude/prompt/appointment-settings-move-schedule-and-cancel-log.md b/.claude/prompt/appointment-settings-move-schedule-and-cancel-log.md new file mode 100644 index 00000000..cab49e61 --- /dev/null +++ b/.claude/prompt/appointment-settings-move-schedule-and-cancel-log.md @@ -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 پروفایل → `` | +| `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 + +``` +- `DoctorDetailPage.tsx:2899` کل `ScheduleSection` (سه‌تب: `weekly` / `overrides` / `holidays` + هدر «برنامه کاری») را رندر می‌کند و این برای **هم پروفایل و هم نمای ادمینِ جزئیات پزشک** اجرا می‌شود: +```tsx +{uuid && } +``` + +### هدف + +برنامه کاری فقط از صفحه تنظیمات نوبت‌دهی مدیریت شود؛ از پروفایل کاملاً حذف شود. + +### وظایف + +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 && } +``` +(نام دقیق prop تشخیص پروفایل را از خود کامپوننت بردار — `isOwnProfile` که `DoctorProfilePage` پاس می‌دهد.) + +### نکات + +- بعد از انتقال، مطمئن شو دکمه‌های ذخیره داخل تب‌ها (`ذخیره برنامه هفتگی` L1608-1612 و مشابه در overrides/holidays) درست کار می‌کنند — آن‌ها API خودشان را دارند و مستقل از دکمه ذخیره تسک ۲ هستند. +- نمای ادمینِ «جزئیات پزشک» (وقتی ادمین پزشک دیگری را می‌بیند) باید همچنان برنامه کاری را نشان دهد؛ فقط پروفایلِ شخصی نباید. + +--- + +## تسک ۲ — اصلاح چیدمان دکمه ذخیره در صفحه تنظیمات نوبت‌دهی + +### وضعیت فعلی + +در `AppointmentSettingsPage.tsx` ترتیب فعلی: عنوان → `` (شامل 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ها المان خام `` هستند و از این کامپوننت‌ها استفاده نمی‌کنند (مثلاً `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). + - یک فیلد ورودی «هزینه ویزیت (تومان)» با `` اضافه کن؛ اگر `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`، نه `` خام). + +### ۶. 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. diff --git a/.claude/prompt/session-autofill-on-appointment-confirm.md b/.claude/prompt/session-autofill-on-appointment-confirm.md new file mode 100644 index 00000000..ba0c80ef --- /dev/null +++ b/.claude/prompt/session-autofill-on-appointment-confirm.md @@ -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` اعمال می‌شود که اینجا درست می‌شود). diff --git a/assets/admin/components/session/PaymentStep.tsx b/assets/admin/components/session/PaymentStep.tsx index 6e5844fb..85136a62 100644 --- a/assets/admin/components/session/PaymentStep.tsx +++ b/assets/admin/components/session/PaymentStep.tsx @@ -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;