owner = $this->createUser(['ROLE_DOCTOR']); $this->doctor = new Doctor($this->owner, 'دکتر تست'); $this->em->persist($this->doctor); $this->em->flush(); $patient = $this->createUser(['ROLE_USER']); $this->record = new PatientRecord('doctor', $this->doctor->getId(), $patient, 'doctor', $this->doctor->getId()); $this->em->persist($this->record); $this->em->flush(); } /** * Invoice of $total split into insurance/patient shares, with its claim(s). * * @param list $insuranceIds one per claim; defaults to synthetic ids when the * test does not care which insurance it is */ private function invoiceWithClaims(int $total, int $baseShare, int $suppShare, array $statuses = ['pending'], array $insuranceIds = []): Invoice { $patient = $total - $baseShare - $suppShare; $invoice = new Invoice('doctor', $this->doctor->getId()); $invoice->setPatientRecordId((int) $this->record->getId()); $item = new InvoiceItem($invoice, 'جراحی', $total, 1, new ShareBreakdown($total, $baseShare, $suppShare, $patient)); $invoice->addItem($item); $invoice->recalculateTotals(); $this->em->persist($invoice); $this->em->persist($item); $this->em->flush(); foreach ($statuses as $i => $status) { $kind = $i === 0 ? Claim::KIND_BASE : Claim::KIND_SUPPLEMENTARY; $share = $i === 0 ? $baseShare : $suppShare; $claim = new Claim('doctor', $this->doctor->getId(), $insuranceIds[$i] ?? (1 + $i), $kind); $claimItem = new ClaimItem($claim, (int) $item->getId(), $share); $claim->addItem($claimItem); if ($status !== Claim::STATUS_PENDING) { $claim->submit(); } if ($status === Claim::STATUS_PAID) { $claim->approve($share); $claim->pay($share); } $this->em->persist($claim); $this->em->persist($claimItem); } $this->em->flush(); return $invoice; } public function testAggregatesOneRowPerPatientWithConsistentShares(): void { $this->invoiceWithClaims(10_000_000, 7_000_000, 0); $res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $this->owner); self::assertSame(200, $this->responseCode()); self::assertCount(1, $res['data']); $row = $res['data'][0]; self::assertSame(1, $row['claims_count']); self::assertSame(10_000_000, $row['total_services_rials']); self::assertSame(7_000_000, $row['total_insurance_rials']); self::assertSame(3_000_000, $row['total_patient_rials']); self::assertSame('pending', $row['overall_status']); } public function testServiceTotalIsNotDoubleCountedWhenAnInvoiceHasTwoClaims(): void { // پایه و مکمل روی یک صورتحساب: مبلغ خدمات باید یک‌بار شمرده شود. $this->invoiceWithClaims(10_000_000, 6_000_000, 2_000_000, ['pending', 'pending']); $res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $this->owner); $row = $res['data'][0]; self::assertSame(2, $row['claims_count']); self::assertSame(10_000_000, $row['total_services_rials']); self::assertSame(8_000_000, $row['total_insurance_rials']); self::assertSame( $row['total_services_rials'], $row['total_insurance_rials'] + $row['total_patient_rials'], ); } public function testOverallStatusIsMixedWhenClaimsDisagree(): void { $this->invoiceWithClaims(10_000_000, 6_000_000, 2_000_000, [Claim::STATUS_PAID, Claim::STATUS_PENDING]); $res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $this->owner); self::assertSame('mixed', $res['data'][0]['overall_status']); } public function testEachRowCarriesTheInsurancesItsClaimsBelongTo(): void { $iran = new Insurance('بیمه ایران تست', InsuranceType::Basic); $asia = new Insurance('بیمه آسیا تست', InsuranceType::Supplementary); $this->em->persist($iran); $this->em->persist($asia); $this->em->flush(); $this->invoiceWithClaims(10_000_000, 6_000_000, 2_000_000, ['pending', 'pending'], [ (int) $iran->getId(), (int) $asia->getId(), ]); $res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $this->owner); $row = $res['data'][0]; self::assertCount(2, $row['insurances']); $names = array_column($row['insurances'], 'insurance_name'); self::assertContains('بیمه ایران تست', $names); self::assertContains('بیمه آسیا تست', $names); self::assertSame( ['base', 'supplementary'], array_values(array_unique(array_column($row['insurances'], 'kind'))), ); } public function testKindFilterKeepsOnlyThatKindOfClaim(): void { $this->invoiceWithClaims(10_000_000, 6_000_000, 2_000_000, ['pending', 'pending']); $supp = $this->authJson('GET', '/api/v1/billing/claims/by-patient?kind=supplementary', $this->owner); self::assertSame(1, $supp['data'][0]['claims_count']); self::assertSame(2_000_000, $supp['data'][0]['total_insurance_rials']); $base = $this->authJson('GET', '/api/v1/billing/claims/by-patient?kind=base', $this->owner); self::assertSame(6_000_000, $base['data'][0]['total_insurance_rials']); } public function testAnUnknownKindIsRejected(): void { $body = $this->authJson('GET', '/api/v1/billing/claims/by-patient?kind=bogus', $this->owner); self::assertSame(422, $this->responseCode()); self::assertSame('kind', $body['errors'][0]['field']); } public function testSearchAlsoMatchesTheInsuranceName(): void { $asia = new Insurance('بیمه آسیا جستجو', InsuranceType::Supplementary); $this->em->persist($asia); $this->em->flush(); $this->invoiceWithClaims(10_000_000, 0, 4_000_000, ['pending'], [(int) $asia->getId()]); $hit = $this->authJson('GET', '/api/v1/billing/claims/by-patient?search=' . urlencode('آسیا'), $this->owner); self::assertSame(1, $hit['meta']['totalRecords']); $miss = $this->authJson('GET', '/api/v1/billing/claims/by-patient?search=' . urlencode('بیمه ناموجود'), $this->owner); self::assertSame(0, $miss['meta']['totalRecords']); self::assertSame([], $miss['data']); } public function testStatusFilterNarrowsTheAggregation(): void { $this->invoiceWithClaims(10_000_000, 7_000_000, 0); $res = $this->authJson('GET', '/api/v1/billing/claims/by-patient?status=paid', $this->owner); self::assertSame(0, $res['meta']['totalRecords']); } public function testDetailListsClaimsWithCoverageAndTimeline(): void { $this->invoiceWithClaims(10_000_000, 7_000_000, 0); $res = $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $this->owner); self::assertSame(200, $this->responseCode()); $claim = $res['data']['claims'][0]; // JSON یک float گِرد را به int تبدیل می‌کند؛ مقایسه‌ی نوع‌محور اینجا معنا ندارد. self::assertEquals(70, $claim['coverage_percent']); self::assertSame(7_000_000, $claim['insurance_share_rials']); self::assertSame(3_000_000, $claim['patient_share_rials']); self::assertSame(['submitted'], $claim['allowed_transitions']); } public function testDetailRefusesARecordOfAnotherTenant(): void { $otherOwner = $this->createUser(['ROLE_DOCTOR']); $otherDoctor = new Doctor($otherOwner, 'دکتر دیگر'); $this->em->persist($otherDoctor); $this->em->flush(); $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $otherOwner); self::assertSame(404, $this->responseCode()); } public function testSubmitStoresTheTrackingNumberAndLogsTheTransition(): void { $this->invoiceWithClaims(10_000_000, 7_000_000, 0); $detail = $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $this->owner); $uuid = $detail['data']['claims'][0]['uuid']; $this->authJson('POST', '/api/v1/billing/claims/' . $uuid . '/submit', $this->owner, [ 'tracking_number' => 'TM-1', ]); self::assertSame(200, $this->responseCode()); $after = $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $this->owner); $claim = $after['data']['claims'][0]; self::assertSame('TM-1', $claim['tracking_number']); self::assertSame('submitted', $claim['status']); self::assertSame('submitted', end($claim['logs'])['to_status']); } public function testRejectWithoutAReasonIsRefused(): void { $this->invoiceWithClaims(10_000_000, 7_000_000, 0, [Claim::STATUS_SUBMITTED]); $detail = $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $this->owner); $uuid = $detail['data']['claims'][0]['uuid']; $this->authJson('POST', '/api/v1/billing/claims/' . $uuid . '/reject', $this->owner, []); self::assertSame(422, $this->responseCode()); } /** * این داشبورد با SQL خام ساخته می‌شود، پس TenantFilter آن را نمی‌بیند و تنها * محافظش شرط دستیِ `c.entity_type/:entity_id` در ClaimRepository است. اگر آن * WHERE روزی برداشته شود، هیچ چیز جز این تست خبر نمی‌دهد. */ public function testAnotherTenantsClaimsNeverAppearInTheDashboard(): void { $this->invoiceWithClaims(10_000_000, 7_000_000, 0); $strangerUser = $this->createUser(['ROLE_DOCTOR']); $strangerDoctor = new Doctor($strangerUser, 'دکتر بیگانه'); $this->em->persist($strangerDoctor); $this->em->flush(); $res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $strangerUser); self::assertSame(200, $this->responseCode()); self::assertSame([], $res['data'], 'مطالبات پزشک دیگر نباید دیده شود'); } }