feat(claims): add tracking number and status history for claims
- Introduced `tracking_number` field in the `claims` table to store the insurance tracking number. - Created `claim_status_logs` table to maintain a history of status changes for claims, including who made the change and when. - Implemented `ClaimStatusLog` entity and repository for managing status log entries. - Updated `ClaimService` to log transitions and handle tracking numbers during claim submissions. - Added new API endpoint for fetching claims by patient, including detailed claim history and status logs. - Enhanced frontend with a new `ClaimPatientDetailPage` to display claims and their status history. - Added tests to ensure correct aggregation of claims and proper handling of status transitions.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Billing;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Billing\Entity\Claim;
|
||||
use App\Billing\Entity\ClaimItem;
|
||||
use App\Billing\Entity\Invoice;
|
||||
use App\Billing\Entity\InvoiceItem;
|
||||
use App\Billing\ValueObject\ShareBreakdown;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/billing/claims/by-patient — the claims dashboard's first level.
|
||||
*
|
||||
* A claim reaches its patient only through claim_item → invoice_item → invoice →
|
||||
* patient_record, and one invoice can carry both a base and a supplementary claim.
|
||||
* These tests pin the aggregation against double-counting the service amount.
|
||||
*/
|
||||
class ClaimsByPatientTest extends ApiTestCase
|
||||
{
|
||||
private User $owner;
|
||||
private Doctor $doctor;
|
||||
private PatientRecord $record;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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). */
|
||||
private function invoiceWithClaims(int $total, int $baseShare, int $suppShare, array $statuses = ['pending']): 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(), 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 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user