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
<?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 ...@@ -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'); $instRateData = RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$instMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS'); $instMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS');
...@@ -293,6 +304,7 @@ class MemberController extends Controller ...@@ -293,6 +304,7 @@ class MemberController extends Controller
'cancelledRequests' => $cancelledRequests, 'cancelledRequests' => $cancelledRequests,
'isSuperAdmin' => self::isSuperAdmin(), 'isSuperAdmin' => self::isSuperAdmin(),
'divorceTransfer' => $divorceTransfer, 'divorceTransfer' => $divorceTransfer,
'deathTransfer' => $deathTransfer,
'waiverTransfer' => $waiverTransfer, 'waiverTransfer' => $waiverTransfer,
'waiverArchiveSnapshot' => $waiverArchiveSnapshot, 'waiverArchiveSnapshot' => $waiverArchiveSnapshot,
'installmentPlan' => $member->status === 'pending_cheques' 'installmentPlan' => $member->status === 'pending_cheques'
...@@ -861,4 +873,22 @@ class MemberController extends Controller ...@@ -861,4 +873,22 @@ class MemberController extends Controller
'tempLogs' => $tempLogs, '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 [ ...@@ -24,6 +24,7 @@ return [
['GET', '/members/{id}/fill-form', 'Members\Controllers\MemberController@fillForm', ['auth'], 'member.fill_form'], ['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'], ['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}/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'], ['POST', '/api/members/parse-nid', 'Members\Controllers\MemberApiController@parseNid', ['auth'], 'member.create'],
['GET', '/api/members/search', 'Members\Controllers\MemberApiController@searchGet', ['auth'], 'member.view'], ['GET', '/api/members/search', 'Members\Controllers\MemberApiController@searchGet', ['auth'], 'member.view'],
['POST', '/api/members/search', 'Members\Controllers\MemberApiController@search', ['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)); ...@@ -113,6 +113,9 @@ $canEdit = can('member.edit') && (!$isLocked || ($isSuperAdmin ?? false));
<?php endif; ?> <?php endif; ?>
<?php if (!empty($member->transferred_from_death_id)): ?> <?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> <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 endif; ?>
<?php if (!empty($waiverTransfer)): ?> <?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> <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'])); ...@@ -830,6 +833,7 @@ $hasIncomplete = !empty(array_filter($missingSteps, fn($s) => !$s['done']));
<div style="margin-bottom:0;"> <div style="margin-bottom:0;">
<div style="font-size:12px;color:#6B7280;font-weight:600;margin-bottom:8px;text-transform:uppercase;">🗄️ الأرشيف والمراجعة</div> <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;"> <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="/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> <a href="/archive/entity/members/<?= (int) $member->id ?>" class="btn btn-sm btn-outline">🗄️ اللقطات الأرشيفية</a>
<?php if ($member->membership_number): ?> <?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 # Death Module — Architecture Map
> **Last updated:** 2026-07-21 > **Last updated:** 2026-07-22
> **Status:** Living document — incrementally updated as new information is discovered > **Status:** Living document — incrementally updated as new information is discovered
--- ---
...@@ -39,6 +39,8 @@ app/Modules/Death/ ...@@ -39,6 +39,8 @@ app/Modules/Death/
│ └── DeathController.php # Main controller (index, CRUD, form fill, payment, completion) │ └── DeathController.php # Main controller (index, CRUD, form fill, payment, completion)
├── Models/ ├── Models/
│ └── DeathCase.php # Main model (no soft delete) │ └── DeathCase.php # Main model (no soft delete)
├── Services/
│ └── DeathAuditService.php # Arabic audit log for death case operations
└── Views/ └── Views/
├── index.php # Paginated death case list ├── index.php # Paginated death case list
├── create.php # New death case form (select deceased type, spouse assignment) ├── create.php # New death case form (select deceased type, spouse assignment)
...@@ -82,6 +84,22 @@ app/Modules/Death/ ...@@ -82,6 +84,22 @@ app/Modules/Death/
- completed: 3 - completed: 3
- fee_paid: 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 ## 4. Death Case Status Lifecycle
...@@ -118,39 +136,44 @@ recorded → completed (for spouse/child death — no fee, simpler flow) ...@@ -118,39 +136,44 @@ recorded → completed (for spouse/child death — no fee, simpler flow)
- Fee = (form_fee + annual_sub) × (1 + secondary_count) - Fee = (form_fee + annual_sub) × (1 + secondary_count)
- Create DeathCase (status='recorded') - Create DeathCase (status='recorded')
- Dispatch: death.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') - 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 - 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 - Display full membership form pre-populated with spouse data
- Loads qualifications, governorates, countries for dropdowns
5. POST /death/{id}/fill-form 6. POST /death/{id}/fill-form
- Validate required fields (full_name_ar, national_id, DOB, gender, qualification_id) - Validate required fields, store form_data in notes JSON
- Auto-parse NID for DOB/gender
- Store form_data in `notes` JSON field
- Set primary_spouse_form_filled = 1 - Set primary_spouse_form_filled = 1
6. POST /death/{id}/complete 7. POST /death/{id}/complete
- Check: primary_spouse_form_filled must be 1 - Preconditions: board_approved + payment confirmed + form filled
- Transaction: - 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) b. Archive deceased member (status='deceased', membership_number=NULL, is_archived=1)
c. Create new member with SAME membership_number (inherits from deceased) — All original data preserved, only status fields change
d. Apply form_data to new member record c. Create new member with SAME membership_number
e. Archive primary spouse record (status='transferred') — membership_value = CURRENT plan price (not old value)
f. Assign children per children_assignment_json (or all to primary by default) — join_date = today
g. For EACH secondary spouse: d. Archive primary spouse record (status='transferred')
- Create new member (gets NEW number via MemberNumberGenerator::assign) e. Assign children per children_assignment_json (or all to primary)
- Archive spouse record f. For EACH secondary spouse: create new member (NEW number), move assigned children
- Move assigned children to that new member g. Transfer temporary_members to appropriate new member
h. Transfer temporary_members to new primary h. **Transfer active installment plans** to new member (schedule + cheques follow via FK)
i. Record number chain via ArchiveService i. **Duplicate documents** to new member (originals stay with archived)
j. Status → 'completed' 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 - 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