diff --git a/assets/admin/components/AppointmentActions.tsx b/assets/admin/components/AppointmentActions.tsx index 76607193..67030854 100644 --- a/assets/admin/components/AppointmentActions.tsx +++ b/assets/admin/components/AppointmentActions.tsx @@ -20,7 +20,7 @@ import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import type { ApiResponse } from "../lib/api"; import { api } from "../lib/api"; -import { formatRial, tehranWallClockToUnix } from "../lib/utils"; +import { formatRial, tehranWallClockToUnix, rialToToman, tomanToRial } from "../lib/utils"; import type { Appointment } from "../types"; import AppointmentStatusDropdown from "./ui/AppointmentStatusDropdown"; import Modal from "./ui/Modal"; @@ -702,8 +702,8 @@ export function ReplaceAppointmentModal({ const [depositRequired, setDepositRequired] = useState( !!a.deposit_required, ); - const [depositRials, setDepositRials] = useState( - a.deposit_amount_rials ?? 0, + const [depositToman, setDepositToman] = useState( + rialToToman(a.deposit_amount_rials ?? 0), ); const [note, setNote] = useState(""); @@ -719,7 +719,7 @@ export function ReplaceAppointmentModal({ service_item_uuid: itemUuid, staff_uuid: staffUuid, deposit_required: depositRequired, - deposit_amount_rials: depositRequired ? depositRials : null, + deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null, ...(note.trim() ? { note: note.trim() } : {}), ...(status !== a.status ? { status } : {}), version: a.version, @@ -904,8 +904,8 @@ export function ReplaceAppointmentModal({
diff --git a/assets/admin/components/FreeVisitPrice.test.tsx b/assets/admin/components/FreeVisitPrice.test.tsx new file mode 100644 index 00000000..5fb10f02 --- /dev/null +++ b/assets/admin/components/FreeVisitPrice.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import FreeVisitPrice from './FreeVisitPrice'; + +const get = api.get as ReturnType; +const put = api.put as ReturnType; + +const pricing = (priceRials: number, require: boolean) => ({ + success: true, + data: { free_visit_price_rials: priceRials, require_visit_price: require }, +}); + +beforeEach(() => { + get.mockReset(); + put.mockReset(); + put.mockResolvedValue({ success: true }); +}); + +describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () => { + it('toggle فعال + قیمت صفر → خطای inline و عدم ارسال درخواست', async () => { + get.mockResolvedValue(pricing(0, false)); + renderWithProviders(); + await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0)); + + fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })); + fireEvent.click(screen.getByText('ذخیره')); + + expect(await screen.findByText('با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است')).toBeInTheDocument(); + expect(put).not.toHaveBeenCalled(); + }); + + it('toggle فعال + قیمت معتبر → PUT با هر دو کلید (تومان → ریال)', async () => { + get.mockResolvedValue(pricing(0, false)); + renderWithProviders(); + await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0)); + + fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })); + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '50000' } }); + fireEvent.click(screen.getByText('ذخیره')); + + await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', { + free_visit_price_rials: 500_000, + require_visit_price: true, + })); + }); + + it('toggle غیرفعال + قیمت صفر → رفتار قبلی حفظ می‌شود (ارسال مجاز)', async () => { + get.mockResolvedValue(pricing(0, false)); + renderWithProviders(); + await waitFor(() => expect(screen.getByRole('spinbutton')).toHaveValue(0)); + + fireEvent.click(screen.getByText('ذخیره')); + + await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', { + free_visit_price_rials: 0, + require_visit_price: false, + })); + }); + + it('فلگ ذخیره‌شده true → سوییچ روشن و ستاره روی label قیمت', async () => { + get.mockResolvedValue(pricing(500_000, true)); + renderWithProviders(); + + await waitFor(() => expect(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })).toBeChecked()); + expect(screen.getByText('قیمت (تومان)').querySelector('span')?.textContent).toContain('*'); + expect(screen.getByRole('spinbutton')).toHaveValue(50_000); + }); +}); diff --git a/assets/admin/components/NewAppointmentDrawer.tsx b/assets/admin/components/NewAppointmentDrawer.tsx index 47540190..2efc0ab6 100644 --- a/assets/admin/components/NewAppointmentDrawer.tsx +++ b/assets/admin/components/NewAppointmentDrawer.tsx @@ -9,7 +9,7 @@ import PersianDateInput from './ui/PersianDateInput'; import PriceInput from './ui/PriceInput'; import SearchableSelect from './ui/SearchableSelect'; import { WalletChargeLink } from './AppointmentActions'; -import { tehranWallClockToUnix } from '../lib/utils'; +import { tehranWallClockToUnix, tomanToRial } from '../lib/utils'; interface Option { uuid: string; name?: string; full_name?: string } interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string } @@ -104,7 +104,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey // ── deposit / status / notes ─────────────────────────────────────────────── const [depositRequired, setDepositRequired] = useState(false); - const [depositRials, setDepositRials] = useState(0); + const [depositToman, setDepositToman] = useState(0); const [status, setStatus] = useState('pending'); const [note, setNote] = useState(''); @@ -135,7 +135,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey // حالت سرویس: چند سرویس؛ حالت اسلاتی: تک سرویسِ workflow (اختیاری). ...(serviceMode ? { service_item_uuids: serviceUuids } : itemUuid ? { service_item_uuid: itemUuid } : {}), ...(staffUuid ? { staff_uuid: staffUuid } : {}), - ...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}), + ...(depositRequired ? { deposit_required: true, deposit_amount_rials: tomanToRial(depositToman) } : {}), ...(note.trim() ? { note: note.trim() } : {}), }; const res: any = await api.post('/api/v1/my/appointment', payload); @@ -331,7 +331,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
- +
diff --git a/assets/admin/components/session/CreateStep.test.tsx b/assets/admin/components/session/CreateStep.test.tsx new file mode 100644 index 00000000..cd1d44ab --- /dev/null +++ b/assets/admin/components/session/CreateStep.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../../test/utils'; + +vi.mock('../../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../../lib/api'; +import CreateStep from './CreateStep'; + +const get = api.get as ReturnType; +const post = api.post as ReturnType; + +function mockEndpoints(pricing: { free_visit_price_rials: number; require_visit_price: boolean }) { + get.mockImplementation((url: string) => { + if (url === '/api/v1/insurance-pricing') return Promise.resolve({ success: true, data: pricing }); + if (url === '/api/v1/inventory-items') return Promise.resolve({ success: true, data: { items: [] } }); + if (url === '/api/v1/billing/tenant-insurances') return Promise.resolve({ success: true, data: { data: [] } }); + return Promise.resolve({ success: true, data: [] }); + }); +} + +beforeEach(() => { + get.mockReset(); + post.mockReset(); + post.mockResolvedValue({ success: true, data: { uuid: 's-1' } }); +}); + +const renderStep = (onCreated = vi.fn()) => { + renderWithProviders( + {}} />, + ); + return onCreated; +}; + +describe('CreateStep — الزامی بودن قیمت ویزیت با فلگ require_visit_price', () => { + it('فلگ فعال + قیمت صفر → خطای inline، ستاره روی label و عدم ارسال', async () => { + mockEndpoints({ free_visit_price_rials: 0, require_visit_price: true }); + renderStep(); + await waitFor(() => expect(screen.getByText(/قیمت ویزیت \(تومان\)/)).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('*')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('ایجاد سرویس')); + + expect(await screen.findByText('هزینه ویزیت الزامی است')).toBeInTheDocument(); + expect(post).not.toHaveBeenCalled(); + }); + + it('فلگ فعال + مقدار معتبر → POST با visit_price_rials ریالی', async () => { + mockEndpoints({ free_visit_price_rials: 0, require_visit_price: true }); + renderStep(); + await waitFor(() => expect(screen.getByText('*')).toBeInTheDocument()); + + fireEvent.change(screen.getByLabelText('قیمت ویزیت'), { target: { value: '50000' } }); + fireEvent.click(screen.getByText('ایجاد سرویس')); + + await waitFor(() => expect(post).toHaveBeenCalled()); + expect(post.mock.calls[0][0]).toBe('/api/v1/patient/r-1/session'); + expect(post.mock.calls[0][1]).toMatchObject({ visit_price_rials: 500_000 }); + }); + + it('فلگ غیرفعال + قیمت صفر → رفتار قبلی: ثبت مجاز', async () => { + mockEndpoints({ free_visit_price_rials: 0, require_visit_price: false }); + const onCreated = renderStep(); + await waitFor(() => expect(get).toHaveBeenCalledWith('/api/v1/insurance-pricing')); + + fireEvent.click(screen.getByText('ایجاد سرویس')); + + await waitFor(() => expect(post).toHaveBeenCalled()); + expect(post.mock.calls[0][1]).toMatchObject({ visit_price_rials: 0 }); + await waitFor(() => expect(onCreated).toHaveBeenCalledWith('s-1')); + expect(screen.queryByText('هزینه ویزیت الزامی است')).not.toBeInTheDocument(); + }); + + it('پیش‌فرض قیمت از «قیمت ویزیت آزاد» پر می‌شود (ریال → تومان)', async () => { + mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false }); + renderStep(); + + await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue(30_000)); + }); +}); diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index ca2002f1..e291add2 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -100,7 +100,7 @@ export default function AppointmentCreatePage() { // ── بیعانه / وضعیت / توضیحات const [depositRequired, setDepositRequired] = useState(false); - const [depositRials, setDepositRials] = useState(0); + const [depositToman, setDepositToman] = useState(0); const [status, setStatus] = useState('pending'); const [note, setNote] = useState(''); @@ -145,7 +145,7 @@ export default function AppointmentCreatePage() { ...(selectedServices.length ? { service_item_uuids: selectedServices.map(s => s.uuid) } : {}), }), ...(staffUuid ? { staff_uuid: staffUuid } : {}), - ...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}), + ...(depositRequired ? { deposit_required: true, deposit_amount_rials: tomanToRial(depositToman) } : {}), ...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}), ...(note.trim() ? { note: note.trim() } : {}), }; @@ -468,7 +468,7 @@ export default function AppointmentCreatePage() {
-
+
diff --git a/assets/admin/pages/AppointmentEditPage.tsx b/assets/admin/pages/AppointmentEditPage.tsx index 03f6ec16..ccfb8639 100644 --- a/assets/admin/pages/AppointmentEditPage.tsx +++ b/assets/admin/pages/AppointmentEditPage.tsx @@ -9,6 +9,7 @@ import PersianDateInput from '../components/ui/PersianDateInput'; import PriceInput from '../components/ui/PriceInput'; import SearchableSelect from '../components/ui/SearchableSelect'; import { WalletChargeLink } from '../components/AppointmentActions'; +import { rialToToman, tomanToRial } from '../lib/utils'; interface Option { uuid: string; name?: string; full_name?: string } @@ -51,7 +52,7 @@ export default function AppointmentEditPage() { const [start, setStart] = useState(''); const [end, setEnd] = useState(''); const [depositRequired, setDepositRequired] = useState(false); - const [depositRials, setDepositRials] = useState(0); + const [depositToman, setDepositToman] = useState(0); const [status, setStatus] = useState(''); const [note, setNote] = useState(''); @@ -65,7 +66,7 @@ export default function AppointmentEditPage() { setStart(isoTime(a.slot_start)); setEnd(isoTime(a.slot_end)); setDepositRequired(!!a.deposit_required); - setDepositRials(a.deposit_amount_rials ?? 0); + setDepositToman(rialToToman(a.deposit_amount_rials ?? 0)); setStatus(a.status); setNote(a.note ?? ''); }, [a]); @@ -86,7 +87,7 @@ export default function AppointmentEditPage() { service_item_uuid: itemUuid, staff_uuid: staffUuid, deposit_required: depositRequired, - deposit_amount_rials: depositRequired ? depositRials : null, + deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null, note, ...(status !== a?.status ? { status } : {}), version: a?.version, @@ -191,7 +192,7 @@ export default function AppointmentEditPage() { <>
-
+
diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 5489a617..fd9016e6 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -633,6 +633,7 @@ Response `200`: `{ success, data: { data: } }` ### POST `/api/v1/my/appointment` (extended) Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_uuid`, `deposit_required`, `deposit_amount_rials`, `visit_price_rials`, `is_reserve`, `service_item_uuids[]`, `duration_from_services`. +`deposit_amount_rials` **ریال** است (مثل بقیه فیلدهای `_rials`)؛ UI ادمین تومان می‌گیرد و با `tomanToRial` تبدیل می‌کند. داده‌های قدیمی که تومانِ خام ذخیره شده بودند با migration `Version20260717093000` ×۱۰ اصلاح شدند. `is_reserve: true` → day-level reserve entry: `slot_end` may equal `slot_start`, the past-slot rule is skipped, and the entry never occupies a slot (several reserves may share a day). Response `201` now also returns `is_reserve`. `service_item_uuids[]` (غیرِ رزرو): یک یا چند سرویس که به نوبت **پیوست** می‌شوند (چند سرویس)؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` برمی‌گردند. UUID ناموجود ⇒ `422`. `duration_from_services: true` (حالت نوبت‌دهی سرویسی): مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ می‌شود. diff --git a/migrations/Version20260717093000.php b/migrations/Version20260717093000.php new file mode 100644 index 00000000..f8dace61 --- /dev/null +++ b/migrations/Version20260717093000.php @@ -0,0 +1,31 @@ +addSql('UPDATE appointments SET deposit_amount_rials = deposit_amount_rials * 10 WHERE deposit_amount_rials IS NOT NULL AND deposit_amount_rials > 0'); + } + + public function down(Schema $schema): void + { + $this->addSql('UPDATE appointments SET deposit_amount_rials = deposit_amount_rials / 10 WHERE deposit_amount_rials IS NOT NULL AND deposit_amount_rials > 0'); + } +} diff --git a/tests/Insurance/RequireVisitPriceTest.php b/tests/Insurance/RequireVisitPriceTest.php new file mode 100644 index 00000000..68faac41 --- /dev/null +++ b/tests/Insurance/RequireVisitPriceTest.php @@ -0,0 +1,259 @@ + 0 → 422؛ فلگ-فقط بدون ردیف → ردیف ساخته نشود. + * - POST /patient/{uuid}/session: با فلگ فعال، visit_price_rials <= 0 → 422. + * - POST /my/appointment و /admin/appointment: فلگ برای پزشکِ نوبت resolve می‌شود + * (ردیف پزشک، وگرنه کلینیکِ واحد او)؛ بدون هزینه ویزیت → 422. + */ +class RequireVisitPriceTest extends ApiTestCase +{ + /** @return array{0: User, 1: Doctor} */ + private function doctor(): array + { + $owner = $this->createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر'); + $this->em->persist($doctor); + $this->em->flush(); + + return [$owner, $doctor]; + } + + private function pricingRow(string $type, int $entityId, int $priceRials, bool $require): EntityInsurancePricing + { + $row = new EntityInsurancePricing($type, $entityId, null, $priceRials); + $row->setRequireVisitPrice($require); + $this->em->persist($row); + $this->em->flush(); + + return $row; + } + + private function recordFor(Doctor $doctor): PatientRecord + { + $patient = $this->createUser(['ROLE_USER']); + $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId()); + $this->em->persist($record); + $this->em->flush(); + + return $record; + } + + private function appointmentBody(string $doctorUuid): array + { + $start = time() + 86_400 + random_int(0, 3_600) * 100; + + return [ + 'doctor_uuid' => $doctorUuid, + 'slot_start' => $start, + 'slot_end' => $start + 1_800, + 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'patient_name' => 'بیمار تست', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), + ]; + } + + // ── PUT /api/v1/insurance-pricing ──────────────────────────────────────── + + public function testSaveFlagOnWithZeroPriceIs422(): void + { + [$owner] = $this->doctor(); + + $res = $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [ + 'free_visit_price_rials' => 0, + 'require_visit_price' => true, + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame(ErrorCodes::ERR_VALIDATION_001, $res['errors'][0]['code']); + } + + public function testSaveFlagOnWithValidPricePersistsBoth(): void + { + [$owner] = $this->doctor(); + + $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [ + 'free_visit_price_rials' => 500_000, + 'require_visit_price' => true, + ]); + self::assertSame(200, $this->responseCode()); + + $res = $this->authJson('GET', '/api/v1/insurance-pricing', $owner); + self::assertSame(500_000, $res['data']['free_visit_price_rials']); + self::assertTrue($res['data']['require_visit_price']); + } + + public function testSaveFlagAloneKeepsStoredPrice(): void + { + [$owner, $doctor] = $this->doctor(); + $this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 300_000, false); + + $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, ['require_visit_price' => true]); + self::assertSame(200, $this->responseCode()); + + $res = $this->authJson('GET', '/api/v1/insurance-pricing', $owner); + self::assertSame(300_000, $res['data']['free_visit_price_rials']); + self::assertTrue($res['data']['require_visit_price']); + } + + public function testSaveFlagAloneOnEnabledRowWithZeroStoredPriceIs422(): void + { + [$owner, $doctor] = $this->doctor(); + $this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 0, false); + + $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, ['require_visit_price' => true]); + self::assertSame(422, $this->responseCode()); + } + + public function testSaveFlagFalseWithoutRowDoesNotCreateRow(): void + { + [$owner, $doctor] = $this->doctor(); + + $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, ['require_visit_price' => false]); + self::assertSame(200, $this->responseCode()); + + $row = $this->em->getRepository(EntityInsurancePricing::class)->findOneBy([ + 'entityType' => EntityInsurancePricing::TYPE_DOCTOR, + 'entityId' => $doctor->getId(), + 'insuranceId' => null, + ]); + self::assertNull($row); + } + + // ── POST /api/v1/patient/{uuid}/session ───────────────────────────────── + + public function testSessionWithoutVisitPriceIs422WhenFlagOn(): void + { + [$owner, $doctor] = $this->doctor(); + $this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, true); + $record = $this->recordFor($doctor); + + $res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [ + 'visit_price_rials' => 0, + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame(ErrorCodes::ERR_VALIDATION_001, $res['errors'][0]['code']); + } + + public function testSessionWithVisitPriceIs201WhenFlagOn(): void + { + [$owner, $doctor] = $this->doctor(); + $this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, true); + $record = $this->recordFor($doctor); + + $res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [ + 'visit_price_rials' => 400_000, + ]); + + self::assertSame(201, $this->responseCode()); + self::assertSame(400_000, $res['data']['visit_price_rials']); + } + + public function testSessionWithZeroVisitPriceStaysAllowedWhenFlagOff(): void + { + [$owner, $doctor] = $this->doctor(); + $this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, false); + $record = $this->recordFor($doctor); + + $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [ + 'visit_price_rials' => 0, + ]); + + self::assertSame(201, $this->responseCode()); + } + + // ── POST /api/v1/my/appointment ───────────────────────────────────────── + + public function testMyAppointmentWithoutVisitPriceIs422WhenFlagOn(): void + { + [$owner, $doctor] = $this->doctor(); + $this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, true); + + $res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->appointmentBody($doctor->getUuid())); + + self::assertSame(422, $this->responseCode()); + self::assertSame('visit_price_rials', $res['errors'][0]['field']); + } + + public function testMyAppointmentWithVisitPriceIs201AndStored(): void + { + [$owner, $doctor] = $this->doctor(); + $this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, true); + + $body = $this->appointmentBody($doctor->getUuid()); + $body['visit_price_rials'] = 500_000; + $res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $body); + + self::assertSame(201, $this->responseCode()); + $appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $res['data']['uuid']]); + self::assertSame(500_000, $appointment->getVisitPriceRials()); + } + + public function testMyAppointmentWithoutVisitPriceIs201WhenFlagOff(): void + { + [$owner, $doctor] = $this->doctor(); + + $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->appointmentBody($doctor->getUuid())); + + self::assertSame(201, $this->responseCode()); + } + + public function testFlagFallsBackToSingleClinicOfDoctor(): void + { + [$owner, $doctor] = $this->doctor(); + + $clinicOwner = $this->createUser(['ROLE_CLINIC']); + $clinic = new Clinic($clinicOwner); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + $this->pricingRow(EntityInsurancePricing::TYPE_CLINIC, $clinic->getId(), 500_000, true); + + $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->appointmentBody($doctor->getUuid())); + + self::assertSame(422, $this->responseCode()); + } + + // ── POST /api/v1/admin/appointment ────────────────────────────────────── + + public function testAdminAppointmentWithoutVisitPriceIs422WhenFlagOn(): void + { + [, $doctor] = $this->doctor(); + $this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, true); + $admin = $this->createUser(['ROLE_ADMIN']); + + $res = $this->authJson('POST', '/api/v1/admin/appointment', $admin, $this->appointmentBody($doctor->getUuid())); + + self::assertSame(422, $this->responseCode()); + self::assertSame('visit_price_rials', $res['errors'][0]['field']); + } + + public function testAdminAppointmentWithVisitPriceIs201(): void + { + [, $doctor] = $this->doctor(); + $this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, true); + $admin = $this->createUser(['ROLE_ADMIN']); + + $body = $this->appointmentBody($doctor->getUuid()); + $body['visit_price_rials'] = 500_000; + $res = $this->authJson('POST', '/api/v1/admin/appointment', $admin, $body); + + self::assertSame(201, $this->responseCode()); + $appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $res['data']['uuid']]); + self::assertSame(500_000, $appointment->getVisitPriceRials()); + } +}