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
@@ -30,6 +30,7 @@ class TenantInsuranceService
private readonly ServiceItemRepository $serviceItemRepo,
private readonly TenantInsuranceCategoryCoverageRepository $categoryCoverageRepo,
private readonly InsuranceCoverageDefaultService $coverageDefaults,
private readonly TenantServiceCategoryService $serviceCategories,
) {}
/**
@@ -41,7 +42,7 @@ class TenantInsuranceService
int $entityId,
int $insuranceId,
float $coveragePercent,
int $franchiseRials = 0,
float $franchisePercent = 0.0,
?int $annualCeilingRials = null,
?int $effectiveFrom = null,
?int $effectiveTo = null,
@@ -58,8 +59,10 @@ class TenantInsuranceService
$contract = new TenantInsurance($entityType, $entityId, $insuranceId, $version);
}
$this->assertPercentInRange($franchisePercent, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 'franchise_percent');
$contract->setCoveragePercent($coveragePercent)
->setFranchiseRials($franchiseRials)
->setFranchisePercent($franchisePercent)
->setAnnualCeilingRials($annualCeilingRials)
// kind defaults to the catalog type; caller may override to categorise the contract.
->setKind($kind ?? $insurance->getType()->value)
@@ -85,13 +88,18 @@ class TenantInsuranceService
/**
* Replaces the contract's category overrides. A row whose percentage is null is
* dropped, which hands that category back to the central admin default.
* dropped, which hands that category back to the central admin default — but only
* for a service kind the tenant does not cover; an enabled kind must carry a
* percentage, otherwise the contract would silently bill it at zero.
*
* @param list<array{key?: string, coverage_percent?: mixed}> $rows
* @throws AppException on an unknown category or an out-of-range percentage
* @throws AppException on an unknown category, an out-of-range percentage, or a
* priced-out enabled service kind
*/
public function setCategoryCoverages(TenantInsurance $contract, array $rows): void
{
$this->assertEnabledCategoriesArePriced($contract, $rows);
foreach ($rows as $row) {
$category = ServiceCategory::tryFromValue(isset($row['key']) ? (string) $row['key'] : null);
if ($category === null) {
@@ -113,9 +121,7 @@ class TenantInsuranceService
}
$percent = (float) $raw;
if ($percent < 0 || $percent > 100) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد', 422);
}
$this->assertPercentInRange($percent, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد');
$entity = $existing ?? new TenantInsuranceCategoryCoverage($contract->getId(), $category);
$this->categoryCoverageRepo->save($entity->setCoveragePercent($percent), false);
@@ -124,6 +130,58 @@ class TenantInsuranceService
$this->categoryCoverageRepo->flush();
}
/**
* هر نوع خدمتی که tenant آن را بیمه‌ای کرده باید در پایانِ این ذخیره‌سازی درصد
* پوشش مؤثر داشته باشد — از خودِ payload، از override قبلی، یا از پیش‌فرض مرکزی
* ادمین. fallback زنده حفظ می‌شود؛ چیزی که رد می‌شود قراردادی است که نوع خدمتِ
* فعال را عملاً صفر درصد می‌کند.
*
* نیامدنِ کلید `category_coverages` اصلاً به اینجا نمی‌رسد — آن حالت یعنی
* «قرارداد دست‌نخورده روی همان مسیر resolve بماند».
*
* @param list<array{key?: string, coverage_percent?: mixed}> $rows
* @throws AppException وقتی نوع خدمتِ فعالی بدون درصد مؤثر بماند
*/
private function assertEnabledCategoriesArePriced(TenantInsurance $contract, array $rows): void
{
$sent = [];
foreach ($rows as $row) {
$sent[(string) ($row['key'] ?? '')] = $row['coverage_percent'] ?? null;
}
$overrides = $this->categoryCoverageRepo->percentMapFor($contract->getId());
$defaults = $this->coverageDefaults->percentMap($contract->getInsuranceId());
foreach ($this->serviceCategories->enabledKeys($contract->getEntityType(), $contract->getEntityId()) as $key) {
// ارسال صریحِ null یعنی «override را بردار»، پس نباید خودِ همان override
// که همین حالا حذف می‌شود، اعتبارسنجی را نجات بدهد.
// ستون قدیمیِ coverage_percent قرارداد عمداً fallback حساب نمی‌شود: با آن،
// نوع خدمتی که درصدش نیامده بی‌صدا نرخ نوع دیگر را ارث می‌برد.
$percent = match (true) {
array_key_exists($key, $sent) && $sent[$key] !== null && $sent[$key] !== '' => (float) $sent[$key],
array_key_exists($key, $sent) => $defaults[$key] ?? 0.0,
default => $overrides[$key] ?? $defaults[$key] ?? 0.0,
};
if ($percent <= 0) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('درصد پوشش %s الزامی است', ServiceCategory::from($key)->label()),
422,
'category_coverages',
);
}
}
}
/** @throws AppException وقتی درصد بیرون از بازهٔ ۰ تا ۱۰۰ باشد */
private function assertPercentInRange(float $percent, string $message, ?string $field = null): void
{
if ($percent < 0 || $percent > 100) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, $message, 422, $field);
}
}
/**
* Effective percentage per category plus where each value came from, so the panel
* can tell an explicit override apart from an inherited central default.
@@ -277,14 +335,14 @@ class TenantInsuranceService
?TenantServiceCoverage $override,
): CoverageRule {
$franchise = $this->isSupplementary($contract)
? ($override?->getFranchiseRials() ?? $contract->getFranchiseRials())
: 0;
? ($override?->getFranchisePercent() ?? $contract->getFranchisePercent())
: 0.0;
return new CoverageRule(
coveragePercent: $this->resolvePercent($contract, $category, $override),
franchiseRials: $franchise,
ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(),
covered: true,
coveragePercent: $this->resolvePercent($contract, $category, $override),
franchisePercent: $franchise,
ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(),
covered: true,
);
}
@@ -324,7 +382,7 @@ class TenantInsuranceService
return $kind === InsuranceType::Supplementary->value;
}
/** @return array{covered: bool, percent: float|null, franchise: int|null, ceiling: int|null}|null */
/** @return array{covered: bool, coverage_percent: float|null, franchise_percent: float|null, ceiling_rials: int|null}|null */
public function getServiceCoverage(int $tenantInsuranceId, int $serviceItemId): ?array
{
$override = $this->coverageRepo->findOneFor($tenantInsuranceId, $serviceItemId);
@@ -336,15 +394,22 @@ class TenantInsuranceService
int $serviceItemId,
bool $covered,
?float $coveragePercent,
?int $franchiseRials,
?float $franchisePercent,
?int $ceilingRials,
): void {
if ($coveragePercent !== null) {
$this->assertPercentInRange($coveragePercent, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد', 'coverage_percent');
}
if ($franchisePercent !== null) {
$this->assertPercentInRange($franchisePercent, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 'franchise_percent');
}
$override = $this->coverageRepo->findOneFor($contract->getId(), $serviceItemId)
?? new TenantServiceCoverage($contract->getId(), $serviceItemId);
$override->setCovered($covered)
->setCoveragePercent($coveragePercent)
->setFranchiseRials($franchiseRials)
->setFranchisePercent($franchisePercent)
->setCeilingRials($ceilingRials);
$this->coverageRepo->save($override);