feat: convert deposit amounts from toman to rials in appointment handling
This commit is contained in:
@@ -20,7 +20,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { ApiResponse } from "../lib/api";
|
import type { ApiResponse } from "../lib/api";
|
||||||
import { api } 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 type { Appointment } from "../types";
|
||||||
import AppointmentStatusDropdown from "./ui/AppointmentStatusDropdown";
|
import AppointmentStatusDropdown from "./ui/AppointmentStatusDropdown";
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
@@ -702,8 +702,8 @@ export function ReplaceAppointmentModal({
|
|||||||
const [depositRequired, setDepositRequired] = useState(
|
const [depositRequired, setDepositRequired] = useState(
|
||||||
!!a.deposit_required,
|
!!a.deposit_required,
|
||||||
);
|
);
|
||||||
const [depositRials, setDepositRials] = useState(
|
const [depositToman, setDepositToman] = useState(
|
||||||
a.deposit_amount_rials ?? 0,
|
rialToToman(a.deposit_amount_rials ?? 0),
|
||||||
);
|
);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
@@ -719,7 +719,7 @@ export function ReplaceAppointmentModal({
|
|||||||
service_item_uuid: itemUuid,
|
service_item_uuid: itemUuid,
|
||||||
staff_uuid: staffUuid,
|
staff_uuid: staffUuid,
|
||||||
deposit_required: depositRequired,
|
deposit_required: depositRequired,
|
||||||
deposit_amount_rials: depositRequired ? depositRials : null,
|
deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null,
|
||||||
...(note.trim() ? { note: note.trim() } : {}),
|
...(note.trim() ? { note: note.trim() } : {}),
|
||||||
...(status !== a.status ? { status } : {}),
|
...(status !== a.status ? { status } : {}),
|
||||||
version: a.version,
|
version: a.version,
|
||||||
@@ -904,8 +904,8 @@ export function ReplaceAppointmentModal({
|
|||||||
<label style={label}>مبلغ بیعانه (تومان)</label>
|
<label style={label}>مبلغ بیعانه (تومان)</label>
|
||||||
<div style={{ marginTop: 6 }}>
|
<div style={{ marginTop: 6 }}>
|
||||||
<PriceInput
|
<PriceInput
|
||||||
value={depositRials}
|
value={depositToman}
|
||||||
onChange={setDepositRials}
|
onChange={setDepositToman}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
const put = api.put as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
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(<FreeVisitPrice />);
|
||||||
|
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(<FreeVisitPrice />);
|
||||||
|
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(<FreeVisitPrice />);
|
||||||
|
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(<FreeVisitPrice />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })).toBeChecked());
|
||||||
|
expect(screen.getByText('قیمت (تومان)').querySelector('span')?.textContent).toContain('*');
|
||||||
|
expect(screen.getByRole('spinbutton')).toHaveValue(50_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ import PersianDateInput from './ui/PersianDateInput';
|
|||||||
import PriceInput from './ui/PriceInput';
|
import PriceInput from './ui/PriceInput';
|
||||||
import SearchableSelect from './ui/SearchableSelect';
|
import SearchableSelect from './ui/SearchableSelect';
|
||||||
import { WalletChargeLink } from './AppointmentActions';
|
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 Option { uuid: string; name?: string; full_name?: string }
|
||||||
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: 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 ───────────────────────────────────────────────
|
// ── deposit / status / notes ───────────────────────────────────────────────
|
||||||
const [depositRequired, setDepositRequired] = useState(false);
|
const [depositRequired, setDepositRequired] = useState(false);
|
||||||
const [depositRials, setDepositRials] = useState(0);
|
const [depositToman, setDepositToman] = useState(0);
|
||||||
const [status, setStatus] = useState('pending');
|
const [status, setStatus] = useState('pending');
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
|||||||
// حالت سرویس: چند سرویس؛ حالت اسلاتی: تک سرویسِ workflow (اختیاری).
|
// حالت سرویس: چند سرویس؛ حالت اسلاتی: تک سرویسِ workflow (اختیاری).
|
||||||
...(serviceMode ? { service_item_uuids: serviceUuids } : itemUuid ? { service_item_uuid: itemUuid } : {}),
|
...(serviceMode ? { service_item_uuids: serviceUuids } : itemUuid ? { service_item_uuid: itemUuid } : {}),
|
||||||
...(staffUuid ? { staff_uuid: staffUuid } : {}),
|
...(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() } : {}),
|
...(note.trim() ? { note: note.trim() } : {}),
|
||||||
};
|
};
|
||||||
const res: any = await api.post('/api/v1/my/appointment', payload);
|
const res: any = await api.post('/api/v1/my/appointment', payload);
|
||||||
@@ -331,7 +331,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
|||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<label style={label}>مبلغ بیعانه (تومان)</label>
|
<label style={label}>مبلغ بیعانه (تومان)</label>
|
||||||
<div style={{ marginTop: 6 }}>
|
<div style={{ marginTop: 6 }}>
|
||||||
<PriceInput value={depositRials} onChange={setDepositRials} />
|
<PriceInput value={depositToman} onChange={setDepositToman} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<WalletChargeLink mobile={effectiveMobile} />
|
<WalletChargeLink mobile={effectiveMobile} />
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
const post = api.post as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CreateStep recordUuid="r-1" profile={null} onCreated={onCreated} onCancel={() => {}} />,
|
||||||
|
);
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -100,7 +100,7 @@ export default function AppointmentCreatePage() {
|
|||||||
|
|
||||||
// ── بیعانه / وضعیت / توضیحات
|
// ── بیعانه / وضعیت / توضیحات
|
||||||
const [depositRequired, setDepositRequired] = useState(false);
|
const [depositRequired, setDepositRequired] = useState(false);
|
||||||
const [depositRials, setDepositRials] = useState(0);
|
const [depositToman, setDepositToman] = useState(0);
|
||||||
const [status, setStatus] = useState('pending');
|
const [status, setStatus] = useState('pending');
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ export default function AppointmentCreatePage() {
|
|||||||
...(selectedServices.length ? { service_item_uuids: selectedServices.map(s => s.uuid) } : {}),
|
...(selectedServices.length ? { service_item_uuids: selectedServices.map(s => s.uuid) } : {}),
|
||||||
}),
|
}),
|
||||||
...(staffUuid ? { staff_uuid: staffUuid } : {}),
|
...(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) } : {}),
|
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
|
||||||
...(note.trim() ? { note: note.trim() } : {}),
|
...(note.trim() ? { note: note.trim() } : {}),
|
||||||
};
|
};
|
||||||
@@ -468,7 +468,7 @@ export default function AppointmentCreatePage() {
|
|||||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 12, margin: '12px 0' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 12, margin: '12px 0' }}>
|
||||||
<div style={{ width: 300, maxWidth: '100%' }}>
|
<div style={{ width: 300, maxWidth: '100%' }}>
|
||||||
<label style={label}>مبلغ بیعانه (تومان)</label>
|
<label style={label}>مبلغ بیعانه (تومان)</label>
|
||||||
<div className="field" style={{ marginTop: 6, height: 44 }}><PriceInput value={depositRials} onChange={setDepositRials} /></div>
|
<div className="field" style={{ marginTop: 6, height: 44 }}><PriceInput value={depositToman} onChange={setDepositToman} /></div>
|
||||||
</div>
|
</div>
|
||||||
<WalletChargeLink mobile={effectiveMobile} />
|
<WalletChargeLink mobile={effectiveMobile} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import PersianDateInput from '../components/ui/PersianDateInput';
|
|||||||
import PriceInput from '../components/ui/PriceInput';
|
import PriceInput from '../components/ui/PriceInput';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
import { WalletChargeLink } from '../components/AppointmentActions';
|
import { WalletChargeLink } from '../components/AppointmentActions';
|
||||||
|
import { rialToToman, tomanToRial } from '../lib/utils';
|
||||||
|
|
||||||
interface Option { uuid: string; name?: string; full_name?: string }
|
interface Option { uuid: string; name?: string; full_name?: string }
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ export default function AppointmentEditPage() {
|
|||||||
const [start, setStart] = useState('');
|
const [start, setStart] = useState('');
|
||||||
const [end, setEnd] = useState('');
|
const [end, setEnd] = useState('');
|
||||||
const [depositRequired, setDepositRequired] = useState(false);
|
const [depositRequired, setDepositRequired] = useState(false);
|
||||||
const [depositRials, setDepositRials] = useState(0);
|
const [depositToman, setDepositToman] = useState(0);
|
||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState('');
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ export default function AppointmentEditPage() {
|
|||||||
setStart(isoTime(a.slot_start));
|
setStart(isoTime(a.slot_start));
|
||||||
setEnd(isoTime(a.slot_end));
|
setEnd(isoTime(a.slot_end));
|
||||||
setDepositRequired(!!a.deposit_required);
|
setDepositRequired(!!a.deposit_required);
|
||||||
setDepositRials(a.deposit_amount_rials ?? 0);
|
setDepositToman(rialToToman(a.deposit_amount_rials ?? 0));
|
||||||
setStatus(a.status);
|
setStatus(a.status);
|
||||||
setNote(a.note ?? '');
|
setNote(a.note ?? '');
|
||||||
}, [a]);
|
}, [a]);
|
||||||
@@ -86,7 +87,7 @@ export default function AppointmentEditPage() {
|
|||||||
service_item_uuid: itemUuid,
|
service_item_uuid: itemUuid,
|
||||||
staff_uuid: staffUuid,
|
staff_uuid: staffUuid,
|
||||||
deposit_required: depositRequired,
|
deposit_required: depositRequired,
|
||||||
deposit_amount_rials: depositRequired ? depositRials : null,
|
deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null,
|
||||||
note,
|
note,
|
||||||
...(status !== a?.status ? { status } : {}),
|
...(status !== a?.status ? { status } : {}),
|
||||||
version: a?.version,
|
version: a?.version,
|
||||||
@@ -191,7 +192,7 @@ export default function AppointmentEditPage() {
|
|||||||
<>
|
<>
|
||||||
<div style={{ minWidth: 220 }}>
|
<div style={{ minWidth: 220 }}>
|
||||||
<label style={label}>مبلغ بیعانه (تومان)</label>
|
<label style={label}>مبلغ بیعانه (تومان)</label>
|
||||||
<div style={{ marginTop: 6 }}><PriceInput value={depositRials} onChange={setDepositRials} /></div>
|
<div style={{ marginTop: 6 }}><PriceInput value={depositToman} onChange={setDepositToman} /></div>
|
||||||
</div>
|
</div>
|
||||||
<WalletChargeLink mobile={a.patient_mobile || a.user?.mobile || ''} />
|
<WalletChargeLink mobile={a.patient_mobile || a.user?.mobile || ''} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -633,6 +633,7 @@ Response `200`: `{ success, data: { data: <appointment.toArray()> } }`
|
|||||||
|
|
||||||
### POST `/api/v1/my/appointment` (extended)
|
### 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`.
|
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`.
|
`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`.
|
`service_item_uuids[]` (غیرِ رزرو): یک یا چند سرویس که به نوبت **پیوست** میشوند (چند سرویس)؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` برمیگردند. UUID ناموجود ⇒ `422`.
|
||||||
`duration_from_services: true` (حالت نوبتدهی سرویسی): مدت نوبت از مجموع `duration_minutes` سرویسها محاسبه و `slot_end` بازنویسی میشود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ میشود.
|
`duration_from_services: true` (حالت نوبتدهی سرویسی): مدت نوبت از مجموع `duration_minutes` سرویسها محاسبه و `slot_end` بازنویسی میشود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ میشود.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin UI has always labeled the deposit field "toman" but stored the raw
|
||||||
|
* entered number in deposit_amount_rials. The UI now converts toman → rial on
|
||||||
|
* save; this backfills existing rows so stored values become true rials.
|
||||||
|
*/
|
||||||
|
final class Version20260717093000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Convert existing appointment deposit amounts from toman to rials';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Insurance;
|
||||||
|
|
||||||
|
use App\Appointment\Entity\Appointment;
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\Clinic\Entity\Clinic;
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Insurance\Entity\EntityInsurancePricing;
|
||||||
|
use App\Patient\Entity\PatientRecord;
|
||||||
|
use App\Shared\Constant\ErrorCodes;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فلگ «الزامی کردن هزینه ویزیت» (require_visit_price روی ردیف free-visit):
|
||||||
|
* - PUT /insurance-pricing: فلگ فعال بدون قیمت > 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user