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
@@ -330,6 +330,10 @@ class BillingController extends BaseController
}
$filters = $this->claimFilters($request);
if ($filters['kind'] !== null && !in_array($filters['kind'], [Claim::KIND_BASE, Claim::KIND_SUPPLEMENTARY], true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع بیمه نامعتبر است', 422, 'kind');
}
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
$sort = (string) $request->query->get('sort', 'last_activity_at');
@@ -423,6 +427,7 @@ class BillingController extends BaseController
return [
'status' => $request->query->get('status') ?: null,
'insurance_id' => $request->query->get('insurance_id') ?: null,
'kind' => $request->query->get('kind') ?: null,
'doctor_id' => $request->query->get('doctor_id') ?: null,
'payment_status' => $request->query->get('payment_status') ?: null,
'from' => $request->query->get('from') ?: null,
+41 -1
View File
@@ -169,11 +169,41 @@ class ClaimRepository extends ServiceEntityRepository
'total_approved_rials' => (int) $r['total_approved_rials'],
'total_paid_rials' => (int) $r['total_paid_rials'],
'overall_status' => count($statuses) === 1 ? reset($statuses) : 'mixed',
'insurances' => self::parseInsurances((string) ($r['insurances'] ?? '')),
'last_activity_at' => (int) $r['last_activity_at'],
];
}, $rows);
}
/**
* ردیف‌های GROUP_CONCAT بیمه‌های یک بیمار → آرایهٔ ساخت‌یافته. یک بیمار می‌تواند
* مطالبه زیر چند بیمه داشته باشد، پس ستون «بیمه» یک لیست است نه یک مقدار.
*
* @return list<array{insurance_id: int, insurance_name: string|null, kind: string|null}>
*/
private static function parseInsurances(string $concatenated): array
{
if ($concatenated === '') {
return [];
}
$rows = [];
foreach (explode('~', $concatenated) as $chunk) {
[$id, $name, $kind] = array_pad(explode('|', $chunk), 3, null);
if ($id === null || $id === '') {
continue;
}
$rows[] = [
'insurance_id' => (int) $id,
'insurance_name' => $name === '' ? null : $name,
'kind' => $kind === '' ? null : $kind,
];
}
return $rows;
}
public function countPatientsWithClaims(string $entityType, int $entityId, array $filters): int
{
[$where, $params] = $this->patientAggregateFilters($filters);
@@ -222,12 +252,14 @@ class ClaimRepository extends ServiceEntityRepository
WHERE i3.id IN (SELECT DISTINCT cm3.invoice_id FROM claim_map cm3 WHERE cm3.record_id = pr.id)
), 0) AS total_patient_rials,
GROUP_CONCAT(DISTINCT c.status) AS statuses,
GROUP_CONCAT(DISTINCT CONCAT_WS('|', c.insurance_id, COALESCE(ins.name, ''), c.insurance_kind) SEPARATOR '~') AS insurances,
MAX(c.updated_at) AS last_activity_at
FROM claim_map cm
JOIN claims c ON c.id = cm.claim_id
JOIN invoices inv ON inv.id = cm.invoice_id
JOIN patient_records pr ON pr.id = cm.record_id
JOIN users u ON u.id = pr.user_id
LEFT JOIN insurances ins ON ins.id = c.insurance_id
{$where}
GROUP BY pr.id, u.uuid, pr.uuid, u.real_name, u.mobile_number, u.national_code
SQL;
@@ -250,6 +282,10 @@ class ClaimRepository extends ServiceEntityRepository
$conditions[] = 'c.insurance_id = :insId';
$params['insId'] = (int) $filters['insurance_id'];
}
if (!empty($filters['kind'])) {
$conditions[] = 'c.insurance_kind = :kind';
$params['kind'] = (string) $filters['kind'];
}
if (!empty($filters['from'])) {
$conditions[] = 'c.created_at >= :from';
$params['from'] = (int) $filters['from'];
@@ -270,7 +306,10 @@ class ClaimRepository extends ServiceEntityRepository
: 'c.status <> \'paid\'';
}
if (!empty($filters['search'])) {
$conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search OR u.national_code LIKE :search)';
// نام بیمه هم جستجو می‌شود: کاربر «آسیا» را می‌نویسد و انتظار دارد بیماران
// همان بیمه بیایند، نه فقط بیماری که اسمش آسیاست.
$conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search'
. ' OR u.national_code LIKE :search OR ins.name LIKE :search)';
$params['search'] = '%' . trim((string) $filters['search']) . '%';
}
@@ -328,6 +367,7 @@ class ClaimRepository extends ServiceEntityRepository
LEFT JOIN patient_sessions ps ON ps.id = inv.patient_session_id
LEFT JOIN appointments a ON a.id = ps.appointment_id
LEFT JOIN doctors d ON d.id = a.doctor_id
LEFT JOIN insurances ins ON ins.id = c.insurance_id
{$where}
ORDER BY c.created_at DESC, c.id DESC
SQL;
+9 -7
View File
@@ -10,7 +10,7 @@ class BillingCalculator
{
/**
* محاسبه‌ی سهم برای یک آیتم.
* ترتیب: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل روی باقیمانده (با سقف) → فرانشیز تکمیلی.
* ترتیب: کل → پوشش پایه (با سقف) → باقیمانده → تعهد مکمل روی باقیمانده منهای فرانشیز (با سقف).
*/
public function calculateItem(
Money $total,
@@ -30,17 +30,19 @@ class BillingCalculator
$suppShare = Money::zero();
if ($supplementary !== null && $supplementary->covered) {
$suppShare = $remaining->percent($supplementary->coveragePercent);
// فرانشیز سهم اجباری بیمار از همین مبلغ است و از تعهد تکمیلی کسر می‌شود —
// نه اینکه روی سهم بیمار سوار شود، وگرنه جمع سهم‌ها از کل بیشتر می‌شد و
// مطالبهٔ ارسالی به بیمه بیش از سهم واقعی‌اش می‌بود.
$suppShare = $remaining->percent($supplementary->coveragePercent)
->sub($remaining->percent($supplementary->franchisePercent));
if ($supplementary->ceilingRials !== null) {
$suppShare = $suppShare->min(new Money($supplementary->ceilingRials));
}
$remaining = $remaining->sub($suppShare);
}
// بیمهٔ پایه صرفاً درصدی است: سهم بیمار = کل − سهم پایه. فرانشیز فقط در بیمهٔ
// تکمیلی معنا دارد و سهم بیمار را از کل بیشتر نمی‌کند.
$franchise = new Money($supplementary?->franchiseRials ?? 0);
$patient = $remaining->add($franchise)->min($total);
// بیمهٔ پایه صرفاً درصدی است و فرانشیزش در محاسبه دخالت نمی‌کند.
$patient = $total->sub($baseShare)->sub($suppShare);
return new ShareBreakdown(
totalRials: $total->rials,
@@ -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,
);
+6 -5
View File
@@ -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 -3
View File
@@ -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);
}
}
@@ -27,7 +27,7 @@ class NumericFieldNormalizerSubscriber implements EventSubscriberInterface
'price_rials', 'amount_rials', 'amount', 'free_visit_price_rials',
'insurance_price_rials', 'patient_share_rials', 'visit_price_rials',
'duration_minutes', 'duration', 'commission_percent', 'coverage',
'coverage_percent', 'franchise', 'ceiling', 'tax_percent',
'coverage_percent', 'franchise', 'franchise_percent', 'ceiling', 'tax_percent',
'base_insurance_discount_percent', 'supplementary_discount_percent',
];