From bb2dbc33712318020bd865f2c36385ecd8151b9c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 10 Aug 2026 12:21:28 +0330 Subject: [PATCH] fix(appointment): ensure appointment details are always fetched from the server to avoid incorrect pricing --- .../ConfirmAppointmentModal.test.tsx | 124 +++++++++++++++--- .../appointments/ConfirmAppointmentModal.tsx | 18 ++- 2 files changed, 119 insertions(+), 23 deletions(-) diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx index d98a49a9..066640b8 100644 --- a/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx +++ b/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx @@ -29,8 +29,12 @@ function render() { beforeEach(() => { vi.clearAllMocks(); - // payment-methods (pos / bank-accounts) و بقیهٔ GETها - get.mockResolvedValue({ success: true, data: [] }); + // مودال همیشه جزئیات نوبت را از سرور می‌گیرد — ردیفِ فهرست مبلغ ویزیت ندارد. + get.mockImplementation((url: string) => + url.startsWith('/api/v1/appointment/') + ? Promise.resolve({ success: true, data: appointment }) + // payment-methods (pos / bank-accounts) و بقیهٔ GETها + : Promise.resolve({ success: true, data: [] })); post.mockResolvedValue({ success: true, data: {} }); }); @@ -45,6 +49,14 @@ function amountInputs() { return screen.getAllByPlaceholderText('0') as HTMLInputElement[]; } +/** + * مودال تا رسیدنِ جزئیات نوبت از سرور، دکمه‌هایش قفل است — مبلغ نباید از ردیفِ + * ناقصِ فهرست خوانده شود. هر تست اول منتظر همین می‌ماند. + */ +async function settle() { + await waitFor(() => expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).not.toBeDisabled()); +} + /** از مرحلهٔ «بیمه و هزینه» به «پرداخت» می‌رود و ردیف‌های مبلغ را برمی‌گرداند. */ function goToPayment() { nextStep(); @@ -57,31 +69,35 @@ function goToReview() { } describe('ConfirmAppointmentModal', () => { - it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', () => { + it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', async () => { render(); + await settle(); expect(screen.getByText('محمد رضایی')).toBeInTheDocument(); expect(screen.getByText('ویزیت')).toBeInTheDocument(); expect(screen.getByText('لیزر')).toBeInTheDocument(); expect(screen.getByText('جمع کل')).toBeInTheDocument(); }); - it('ردیفِ اول پیش‌فرض برابر کل هزینه است و وضعیت «تسویه کامل» می‌شود', () => { + it('ردیفِ اول پیش‌فرض برابر کل هزینه است و وضعیت «تسویه کامل» می‌شود', async () => { render(); + await settle(); // ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰'); goToReview(); expect(screen.getByText('تسویه کامل')).toBeInTheDocument(); }); - it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» می‌شود', () => { + it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» می‌شود', async () => { render(); + await settle(); fireEvent.change(goToPayment()[0], { target: { value: '100000' } }); goToReview(); expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument(); }); - it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال می‌شود', () => { + it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال می‌شود', async () => { render(); + await settle(); const rows = goToPayment(); fireEvent.change(rows[0], { target: { value: '9000000' } }); @@ -96,6 +112,7 @@ describe('ConfirmAppointmentModal', () => { it('پرداخت جزئی مجاز است و همان یک روش را ثبت می‌کند', async () => { render(); + await settle(); fireEvent.change(goToPayment()[0], { target: { value: '100000' } }); goToReview(); fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })); @@ -108,6 +125,7 @@ describe('ConfirmAppointmentModal', () => { it('تقسیم پرداخت بین دو روش: مجموع ردیف‌ها به‌صورت آرایه ثبت می‌شود', async () => { render(); + await settle(); // ردیف اول را به ۲۰۰٬۰۰۰ تومان کم می‌کنیم fireEvent.change(goToPayment()[0], { target: { value: '200000' } }); // افزودن روش دوم — پیش‌فرض با باقی‌ماندهٔ ۱۰۰٬۰۰۰ تومان پر می‌شود @@ -131,8 +149,9 @@ describe('ConfirmAppointmentModal', () => { })); }); - it('حذف ردیف اضافه‌شده مجموع را دوباره محاسبه می‌کند', () => { + it('حذف ردیف اضافه‌شده مجموع را دوباره محاسبه می‌کند', async () => { render(); + await settle(); goToPayment(); fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ })); expect(amountInputs()).toHaveLength(2); @@ -162,12 +181,20 @@ const SUPP_CONTRACT = { category_coverages: { outpatient: 90, inpatient: 60 }, }; +/** + * @param detail نوبتی که اندپوینت جزئیات برمی‌گرداند — مودال همیشه آن را می‌خواند، + * چون ردیفِ فهرست مبلغ ویزیت و بیمه ندارد. + */ function mockInsurance( categories: { key: string; label: string; enabled: boolean }[], freeVisitPriceRials = 0, contracts: unknown[] = [CONTRACT], + detail: unknown = referenceAppointment, ) { get.mockImplementation((url: string) => { + if (url.startsWith('/api/v1/appointment/')) { + return Promise.resolve({ success: true, data: detail }); + } if (url === '/api/v1/insurance-pricing') { return Promise.resolve({ success: true, @@ -211,6 +238,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => { it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده می‌شود', async () => { mockInsurance(BOTH); renderReference(); + await settle(); expect(await screen.findByText('نوع خدمت')).toBeInTheDocument(); expect(screen.getByText('بیمه پایه')).toBeInTheDocument(); @@ -222,6 +250,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => { { key: 'inpatient', label: 'خدمات بستری', enabled: false }, ]); renderReference(); + await settle(); expect(await screen.findByText('بیمه پایه')).toBeInTheDocument(); expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument(); @@ -230,6 +259,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => { it('بدون انتخاب بیمه، مبلغ قابل پرداخت همان جمع کل است', async () => { mockInsurance(BOTH); renderReference(); + await settle(); await screen.findByText('نوع خدمت'); expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument(); @@ -239,6 +269,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => { it('با انتخاب بیمه، سهم بیمه و سهم بیمار محاسبه و ارسال می‌شوند (سرپایی ۷۰٪)', async () => { mockInsurance(BOTH); renderReference(); + await settle(); await screen.findByText('نوع خدمت'); await pick('انتخاب نوع خدمت', 'خدمات سرپایی'); @@ -261,6 +292,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => { it('بدون قرارداد تکمیلی، انتخاب بیمهٔ تکمیلی نمایش داده نمی‌شود', async () => { mockInsurance(BOTH); renderReference(); + await settle(); expect(await screen.findByText('بیمه پایه')).toBeInTheDocument(); expect(screen.queryByText('بیمه تکمیلی')).not.toBeInTheDocument(); @@ -269,6 +301,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => { it('سهم پایه و تکمیلی جدا و زنجیره‌ای محاسبه می‌شوند و هر دو ارسال می‌گردند', async () => { mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]); renderReference(); + await settle(); await screen.findByText('نوع خدمت'); await pick('انتخاب نوع خدمت', 'خدمات سرپایی'); @@ -296,6 +329,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => { it('بیمهٔ تکمیلیِ تنها هم روی کل مبلغ اعمال می‌شود', async () => { mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]); renderReference(); + await settle(); await screen.findByText('نوع خدمت'); await pick('انتخاب نوع خدمت', 'خدمات سرپایی'); @@ -308,15 +342,17 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => { }); it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان می‌دهد (نه صفر)', async () => { - mockInsurance(BOTH, 5_952_000); + const priceless = { uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] }; + mockInsurance(BOTH, 5_952_000, [CONTRACT], priceless); renderWithProviders( {}} />, ); + await settle(); expect(await screen.findByText('مبلغ قابل پرداخت')).toBeInTheDocument(); // ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه می‌گذارد. @@ -336,6 +372,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => { it('نوع بستری درصد خودش را می‌گیرد (۳۰٪)', async () => { mockInsurance(BOTH); renderReference(); + await settle(); await screen.findByText('نوع خدمت'); await pick('انتخاب نوع خدمت', 'خدمات بستری'); @@ -353,8 +390,9 @@ describe('ConfirmAppointmentModal — ویزارد', () => { Array.from(document.querySelectorAll('ol[aria-label="مراحل قطعی کردن نوبت"] li')) .map(li => li.textContent?.replace(/^\d+/, '').trim()); - it('سه مرحله دارد و از «بیمه و هزینه» شروع می‌شود', () => { + it('سه مرحله دارد و از «بیمه و هزینه» شروع می‌شود', async () => { render(); + await settle(); expect(stepLabels()).toEqual(['بیمه و هزینه', 'پرداخت', 'تأیید']); // جدول هزینه در مرحلهٔ اول است، ردیف پرداخت هنوز نه. @@ -362,16 +400,18 @@ describe('ConfirmAppointmentModal — ویزارد', () => { expect(screen.queryByPlaceholderText('0')).toBeNull(); }); - it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', () => { + it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', async () => { render(); + await settle(); expect(screen.queryByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument(); }); - it('مرحلهٔ قبل مقادیر واردشده را نگه می‌دارد', () => { + it('مرحلهٔ قبل مقادیر واردشده را نگه می‌دارد', async () => { render(); + await settle(); fireEvent.change(goToPayment()[0], { target: { value: '100000' } }); fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ قبل' })); @@ -382,8 +422,9 @@ describe('ConfirmAppointmentModal — ویزارد', () => { expect(amountInputs()[0]).toHaveValue('۱۰۰٬۰۰۰'); }); - it('مرحلهٔ آخر روش‌های پرداخت را قبل از ثبت خلاصه می‌کند', () => { + it('مرحلهٔ آخر روش‌های پرداخت را قبل از ثبت خلاصه می‌کند', async () => { render(); + await settle(); fireEvent.change(goToPayment()[0], { target: { value: '100000' } }); goToReview(); @@ -398,8 +439,9 @@ describe('ConfirmAppointmentModal — میان‌بُرهای درصدی', () => const percentButton = (percent: string) => screen.getAllByRole('button', { name: `${percent}٪` })[0]; - it('کلیک روی هر درصد، همان کسر از سهم بیمار را در مبلغ می‌گذارد', () => { + it('کلیک روی هر درصد، همان کسر از سهم بیمار را در مبلغ می‌گذارد', async () => { render(); + await settle(); const [amount] = goToPayment(); fireEvent.click(percentButton('۲۰')); @@ -417,6 +459,7 @@ describe('ConfirmAppointmentModal — میان‌بُرهای درصدی', () => it('درصدِ اعمال‌شده تا مرحلهٔ ثبت می‌ماند و به سرور می‌رسد', async () => { render(); + await settle(); goToPayment(); fireEvent.click(percentButton('۵۰')); @@ -429,8 +472,9 @@ describe('ConfirmAppointmentModal — میان‌بُرهای درصدی', () => ]); }); - it('هر ردیف درصد خودش را دارد؛ ردیف دیگر دست نمی‌خورد', () => { + it('هر ردیف درصد خودش را دارد؛ ردیف دیگر دست نمی‌خورد', async () => { render(); + await settle(); goToPayment(); fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ })); @@ -445,17 +489,63 @@ describe('ConfirmAppointmentModal — میان‌بُرهای درصدی', () => expect(rows[1]).toHaveValue('۶۰٬۰۰۰'); }); - it('نوبتِ بدون هزینه اصلاً دکمهٔ درصدی ندارد', () => { + it('نوبتِ بدون هزینه اصلاً دکمهٔ درصدی ندارد', async () => { + const free = { uuid: 'a2', version: 1, visit_price_rials: 0, service_items: [] }; + // جزئیات هم باید همان نوبتِ بی‌هزینه باشد؛ مودال مبلغ را از سرور می‌خواند. + get.mockImplementation((url: string) => + Promise.resolve({ success: true, data: url.startsWith('/api/v1/appointment/') ? free : [] })); + renderWithProviders( {}} />, ); + await settle(); nextStep(); expect(screen.queryByRole('button', { name: '۵۰٪' })).not.toBeInTheDocument(); }); }); + +/** + * باگ واقعی: تایم‌لاین ردیفِ فهرست نوبت‌ها را پاس می‌داد و آن ردیف اصلاً + * `visit_price_rials` ندارد. مودال به همان اعتماد می‌کرد، «۰ تومان» نشان می‌داد و + * همان صفر را ثبت می‌کرد — هزینهٔ ویزیتِ ثبت‌شده در نوبت نادیده می‌ماند. + */ +describe('ConfirmAppointmentModal — ردیفِ ناقصِ فهرست', () => { + /** دقیقاً شکل ردیفِ `/api/v1/my/appointments`: بدون مبلغ و بدون بیمه. */ + const listRow = { + uuid: 'a1', + version: 1, + patient_name: 'محمد رضایی', + service_items: [], + }; + + it('مبلغ را از جزئیات سرور می‌گیرد، نه از ردیفِ پاس‌داده‌شده', async () => { + renderWithProviders( + {}} />, + ); + await settle(); + + // ۳٬۰۰۰٬۰۰۰ ریالِ جزئیات = ۳۰۰٬۰۰۰ تومان، نه صفرِ ردیفِ فهرست. + expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰'); + }); + + it('تا نیامدنِ جزئیات، رفتن به مرحلهٔ پرداخت قفل است', () => { + let resolveDetail: (v: unknown) => void = () => {}; + get.mockImplementation((url: string) => + url.startsWith('/api/v1/appointment/') + ? new Promise((resolve) => { resolveDetail = resolve; }) + : Promise.resolve({ success: true, data: [] })); + + renderWithProviders( + {}} />, + ); + + expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled(); + resolveDetail({ success: true, data: appointment }); + }); +}); diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx index 4cdb4130..389ab5ed 100644 --- a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx +++ b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx @@ -119,12 +119,14 @@ export default function ConfirmAppointmentModal({ const [touched, setTouched] = useState(false); const [stepIdx, setStepIdx] = useState(0); - // وقتی صفحه‌ی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را می‌گیریم: - // مبلغ ویزیت و قیمت سرویس‌ها فقط در detail هستند. + // جزئیات همیشه گرفته می‌شود، حتی وقتی صفحهٔ میزبان نوبتی پاس داده است: ردیفِ + // فهرست نوبت‌ها `visit_price_rials` و بیمه را ندارد، و مودال با اعتماد به همان + // ردیف، هزینهٔ ویزیتِ ثبت‌شده را صفر نشان می‌داد و همان صفر را هم ثبت می‌کرد. + // مبلغ چیزی نیست که از یک payload ناقص حدس زده شود. const detailQuery = useQuery({ queryKey: ['appointment', appointmentUuid], queryFn: () => api.get>(`/api/v1/appointment/${appointmentUuid}`), - enabled: open && !appointment, + enabled: open, }); // روش‌های پرداختِ ثبت‌شده — فقط وقتی مودال باز است. @@ -151,8 +153,10 @@ export default function ConfirmAppointmentModal({ [bankQuery.data], ); - const appt: AppointmentLike | null = appointment - ?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null); + // پاسخ سرور مرجع است؛ نوبتِ پاس‌داده‌شده فقط تا رسیدنِ آن، صفحه را خالی نگه نمی‌دارد. + const detail: AppointmentLike | null = + (detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null; + const appt: AppointmentLike | null = detail ?? appointment ?? null; // ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ────────────────────────────────────── const insurance = useAppointmentInsurance(open); @@ -278,7 +282,9 @@ export default function ConfirmAppointmentModal({ onClose(); } - const loading = detailQuery.isLoading && !appointment; + // تا رسیدنِ جزئیات، دکمه‌ها قفل‌اند: کاربر نباید روی مبلغی که هنوز از سرور نیامده + // «مرحلهٔ بعد» بزند و پرداختِ صفر ثبت کند. + const loading = detailQuery.isLoading; const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key; const isLastStep = currentStep === 'review';