fix(session): show and keep the doctor's insurance when editing an encounter

The encounter edit form asked for contracts in the panel's own environment, so
inside a clinic it got an empty list: no insurance to choose, and the one
already recorded on the encounter had no matching option to display. It now
scopes the request by the encounter's doctor, the same way the confirm modal
does, which is why the session payload's existing doctor_uuid is threaded
through.

Saving the form had the matching defect on the server. Recalculating the totals
looked up the coverage percentage against the record's tenant rather than the
contract's, so a single save on a correctly-billed encounter zeroed the
insurance share and moved the whole amount back onto the patient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-18 17:56:57 +03:30
co-authored by Claude Opus 5
parent 4f76cff503
commit 6e3d574e38
4 changed files with 69 additions and 6 deletions
@@ -28,6 +28,8 @@ export interface SessionCardData {
base_insurance_discount_percent?: number;
supplementary_discount_percent?: number;
doctor_name?: string | null;
/** پزشکِ نوبتِ این مراجعه — محدودهٔ قرارداد بیمه از روی او حل می‌شود. */
doctor_uuid?: string | null;
final_price_rials?: number;
// تفکیک بیمه — سرور محاسبه می‌کند (PatientSession::applyShares)
gross_total_rials?: number;
+11 -2
View File
@@ -105,11 +105,20 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
const { data: packagesData } = useQuery<ApiResponse<PackageRow[]>>({
queryKey: ['inventory-packages'], queryFn: () => api.get('/api/v1/inventory-packages'),
});
// مراجعهٔ برخاسته از نوبت، قراردادِ پزشکِ همان نوبت را دارد نه قراردادِ محیط پنل:
// در کلینیک، قرارداد روی خودِ پزشک ثبت می‌شود و بدون این محدوده، فهرست بیمه‌ها
// خالی می‌آمد و بیمهٔ ثبت‌شدهٔ همان مراجعه هم در فرم پیدا نمی‌شد.
const insuranceScope = editSession?.doctor_uuid
? `?doctor_uuid=${encodeURIComponent(editSession.doctor_uuid)}&inherit=1`
: '';
const { data: contractsData } = useQuery<{ data: { data: Contract[] } }>({
queryKey: ['tenant-insurances'], queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
queryKey: ['tenant-insurances', editSession?.doctor_uuid ?? null],
queryFn: () => api.get(`/api/v1/billing/tenant-insurances${insuranceScope}`),
});
const { data: pricingData } = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({
queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'),
queryKey: ['insurance-pricing', editSession?.doctor_uuid ?? null],
queryFn: () => api.get(`/api/v1/insurance-pricing${insuranceScope}`),
});
const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0;
const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false;
+28 -4
View File
@@ -165,6 +165,27 @@ class PatientService
->coveragePercent;
}
/**
* محیطی که قرارداد بیمهٔ این مراجعه در آن ثبت شده — اول پزشکِ نوبت، بعد کلینیکش.
*
* مراجعهٔ دستی نوبتی ندارد و پزشکی برای پرسیدن نیست، پس همان محیط پرونده می‌ماند.
* {@see \App\Insurance\Service\InsuranceScopeResolver}
*
* @return array{0: string, 1: int}
*/
private function coverageScopeOf(PatientSession $session, string $entityType, int $entityId): array
{
$appointment = $session->getAppointment();
if ($appointment === null) {
return [$entityType, $entityId];
}
return $this->insuranceScope->forContracts(
(int) $appointment->getDoctor()->getId(),
$appointment->getClinic()?->getId() !== null ? (int) $appointment->getClinic()->getId() : null,
);
}
/**
* سهم‌های مراجعهٔ یک نوبت را دوباره حساب می‌کند — برای وقتی بیمهٔ نوبت *بعد از*
* ساخته‌شدنِ مراجعه عوض می‌شود.
@@ -543,14 +564,17 @@ class PatientService
$session->getServices()->toArray(),
);
$visitCategory = $session->getInsuranceServiceCategory();
$session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId(), $visitCategory));
$session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId(), $visitCategory));
// درصد پوشش از محیطِ قرارداد می‌آید، نه محیطِ پرونده — همان قاعدهٔ ساخت خودکار.
[$coverageType, $coverageId] = $this->coverageScopeOf($session, $entityType, $entityId);
$session->setBaseInsuranceDiscountPercent($this->contractPercent($coverageType, $coverageId, $session->getInsuranceBaseId(), $visitCategory));
$session->setSupplementaryDiscountPercent($this->contractPercent($coverageType, $coverageId, $session->getInsuranceSupplementaryId(), $visitCategory));
$priceCalc = $this->calculateFinalPrice(
$session->getVisitPriceRials(),
$serviceItemsData,
$entityType,
$entityId,
$coverageType,
$coverageId,
$session->getInsuranceBaseId(),
$session->getInsuranceSupplementaryId(),
$visitCategory,
@@ -111,6 +111,34 @@ class ClinicAppointmentUsesDoctorContractTest extends ApiTestCase
self::assertSame(990_000, $claims[0]->getTotalClaimedRials());
}
/**
* ویرایش دستیِ همان مراجعه هم نباید سهم بیمه را دور بیندازد: فرم ویرایش سرویس‌ها
* کل مبلغ را دوباره حساب می‌کند و اگر درصد را از محیط اشتباه بگیرد، مراجعه‌ای که
* درست ساخته شده بود با یک ذخیرهٔ ساده خراب می‌شود.
*/
public function testEditingTheSessionKeepsTheInsuranceShare(): void
{
[$owner, $doctor, $clinic, $insurance] = $this->scenario(30);
$appointment = $this->appointment($doctor, $clinic, $insurance);
$session = static::getContainer()->get(PatientService::class)
->autoCreateOnAppointmentConfirm($appointment);
$this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
'visit_price_rials' => self::VISIT_PRICE,
]);
self::assertSame(200, $this->responseCode());
// درخواستِ کرنل روی EntityManager دیگری نوشت، پس ردیف دوباره خوانده می‌شود.
$row = $this->em->getConnection()->fetchAssociative(
'SELECT base_insurance_rials, patient_share_rials FROM patient_sessions WHERE uuid = ?',
[$session->getUuid()],
);
self::assertSame(990_000, (int) $row['base_insurance_rials']);
self::assertSame(2_310_000, (int) $row['patient_share_rials']);
}
/** نوبتِ بدون قرارداد، مثل قبل کاملاً سهم بیمار می‌ماند. */
public function testAnAppointmentWithoutAnyContractStillBillsThePatientInFull(): void
{