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,