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:
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace App\Insurance\Command;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Billing\Entity\Claim;
|
||||
use App\Billing\Service\ClaimService;
|
||||
use App\Billing\Service\InvoiceService;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Insurance\Entity\TenantInsurance;
|
||||
use App\Insurance\Enum\ServiceCategory;
|
||||
use App\Insurance\Repository\InsuranceRepository;
|
||||
use App\Insurance\Service\TenantInsuranceService;
|
||||
use App\Insurance\Service\TenantServiceCategoryService;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* سناریوی قابلاجرای بیمهٔ تکمیلی برای یک پزشک: چند قرارداد با درصد و فرانشیز
|
||||
* متفاوت، چند بیمار که از آنها استفاده میکنند، و مطالبات در وضعیتهای مختلف.
|
||||
*
|
||||
* صورتحسابها از مسیر واقعی InvoiceService/ClaimService ساخته میشوند تا اعداد را
|
||||
* همان BillingCalculator تولید کند — نه INSERT دستی که با فرمول واگرا میشود.
|
||||
*
|
||||
* Marker پاکسازی: موبایل بیمارها با پیشوند 09129900 ساخته میشود.
|
||||
*
|
||||
* ddev exec php bin/console app:seed-insurance-scenario --doctor-mobile=09389388131 --purge
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:seed-insurance-scenario',
|
||||
description: 'Seed supplementary-insurance contracts, patients and claims for one doctor',
|
||||
)]
|
||||
class SeedInsuranceScenarioCommand extends Command
|
||||
{
|
||||
private const PATIENT_MOBILE_PREFIX = '09129900';
|
||||
|
||||
/** نام بیمههای کاتالوگ + درصدها؛ نامها با seed کاتالوگ همخواناند. */
|
||||
private const CONTRACTS = [
|
||||
['name' => 'تامین اجتماعی', 'kind' => 'basic', 'outpatient' => 30.0, 'inpatient' => 40.0, 'franchise' => 0.0, 'ceiling' => null],
|
||||
['name' => 'بیمه ایران', 'kind' => 'supplementary', 'outpatient' => 90.0, 'inpatient' => 80.0, 'franchise' => 10.0, 'ceiling' => 50_000_000],
|
||||
['name' => 'بیمه آسیا', 'kind' => 'supplementary', 'outpatient' => 70.0, 'inpatient' => 60.0, 'franchise' => 20.0, 'ceiling' => null],
|
||||
['name' => 'بیمه دی', 'kind' => 'supplementary', 'outpatient' => 100.0, 'inpatient' => 50.0, 'franchise' => 0.0, 'ceiling' => 10_000_000],
|
||||
];
|
||||
|
||||
/**
|
||||
* بیمارهای سناریو. `base`/`supp` نام بیمهاند، `visit` قیمت ویزیت به ریال و
|
||||
* `claim` وضعیت نهاییِ مطالبهها.
|
||||
*/
|
||||
private const PATIENTS = [
|
||||
['name' => 'زهرا رضایی', 'base' => 'تامین اجتماعی', 'supp' => null, 'visit' => 5_000_000, 'category' => 'outpatient', 'claim' => Claim::STATUS_PENDING],
|
||||
['name' => 'علی محمدی', 'base' => 'تامین اجتماعی', 'supp' => 'بیمه ایران', 'visit' => 8_000_000, 'category' => 'outpatient', 'claim' => Claim::STATUS_SUBMITTED],
|
||||
['name' => 'مریم کاظمی', 'base' => null, 'supp' => 'بیمه آسیا', 'visit' => 6_000_000, 'category' => 'inpatient', 'claim' => Claim::STATUS_APPROVED],
|
||||
['name' => 'حسین نوروزی', 'base' => 'تامین اجتماعی', 'supp' => 'بیمه دی', 'visit' => 40_000_000, 'category' => 'inpatient', 'claim' => Claim::STATUS_PAID],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly InsuranceRepository $insuranceRepo,
|
||||
private readonly TenantInsuranceService $tenantInsuranceService,
|
||||
private readonly TenantServiceCategoryService $serviceCategories,
|
||||
private readonly InvoiceService $invoiceService,
|
||||
private readonly ClaimService $claimService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('doctor-mobile', null, InputOption::VALUE_REQUIRED, 'موبایل کاربرِ پزشک', '09389388131')
|
||||
->addOption('purge', null, InputOption::VALUE_NONE, 'فقط پاکسازی دادهی سناریو، بدون ساخت دوباره');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$mobile = (string) $input->getOption('doctor-mobile');
|
||||
|
||||
$doctor = $this->doctorRepo->createQueryBuilder('d')
|
||||
->join('d.user', 'u')
|
||||
->andWhere('u.mobileNumber = :mobile')
|
||||
->setParameter('mobile', $mobile)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
|
||||
if ($doctor === null) {
|
||||
$io->error(sprintf('پزشکی با موبایل %s یافت نشد', $mobile));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$entityType = TenantInsurance::TYPE_DOCTOR;
|
||||
$entityId = (int) $doctor->getId();
|
||||
$io->title(sprintf('سناریوی بیمه — %s (doctor #%d)', $doctor->getName(), $entityId));
|
||||
|
||||
// پاکسازی همیشه انجام میشود تا اجرای دوباره دادهی تکراری نسازد؛ marker موبایل
|
||||
// مخصوص همین سناریوست و به دادهی واقعی نمیرسد.
|
||||
$purged = $this->purge($entityType, $entityId);
|
||||
$io->text(sprintf('پاکسازی: %d بیمار سناریو حذف شد', $purged));
|
||||
|
||||
if ($input->getOption('purge')) {
|
||||
$io->success('فقط پاکسازی انجام شد.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->serviceCategories->save($entityType, $entityId, [
|
||||
['key' => 'outpatient', 'enabled' => true],
|
||||
['key' => 'inpatient', 'enabled' => true],
|
||||
]);
|
||||
|
||||
$contracts = $this->seedContracts($entityType, $entityId, $io);
|
||||
if ($contracts === []) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach (self::PATIENTS as $i => $spec) {
|
||||
$rows[] = $this->seedPatient($entityType, $entityId, $i, $spec);
|
||||
}
|
||||
|
||||
$io->section('قراردادهای بیمه');
|
||||
$io->table(
|
||||
['بیمه', 'نوع', 'سرپایی', 'بستری', 'فرانشیز', 'سقف سالانه'],
|
||||
array_map(static fn(array $c) => [
|
||||
$c['name'],
|
||||
$c['kind'] === 'basic' ? 'پایه' : 'تکمیلی',
|
||||
$c['outpatient'] . '٪',
|
||||
$c['inpatient'] . '٪',
|
||||
$c['franchise'] . '٪',
|
||||
$c['ceiling'] === null ? 'نامحدود' : number_format($c['ceiling']),
|
||||
], self::CONTRACTS),
|
||||
);
|
||||
|
||||
$io->section('بیماران و مطالبات');
|
||||
$io->table(['بیمار', 'پایه', 'تکمیلی', 'کل', 'سهم بیمه', 'سهم بیمار', 'وضعیت'], $rows);
|
||||
$io->success('سناریو ساخته شد. ورود به پنل با ' . $mobile);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, TenantInsurance> کلید = نام بیمه
|
||||
*/
|
||||
private function seedContracts(string $entityType, int $entityId, SymfonyStyle $io): array
|
||||
{
|
||||
$contracts = [];
|
||||
foreach (self::CONTRACTS as $spec) {
|
||||
$insurance = $this->insuranceRepo->findOneBy(['name' => $spec['name']]);
|
||||
if ($insurance === null) {
|
||||
$io->error(sprintf('بیمهٔ «%s» در کاتالوگ نیست؛ اول کاتالوگ بیمه را seed کنید', $spec['name']));
|
||||
return [];
|
||||
}
|
||||
|
||||
$contract = $this->tenantInsuranceService->activate(
|
||||
$entityType,
|
||||
$entityId,
|
||||
(int) $insurance->getId(),
|
||||
$spec['outpatient'],
|
||||
$spec['franchise'],
|
||||
$spec['ceiling'],
|
||||
null,
|
||||
null,
|
||||
$spec['kind'],
|
||||
);
|
||||
|
||||
$this->tenantInsuranceService->setCategoryCoverages($contract, [
|
||||
['key' => 'outpatient', 'coverage_percent' => $spec['outpatient']],
|
||||
['key' => 'inpatient', 'coverage_percent' => $spec['inpatient']],
|
||||
]);
|
||||
|
||||
$contracts[$spec['name']] = $contract;
|
||||
}
|
||||
|
||||
return $contracts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{name: string, base: ?string, supp: ?string, visit: int, category: string, claim: string} $spec
|
||||
* @return list<string> ردیف جدول خلاصه
|
||||
*/
|
||||
private function seedPatient(string $entityType, int $entityId, int $index, array $spec): array
|
||||
{
|
||||
$mobile = self::PATIENT_MOBILE_PREFIX . str_pad((string) ($index + 1), 3, '0', STR_PAD_LEFT);
|
||||
|
||||
$user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]) ?? new User($mobile);
|
||||
$user->setRealName($spec['name'])->setRoles(['ROLE_USER'])->setStatus(1);
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
$record = new PatientRecord($entityType, $entityId, $user, $entityType, $entityId);
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
$session = new PatientSession($record);
|
||||
$session->setVisitPriceRials($spec['visit'])
|
||||
->setInsuranceBaseId($this->insuranceIdOf($spec['base']))
|
||||
->setInsuranceSupplementaryId($this->insuranceIdOf($spec['supp']))
|
||||
->setInsuranceServiceCategory(ServiceCategory::from($spec['category']))
|
||||
->setSessionAt(time());
|
||||
$this->em->persist($session);
|
||||
$this->em->flush();
|
||||
|
||||
$invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId);
|
||||
$this->invoiceService->finalize($invoice);
|
||||
|
||||
$claims = $this->claimService->createFromInvoice($invoice);
|
||||
foreach ($claims as $claim) {
|
||||
$this->advanceClaim($claim, $spec['claim']);
|
||||
}
|
||||
|
||||
$insuranceShare = $invoice->getTotalRials() - $invoice->getPatientRials();
|
||||
|
||||
return [
|
||||
$spec['name'],
|
||||
$spec['base'] ?? '—',
|
||||
$spec['supp'] ?? '—',
|
||||
number_format($invoice->getTotalRials()),
|
||||
number_format($insuranceShare),
|
||||
number_format($invoice->getPatientRials()),
|
||||
$spec['claim'],
|
||||
];
|
||||
}
|
||||
|
||||
/** مطالبه را تا وضعیت هدف جلو میبرد؛ هر گام از همان transition واقعی رد میشود. */
|
||||
private function advanceClaim(Claim $claim, string $target): void
|
||||
{
|
||||
$path = match ($target) {
|
||||
Claim::STATUS_SUBMITTED => [Claim::STATUS_SUBMITTED],
|
||||
Claim::STATUS_APPROVED => [Claim::STATUS_SUBMITTED, Claim::STATUS_APPROVED],
|
||||
Claim::STATUS_PAID => [Claim::STATUS_SUBMITTED, Claim::STATUS_APPROVED, Claim::STATUS_PAID],
|
||||
Claim::STATUS_REJECTED => [Claim::STATUS_SUBMITTED, Claim::STATUS_REJECTED],
|
||||
default => [],
|
||||
};
|
||||
|
||||
foreach ($path as $step) {
|
||||
$this->claimService->transition($claim, $step, [
|
||||
'approved_rials' => $step === Claim::STATUS_APPROVED ? $claim->getTotalClaimedRials() : null,
|
||||
'paid_rials' => $step === Claim::STATUS_PAID ? $claim->getTotalApprovedRials() : null,
|
||||
'reason' => $step === Claim::STATUS_REJECTED ? 'مدارک ناقص است' : '',
|
||||
'tracking_number' => $step === Claim::STATUS_SUBMITTED ? 'SC-' . $claim->getId() : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function insuranceIdOf(?string $name): ?int
|
||||
{
|
||||
if ($name === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->insuranceRepo->findOneBy(['name' => $name])?->getId();
|
||||
}
|
||||
|
||||
/** حذف بیماران سناریو و هرچه از آنها آویزان است. قراردادها با activate بازنویسی میشوند. */
|
||||
private function purge(string $entityType, int $entityId): int
|
||||
{
|
||||
$conn = $this->em->getConnection();
|
||||
$ids = $conn->executeQuery(
|
||||
'SELECT id FROM users WHERE mobile_number LIKE :prefix',
|
||||
['prefix' => self::PATIENT_MOBILE_PREFIX . '%'],
|
||||
)->fetchFirstColumn();
|
||||
|
||||
if ($ids === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$params = ['ids' => $ids, 'type' => $entityType, 'entity' => $entityId];
|
||||
$types = ['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER];
|
||||
|
||||
$conn->executeStatement(
|
||||
'DELETE ci FROM claim_items ci
|
||||
JOIN invoice_items ii ON ii.id = ci.invoice_item_id
|
||||
JOIN invoices inv ON inv.id = ii.invoice_id
|
||||
JOIN patient_records pr ON pr.id = inv.patient_record_id
|
||||
WHERE pr.user_id IN (:ids) AND pr.entity_type = :type AND pr.entity_id = :entity',
|
||||
$params,
|
||||
$types,
|
||||
);
|
||||
$conn->executeStatement(
|
||||
'DELETE csl FROM claim_status_logs csl
|
||||
JOIN claims c ON c.id = csl.claim_id
|
||||
LEFT JOIN claim_items ci ON ci.claim_id = c.id
|
||||
WHERE ci.id IS NULL AND c.entity_type = :type AND c.entity_id = :entity',
|
||||
['type' => $entityType, 'entity' => $entityId],
|
||||
);
|
||||
$conn->executeStatement(
|
||||
'DELETE c FROM claims c
|
||||
LEFT JOIN claim_items ci ON ci.claim_id = c.id
|
||||
WHERE ci.id IS NULL AND c.entity_type = :type AND c.entity_id = :entity',
|
||||
['type' => $entityType, 'entity' => $entityId],
|
||||
);
|
||||
$conn->executeStatement(
|
||||
'DELETE ii FROM invoice_items ii
|
||||
JOIN invoices inv ON inv.id = ii.invoice_id
|
||||
JOIN patient_records pr ON pr.id = inv.patient_record_id
|
||||
WHERE pr.user_id IN (:ids)',
|
||||
$params,
|
||||
$types,
|
||||
);
|
||||
$conn->executeStatement(
|
||||
'DELETE inv FROM invoices inv
|
||||
JOIN patient_records pr ON pr.id = inv.patient_record_id
|
||||
WHERE pr.user_id IN (:ids)',
|
||||
$params,
|
||||
$types,
|
||||
);
|
||||
$conn->executeStatement(
|
||||
'DELETE ps FROM patient_sessions ps
|
||||
JOIN patient_records pr ON pr.id = ps.record_id
|
||||
WHERE pr.user_id IN (:ids)',
|
||||
$params,
|
||||
$types,
|
||||
);
|
||||
$conn->executeStatement('DELETE FROM patient_records WHERE user_id IN (:ids)', $params, $types);
|
||||
$conn->executeStatement('DELETE FROM users WHERE id IN (:ids)', $params, $types);
|
||||
|
||||
return count($ids);
|
||||
}
|
||||
}
|
||||
@@ -499,7 +499,7 @@ class InsuranceController extends BaseController
|
||||
$entityId,
|
||||
$insuranceId,
|
||||
(float) ($data['coverage_percent'] ?? 0),
|
||||
(int) ($data['franchise_rials'] ?? 0),
|
||||
(float) ($data['franchise_percent'] ?? 0),
|
||||
isset($data['annual_ceiling_rials']) && $data['annual_ceiling_rials'] !== null
|
||||
? (int) $data['annual_ceiling_rials'] : null,
|
||||
isset($data['effective_from']) && $data['effective_from'] !== null ? (int) $data['effective_from'] : null,
|
||||
@@ -561,8 +561,12 @@ class InsuranceController extends BaseController
|
||||
if (array_key_exists('coverage_percent', $data)) {
|
||||
$contract->setCoveragePercent((float) $data['coverage_percent']);
|
||||
}
|
||||
if (array_key_exists('franchise_rials', $data)) {
|
||||
$contract->setFranchiseRials((int) $data['franchise_rials']);
|
||||
if (array_key_exists('franchise_percent', $data)) {
|
||||
$percent = (float) $data['franchise_percent'];
|
||||
if ($percent < 0 || $percent > 100) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 422, 'franchise_percent');
|
||||
}
|
||||
$contract->setFranchisePercent($percent);
|
||||
}
|
||||
if (array_key_exists('annual_ceiling_rials', $data)) {
|
||||
$contract->setAnnualCeilingRials($data['annual_ceiling_rials'] !== null ? (int) $data['annual_ceiling_rials'] : null);
|
||||
@@ -682,7 +686,7 @@ class InsuranceController extends BaseController
|
||||
$serviceItemId,
|
||||
(bool) ($data['covered'] ?? true),
|
||||
isset($data['coverage_percent']) && $data['coverage_percent'] !== null ? (float) $data['coverage_percent'] : null,
|
||||
isset($data['franchise_rials']) && $data['franchise_rials'] !== null ? (int) $data['franchise_rials'] : null,
|
||||
isset($data['franchise_percent']) && $data['franchise_percent'] !== null ? (float) $data['franchise_percent'] : null,
|
||||
isset($data['ceiling_rials']) && $data['ceiling_rials'] !== null ? (int) $data['ceiling_rials'] : null,
|
||||
);
|
||||
|
||||
|
||||
@@ -41,8 +41,9 @@ class TenantInsurance
|
||||
#[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2)]
|
||||
private string $coveragePercent = '0.00';
|
||||
|
||||
#[ORM\Column(name: 'franchise_rials', type: 'integer')]
|
||||
private int $franchiseRials = 0;
|
||||
/** درصدِ سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در قرارداد تکمیلی اثر دارد. */
|
||||
#[ORM\Column(name: 'franchise_percent', type: 'decimal', precision: 5, scale: 2)]
|
||||
private string $franchisePercent = '0.00';
|
||||
|
||||
#[ORM\Column(name: 'annual_ceiling_rials', type: 'integer', nullable: true)]
|
||||
private ?int $annualCeilingRials = null;
|
||||
@@ -83,7 +84,7 @@ class TenantInsurance
|
||||
public function getVersion(): int { return $this->version; }
|
||||
public function isActive(): bool { return $this->isActive; }
|
||||
public function getCoveragePercent(): float { return (float) $this->coveragePercent; }
|
||||
public function getFranchiseRials(): int { return $this->franchiseRials; }
|
||||
public function getFranchisePercent(): float { return (float) $this->franchisePercent; }
|
||||
public function getAnnualCeilingRials(): ?int { return $this->annualCeilingRials; }
|
||||
public function getKind(): ?string { return $this->kind; }
|
||||
public function getEffectiveFrom(): int { return $this->effectiveFrom; }
|
||||
@@ -91,7 +92,7 @@ class TenantInsurance
|
||||
|
||||
public function setActive(bool $v): self { $this->isActive = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setCoveragePercent(float $v): self { $this->coveragePercent = (string) $v; $this->updatedAt = time(); return $this; }
|
||||
public function setFranchiseRials(int $v): self { $this->franchiseRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setFranchisePercent(float $v): self { $this->franchisePercent = (string) $v; $this->updatedAt = time(); return $this; }
|
||||
public function setAnnualCeilingRials(?int $v): self { $this->annualCeilingRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setKind(?string $v): self { $this->kind = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setEffectiveFrom(int $v): self { $this->effectiveFrom = $v; $this->updatedAt = time(); return $this; }
|
||||
@@ -107,7 +108,7 @@ class TenantInsurance
|
||||
'version' => $this->version,
|
||||
'is_active' => $this->isActive,
|
||||
'coverage_percent' => (float) $this->coveragePercent,
|
||||
'franchise_rials' => $this->franchiseRials,
|
||||
'franchise_percent' => (float) $this->franchisePercent,
|
||||
'annual_ceiling_rials' => $this->annualCeilingRials,
|
||||
'kind' => $this->kind,
|
||||
'effective_from' => $this->effectiveFrom,
|
||||
|
||||
@@ -32,8 +32,9 @@ class TenantServiceCoverage
|
||||
#[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2, nullable: true)]
|
||||
private ?string $coveragePercent = null;
|
||||
|
||||
#[ORM\Column(name: 'franchise_rials', type: 'integer', nullable: true)]
|
||||
private ?int $franchiseRials = null;
|
||||
/** null = ارث از قرارداد؛ درصد است، نه مبلغ. */
|
||||
#[ORM\Column(name: 'franchise_percent', type: 'decimal', precision: 5, scale: 2, nullable: true)]
|
||||
private ?string $franchisePercent = null;
|
||||
|
||||
#[ORM\Column(name: 'ceiling_rials', type: 'integer', nullable: true)]
|
||||
private ?int $ceilingRials = null;
|
||||
@@ -55,12 +56,12 @@ class TenantServiceCoverage
|
||||
public function getServiceItemId(): int { return $this->serviceItemId; }
|
||||
public function isCovered(): bool { return $this->covered; }
|
||||
public function getCoveragePercent(): ?float { return $this->coveragePercent !== null ? (float) $this->coveragePercent : null; }
|
||||
public function getFranchiseRials(): ?int { return $this->franchiseRials; }
|
||||
public function getFranchisePercent(): ?float { return $this->franchisePercent !== null ? (float) $this->franchisePercent : null; }
|
||||
public function getCeilingRials(): ?int { return $this->ceilingRials; }
|
||||
|
||||
public function setCovered(bool $v): self { $this->covered = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setCoveragePercent(?float $v): self { $this->coveragePercent = $v !== null ? (string) $v : null; $this->updatedAt = time(); return $this; }
|
||||
public function setFranchiseRials(?int $v): self { $this->franchiseRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setFranchisePercent(?float $v): self { $this->franchisePercent = $v !== null ? (string) $v : null; $this->updatedAt = time(); return $this; }
|
||||
public function setCeilingRials(?int $v): self { $this->ceilingRials = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
@@ -71,7 +72,7 @@ class TenantServiceCoverage
|
||||
'service_item_id' => $this->serviceItemId,
|
||||
'covered' => $this->covered,
|
||||
'coverage_percent' => $this->getCoveragePercent(),
|
||||
'franchise_rials' => $this->franchiseRials,
|
||||
'franchise_percent' => $this->getFranchisePercent(),
|
||||
'ceiling_rials' => $this->ceilingRials,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,14 +6,17 @@ final readonly class CoverageRule
|
||||
{
|
||||
public function __construct(
|
||||
public float $coveragePercent,
|
||||
/** فقط برای بیمهٔ تکمیلی معنا دارد؛ در بیمهٔ پایه در محاسبه دخالت نمیکند. */
|
||||
public int $franchiseRials,
|
||||
/**
|
||||
* درصدِ سهم اجباری بیمار از مبلغ تحت پوشش — از سهم بیمه کسر میشود.
|
||||
* فقط برای بیمهٔ تکمیلی معنا دارد؛ در بیمهٔ پایه در محاسبه دخالت نمیکند.
|
||||
*/
|
||||
public float $franchisePercent,
|
||||
public ?int $ceilingRials,
|
||||
public bool $covered = true,
|
||||
) {}
|
||||
|
||||
public static function notCovered(): self
|
||||
{
|
||||
return new self(0.0, 0, null, false);
|
||||
return new self(0.0, 0.0, null, false);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user