Commit fdf6b018 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(transfers): add form-fill step to separation workflow (like death cycle)

Separation now follows: create → board approval → cashier payment → fill form → complete.
After cashier confirms payment, status moves to pending_form_fill. A new form page
collects personal data (name, NID, DOB, contact, address, qualification) before executing
the transfer. TransferProcessor uses form_data from notes to create the new member record.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 44095901
......@@ -164,13 +164,12 @@ EventBus::listen('payment_request.completed', function (array $data) {
], '`id` = ?', [$entityId]);
EventBus::dispatch($cfg['event'], [$cfg['key'] => $entityId, 'payment_id' => $paymentId]);
// Auto-complete separation transfers after payment (no manual button needed)
// After separation fee paid: move to pending_form_fill (form must be filled before completion)
if ($paymentType === 'separation_fee') {
try {
\App\Modules\Transfers\Services\TransferProcessor::execute($entityId);
} catch (\Throwable $autoErr) {
\App\Core\Logger::error("Auto-complete transfer #{$entityId} failed: " . $autoErr->getMessage());
}
$db->update('transfer_requests', [
'status' => 'pending_form_fill',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$entityId]);
}
}
}
......
......@@ -347,57 +347,9 @@ class TransferController extends Controller
'type' => $transferType,
]);
// Send payment to cashier queue
$amount = $totalWithSurcharge;
if (bccomp((string) $amount, '0', 2) > 0) {
// Build payment request notes with child name + fee breakdown for cashier display
$paymentNotes = null;
if ($transferType === 'child_separation' && $child) {
$childName = $child['full_name_ar'] ?? '';
$feeBreakdown = [];
if (bccomp($feeCalc['separation_fee'] ?? '0', '0', 2) > 0) {
$feeBreakdown[] = 'رسوم الفصل: ' . money($feeCalc['separation_fee']);
}
if (bccomp($feeCalc['form_fee'] ?? '0', '0', 2) > 0) {
$feeBreakdown[] = 'رسوم الاستمارة: ' . money($feeCalc['form_fee']);
}
if (bccomp($feeCalc['annual_subscription_fee'] ?? '0', '0', 2) > 0) {
$feeBreakdown[] = 'الاشتراك السنوي: ' . money($feeCalc['annual_subscription_fee']);
}
if (bccomp($companionSurcharge, '0', 2) > 0) {
$feeBreakdown[] = 'فرق مرافقين: ' . money($companionSurcharge);
}
$feeBreakdown[] = '💵 الإجمالي: ' . money($amount);
$paymentNotes = json_encode([
'child_name' => $childName,
'fee_breakdown' => $feeBreakdown,
], JSON_UNESCAPED_UNICODE);
}
$result = PaymentRequestService::createRequest([
'member_id' => (int) $memberId,
'amount' => $amount,
'payment_type' => 'separation_fee',
'related_entity_type' => 'transfer_requests',
'related_entity_id' => (int) $transferReq->id,
'description_ar' => $transferType === 'child_separation' && $child
? 'رسوم فصل — ' . ($child['full_name_ar'] ?? '') . ' — طلب #' . $transferReq->id
: 'رسوم تحويل/فصل — طلب #' . $transferReq->id,
'notes' => $paymentNotes,
]);
if ($result['success']) {
return $this->redirect("/transfers/{$transferReq->id}")->withSuccess(
'تم تقديم طلب التحويل/الفصل وإرسال طلب الدفع للخزينة — الإجمالي: ' . money($amount) . ' — رقم الطلب: ' . $result['request_number']
);
}
return $this->redirect("/transfers/{$transferReq->id}")->withError(
'تم تقديم الطلب لكن فشل إنشاء طلب الدفع: ' . ($result['error'] ?? 'خطأ غير معروف')
);
}
return $this->redirect("/transfers/{$transferReq->id}")->withSuccess('تم تقديم طلب التحويل/الفصل — الإجمالي: ' . money($totalWithSurcharge));
return $this->redirect("/transfers/{$transferReq->id}")->withSuccess(
'تم تقديم طلب الفصل — الإجمالي: ' . money($totalWithSurcharge) . ' — في انتظار اعتماد مجلس الإدارة'
);
}
public function show(Request $request, string $id): Response
......@@ -479,7 +431,41 @@ class TransferController extends Controller
EventBus::dispatch('transfer.approved', ['transfer_id' => (int) $id]);
return $this->redirect("/transfers/{$id}")->withSuccess('تمت الموافقة على طلب التحويل');
// Send payment to cashier queue after approval
$amount = $transfer['total_fee'] ?? '0.00';
if (bccomp((string) $amount, '0', 2) > 0) {
$child = $transfer['child_id'] ? $db->selectOne("SELECT full_name_ar FROM children WHERE id = ?", [(int) $transfer['child_id']]) : null;
$childName = $child['full_name_ar'] ?? '';
$feeBreakdown = [];
if (bccomp($transfer['separation_fee'] ?? '0', '0', 2) > 0) {
$feeBreakdown[] = 'رسوم الفصل: ' . money($transfer['separation_fee']);
}
if (bccomp($transfer['form_fee'] ?? '0', '0', 2) > 0) {
$feeBreakdown[] = 'رسوم الاستمارة: ' . money($transfer['form_fee']);
}
if (bccomp($transfer['annual_subscription_fee'] ?? '0', '0', 2) > 0) {
$feeBreakdown[] = 'الاشتراك السنوي: ' . money($transfer['annual_subscription_fee']);
}
if (bccomp($transfer['companion_surcharge'] ?? '0', '0', 2) > 0) {
$feeBreakdown[] = 'فرق مرافقين: ' . money($transfer['companion_surcharge']);
}
$feeBreakdown[] = '💵 الإجمالي: ' . money($amount);
PaymentRequestService::createRequest([
'member_id' => (int) $transfer['source_member_id'],
'amount' => $amount,
'payment_type' => 'separation_fee',
'related_entity_type' => 'transfer_requests',
'related_entity_id' => (int) $id,
'description_ar' => $childName
? 'رسوم فصل — ' . $childName . ' — طلب #' . $id
: 'رسوم تحويل/فصل — طلب #' . $id,
'notes' => json_encode(['child_name' => $childName, 'fee_breakdown' => $feeBreakdown], JSON_UNESCAPED_UNICODE),
]);
}
return $this->redirect("/transfers/{$id}")->withSuccess('تمت الموافقة وإرسال طلب الدفع للخزينة — المبلغ: ' . money($amount));
}
public function reject(Request $request, string $id): Response
......@@ -502,6 +488,75 @@ class TransferController extends Controller
return $this->redirect('/transfers')->withSuccess('تم رفض طلب التحويل');
}
public function fillForm(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$transfer = $db->selectOne("SELECT * FROM transfer_requests WHERE id = ?", [(int) $id]);
if (!$transfer || $transfer['status'] !== 'pending_form_fill') {
return $this->redirect("/transfers/{$id}")->withError('لا يمكن ملء الاستمارة في هذه المرحلة');
}
$child = $transfer['child_id'] ? $db->selectOne("SELECT * FROM children WHERE id = ?", [(int) $transfer['child_id']]) : null;
$sourceMember = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $transfer['source_member_id']]);
return $this->view('Transfers.Views.form_fill', [
'transfer' => $transfer,
'child' => $child,
'sourceMember' => $sourceMember,
'qualifications' => $db->select("SELECT id, name_ar FROM qualifications WHERE is_active = 1 ORDER BY sort_order"),
]);
}
public function saveFillForm(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$transfer = $db->selectOne("SELECT * FROM transfer_requests WHERE id = ?", [(int) $id]);
if (!$transfer || $transfer['status'] !== 'pending_form_fill') {
return $this->redirect("/transfers/{$id}")->withError('لا يمكن ملء الاستمارة في هذه المرحلة');
}
$data = $request->all();
unset($data['_csrf_token']);
$nid = trim((string) ($data['national_id'] ?? ''));
if ($nid !== '' && strlen($nid) === 14) {
$parsed = NationalIdParser::parse($nid);
if ($parsed['is_valid']) {
$data['date_of_birth'] = $parsed['dob'];
$data['gender'] = $parsed['gender'];
}
}
$requiredFields = ['full_name_ar', 'national_id', 'phone_mobile', 'qualification_id'];
foreach ($requiredFields as $field) {
if (empty($data[$field])) {
$labels = ['full_name_ar' => 'الاسم بالعربي', 'national_id' => 'الرقم القومي', 'phone_mobile' => 'رقم المحمول', 'qualification_id' => 'المؤهل'];
return $this->redirect("/transfers/{$id}/fill-form")->withError('الحقل مطلوب: ' . ($labels[$field] ?? $field));
}
}
// Store form data in notes JSON
$existingNotes = !empty($transfer['notes']) ? json_decode($transfer['notes'], true) : [];
if (!is_array($existingNotes)) $existingNotes = [];
$existingNotes['form_data'] = $data;
$db->update('transfer_requests', [
'notes' => json_encode($existingNotes, JSON_UNESCAPED_UNICODE),
'status' => 'form_filled',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
// Auto-complete after form fill
$result = TransferProcessor::execute((int) $id);
if (!$result['success']) {
return $this->redirect("/transfers/{$id}")->withError($result['error']);
}
return $this->redirect("/transfers/{$id}")->withSuccess(
'تم ملء الاستمارة وإتمام الفصل — رقم العضوية الجديد: ' . $result['new_number']
);
}
public function complete(Request $request, string $id): Response
{
$result = TransferProcessor::execute((int) $id);
......
......@@ -10,6 +10,8 @@ return [
['POST', '/transfers/{id}/approve', 'Transfers\Controllers\TransferController@approve', ['auth', 'csrf'], 'transfer.approve'],
['POST', '/transfers/{id}/reject', 'Transfers\Controllers\TransferController@reject', ['auth', 'csrf'], 'transfer.approve'],
['POST', '/transfers/{id}/complete', 'Transfers\Controllers\TransferController@complete',['auth', 'csrf'], 'transfer.approve'],
['GET', '/transfers/{id}/fill-form', 'Transfers\Controllers\TransferController@fillForm',['auth'], 'transfer.approve'],
['POST', '/transfers/{id}/fill-form', 'Transfers\Controllers\TransferController@saveFillForm',['auth', 'csrf'], 'transfer.approve'],
['GET', '/transfers/preview/{memberId}', 'Transfers\Controllers\TransferController@preview', ['auth'], 'transfer.initiate'],
['POST', '/api/transfers/calculate-fee', 'Transfers\Controllers\TransferController@calculateFee', ['auth'], 'transfer.initiate'],
];
\ No newline at end of file
......@@ -33,7 +33,7 @@ final class TransferProcessor
return ['success' => false, 'error' => 'طلب التحويل غير موجود'];
}
if ($request['status'] !== 'approved' && $request['status'] !== 'fee_paid') {
if (!in_array($request['status'], ['approved', 'fee_paid', 'pending_form_fill', 'form_filled'], true)) {
return ['success' => false, 'error' => 'طلب التحويل غير مُعتمد أو لم يتم السداد'];
}
......@@ -76,11 +76,15 @@ final class TransferProcessor
}
}
// For full_transfer type, use recipient data stored in notes
// Use recipient/form data stored in notes
$recipientData = [];
if ($request['transfer_type'] === 'full_transfer' && !empty($request['notes'])) {
$formData = [];
if (!empty($request['notes'])) {
$notesDecoded = json_decode($request['notes'], true);
$recipientData = $notesDecoded['recipient_data'] ?? [];
if ($request['transfer_type'] === 'full_transfer') {
$recipientData = $notesDecoded['recipient_data'] ?? [];
}
$formData = $notesDecoded['form_data'] ?? [];
}
// Find the separation_fee payment that was made for this transfer
......@@ -153,22 +157,22 @@ final class TransferProcessor
: FormNumberGenerator::next();
$newMemberData = [
'full_name_ar' => $recipientData['full_name_ar'] ?? $subjectData['full_name_ar'] ?? $subjectName,
'full_name_en' => $recipientData['full_name_en'] ?? $subjectData['full_name_en'] ?? null,
'national_id' => $nid,
'full_name_ar' => $formData['full_name_ar'] ?? $recipientData['full_name_ar'] ?? $subjectData['full_name_ar'] ?? $subjectName,
'full_name_en' => $formData['full_name_en'] ?? $recipientData['full_name_en'] ?? $subjectData['full_name_en'] ?? null,
'national_id' => $formData['national_id'] ?? $nid,
'passport_number' => $subjectData['passport_number'] ?? null,
'id_type' => $subjectData['id_type'] ?? 'national_id',
'date_of_birth' => $dob ?: '1900-01-01',
'gender' => $gender ?: 'male',
'nationality' => $recipientData['nationality'] ?? $subjectData['nationality'] ?? 'مصري',
'date_of_birth' => $formData['date_of_birth'] ?? $dob ?: '1900-01-01',
'gender' => $formData['gender'] ?? $gender ?: 'male',
'nationality' => $formData['nationality'] ?? $recipientData['nationality'] ?? $subjectData['nationality'] ?? 'مصري',
'branch_id' => (int) $sourceMember['branch_id'],
'membership_type' => 'working',
'member_category' => 'working_member',
'status' => 'active',
'activated_at' => date('Y-m-d H:i:s'),
'activated_by_payment_id'=> $transferPaymentId,
'qualification_id' => $recipientData['qualification_id'] ?? $subjectData['qualification_id'] ?? $sourceMember['qualification_id'],
'phone_mobile' => $phoneMobile,
'qualification_id' => $formData['qualification_id'] ?? $recipientData['qualification_id'] ?? $subjectData['qualification_id'] ?? $sourceMember['qualification_id'],
'phone_mobile' => $formData['phone_mobile'] ?? $phoneMobile,
'membership_value' => $request['new_membership_value'],
'membership_number' => $newNumber,
'form_number' => $formNumber ? (string) $formNumber : null,
......@@ -177,10 +181,12 @@ final class TransferProcessor
'created_by' => $employee ? (int) $employee->id : null,
];
// Add optional recipient fields for full_transfer
// Add optional fields from form_data or recipient_data
$optionalFields = ['marital_status', 'religion', 'phone_home', 'email', 'emergency_name', 'emergency_phone', 'residence_address', 'area', 'governorate', 'occupation', 'job_title'];
foreach ($optionalFields as $field) {
if (!empty($recipientData[$field])) {
if (!empty($formData[$field])) {
$newMemberData[$field] = $formData[$field];
} elseif (!empty($recipientData[$field])) {
$newMemberData[$field] = $recipientData[$field];
}
}
......
This diff is collapsed.
......@@ -21,7 +21,7 @@
<td style="font-size:13px;"><?= match($r['transfer_type'] ?? '') { 'child_separation' => 'فصل ابن', 'child_mandatory_25' => 'تحويل وجوبي (25)', 'sports_conversion' => 'تحويل رياضي', 'cross_branch' => 'تحويل بين فروع', default => $r['transfer_type'] ?? '' } ?></td>
<td style="font-weight:600;"><?= money($r['separation_fee'] ?? '0') ?></td>
<td style="font-weight:600;color:#0D7377;"><?= e($r['new_membership_number'] ?? '—') ?></td>
<td><span style="color:<?= match($r['status'] ?? '') { 'completed' => '#059669', 'fee_paid' => '#2563EB', 'approved' => '#0284C7', 'rejected' => '#DC2626', default => '#D97706' } ?>;font-weight:600;"><?= match($r['status'] ?? '') { 'requested' => 'في انتظار الدفع', 'approved' => 'معتمد', 'fee_paid' => 'تم الدفع', 'completed' => 'مكتمل', 'rejected' => 'مرفوض', default => $r['status'] ?? '' } ?></span></td>
<td><span style="color:<?= match($r['status'] ?? '') { 'completed' => '#059669', 'form_filled' => '#059669', 'fee_paid' => '#2563EB', 'pending_form_fill' => '#7C3AED', 'approved' => '#0284C7', 'rejected' => '#DC2626', default => '#D97706' } ?>;font-weight:600;"><?= match($r['status'] ?? '') { 'requested' => 'في انتظار الاعتماد', 'approved' => 'معتمد — في انتظار الدفع', 'fee_paid' => 'تم الدفع', 'pending_form_fill' => 'في انتظار الاستمارة', 'form_filled' => 'تم ملء الاستمارة', 'completed' => 'مكتمل', 'rejected' => 'مرفوض', default => $r['status'] ?? '' } ?></span></td>
<td style="font-size:12px;"><?= e(substr($r['created_at'] ?? '', 0, 10)) ?></td>
<td><a href="/transfers/<?= (int) $r['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
......
......@@ -24,7 +24,7 @@ if ($transfer['transfer_type'] === 'full_transfer' && !empty($transfer['notes'])
<tr><td style="padding:8px 0;color:#6B7280;">الزوج/ة</td><td style="padding:8px 0;font-weight:600;"><?= e($transfer['spouse_name']) ?></td></tr>
<?php endif; ?>
<tr><td style="padding:8px 0;color:#6B7280;">النوع</td><td style="padding:8px 0;font-weight:600;"><?= e($typeLabels[$transfer['transfer_type']] ?? $transfer['transfer_type']) ?></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">الحالة</td><td style="padding:8px 0;font-weight:700;color:<?= match($transfer['status']) { 'completed' => '#059669', 'fee_paid' => '#2563EB', 'approved' => '#0284C7', 'rejected' => '#DC2626', default => '#D97706' } ?>;"><?= match($transfer['status']) { 'completed' => 'مكتمل', 'fee_paid' => 'تم الدفع', 'approved' => 'معتمد — في انتظار الدفع', 'rejected' => 'مرفوض', 'requested' => 'في انتظار الدفع', default => $transfer['status'] } ?></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">الحالة</td><td style="padding:8px 0;font-weight:700;color:<?= match($transfer['status']) { 'completed' => '#059669', 'fee_paid' => '#2563EB', 'form_filled' => '#059669', 'pending_form_fill' => '#7C3AED', 'approved' => '#0284C7', 'rejected' => '#DC2626', default => '#D97706' } ?>;"><?= match($transfer['status']) { 'completed' => 'مكتمل', 'fee_paid' => 'تم الدفع — في انتظار الاستمارة', 'form_filled' => 'تم ملء الاستمارة', 'pending_form_fill' => 'في انتظار ملء الاستمارة', 'approved' => 'معتمد — في انتظار الدفع', 'rejected' => 'مرفوض', 'requested' => 'في انتظار اعتماد مجلس الإدارة', default => $transfer['status'] } ?></td></tr>
<?php if ($transfer['new_membership_number']): ?><tr><td style="padding:8px 0;color:#6B7280;">رقم العضوية (نفس الرقم)</td><td style="padding:8px 0;font-weight:700;color:#0D7377;font-size:20px;"><?= e($transfer['new_membership_number']) ?></td></tr><?php endif; ?>
<?php if (!empty($transfer['form_number'])): ?><tr><td style="padding:8px 0;color:#6B7280;">رقم الاستمارة</td><td style="padding:8px 0;font-weight:700;color:#1A1A2E;"><?= e($transfer['form_number']) ?></td></tr><?php endif; ?>
<?php if ($transfer['source_companions_count'] !== null): ?>
......@@ -135,6 +135,14 @@ if ($transfer['transfer_type'] === 'full_transfer' && !empty($transfer['notes'])
<?php endif; ?>
<?php endif; ?>
<?php if ($transfer['status'] === 'pending_form_fill'): ?>
<div class="card" style="padding:20px;margin-bottom:20px;background:#F5F3FF;border:2px solid #7C3AED;">
<h4 style="margin:0 0 10px;color:#7C3AED;">📝 ملء استمارة العضوية الجديدة</h4>
<p style="margin:0 0 15px;color:#6B7280;font-size:14px;">تم دفع الرسوم — يجب ملء استمارة العضوية الجديدة للعضو المنفصل لإتمام عملية الفصل.</p>
<a href="/transfers/<?= (int) $transfer['id'] ?>/fill-form" class="btn btn-primary" style="padding:12px 30px;font-size:16px;background:#7C3AED;border-color:#7C3AED;">📝 ملء الاستمارة</a>
</div>
<?php endif; ?>
<?php if (in_array($transfer['status'], ['approved', 'fee_paid']) && ($transfer['transfer_type'] ?? '') !== 'child_separation'): ?>
<?php if (can('transfer.approve')): ?>
<form method="POST" action="/transfers/<?= (int) $transfer['id'] ?>/complete" style="margin-top:15px;">
......
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