Commit 21595929 authored by Fares's avatar Fares

feat(death): complete overhaul of death case execution process

- Add DeathAuditService for full Arabic audit logging of every step
- Create death_case_audit_log table (migration Phase_99_001)
- Transfer active installment plans to new membership on completion
- Duplicate documents to new membership (originals preserved on archived)
- Create annual subscriptions for new membership (current financial year)
- Set membership_value to current plan price (not old historical value)
- Add "عرض العضوية الأصلية" button linking to archived member
- Add "السجل التأميني" (Insurance Record) page on member profile
- Add insurance-record route and view with timeline UI
- Update architecture map with new services, tables, and flows
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 8f9a4b37
......@@ -16,6 +16,7 @@ use App\Modules\Payments\Services\PaymentService;
use App\Modules\Cashier\Services\PaymentRequestService;
use App\Modules\Members\Services\NationalIdParser;
use App\Modules\ServiceCatalog\Models\ServicePrice;
use App\Modules\Death\Services\DeathAuditService;
class DeathController extends Controller
{
......@@ -723,6 +724,7 @@ class DeathController extends Controller
if (!$case || $case['status'] === 'completed') return $this->redirect('/death')->withError('الحالة غير صالحة');
$employee = App::getInstance()->currentEmployee();
$caseId = (int) $id;
if ($case['deceased_type'] === 'primary_member') {
if (!$case['board_approved_at']) {
......@@ -734,7 +736,7 @@ class DeathController extends Controller
$deathPaymentCheck = $db->selectOne(
"SELECT id FROM payments WHERE member_id = ? AND payment_type = 'death_fee'
AND related_entity_type = 'death_cases' AND related_entity_id = ? AND is_voided = 0",
[(int) $case['member_id'], (int) $id]
[(int) $case['member_id'], $caseId]
);
if (!$deathPaymentCheck) {
return $this->redirect("/death/{$id}")->withError('لم يتم سداد رسوم الوفاة في الخزينة بعد');
......@@ -749,6 +751,8 @@ class DeathController extends Controller
$snapshotId = ArchiveService::takeSnapshot('members', (int) $case['member_id'], 'death', 'وفاة — حالة #' . $id);
$auditLog = [];
DeathAuditService::log($caseId, 'snapshot_taken', 'تم أخذ لقطة أرشيفية كاملة للعضوية الأصلية', 'archive_snapshots', $snapshotId);
if ($case['deceased_type'] === 'spouse' && $case['spouse_id']) {
$spouseRow = $db->selectOne("SELECT full_name_ar FROM spouses WHERE id = ?", [(int) $case['spouse_id']]);
$db->update('spouses', [
......@@ -756,6 +760,7 @@ class DeathController extends Controller
'archived_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $case['spouse_id']]);
$auditLog[] = 'تم تسجيل وفاة الزوج/ة: ' . ($spouseRow['full_name_ar'] ?? '');
DeathAuditService::log($caseId, 'spouse_deceased', 'تم تسجيل وفاة الزوج/ة: ' . ($spouseRow['full_name_ar'] ?? ''), 'spouses', (int) $case['spouse_id']);
} elseif ($case['deceased_type'] === 'child' && $case['child_id']) {
$childRow = $db->selectOne("SELECT full_name_ar FROM children WHERE id = ?", [(int) $case['child_id']]);
......@@ -764,6 +769,7 @@ class DeathController extends Controller
'archived_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $case['child_id']]);
$auditLog[] = 'تم تسجيل وفاة الابن/الابنة: ' . ($childRow['full_name_ar'] ?? '');
DeathAuditService::log($caseId, 'child_deceased', 'تم تسجيل وفاة الابن/الابنة: ' . ($childRow['full_name_ar'] ?? ''), 'children', (int) $case['child_id']);
} elseif ($case['deceased_type'] === 'primary_member') {
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $case['member_id']]);
......@@ -783,11 +789,11 @@ class DeathController extends Controller
"SELECT id FROM payments WHERE member_id = ? AND payment_type = 'death_fee'
AND related_entity_type = 'death_cases' AND related_entity_id = ? AND is_voided = 0
ORDER BY id DESC LIMIT 1",
[(int) $case['member_id'], (int) $id]
[(int) $case['member_id'], $caseId]
);
$deathPaymentId = $deathPayment ? (int) $deathPayment['id'] : null;
// STEP 1: Archive deceased member
// STEP 1: Archive deceased member (preserve all original data, only change status fields)
$db->update('members', [
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
......@@ -795,7 +801,11 @@ class DeathController extends Controller
'membership_number' => null,
], '`id` = ?', [(int) $case['member_id']]);
// STEP 2: Create new primary member
DeathAuditService::log($caseId, 'member_archived', 'تم أرشفة العضوية الأصلية — العضو: ' . $member['full_name_ar'] . ' — رقم: ' . $inheritedNumber, 'members', (int) $case['member_id']);
// STEP 2: Create new primary member with current plan price
$currentPlanPrice = $this->getCurrentPlanPrice($db, $member);
$newMemberData = [
'membership_number' => $inheritedNumber,
'full_name_ar' => $formData['full_name_ar'] ?? $spouse['full_name_ar'],
......@@ -812,8 +822,8 @@ class DeathController extends Controller
'activated_by_payment_id' => $deathPaymentId,
'qualification_id' => !empty($formData['qualification_id']) ? (int) $formData['qualification_id'] : ($spouse['qualification_id'] ?? $member['qualification_id']),
'phone_mobile' => $formData['phone_mobile'] ?? $spouse['mobile'] ?? $member['phone_mobile'],
'membership_value' => $member['membership_value'],
'transferred_from_death_id' => (int) $id,
'membership_value' => $currentPlanPrice,
'transferred_from_death_id' => $caseId,
'original_membership_number' => $inheritedNumber,
'form_number' => $formData['form_number'] ?? null,
'deceased_member_name' => $member['full_name_ar'],
......@@ -832,6 +842,11 @@ class DeathController extends Controller
$newMemberId = $db->insert('members', $newMemberData);
$auditLog[] = 'تم تحويل العضوية إلى: ' . ($formData['full_name_ar'] ?? $spouse['full_name_ar']) . ' — رقم العضوية: ' . $inheritedNumber;
DeathAuditService::log($caseId, 'new_member_created', 'تم إنشاء عضوية جديدة باسم: ' . ($formData['full_name_ar'] ?? $spouse['full_name_ar']) . ' — رقم: ' . $inheritedNumber, 'members', $newMemberId, [
'membership_value' => $currentPlanPrice,
'inherited_number' => $inheritedNumber,
]);
// Archive primary spouse
$db->update('spouses', [
'status' => 'transferred', 'is_archived' => 1,
......@@ -857,8 +872,10 @@ class DeathController extends Controller
if (!empty($childrenMoved)) {
$auditLog[] = 'تم نقل الأبناء التاليين إلى العضوية الجديدة: ' . implode('، ', $childrenMoved);
DeathAuditService::log($caseId, 'children_transferred', 'تم نقل الأبناء: ' . implode('، ', $childrenMoved), 'members', $newMemberId);
} elseif (empty($childrenAssignment)) {
$auditLog[] = 'تم نقل جميع الأبناء إلى العضوية الجديدة';
DeathAuditService::log($caseId, 'children_transferred', 'تم نقل جميع الأبناء إلى العضوية الجديدة', 'members', $newMemberId);
}
// Handle secondary spouses
......@@ -883,8 +900,8 @@ class DeathController extends Controller
'activated_at' => date('Y-m-d H:i:s'),
'activated_by_payment_id' => $deathPaymentId,
'phone_mobile' => $secSpouse['mobile'] ?? $member['phone_mobile'] ?? '00000000000',
'membership_value' => $member['membership_value'],
'transferred_from_death_id' => (int) $id,
'membership_value' => $currentPlanPrice,
'transferred_from_death_id' => $caseId,
'deceased_member_name' => $member['full_name_ar'],
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
......@@ -901,6 +918,7 @@ class DeathController extends Controller
$secNumber = $db->selectOne("SELECT membership_number FROM members WHERE id = ?", [$secMemberId]);
$auditLog[] = 'تم إنشاء عضوية منفصلة للزوجة: ' . $secSpouse['full_name_ar'] . ' — رقم: ' . ($secNumber['membership_number'] ?? '');
DeathAuditService::log($caseId, 'secondary_member_created', 'تم إنشاء عضوية منفصلة للزوجة: ' . $secSpouse['full_name_ar'] . ' — رقم: ' . ($secNumber['membership_number'] ?? ''), 'members', $secMemberId);
// Move children assigned to this secondary spouse
if (!empty($childrenAssignment)) {
......@@ -959,19 +977,29 @@ class DeathController extends Controller
if (!empty($tempsMoved)) {
$auditLog[] = 'تم نقل الأعضاء المؤقتين: ' . implode('، ', $tempsMoved);
DeathAuditService::log($caseId, 'temps_transferred', 'تم نقل الأعضاء المؤقتين: ' . implode('، ', $tempsMoved), 'members', $newMemberId);
} else {
$tempCount = (int) ($db->selectOne("SELECT COUNT(*) as cnt FROM temporary_members WHERE member_id = ? AND is_archived = 0", [$newMemberId])['cnt'] ?? 0);
if ($tempCount > 0) {
$auditLog[] = 'تم نقل ' . $tempCount . ' عضو مؤقت إلى العضوية الجديدة';
DeathAuditService::log($caseId, 'temps_transferred', 'تم نقل ' . $tempCount . ' عضو مؤقت إلى العضوية الجديدة', 'members', $newMemberId);
}
}
// STEP 3: Transfer active financial data to new membership
$this->transferActiveData($db, (int) $case['member_id'], $newMemberId, $caseId);
// STEP 4: Create annual subscriptions for new membership
$this->createNewSubscriptions($db, $newMemberId, $caseId);
ArchiveService::recordNumberTransfer($inheritedNumber, 'death_transfer', 'members', $newMemberId);
$auditLog[] = 'تاريخ التنفيذ: ' . date('Y-m-d H:i:s');
$auditLog[] = 'بواسطة: ' . ($employee ? $employee->full_name_ar : 'النظام');
$auditLog[] = 'تم إنشاء العضوية الجديدة بعد اعتماد السداد من الخزينة';
DeathAuditService::log($caseId, 'transfer_completed', 'تم إتمام نقل العضوية بالكامل — رقم العضوية: ' . $inheritedNumber, 'death_cases', $caseId);
// Store audit log in notes (merge with existing)
$finalNotes = !empty($case['notes']) ? json_decode($case['notes'], true) : [];
if (!is_array($finalNotes)) $finalNotes = [];
......@@ -983,11 +1011,11 @@ class DeathController extends Controller
'status' => 'completed',
'notes' => json_encode($finalNotes, JSON_UNESCAPED_UNICODE),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
], '`id` = ?', [$caseId]);
$db->commit();
EventBus::dispatch('death.completed', ['case_id' => (int) $id, 'new_member_id' => $newMemberId]);
EventBus::dispatch('death.completed', ['case_id' => $caseId, 'new_member_id' => $newMemberId]);
Logger::info("Death case #{$id} completed", ['new_member' => $newMemberId, 'audit' => $auditLog]);
return $this->redirect("/death/{$id}")->withSuccess('تم نقل العضوية بنفس الرقم — رقم العضوية: ' . $inheritedNumber);
......@@ -997,6 +1025,8 @@ class DeathController extends Controller
$auditLog[] = 'تاريخ التنفيذ: ' . date('Y-m-d H:i:s');
$auditLog[] = 'بواسطة: ' . ($employee ? $employee->full_name_ar : 'النظام');
DeathAuditService::log($caseId, 'case_completed', 'تم إتمام حالة الوفاة', 'death_cases', $caseId);
$finalNotes = !empty($case['notes']) ? json_decode($case['notes'], true) : [];
if (!is_array($finalNotes)) $finalNotes = [];
$finalNotes['audit_log'] = $auditLog;
......@@ -1006,7 +1036,7 @@ class DeathController extends Controller
'status' => 'completed',
'notes' => json_encode($finalNotes, JSON_UNESCAPED_UNICODE),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
], '`id` = ?', [$caseId]);
$db->commit();
return $this->redirect("/death/{$id}")->withSuccess('تم إتمام حالة الوفاة');
......@@ -1016,4 +1046,195 @@ class DeathController extends Controller
return $this->redirect("/death/{$id}")->withError('فشل: ' . $e->getMessage());
}
}
private function getCurrentPlanPrice($db, array $member): string
{
$branchId = (int) ($member['branch_id'] ?? 1);
$qualId = !empty($member['qualification_id']) ? (int) $member['qualification_id'] : null;
$currentPlanPrice = '0.00';
if ($qualId) {
$qual = $db->selectOne("SELECT code FROM qualifications WHERE id = ?", [$qualId]);
if ($qual && $qual['code']) {
$priceInfo = \App\Modules\Pricing\Services\PricingEngine::getMembershipPrice($branchId, $qual['code']);
if (!empty($priceInfo['price']) && bccomp($priceInfo['price'], '0.01', 2) >= 0) {
$currentPlanPrice = $priceInfo['price'];
}
}
}
if (bccomp($currentPlanPrice, '0.01', 2) < 0) {
$pr = $db->selectOne(
"SELECT price FROM pricing_configs WHERE branch_id = ? AND is_active = 1 AND effective_from <= CURDATE() AND (effective_to IS NULL OR effective_to >= CURDATE()) ORDER BY price DESC LIMIT 1",
[$branchId]
);
if ($pr) $currentPlanPrice = $pr['price'];
}
if (bccomp($currentPlanPrice, '0.01', 2) < 0) {
$pr = $db->selectOne("SELECT price FROM pricing_configs WHERE is_active = 1 ORDER BY price DESC LIMIT 1");
if ($pr) $currentPlanPrice = $pr['price'];
}
if (bccomp($currentPlanPrice, '0.01', 2) < 0) {
$currentPlanPrice = $member['membership_value'] ?? '0.00';
}
return $currentPlanPrice;
}
private function transferActiveData($db, int $deceasedMemberId, int $newMemberId, int $caseId): void
{
// Transfer active/unpaid installment plans (schedule + cheques follow via FK)
$installments = $db->select(
"SELECT id FROM installment_plans WHERE member_id = ? AND status IN ('active', 'partial')",
[$deceasedMemberId]
);
if (!empty($installments)) {
$ids = array_column($installments, 'id');
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$db->query(
"UPDATE installment_plans SET member_id = ?, updated_at = NOW() WHERE id IN ({$placeholders})",
array_merge([$newMemberId], $ids)
);
DeathAuditService::log($caseId, 'installments_transferred', 'تم نقل ' . count($installments) . ' خطة أقساط نشطة إلى العضوية الجديدة', 'members', $newMemberId);
}
// Transfer documents (duplicate record pointing to same file, keep originals with archived member)
$docs = $db->select(
"SELECT * FROM documents WHERE member_id = ? AND is_archived = 0",
[$deceasedMemberId]
);
if (!empty($docs)) {
foreach ($docs as $doc) {
$db->insert('documents', [
'member_id' => $newMemberId,
'document_type' => $doc['document_type'],
'original_filename' => $doc['original_filename'],
'stored_filename' => $doc['stored_filename'],
'file_path' => $doc['file_path'],
'file_size' => (int) $doc['file_size'],
'mime_type' => $doc['mime_type'],
'related_entity_type' => $doc['related_entity_type'],
'related_entity_id' => $doc['related_entity_id'] ? (int) $doc['related_entity_id'] : null,
'description' => ($doc['description'] ?? '') . ' (منقول من العضوية الأصلية #' . $deceasedMemberId . ')',
'uploaded_by' => $doc['uploaded_by'] ? (int) $doc['uploaded_by'] : null,
'uploaded_at' => $doc['uploaded_at'],
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
DeathAuditService::log($caseId, 'documents_linked', 'تم نقل ' . count($docs) . ' مستند إلى العضوية الجديدة', 'members', $newMemberId);
}
}
private function createNewSubscriptions($db, int $newMemberId, int $caseId): void
{
$now = date('Y-m-d H:i:s');
$currentMonth = (int) date('n');
$currentYear = (int) date('Y');
$financialYear = $currentMonth >= 7
? $currentYear . '/' . ($currentYear + 1)
: ($currentYear - 1) . '/' . $currentYear;
$memberSub = ServicePrice::getPrice('SVC_ANNUAL_MEMBER', '492.00');
$spouseSub = ServicePrice::getPrice('SVC_ANNUAL_SPOUSE', '492.00');
$childSub = ServicePrice::getPrice('SVC_ANNUAL_CHILD', '222.00');
$tempSub = ServicePrice::getPrice('SVC_ANNUAL_TEMP', '222.00');
$devFeeData = RuleEngine::get('DEVELOPMENT_FEE');
$devFee = $devFeeData['amount'] ?? '35.00';
$employee = App::getInstance()->currentEmployee();
$createdBy = $employee ? (int) $employee->id : null;
// Member subscription
$db->insert('subscriptions', [
'member_id' => $newMemberId,
'financial_year' => $financialYear,
'person_type' => 'member',
'person_id' => $newMemberId,
'base_amount' => $memberSub,
'development_fee' => $devFee,
'discount_amount' => '0.00',
'total_amount' => bcadd($memberSub, $devFee, 2),
'paid_amount' => '0.00',
'fine_amount' => '0.00',
'status' => 'pending',
'created_at' => $now,
'updated_at' => $now,
'created_by' => $createdBy,
]);
// Spouse subscriptions
$spouses = $db->select("SELECT id, full_name_ar FROM spouses WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$newMemberId]);
foreach ($spouses as $s) {
$db->insert('subscriptions', [
'member_id' => $newMemberId,
'financial_year' => $financialYear,
'person_type' => 'spouse',
'person_id' => (int) $s['id'],
'person_name' => $s['full_name_ar'],
'base_amount' => $spouseSub,
'development_fee' => '0.00',
'discount_amount' => '0.00',
'total_amount' => $spouseSub,
'paid_amount' => '0.00',
'fine_amount' => '0.00',
'status' => 'pending',
'created_at' => $now,
'updated_at' => $now,
'created_by' => $createdBy,
]);
}
// Child subscriptions
$children = $db->select("SELECT id, full_name_ar FROM children WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$newMemberId]);
foreach ($children as $c) {
$db->insert('subscriptions', [
'member_id' => $newMemberId,
'financial_year' => $financialYear,
'person_type' => 'child',
'person_id' => (int) $c['id'],
'person_name' => $c['full_name_ar'],
'base_amount' => $childSub,
'development_fee' => '0.00',
'discount_amount' => '0.00',
'total_amount' => $childSub,
'paid_amount' => '0.00',
'fine_amount' => '0.00',
'status' => 'pending',
'created_at' => $now,
'updated_at' => $now,
'created_by' => $createdBy,
]);
}
// Temp subscriptions
$temps = $db->select("SELECT id, full_name_ar FROM temporary_members WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$newMemberId]);
foreach ($temps as $t) {
$db->insert('subscriptions', [
'member_id' => $newMemberId,
'financial_year' => $financialYear,
'person_type' => 'temp',
'person_id' => (int) $t['id'],
'person_name' => $t['full_name_ar'],
'base_amount' => $tempSub,
'development_fee' => '0.00',
'discount_amount' => '0.00',
'total_amount' => $tempSub,
'paid_amount' => '0.00',
'fine_amount' => '0.00',
'status' => 'pending',
'created_at' => $now,
'updated_at' => $now,
'created_by' => $createdBy,
]);
}
$totalSubs = 1 + count($spouses) + count($children) + count($temps);
DeathAuditService::log($caseId, 'subscriptions_created', 'تم إنشاء ' . $totalSubs . ' اشتراك سنوي للسنة المالية ' . $financialYear, 'members', $newMemberId, [
'financial_year' => $financialYear,
'member' => 1,
'spouses' => count($spouses),
'children' => count($children),
'temps' => count($temps),
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Death\Services;
use App\Core\App;
final class DeathAuditService
{
public static function log(
int $deathCaseId,
string $actionType,
string $descriptionAr,
?string $entityType = null,
?int $entityId = null,
?array $details = null
): void {
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$db->insert('death_case_audit_log', [
'death_case_id' => $deathCaseId,
'action_type' => $actionType,
'action_description_ar'=> $descriptionAr,
'affected_entity_type' => $entityType,
'affected_entity_id' => $entityId,
'performed_by' => $employee ? (int) $employee->id : null,
'performed_at' => date('Y-m-d H:i:s'),
'details_json' => $details ? json_encode($details, JSON_UNESCAPED_UNICODE) : null,
]);
}
public static function getForCase(int $deathCaseId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT dal.*, e.full_name_ar as performer_name
FROM death_case_audit_log dal
LEFT JOIN employees e ON e.id = dal.performed_by
WHERE dal.death_case_id = ?
ORDER BY dal.performed_at ASC, dal.id ASC",
[$deathCaseId]
);
}
public static function getForMember(int $memberId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT dal.*, e.full_name_ar as performer_name, dc.deceased_type, dc.death_date,
m.full_name_ar as deceased_member_name
FROM death_case_audit_log dal
JOIN death_cases dc ON dc.id = dal.death_case_id
JOIN members m ON m.id = dc.member_id
LEFT JOIN employees e ON e.id = dal.performed_by
WHERE dc.member_id = ? OR dc.transferred_to_member_id = ?
ORDER BY dal.performed_at ASC, dal.id ASC",
[$memberId, $memberId]
);
}
public static function getCasesForMember(int $memberId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT dc.*, m.full_name_ar as member_name
FROM death_cases dc
JOIN members m ON m.id = dc.member_id
WHERE dc.member_id = ? OR dc.transferred_to_member_id = ?
ORDER BY dc.created_at DESC",
[$memberId, $memberId]
);
}
}
......@@ -272,6 +272,17 @@ class MemberController extends Controller
}
}
// Check if this member was created via death transfer
$deathTransfer = null;
if (!empty($member->transferred_from_death_id)) {
$deathTransfer = $db->selectOne(
"SELECT dc.id, dc.member_id as deceased_member_id, m.full_name_ar as deceased_member_name
FROM death_cases dc LEFT JOIN members m ON m.id = dc.member_id
WHERE dc.id = ?",
[(int) $member->transferred_from_death_id]
);
}
$instRateData = RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$instMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS');
......@@ -293,6 +304,7 @@ class MemberController extends Controller
'cancelledRequests' => $cancelledRequests,
'isSuperAdmin' => self::isSuperAdmin(),
'divorceTransfer' => $divorceTransfer,
'deathTransfer' => $deathTransfer,
'waiverTransfer' => $waiverTransfer,
'waiverArchiveSnapshot' => $waiverArchiveSnapshot,
'installmentPlan' => $member->status === 'pending_cheques'
......@@ -861,4 +873,22 @@ class MemberController extends Controller
'tempLogs' => $tempLogs,
]);
}
public function insuranceRecord(Request $request, string $id): Response
{
$this->authorize('member.view');
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT id, full_name_ar, membership_number, status, is_archived FROM members WHERE id = ?", [(int) $id]);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
$cases = \App\Modules\Death\Services\DeathAuditService::getCasesForMember((int) $id);
$auditLogs = \App\Modules\Death\Services\DeathAuditService::getForMember((int) $id);
return $this->view('Members.Views.insurance-record', [
'member' => (object) $member,
'cases' => $cases,
'auditLogs' => $auditLogs,
]);
}
}
\ No newline at end of file
......@@ -24,6 +24,7 @@ return [
['GET', '/members/{id}/fill-form', 'Members\Controllers\MemberController@fillForm', ['auth'], 'member.fill_form'],
['POST', '/members/{id}/fill-form', 'Members\Controllers\MemberController@saveFillForm', ['auth', 'csrf'], 'member.fill_form'],
['GET', '/members/{id}/changelog', 'Members\Controllers\MemberController@changelog', ['auth'], 'member.view'],
['GET', '/members/{id}/insurance-record', 'Members\Controllers\MemberController@insuranceRecord', ['auth'], 'member.view'],
['POST', '/api/members/parse-nid', 'Members\Controllers\MemberApiController@parseNid', ['auth'], 'member.create'],
['GET', '/api/members/search', 'Members\Controllers\MemberApiController@searchGet', ['auth'], 'member.view'],
['POST', '/api/members/search', 'Members\Controllers\MemberApiController@search', ['auth'], 'member.view'],
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>السجل التأميني — <?= e($member->full_name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/members/<?= (int) $member->id ?>" class="btn btn-outline">&#8592; العودة للعضو</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="max-width:900px;">
<!-- Header -->
<div class="card" style="padding:20px;margin-bottom:20px;background:linear-gradient(135deg, #EFF6FF, #DBEAFE);border:1px solid #93C5FD;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<h2 style="margin:0 0 5px;color:#1E40AF;">السجل التأميني</h2>
<p style="margin:0;color:#6B7280;font-size:14px;"><?= e($member->full_name_ar) ?> — رقم العضوية: <?= e($member->membership_number ?? 'مؤرشف') ?></p>
</div>
<div style="font-size:48px;">&#x1f4cb;</div>
</div>
</div>
<!-- Death Cases Summary -->
<?php if (!empty($cases)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:16px;color:#1F2937;">حالات الوفاة المرتبطة</h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>#</th>
<th>العضو المتوفى</th>
<th>نوع الوفاة</th>
<th>تاريخ الوفاة</th>
<th>الحالة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($cases as $c):
$typeLabel = match($c['deceased_type']) {
'primary_member' => 'عضو أساسي',
'spouse' => 'زوج/ة',
'child' => 'ابن/ابنة',
default => $c['deceased_type'],
};
$statusLabel = match($c['status']) {
'recorded' => 'مسجل',
'board_review' => 'مراجعة مجلس الإدارة',
'board_approved' => 'معتمد',
'rejected' => 'مرفوض',
'fee_paid' => 'تم السداد',
'pending_form_fill' => 'انتظار ملء الاستمارة',
'completed' => 'مكتمل',
default => $c['status'],
};
$statusColor = match($c['status']) {
'completed' => '#059669',
'rejected' => '#DC2626',
'board_approved', 'fee_paid' => '#3B82F6',
default => '#F59E0B',
};
?>
<tr>
<td style="font-weight:600;">#<?= (int) $c['id'] ?></td>
<td><?= e($c['member_name']) ?></td>
<td><?= $typeLabel ?></td>
<td><?= e($c['death_date'] ?? '') ?></td>
<td><span style="color:<?= $statusColor ?>;font-weight:600;"><?= $statusLabel ?></span></td>
<td><a href="/death/<?= (int) $c['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Audit Timeline -->
<?php if (!empty($auditLogs)): ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:16px;color:#1F2937;">سجل العمليات التفصيلي</h3>
</div>
<div style="padding:20px;">
<div style="position:relative;padding-right:30px;">
<div style="position:absolute;right:12px;top:0;bottom:0;width:2px;background:#E5E7EB;"></div>
<?php foreach ($auditLogs as $log):
$iconMap = [
'snapshot_taken' => '&#x1f4f7;',
'member_archived' => '&#x1f4e6;',
'new_member_created' => '&#x2728;',
'children_transferred' => '&#x1f46a;',
'temps_transferred' => '&#x1f465;',
'secondary_member_created' => '&#x1f4cc;',
'installments_transferred' => '&#x1f4b3;',
'documents_linked' => '&#x1f4c4;',
'subscriptions_created' => '&#x1f4c5;',
'transfer_completed' => '&#x2705;',
'case_completed' => '&#x2705;',
'spouse_deceased' => '&#x1f54a;',
'child_deceased' => '&#x1f54a;',
];
$icon = $iconMap[$log['action_type']] ?? '&#x25cf;';
?>
<div style="position:relative;margin-bottom:20px;padding-right:25px;">
<div style="position:absolute;right:-6px;top:4px;width:24px;height:24px;background:#fff;border:2px solid #3B82F6;border-radius:50%;text-align:center;line-height:20px;font-size:12px;"><?= $icon ?></div>
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:12px 15px;">
<div style="font-weight:600;color:#1F2937;margin-bottom:4px;"><?= e($log['action_description_ar']) ?></div>
<div style="font-size:12px;color:#6B7280;display:flex;gap:15px;flex-wrap:wrap;">
<span><?= e($log['performed_at']) ?></span>
<?php if ($log['performer_name']): ?>
<span>بواسطة: <?= e($log['performer_name']) ?></span>
<?php endif; ?>
<?php if ($log['affected_entity_type']): ?>
<span style="color:#9CA3AF;"><?= e($log['affected_entity_type']) ?> #<?= (int) $log['affected_entity_id'] ?></span>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php else: ?>
<div class="card" style="padding:40px;text-align:center;">
<div style="font-size:48px;margin-bottom:10px;">&#x1f4cb;</div>
<p style="color:#6B7280;">لا يوجد سجل تأميني لهذا العضو</p>
</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
......@@ -113,6 +113,9 @@ $canEdit = can('member.edit') && (!$isLocked || ($isSuperAdmin ?? false));
<?php endif; ?>
<?php if (!empty($member->transferred_from_death_id)): ?>
<span style="background:#DBEAFE;padding:2px 8px;border-radius:10px;font-size:11px;">أُنشئت من <a href="/death/<?= (int) $member->transferred_from_death_id ?>" style="color:#2563EB;font-weight:700;">حالة وفاة #<?= (int) $member->transferred_from_death_id ?></a><?php if (!empty($member->deceased_member_name)): ?> (<?= e($member->deceased_member_name) ?>)<?php endif; ?></span>
<?php if (!empty($deathTransfer['deceased_member_id'])): ?>
<a href="/members/archive/<?= (int) $deathTransfer['deceased_member_id'] ?>" style="background:#FEF3C7;padding:2px 8px;border-radius:10px;font-size:11px;color:#92400E;font-weight:600;text-decoration:none;">&#x1f4e6; عرض العضوية الأصلية (المؤرشفة)</a>
<?php endif; ?>
<?php endif; ?>
<?php if (!empty($waiverTransfer)): ?>
<span style="background:#F3E8FF;padding:2px 8px;border-radius:10px;font-size:11px;">تنازل من: <a href="/waivers/<?= (int) $waiverTransfer['id'] ?>" style="color:#7C3AED;font-weight:700;"><?= e($waiverTransfer['source_member_name'] ?? $member->waived_from_member_name ?? '—') ?></a></span>
......@@ -830,6 +833,7 @@ $hasIncomplete = !empty(array_filter($missingSteps, fn($s) => !$s['done']));
<div style="margin-bottom:0;">
<div style="font-size:12px;color:#6B7280;font-weight:600;margin-bottom:8px;text-transform:uppercase;">🗄️ الأرشيف والمراجعة</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<a href="/members/<?= (int) $member->id ?>/insurance-record" class="btn btn-sm btn-outline" style="color:#1E40AF;border-color:#1E40AF;">&#x1f4cb; السجل التأميني</a>
<a href="/audit/entity/members/<?= (int) $member->id ?>" class="btn btn-sm btn-outline">📜 سجل المراجعة</a>
<a href="/archive/entity/members/<?= (int) $member->id ?>" class="btn btn-sm btn-outline">🗄️ اللقطات الأرشيفية</a>
<?php if ($member->membership_number): ?>
......
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS death_case_audit_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
death_case_id BIGINT UNSIGNED NOT NULL,
action_type VARCHAR(50) NOT NULL,
action_description_ar TEXT NOT NULL,
affected_entity_type VARCHAR(50) NULL,
affected_entity_id BIGINT UNSIGNED NULL,
performed_by BIGINT UNSIGNED NULL,
performed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
details_json JSON NULL,
INDEX idx_death_case (death_case_id),
INDEX idx_entity (affected_entity_type, affected_entity_id),
INDEX idx_performed_at (performed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
",
'down' => "DROP TABLE IF EXISTS death_case_audit_log;"
];
# Death Module — Architecture Map
> **Last updated:** 2026-07-21
> **Last updated:** 2026-07-22
> **Status:** Living document — incrementally updated as new information is discovered
---
......@@ -39,6 +39,8 @@ app/Modules/Death/
│ └── DeathController.php # Main controller (index, CRUD, form fill, payment, completion)
├── Models/
│ └── DeathCase.php # Main model (no soft delete)
├── Services/
│ └── DeathAuditService.php # Arabic audit log for death case operations
└── Views/
├── index.php # Paginated death case list
├── create.php # New death case form (select deceased type, spouse assignment)
......@@ -82,6 +84,22 @@ app/Modules/Death/
- completed: 3
- fee_paid: 3
### 3.2 `death_case_audit_log` Table
| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| death_case_id | bigint unsigned | NO | MUL | | FK to death_cases |
| action_type | varchar(50) | NO | | | snapshot_taken, member_archived, new_member_created, etc. |
| action_description_ar | text | NO | | | Full Arabic description of the action |
| affected_entity_type | varchar(50) | YES | MUL | | Entity type affected (members, spouses, etc.) |
| affected_entity_id | bigint unsigned | YES | | | Entity ID affected |
| performed_by | bigint unsigned | YES | | | FK to employees |
| performed_at | timestamp | NO | | CURRENT_TIMESTAMP | |
| details_json | json | YES | | | Additional structured details |
**Action types:** snapshot_taken, member_archived, new_member_created, children_transferred, temps_transferred, secondary_member_created, installments_transferred, documents_linked, subscriptions_created, transfer_completed, case_completed, spouse_deceased, child_deceased
---
## 4. Death Case Status Lifecycle
......@@ -118,39 +136,44 @@ recorded → completed (for spouse/child death — no fee, simpler flow)
- Fee = (form_fee + annual_sub) × (1 + secondary_count)
- Create DeathCase (status='recorded')
- Dispatch: death.recorded
- Send payment request to Cashier queue
3. POST /death/{id}/pay (or via Cashier queue)
3. POST /death/{id}/board-approve
- Board reviews case, sets trustee fee (percentage of current plan price OR fixed)
- Calculates total fee (base fees + trustee fee)
- Creates payment request in Cashier queue
- Status → 'board_approved'
4. Payment via Cashier queue
- Process payment (type='death_fee')
- Status → 'pending_form_fill' (NOT 'fee_paid' for primary)
- Status → 'pending_form_fill' (NOT 'fee_paid' for primary)
- Dispatch: death.fee_paid
4. GET /death/{id}/fill-form
5. GET /death/{id}/fill-form
- Display full membership form pre-populated with spouse data
- Loads qualifications, governorates, countries for dropdowns
5. POST /death/{id}/fill-form
- Validate required fields (full_name_ar, national_id, DOB, gender, qualification_id)
- Auto-parse NID for DOB/gender
- Store form_data in `notes` JSON field
6. POST /death/{id}/fill-form
- Validate required fields, store form_data in notes JSON
- Set primary_spouse_form_filled = 1
6. POST /death/{id}/complete
- Check: primary_spouse_form_filled must be 1
7. POST /death/{id}/complete
- Preconditions: board_approved + payment confirmed + form filled
- Transaction:
a. Archive snapshot of source member
a. Archive snapshot of entire member + relations (ArchiveService::takeSnapshot)
b. Archive deceased member (status='deceased', membership_number=NULL, is_archived=1)
c. Create new member with SAME membership_number (inherits from deceased)
d. Apply form_data to new member record
e. Archive primary spouse record (status='transferred')
f. Assign children per children_assignment_json (or all to primary by default)
g. For EACH secondary spouse:
- Create new member (gets NEW number via MemberNumberGenerator::assign)
- Archive spouse record
- Move assigned children to that new member
h. Transfer temporary_members to new primary
i. Record number chain via ArchiveService
j. Status → 'completed'
— All original data preserved, only status fields change
c. Create new member with SAME membership_number
— membership_value = CURRENT plan price (not old value)
— join_date = today
d. Archive primary spouse record (status='transferred')
e. Assign children per children_assignment_json (or all to primary)
f. For EACH secondary spouse: create new member (NEW number), move assigned children
g. Transfer temporary_members to appropriate new member
h. **Transfer active installment plans** to new member (schedule + cheques follow via FK)
i. **Duplicate documents** to new member (originals stay with archived)
j. **Create annual subscriptions** for new membership (current financial year)
k. Record number chain via ArchiveService
l. **Full Arabic audit log** via DeathAuditService (every step recorded)
m. Status → 'completed'
- Dispatch: death.completed
```
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment