feat(migrations): update franchise to percentage in tenant_insurances and tenant_service_coverage

- Changed franchise_rials to franchise_percent in tenant_insurances and tenant_service_coverage tables.
- Reset old rial values to 0/NULL as they are not convertible to percentage.

feat(command): add SeedInsuranceScenarioCommand for seeding insurance data

- Implemented a command to seed supplementary insurance contracts, patients, and claims for a specified doctor.
- Includes functionality for purging existing scenario data and generating new entries with predefined contracts and patient scenarios.
This commit is contained in:
hamed
2026-07-29 13:28:59 +03:30
parent 11b4dcdd34
commit 4f4bce9fe2
31 changed files with 1497 additions and 137 deletions
+52 -13
View File
@@ -27,8 +27,8 @@ class BillingCalculatorTest extends TestCase
public function testBaseAndSupplementary(): void
{
// کل 600,000؛ پایه 70% → 420,000؛ مکمل روی 180,000 با ~66.667% → 120,000؛ بیمار 60,000
$base = new CoverageRule(coveragePercent: 70, franchiseRials: 0, ceilingRials: null);
$supp = new CoverageRule(coveragePercent: 66.6667, franchiseRials: 0, ceilingRials: null);
$base = new CoverageRule(coveragePercent: 70, franchisePercent: 0, ceilingRials: null);
$supp = new CoverageRule(coveragePercent: 66.6667, franchisePercent: 0, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, $supp);
@@ -43,7 +43,7 @@ class BillingCalculatorTest extends TestCase
public function testBaseOnly(): void
{
$base = new CoverageRule(coveragePercent: 70, franchiseRials: 0, ceilingRials: null);
$base = new CoverageRule(coveragePercent: 70, franchisePercent: 0, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(420_000, $b->baseInsuranceRials);
$this->assertSame(0, $b->supplementaryRials);
@@ -53,7 +53,7 @@ class BillingCalculatorTest extends TestCase
public function testBaseCeilingCapsShare(): void
{
// پایه 70% = 420,000 ولی سقف 300,000 → بیمار باقی را می‌دهد
$base = new CoverageRule(coveragePercent: 70, franchiseRials: 0, ceilingRials: 300_000);
$base = new CoverageRule(coveragePercent: 70, franchisePercent: 0, ceilingRials: 300_000);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(300_000, $b->baseInsuranceRials);
$this->assertSame(300_000, $b->patientRials);
@@ -62,7 +62,7 @@ class BillingCalculatorTest extends TestCase
/** سناریوی مرجع کاربر: ویزیت 5,952,000 ریال با پوشش پایهٔ ۳۰٪ (بستری). */
public function testReferenceScenarioBasePercentOnly(): void
{
$base = new CoverageRule(coveragePercent: 30.0, franchiseRials: 0, ceilingRials: null);
$base = new CoverageRule(coveragePercent: 30.0, franchisePercent: 0, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(5_952_000), $base, null);
@@ -74,27 +74,66 @@ class BillingCalculatorTest extends TestCase
public function testBaseFranchiseDoesNotChargePatient(): void
{
// فرانشیز در بیمهٔ پایه بی‌اثر است: پایه 100% → سهم بیمار صفر.
$base = new CoverageRule(coveragePercent: 100, franchiseRials: 50_000, ceilingRials: null);
$base = new CoverageRule(coveragePercent: 100, franchisePercent: 50, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(0, $b->patientRials);
$this->assertSame(600_000, $b->baseInsuranceRials);
}
public function testSupplementaryFranchiseAddedToPatient(): void
public function testSupplementaryFranchiseIsDeductedFromTheInsurerShare(): void
{
$base = new CoverageRule(coveragePercent: 70, franchiseRials: 90_000, ceilingRials: null);
$supp = new CoverageRule(coveragePercent: 100, franchiseRials: 50_000, ceilingRials: null);
// پایه 70% → 420,000؛ باقیمانده 180,000؛ تکمیلی 100% منهای فرانشیز 10% → 162,000.
$base = new CoverageRule(coveragePercent: 70, franchisePercent: 50, ceilingRials: null);
$supp = new CoverageRule(coveragePercent: 100, franchisePercent: 10, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, $supp);
$this->assertSame(420_000, $b->baseInsuranceRials);
$this->assertSame(180_000, $b->supplementaryRials);
$this->assertSame(50_000, $b->patientRials);
$this->assertSame(162_000, $b->supplementaryRials);
$this->assertSame(18_000, $b->patientRials);
$this->assertSame(
$b->totalRials,
$b->baseInsuranceRials + $b->supplementaryRials + $b->patientRials,
'فرانشیز نباید جمع سهم‌ها را از کل بیشتر کند'
);
}
/** سناریوی مرجع: بدون بیمهٔ پایه، تکمیلی ۹۰٪ با فرانشیز ۱۰٪. */
public function testSupplementaryOnlyWithFranchise(): void
{
$supp = new CoverageRule(coveragePercent: 90, franchisePercent: 10, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(1_000_000), null, $supp);
$this->assertSame(800_000, $b->supplementaryRials);
$this->assertSame(200_000, $b->patientRials);
$this->assertSame($b->totalRials, $b->supplementaryRials + $b->patientRials);
}
public function testFullFranchiseLeavesNothingForTheSupplementary(): void
{
$supp = new CoverageRule(coveragePercent: 100, franchisePercent: 100, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), null, $supp);
$this->assertSame(0, $b->supplementaryRials);
$this->assertSame(600_000, $b->patientRials);
}
public function testFranchiseNeverPushesTheInsurerShareBelowZero(): void
{
// فرانشیز بزرگ‌تر از تعهد: سهم بیمه صفر می‌شود، نه منفی.
$supp = new CoverageRule(coveragePercent: 20, franchisePercent: 80, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), null, $supp);
$this->assertSame(0, $b->supplementaryRials);
$this->assertSame(600_000, $b->patientRials);
}
public function testZeroPercentLeavesEverythingToPatient(): void
{
$base = new CoverageRule(coveragePercent: 0, franchiseRials: 0, ceilingRials: null);
$base = new CoverageRule(coveragePercent: 0, franchisePercent: 0, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(0, $b->baseInsuranceRials);
$this->assertSame(600_000, $b->patientRials);
@@ -102,7 +141,7 @@ class BillingCalculatorTest extends TestCase
public function testFullPercentLeavesNothingToPatient(): void
{
$base = new CoverageRule(coveragePercent: 100, franchiseRials: 0, ceilingRials: null);
$base = new CoverageRule(coveragePercent: 100, franchisePercent: 0, ceilingRials: null);
$b = $this->calc->calculateItem(new Money(600_000), $base, null);
$this->assertSame(600_000, $b->baseInsuranceRials);
$this->assertSame(0, $b->patientRials);
+71 -3
View File
@@ -9,6 +9,8 @@ use App\Billing\Entity\Invoice;
use App\Billing\Entity\InvoiceItem;
use App\Billing\ValueObject\ShareBreakdown;
use App\Doctor\Entity\Doctor;
use App\Insurance\Entity\Insurance;
use App\Insurance\Enum\InsuranceType;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
@@ -40,8 +42,13 @@ class ClaimsByPatientTest extends ApiTestCase
$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
/**
* Invoice of $total split into insurance/patient shares, with its claim(s).
*
* @param list<int> $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;
@@ -58,7 +65,7 @@ class ClaimsByPatientTest extends ApiTestCase
$kind = $i === 0 ? Claim::KIND_BASE : Claim::KIND_SUPPLEMENTARY;
$share = $i === 0 ? $baseShare : $suppShare;
$claim = new Claim('doctor', $this->doctor->getId(), 1 + $i, $kind);
$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) {
@@ -118,6 +125,67 @@ class ClaimsByPatientTest extends ApiTestCase
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);
@@ -107,7 +107,11 @@ class CoveragePercentResolutionTest extends ApiTestCase
public function testServiceOverrideWinsOverEveryOtherLevel(): void
{
[$contract, $insurance, $item] = $this->makeContract(55.0);
$this->defaults->save($insurance->getId(), [['key' => 'inpatient', 'coverage_percent' => 30]]);
// سرپایی هم پیش‌فرض دارد، وگرنه ذخیرهٔ درصدها با «درصد پوشش الزامی است» رد می‌شود.
$this->defaults->save($insurance->getId(), [
['key' => 'inpatient', 'coverage_percent' => 30],
['key' => 'outpatient', 'coverage_percent' => 70],
]);
$this->service->setCategoryCoverages($contract, [['key' => 'inpatient', 'coverage_percent' => 90]]);
$this->service->setServiceCoverage($contract, $item->getId(), true, 25.0, null, null);
@@ -139,7 +143,7 @@ class CoveragePercentResolutionTest extends ApiTestCase
);
$this->assertSame(30.0, $rule->coveragePercent);
$this->assertSame(0, $rule->franchiseRials, 'فرانشیز در بیمهٔ پایه صفر می‌ماند');
$this->assertSame(0.0, $rule->franchisePercent, 'فرانشیز در بیمهٔ پایه صفر می‌ماند');
}
/** ویزیت خدمتِ سرپایی است. */
@@ -163,7 +167,10 @@ class CoveragePercentResolutionTest extends ApiTestCase
public function testRemovingTheOverrideReturnsToTheAdminDefault(): void
{
[$contract, $insurance] = $this->makeContract(55.0);
$this->defaults->save($insurance->getId(), [['key' => 'inpatient', 'coverage_percent' => 30]]);
$this->defaults->save($insurance->getId(), [
['key' => 'inpatient', 'coverage_percent' => 30],
['key' => 'outpatient', 'coverage_percent' => 70],
]);
$this->service->setCategoryCoverages($contract, [['key' => 'inpatient', 'coverage_percent' => 90]]);
$this->service->setCategoryCoverages($contract, [['key' => 'inpatient', 'coverage_percent' => null]]);
@@ -70,6 +70,45 @@ class TenantInsuranceCategoryCoverageApiTest extends ApiTestCase
$this->assertSame('admin_default', $row['category_coverage_source']['outpatient']);
}
public function testAnEnabledServiceKindCannotBeLeftWithoutAPercent(): void
{
// بدون پیش‌فرض مرکزی: ارسال درصد فقط برای سرپایی، بستری را صفر رها می‌کند.
[$owner, $insurance] = $this->makeDoctorAndInsurance();
$body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
'insurance_id' => $insurance->getId(),
'category_coverages' => [
['key' => 'outpatient', 'coverage_percent' => 70],
['key' => 'inpatient', 'coverage_percent' => null],
],
]);
$this->assertSame(422, $this->responseCode());
$this->assertSame('category_coverages', $body['errors'][0]['field']);
$this->assertStringContainsString('خدمات بستری', $body['errors'][0]['message']);
}
public function testADisabledServiceKindNeedsNoPercent(): void
{
[$owner, $insurance] = $this->makeDoctorAndInsurance();
$this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [
'service_categories' => [
['key' => 'outpatient', 'enabled' => true],
['key' => 'inpatient', 'enabled' => false],
],
]);
$body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
'insurance_id' => $insurance->getId(),
'category_coverages' => [
['key' => 'outpatient', 'coverage_percent' => 70],
],
]);
$this->assertSame(201, $this->responseCode());
$this->assertEquals(70.0, $body['data']['data']['category_coverages']['outpatient']);
}
public function testListReturnsEffectivePercentsAndSources(): void
{
[$owner, $insurance] = $this->makeDoctorAndInsurance();
@@ -113,8 +152,10 @@ class TenantInsuranceCategoryCoverageApiTest extends ApiTestCase
public function testPatchCanDropAnOverrideBackToTheAdminDefault(): void
{
[$owner, $insurance] = $this->makeDoctorAndInsurance();
// سرپایی هم پیش‌فرض دارد، وگرنه ساخت قرارداد به‌خاطر «درصد پوشش الزامی است» رد می‌شود.
static::getContainer()->get(InsuranceCoverageDefaultService::class)->save($insurance->getId(), [
['key' => 'inpatient', 'coverage_percent' => 30],
['key' => 'outpatient', 'coverage_percent' => 50],
['key' => 'inpatient', 'coverage_percent' => 30],
]);
$created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
@@ -35,7 +35,7 @@ class TenantInsuranceContractApiTest extends ApiTestCase
$body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
'insurance_id' => $insurance->getId(),
'coverage_percent' => 70,
'franchise_rials' => 500_000,
'franchise_percent' => 15,
'annual_ceiling_rials' => 20_000_000,
'effective_from' => 1_700_000_000,
'effective_to' => 1_800_000_000,
@@ -45,7 +45,7 @@ class TenantInsuranceContractApiTest extends ApiTestCase
$this->assertSame(201, $this->responseCode());
$row = $body['data']['data'];
$this->assertEquals(70.0, $row['coverage_percent']);
$this->assertSame(500_000, $row['franchise_rials']);
$this->assertEquals(15.0, $row['franchise_percent']);
$this->assertSame(20_000_000, $row['annual_ceiling_rials']);
$this->assertSame(1_700_000_000, $row['effective_from']);
$this->assertSame(1_800_000_000, $row['effective_to']);
@@ -53,6 +53,37 @@ class TenantInsuranceContractApiTest extends ApiTestCase
$this->assertTrue($row['is_active']);
}
public function testFranchiseAboveHundredIsRejected(): void
{
[$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Supplementary);
$body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
'insurance_id' => $insurance->getId(),
'coverage_percent' => 80,
'franchise_percent' => 150,
]);
$this->assertSame(422, $this->responseCode());
$this->assertSame('franchise_percent', $body['errors'][0]['field']);
}
public function testNegativeFranchiseOnEditIsRejected(): void
{
[$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Supplementary);
$created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
'insurance_id' => $insurance->getId(),
'coverage_percent' => 80,
]);
$uuid = $created['data']['data']['uuid'];
$body = $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, [
'franchise_percent' => -5,
]);
$this->assertSame(422, $this->responseCode());
$this->assertSame('franchise_percent', $body['errors'][0]['field']);
}
public function testKindDefaultsToCatalogTypeWhenOmitted(): void
{
[$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Basic);
@@ -77,7 +108,7 @@ class TenantInsuranceContractApiTest extends ApiTestCase
$body = $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, [
'coverage_percent' => 90,
'franchise_rials' => 123_000,
'franchise_percent' => 20,
'annual_ceiling_rials' => null,
'effective_to' => 1_900_000_000,
'kind' => 'supplementary',
@@ -85,7 +116,7 @@ class TenantInsuranceContractApiTest extends ApiTestCase
$this->assertSame(200, $this->responseCode());
$this->assertEquals(90.0, $body['data']['data']['coverage_percent']);
$this->assertSame(123_000, $body['data']['data']['franchise_rials']);
$this->assertEquals(20.0, $body['data']['data']['franchise_percent']);
$this->assertNull($body['data']['data']['annual_ceiling_rials']);
$this->assertSame(1_900_000_000, $body['data']['data']['effective_to']);
$this->assertSame('supplementary', $body['data']['data']['kind']);