fix(insurance): read a doctor's own settings first, then their clinic's
A clinic owner configures insurance on the doctor (`doctor_uuid`), but an
appointment booked at the clinic belongs to the clinic — so at confirm time
the engine looked for contracts under the clinic, found none, and the operator
had no insurance to pick and no way to save one ("this insurance has no active
contract"). The two sides were writing and reading different tenants.
Contracts, service kinds and the visit price now resolve doctor-first with the
appointment's clinic as fallback, each judged separately: a doctor who holds
their own contracts but leaves the visit price to the clinic gets each from the
right place. The confirm modal asks the same question the engine answers, via
`inherit=1` on the two read endpoints; the settings pages deliberately do not
send it, since editing must target the doctor's own row.
Two further things came out of the same sweep. The service-kind settings
repository had the tenant-filter blindness already fixed for contracts and
pricing — reads pinned to the caller's environment while the target is another
tenant — so it is now exempted the same way. And a coverage percentage of zero
is accepted as a real choice meaning "this contract does not cover that service
kind"; what is still rejected is leaving an enabled kind with no percentage at
all, inheriting a central default of zero included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,10 +74,15 @@ function percentsToStrings(map?: Record<string, number>): Record<string, string>
|
||||
return Object.fromEntries(Object.entries(map ?? {}).map(([k, v]) => [k, String(v)]));
|
||||
}
|
||||
|
||||
/** درصد پوششِ قابل ثبت: عددی بین ۱ تا ۱۰۰ — صفر یعنی قرارداد آن نوع خدمت را پوشش نمیدهد. */
|
||||
/**
|
||||
* درصد پوششِ قابل ثبت: عددی بین ۰ تا ۱۰۰.
|
||||
*
|
||||
* صفر مقدار معتبری است و یعنی «این قرارداد آن نوع خدمت را پوشش نمیدهد» — سهم بیمار
|
||||
* صددرصد. آنچه رد میشود خالیماندنِ فیلد است، نه صفر بودنش.
|
||||
*/
|
||||
export function isValidPercent(raw?: string): boolean {
|
||||
const n = Number(raw);
|
||||
return raw !== undefined && raw !== '' && Number.isFinite(n) && n > 0 && n <= 100;
|
||||
return raw !== undefined && raw !== '' && Number.isFinite(n) && n >= 0 && n <= 100;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,7 @@ interface AppointmentLike {
|
||||
insurance_service_category?: string | null;
|
||||
insurance_base_id?: number | null;
|
||||
insurance_supplementary_id?: number | null;
|
||||
doctor?: { uuid?: string | null } | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -159,7 +160,7 @@ export default function ConfirmAppointmentModal({
|
||||
const appt: AppointmentLike | null = detail ?? appointment ?? null;
|
||||
|
||||
// ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ──────────────────────────────────────
|
||||
const insurance = useAppointmentInsurance(open);
|
||||
const insurance = useAppointmentInsurance(open, appt?.doctor?.uuid ?? null);
|
||||
|
||||
// نوبتِ بدون هزینهٔ ویزیت، سرِ ساختِ مراجعه «قیمت ویزیت آزاد» تنظیمات را میگیرد؛
|
||||
// مودال هم باید همان را نشان دهد، وگرنه صفر نشان میدهد و مبلغ ثبتشده فرق میکند.
|
||||
|
||||
@@ -72,7 +72,8 @@ describe('TurnsTimeline', () => {
|
||||
|
||||
it('نوبتِ دارای بیمه، چیپ «نوع خدمت · بیمه» نشان میدهد', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) =>
|
||||
url === '/api/v1/billing/tenant-insurances'
|
||||
// با پزشکِ نوبت، آدرس `?doctor_uuid=…&inherit=1` هم میگیرد.
|
||||
url.startsWith('/api/v1/billing/tenant-insurances')
|
||||
? Promise.resolve({ success: true, data: { data: [{
|
||||
insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true,
|
||||
coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null,
|
||||
|
||||
@@ -108,7 +108,7 @@ function OccupiedCard({
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
// نام بیمه فقط با نگاشت از قراردادهای کششده به دست میآید؛ payload نوبت نامی ندارد
|
||||
// تا لیستهای نوبت به N+1 نیفتند.
|
||||
const insurance = useAppointmentInsurance(!!a.insurance_base_id);
|
||||
const insurance = useAppointmentInsurance(!!a.insurance_base_id, a.doctor_uuid ?? null);
|
||||
const insuranceChip = [
|
||||
a.insurance_service_category_label,
|
||||
insurance.insuranceNameOf(a.insurance_base_id),
|
||||
|
||||
@@ -22,16 +22,23 @@ interface PricingPayload {
|
||||
* بههمراه محاسبهٔ سهم — مشترک بین مودال «قطعی کردن نوبت» و صفحهٔ ویرایش نوبت تا
|
||||
* هر دو یک قاعده را نشان دهند.
|
||||
*/
|
||||
export function useAppointmentInsurance(enabled: boolean) {
|
||||
export function useAppointmentInsurance(enabled: boolean, doctorUuid?: string | null) {
|
||||
// بدون پزشک، محیطِ خودِ کاربر پرسیده میشود — همان رفتار قبلی برای مطب شخصی.
|
||||
//
|
||||
// با پزشک، `inherit=1` هم میرود: نوبتِ ثبتشده در کلینیک محیطش «کلینیک» است ولی
|
||||
// قرارداد بیمه معمولاً روی خودِ پزشک ذخیره شده. سرور اول تنظیم پزشک را میدهد و
|
||||
// در نبودش تنظیم کلینیک را — دقیقاً همان چیزی که سرِ قطعیکردن اعمال میشود.
|
||||
const scopeQuery = doctorUuid ? `?doctor_uuid=${encodeURIComponent(doctorUuid)}&inherit=1` : '';
|
||||
|
||||
const pricingQuery = useQuery<ApiResponse<PricingPayload>>({
|
||||
queryKey: ['insurance-pricing'],
|
||||
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
||||
queryKey: ['insurance-pricing', doctorUuid ?? null],
|
||||
queryFn: () => api.get(`/api/v1/insurance-pricing${scopeQuery}`),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const contractsQuery = useQuery<ApiResponse<{ data: TenantContract[] }>>({
|
||||
queryKey: ['tenant-insurances'],
|
||||
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
|
||||
queryKey: ['tenant-insurances', doctorUuid ?? null],
|
||||
queryFn: () => api.get(`/api/v1/billing/tenant-insurances${scopeQuery}`),
|
||||
enabled,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user