Commit da118b55 authored by Mahmoud Aglan's avatar Mahmoud Aglan

test

parent 6e7ed452
......@@ -246,52 +246,88 @@ if (!function_exists('number_to_arabic_words')) {
$whole = (int) floor($number);
$fraction = (int) round(($number - $whole) * 100);
$ones = ['', 'واحد', 'اثنان', 'ثلاثة', 'أربعة', 'خمسة', 'ستة', 'سبعة', 'ثمانية', 'تسعة'];
$tens = ['', 'عشرة', 'عشرون', 'ثلاثون', 'أربعون', 'خمسون', 'ستون', 'سبعون', 'ثمانون', 'تسعون'];
$hundreds = ['', 'مائة', 'مائتان', 'ثلاثمائة', 'أربعمائة', 'خمسمائة', 'ستمائة', 'سبعمائة', 'ثمانمائة', 'تسعمائة'];
$thousands = ['', 'ألف', 'ألفان', 'ثلاثة آلاف', 'أربعة آلاف', 'خمسة آلاف', 'ستة آلاف', 'سبعة آلاف', 'ثمانية آلاف', 'تسعة آلاف'];
if ($whole === 0) {
if ($whole === 0 && $fraction === 0) {
return 'صفر جنيه';
}
$result = '';
if ($whole >= 1000) {
$th = (int) floor($whole / 1000);
if ($th <= 9) {
$result .= $thousands[$th] . ' ';
} else {
$result .= number_format($th) . ' ألف ';
if ($whole > 0) {
$result = _arabic_number_convert($whole) . ' جنيه';
}
if ($fraction > 0) {
if ($result !== '') {
$result .= ' و';
}
$whole %= 1000;
$result .= _arabic_number_convert($fraction) . ' قرش';
}
if ($whole >= 100) {
$result .= $hundreds[(int) floor($whole / 100)] . ' ';
$whole %= 100;
return $result;
}
}
if (!function_exists('_arabic_number_convert')) {
function _arabic_number_convert(int $number): string
{
if ($number === 0) {
return 'صفر';
}
if ($whole >= 20) {
$o = $whole % 10;
$t = (int) floor($whole / 10);
if ($o > 0) {
$result .= $ones[$o] . ' و';
$ones = ['', 'واحد', 'اثنان', 'ثلاثة', 'أربعة', 'خمسة', 'ستة', 'سبعة', 'ثمانية', 'تسعة',
'عشرة', 'أحد عشر', 'اثنا عشر', 'ثلاثة عشر', 'أربعة عشر', 'خمسة عشر',
'ستة عشر', 'سبعة عشر', 'ثمانية عشر', 'تسعة عشر'];
$tens = ['', '', 'عشرون', 'ثلاثون', 'أربعون', 'خمسون', 'ستون', 'سبعون', 'ثمانون', 'تسعون'];
$hundreds = ['', 'مائة', 'مائتان', 'ثلاثمائة', 'أربعمائة', 'خمسمائة', 'ستمائة', 'سبعمائة', 'ثمانمائة', 'تسعمائة'];
$parts = [];
if ($number >= 1000000) {
$millions = (int) floor($number / 1000000);
if ($millions === 1) {
$parts[] = 'مليون';
} elseif ($millions === 2) {
$parts[] = 'مليونان';
} elseif ($millions <= 10) {
$parts[] = $ones[$millions] . ' ملايين';
} else {
$parts[] = _arabic_number_convert($millions) . ' مليون';
}
$number %= 1000000;
}
if ($number >= 1000) {
$th = (int) floor($number / 1000);
if ($th === 1) {
$parts[] = 'ألف';
} elseif ($th === 2) {
$parts[] = 'ألفان';
} elseif ($th <= 10) {
$parts[] = $ones[$th] . ' آلاف';
} else {
$parts[] = _arabic_number_convert($th) . ' ألف';
}
$result .= $tens[$t] . ' ';
} elseif ($whole >= 10) {
$specials = [10 => 'عشرة', 11 => 'أحد عشر', 12 => 'اثنا عشر', 13 => 'ثلاثة عشر',
14 => 'أربعة عشر', 15 => 'خمسة عشر', 16 => 'ستة عشر', 17 => 'سبعة عشر',
18 => 'ثمانية عشر', 19 => 'تسعة عشر'];
$result .= ($specials[$whole] ?? '') . ' ';
} elseif ($whole > 0) {
$result .= $ones[$whole] . ' ';
$number %= 1000;
}
$result = trim($result) . ' جنيه';
if ($number >= 100) {
$h = (int) floor($number / 100);
$parts[] = $hundreds[$h];
$number %= 100;
}
if ($fraction > 0) {
$result .= ' و ' . $fraction . ' قرش';
if ($number >= 20) {
$o = $number % 10;
$t = (int) floor($number / 10);
if ($o > 0) {
$parts[] = $ones[$o] . ' و' . $tens[$t];
} else {
$parts[] = $tens[$t];
}
} elseif ($number > 0) {
$parts[] = $ones[$number];
}
return $result;
return implode(' و', $parts);
}
}
......
......@@ -8,14 +8,17 @@ use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Cashier\Services\PaymentRequestService;
use App\Modules\Treasury\Services\TreasuryService;
use App\Modules\Treasury\Services\SessionService;
use App\Modules\Treasury\Services\CustodyService;
class CashierController extends Controller
{
public function queue(Request $request): Response
{
$this->authorize('cashier.view_queue');
$branch = App::getInstance()->currentBranch();
$branchId = $branch ? (int) $branch['id'] : null;
$employee = App::getInstance()->currentEmployee();
$treasury = TreasuryService::getMembershipTreasury();
$filters = [
'status' => trim((string) $request->get('status', '')),
......@@ -23,11 +26,26 @@ class CashierController extends Controller
'search' => trim((string) $request->get('search', '')),
];
$requests = PaymentRequestService::getPendingQueue($branchId, $filters);
$requests = [];
$currentSession = null;
$custodyBalance = '0.00';
if ($treasury) {
$requests = TreasuryService::getQueueForTreasury((int) $treasury['id'], $filters);
$currentSession = SessionService::getCurrentSession((int) $treasury['id'], (int) $employee->id);
$custodyBalance = CustodyService::getCurrentBalance((int) $treasury['id'], (int) $employee->id);
} else {
$branch = App::getInstance()->currentBranch();
$branchId = $branch ? (int) $branch['id'] : null;
$requests = PaymentRequestService::getPendingQueue($branchId, $filters);
}
return $this->view('Cashier.Views.queue', [
'requests' => $requests,
'filters' => $filters,
'requests' => $requests,
'filters' => $filters,
'currentSession' => $currentSession,
'custodyBalance' => $custodyBalance,
'treasury' => $treasury,
]);
}
......@@ -61,13 +79,25 @@ class CashierController extends Controller
public function complete(Request $request, string $id): Response
{
$this->authorize('cashier.process_payment');
$employee = App::getInstance()->currentEmployee();
$paymentMethod = trim((string) $request->post('payment_method', 'cash'));
if ($paymentMethod === '') {
return $this->redirect('/cashier/' . $id)->withError('طريقة الدفع مطلوبة');
}
$result = PaymentRequestService::processRequest((int) $id, $paymentMethod);
$treasury = TreasuryService::getMembershipTreasury();
if ($treasury) {
$currentSession = SessionService::getCurrentSession((int) $treasury['id'], (int) $employee->id);
if (!$currentSession) {
return $this->redirect('/cashier')->withError('يجب فتح وردية أولاً قبل التحصيل');
}
$result = TreasuryService::collectPayment((int) $currentSession['id'], (int) $id, $paymentMethod);
} else {
$result = PaymentRequestService::processRequest((int) $id, $paymentMethod);
}
if (!$result['success']) {
return $this->redirect('/cashier/' . $id)->withError($result['error']);
......@@ -81,6 +111,49 @@ class CashierController extends Controller
);
}
public function openSession(Request $request): Response
{
$this->authorize('cashier.process_payment');
$employee = App::getInstance()->currentEmployee();
$treasury = TreasuryService::getMembershipTreasury();
if (!$treasury) {
return $this->redirect('/cashier')->withError('لم يتم تعريف خزنة العضويات');
}
$result = SessionService::openSession((int) $treasury['id'], (int) $employee->id);
if (!$result['success']) {
return $this->redirect('/cashier')->withError($result['error']);
}
return $this->redirect('/cashier')->withSuccess('تم فتح الوردية بنجاح — رقم: ' . ($result['session_number'] ?? ''));
}
public function closeSession(Request $request): Response
{
$this->authorize('cashier.process_payment');
$employee = App::getInstance()->currentEmployee();
$treasury = TreasuryService::getMembershipTreasury();
if (!$treasury) {
return $this->redirect('/cashier')->withError('لم يتم تعريف خزنة العضويات');
}
$currentSession = SessionService::getCurrentSession((int) $treasury['id'], (int) $employee->id);
if (!$currentSession) {
return $this->redirect('/cashier')->withError('لا توجد وردية مفتوحة');
}
$result = SessionService::closeSession((int) $currentSession['id']);
if (!$result['success']) {
return $this->redirect('/cashier')->withError($result['error']);
}
return $this->redirect('/cashier')->withSuccess('تم إغلاق الوردية — إجمالي التحصيلات: ' . ($result['total_collected'] ?? '0.00'));
}
public function cancel(Request $request, string $id): Response
{
$this->authorize('cashier.cancel_request');
......
......@@ -9,6 +9,10 @@ return [
['POST', '/cashier/{id:\d+}/cancel', 'Cashier\Controllers\CashierController@cancel', ['auth', 'csrf'], 'cashier.cancel_request'],
['POST', '/cashier/{id:\d+}/requeue', 'Cashier\Controllers\CashierController@requeue', ['auth', 'csrf'], 'cashier.cancel_request'],
// Session management
['POST', '/cashier/session/open', 'Cashier\Controllers\CashierController@openSession', ['auth', 'csrf'], 'cashier.process_payment'],
['POST', '/cashier/session/close', 'Cashier\Controllers\CashierController@closeSession', ['auth', 'csrf'], 'cashier.process_payment'],
// Settlement receiving (from sub-treasuries)
['GET', '/cashier/settlements', 'Cashier\Controllers\SettlementReceiveController@index', ['auth'], 'cashier.receive_settlement'],
['POST', '/cashier/settlements/{id:\d+}/receive', 'Cashier\Controllers\SettlementReceiveController@receive', ['auth', 'csrf'], 'cashier.receive_settlement'],
......
......@@ -24,7 +24,7 @@ final class PaymentRequestService
$notes = $data['notes'] ?? null;
$currency = $data['currency'] ?? 'EGP';
if ($memberId <= 0) {
if ($memberId <= 0 && !in_array($paymentType, ['sports_registration', 'hourly_booking'], true)) {
return ['success' => false, 'error' => 'العضو مطلوب'];
}
if ($paymentType === '') {
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>طابور الخزينة<?php $__template->endSection(); ?>
<?php $__template->section('title'); ?>خزنة العضويات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Session Status Bar -->
<?php if (isset($treasury) && $treasury): ?>
<div class="card" style="margin-bottom:15px;border:2px solid <?= isset($currentSession) && $currentSession ? '#059669' : '#D97706' ?>;">
<div style="padding:12px 20px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
<?php if (isset($currentSession) && $currentSession): ?>
<div style="display:flex;align-items:center;gap:15px;">
<span style="background:#ECFDF5;color:#059669;padding:4px 12px;border-radius:8px;font-size:12px;font-weight:700;">وردية مفتوحة</span>
<span style="font-size:13px;color:#374151;">رقم: <strong><?= e($currentSession['session_number']) ?></strong></span>
<span style="font-size:13px;color:#374151;">تحصيلات: <strong><?= money((float)($currentSession['total_collected'] ?? 0)) ?></strong></span>
<span style="font-size:13px;color:#374151;">إيصالات: <strong><?= (int)($currentSession['total_receipts'] ?? 0) ?></strong></span>
<span style="font-size:13px;color:#374151;">العهدة: <strong><?= money((float)($custodyBalance ?? 0)) ?></strong></span>
</div>
<form method="POST" action="/cashier/session/close" style="margin:0;" onsubmit="return confirm('هل تريد إغلاق الوردية الحالية؟');">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="background:#D97706;color:#fff;font-size:12px;">
<i data-lucide="lock" style="width:13px;height:13px;vertical-align:middle;"></i> إغلاق الوردية
</button>
</form>
<?php else: ?>
<div style="display:flex;align-items:center;gap:10px;">
<span style="background:#FEF3C7;color:#D97706;padding:4px 12px;border-radius:8px;font-size:12px;font-weight:700;">لا توجد وردية مفتوحة</span>
<span style="font-size:13px;color:#6B7280;">يجب فتح وردية قبل التحصيل</span>
</div>
<form method="POST" action="/cashier/session/open" style="margin:0;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="background:#059669;color:#fff;font-size:12px;">
<i data-lucide="unlock" style="width:13px;height:13px;vertical-align:middle;"></i> فتح وردية
</button>
</form>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
......@@ -104,7 +138,11 @@
<tr<?= $r['status'] === 'cancelled' ? ' style="opacity:0.5;"' : '' ?><?= $urgent ? ' style="background:#FEF2F2;"' : '' ?>>
<td style="direction:ltr;text-align:right;font-weight:600;font-size:12px;"><?= e($r['request_number']) ?></td>
<td>
<?php if ((int)($r['member_id'] ?? 0) > 0): ?>
<a href="/members/<?= (int)$r['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($r['member_name'] ?? '—') ?></a>
<?php else: ?>
<span style="font-weight:600;"><?= e($r['description_ar'] ?? '—') ?></span>
<?php endif; ?>
</td>
<td style="font-weight:600;color:#D97706;"><?= e($r['form_number'] ?? '—') ?></td>
<td><?= $typeLabel ?></td>
......
......@@ -6,8 +6,8 @@ use App\Core\Registries\PermissionRegistry;
use App\Core\EventBus;
MenuRegistry::register('cashier', [
'label_ar' => 'الخزينة الرئيسية',
'label_en' => 'Main Treasury',
'label_ar' => 'خزنة العضويات',
'label_en' => 'Membership Treasury',
'icon' => 'credit-card',
'route' => '/cashier',
'permission' => 'cashier.view_queue',
......@@ -21,13 +21,13 @@ MenuRegistry::register('cashier', [
]);
PermissionRegistry::register('cashier', [
'cashier.view_queue' => ['ar' => 'عرض طابور الخزينة', 'en' => 'View Cashier Queue'],
'cashier.process_payment' => ['ar' => 'معالجة طلب دفع', 'en' => 'Process Payment Request'],
'cashier.view_queue' => ['ar' => 'عرض طابور خزنة العضويات', 'en' => 'View Membership Treasury Queue'],
'cashier.process_payment' => ['ar' => 'تحصيل دفعة من خزنة العضويات', 'en' => 'Collect Payment from Membership Treasury'],
'cashier.cancel_request' => ['ar' => 'إلغاء طلب دفع', 'en' => 'Cancel Payment Request'],
'cashier.receive_settlement' => ['ar' => 'استلام تسوية من خزنة فرعية', 'en' => 'Receive Sub-Treasury Settlement'],
'cashier.manage_deposits' => ['ar' => 'إدارة إيداعات البنك', 'en' => 'Manage Bank Deposits'],
'cashier.confirm_deposit' => ['ar' => 'تأكيد إيداع بنكي', 'en' => 'Confirm Bank Deposit'],
'cashier.view_custody' => ['ar' => 'عرض رصيد عهدة الخزنة الرئيسية', 'en' => 'View Main Treasury Custody'],
'cashier.view_custody' => ['ar' => 'عرض رصيد عهدة الخزنة', 'en' => 'View Treasury Custody Balance'],
]);
// When a payment is voided, delegate to PaymentLifecycleService for status reversion
......
......@@ -191,7 +191,7 @@ class GroupController extends Controller
WHERE s.player_id = p.id AND s.payment_status IN ('unpaid','overdue','partial')) as unpaid_count
FROM sa_players p
WHERE p.is_archived = 0 AND p.id NOT IN (
SELECT player_id FROM sa_group_players WHERE group_id = ? AND status = 'active'
SELECT player_id FROM sa_group_players WHERE group_id = ? AND status IN ('active','pending_payment')
)
ORDER BY p.full_name_ar ASC LIMIT 100",
[(int) $id]
......@@ -324,7 +324,11 @@ class GroupController extends Controller
$result = EnrollmentService::enroll((int) $id, $playerId);
if ($result['success']) {
return $this->redirect('/sa/groups/' . $id)->withSuccess('تم تسجيل اللاعب في المجموعة بنجاح');
$msg = 'تم تسجيل اللاعب — في انتظار الدفع';
if (!empty($result['request_number'])) {
$msg .= ' (طلب دفع: ' . $result['request_number'] . ')';
}
return $this->redirect('/sa/groups/' . $id . '?enrollment_pending=1')->withSuccess($msg);
}
return $this->redirect('/sa/groups/' . $id)->withError($result['error']);
......
......@@ -57,8 +57,8 @@ class Group extends Model
AND s2.payment_status IN ('unpaid','overdue','partial')) as unpaid_total
FROM sa_group_players gp
JOIN sa_players p ON p.id = gp.player_id
WHERE gp.group_id = ? AND gp.status = 'active'
ORDER BY p.full_name_ar ASC",
WHERE gp.group_id = ? AND gp.status IN ('active','pending_payment')
ORDER BY FIELD(gp.status, 'pending_payment', 'active'), p.full_name_ar ASC",
[$groupId]
);
}
......
......@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Modules\Cashier\Services\PaymentRequestService;
final class EnrollmentService
{
......@@ -39,7 +40,7 @@ final class EnrollmentService
}
$existing = $db->selectOne(
"SELECT id FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status = 'active'",
"SELECT id FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status IN ('active','pending_payment')",
[$groupId, $playerId]
);
if ($existing) {
......@@ -50,28 +51,48 @@ final class EnrollmentService
return ['success' => false, 'error' => 'المجموعة ممتلئة', 'suggest_waitlist' => true];
}
$fee = $player['player_type'] === 'member'
? (string) $group['monthly_fee_member']
: (string) $group['monthly_fee_nonmember'];
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$db->insert('sa_group_players', [
$enrollmentId = $db->insert('sa_group_players', [
'group_id' => $groupId,
'player_id' => $playerId,
'enrolled_at' => date('Y-m-d'),
'status' => 'active',
'status' => 'pending_payment',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employeeId,
]);
$newCount = (int) $group['current_count'] + 1;
$isFull = $newCount >= (int) $group['max_capacity'] ? 1 : 0;
$memberId = !empty($player['member_id']) ? (int) $player['member_id'] : 0;
$description = 'تسجيل رياضي — ' . $group['name_ar'] . ' — ' . $player['full_name_ar'];
$db->update('sa_groups', [
'current_count' => $newCount,
'is_full' => $isFull,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$groupId]);
$requestResult = PaymentRequestService::createRequest([
'member_id' => $memberId,
'payment_type' => 'sports_registration',
'amount' => $fee,
'description_ar' => $description,
'related_entity_type' => 'sa_group_players',
'related_entity_id' => $enrollmentId,
]);
if ($requestResult['success']) {
$db->update('sa_group_players', [
'payment_request_id' => (int) $requestResult['request_id'],
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$enrollmentId]);
}
return ['success' => true, 'new_count' => $newCount];
return [
'success' => true,
'enrollment_id' => $enrollmentId,
'request_id' => $requestResult['request_id'] ?? null,
'request_number' => $requestResult['request_number'] ?? null,
'fee' => $fee,
];
}
public static function withdraw(int $groupId, int $playerId, string $reason = ''): array
......@@ -79,7 +100,7 @@ final class EnrollmentService
$db = App::getInstance()->db();
$enrollment = $db->selectOne(
"SELECT id FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status = 'active'",
"SELECT id, status FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status IN ('active','pending_payment')",
[$groupId, $playerId]
);
......@@ -87,6 +108,8 @@ final class EnrollmentService
return ['success' => false, 'error' => 'اللاعب غير مسجل في هذه المجموعة'];
}
$wasActive = $enrollment['status'] === 'active';
$db->update('sa_group_players', [
'status' => 'withdrawn',
'left_at' => date('Y-m-d'),
......@@ -94,15 +117,80 @@ final class EnrollmentService
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $enrollment['id']]);
$group = $db->selectOne("SELECT current_count FROM sa_groups WHERE id = ?", [$groupId]);
if ($wasActive) {
$group = $db->selectOne("SELECT current_count FROM sa_groups WHERE id = ?", [$groupId]);
$newCount = max(0, (int) $group['current_count'] - 1);
$db->update('sa_groups', [
'current_count' => $newCount,
'is_full' => 0,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$groupId]);
return ['success' => true, 'new_count' => $newCount];
}
return ['success' => true, 'new_count' => null];
}
public static function activateEnrollment(int $enrollmentId, int $paymentId): bool
{
$db = App::getInstance()->db();
$enrollment = $db->selectOne(
"SELECT * FROM sa_group_players WHERE id = ? AND status = 'pending_payment'",
[$enrollmentId]
);
if (!$enrollment) {
return false;
}
$db->update('sa_group_players', [
'status' => 'active',
'activated_by_payment_id' => $paymentId,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$enrollmentId]);
$group = $db->selectOne("SELECT current_count, max_capacity FROM sa_groups WHERE id = ?", [(int) $enrollment['group_id']]);
$newCount = (int) $group['current_count'] + 1;
$isFull = $newCount >= (int) $group['max_capacity'] ? 1 : 0;
$db->update('sa_groups', [
'current_count' => $newCount,
'is_full' => $isFull,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $enrollment['group_id']]);
return true;
}
public static function deactivateEnrollment(int $enrollmentId): bool
{
$db = App::getInstance()->db();
$enrollment = $db->selectOne(
"SELECT * FROM sa_group_players WHERE id = ? AND status = 'active'",
[$enrollmentId]
);
if (!$enrollment) {
return false;
}
$db->update('sa_group_players', [
'status' => 'pending_payment',
'activated_by_payment_id' => null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$enrollmentId]);
$group = $db->selectOne("SELECT current_count FROM sa_groups WHERE id = ?", [(int) $enrollment['group_id']]);
$newCount = max(0, (int) $group['current_count'] - 1);
$db->update('sa_groups', [
'current_count' => $newCount,
'is_full' => 0,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$groupId]);
], 'id = ?', [(int) $enrollment['group_id']]);
return ['success' => true, 'new_count' => $newCount];
return true;
}
}
......@@ -118,14 +118,21 @@ $st = $group['status'] ?? 'active';
<tbody>
<?php if (!empty($players)): ?>
<?php foreach ($players as $pl): ?>
<tr>
<tr<?= ($pl['status'] ?? '') === 'pending_payment' ? ' style="background:#FEF3C7;"' : '' ?>>
<td><code style="font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:4px;"><?= e($pl['player_code'] ?? '') ?></code></td>
<td style="font-weight:600;">
<a href="/sa/players/<?= (int) $pl['player_id'] ?>" style="color:inherit;text-decoration:none;"><?= e($pl['player_name']) ?></a>
<?php if (($pl['status'] ?? '') === 'pending_payment'): ?>
<span style="padding:2px 6px;border-radius:8px;font-size:10px;font-weight:700;background:#FEF3C7;color:#D97706;margin-right:5px;">في انتظار الدفع</span>
<?php endif; ?>
</td>
<td style="direction:ltr;text-align:right;"><?= e($pl['phone'] ?? '—') ?></td>
<td>
<?php if ((int) ($pl['unpaid_count'] ?? 0) > 0): ?>
<?php if (($pl['status'] ?? '') === 'pending_payment'): ?>
<span style="padding:3px 8px;border-radius:8px;font-size:11px;font-weight:700;background:#FEF3C7;color:#D97706;">
في انتظار التحصيل
</span>
<?php elseif ((int) ($pl['unpaid_count'] ?? 0) > 0): ?>
<span style="padding:3px 8px;border-radius:8px;font-size:11px;font-weight:700;background:#FEE2E2;color:#DC2626;">
<?= (int) $pl['unpaid_count'] ?> غير مدفوع — <?= money((float) ($pl['unpaid_total'] ?? 0)) ?>
</span>
......
......@@ -107,6 +107,44 @@ EventBus::listen('payment_request.completed', function (array $data): void {
'receipt_number' => $data['receipt_number'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) ($data['related_entity_id'] ?? 0)]);
} elseif ($entityType === 'sa_group_players') {
$enrollmentId = (int) ($data['related_entity_id'] ?? 0);
$paymentId = (int) ($data['payment_id'] ?? 0);
if ($enrollmentId < 1 || $paymentId < 1) return;
\App\Modules\SportsActivity\Services\EnrollmentService::activateEnrollment($enrollmentId, $paymentId);
$enrollment = $db->selectOne(
"SELECT gp.player_id, gp.group_id, g.name_ar as group_name
FROM sa_group_players gp
LEFT JOIN sa_groups g ON g.id = gp.group_id
WHERE gp.id = ?",
[$enrollmentId]
);
if ($enrollment) {
$periodStart = date('Y-m-d');
$periodEnd = date('Y-m-t');
$subNumber = 'SUB-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$db->insert('sa_subscriptions', [
'subscription_number' => $subNumber,
'player_id' => (int) $enrollment['player_id'],
'group_id' => (int) $enrollment['group_id'],
'period_start' => $periodStart,
'period_end' => $periodEnd,
'amount' => $data['amount'] ?? '0.00',
'discount_amount' => '0.00',
'final_amount' => $data['amount'] ?? '0.00',
'payment_status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
'paid_amount' => $data['amount'] ?? '0.00',
'payment_id' => $paymentId,
'receipt_id' => $data['receipt_id'] ?? null,
'receipt_number' => $data['receipt_number'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
} catch (\Throwable $e) {
Logger::error('SA payment_request.completed listener failed: ' . $e->getMessage());
......@@ -142,6 +180,14 @@ EventBus::listen('payment.voided', function (array $data): void {
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $bk['id']]);
}
$enrollment = $db->selectOne(
"SELECT id FROM sa_group_players WHERE activated_by_payment_id = ? AND status = 'active'",
[$paymentId]
);
if ($enrollment) {
\App\Modules\SportsActivity\Services\EnrollmentService::deactivateEnrollment((int) $enrollment['id']);
}
} catch (\Throwable $e) {
Logger::error('SA payment.voided listener failed: ' . $e->getMessage());
}
......
......@@ -17,6 +17,18 @@ final class TreasuryService
return $db->selectOne("SELECT * FROM treasuries WHERE code = 'SUB_SA' AND is_active = 1");
}
public static function getMembershipTreasury(): ?array
{
$db = App::getInstance()->db();
return $db->selectOne("SELECT * FROM treasuries WHERE code = 'SUB_MEM' AND is_active = 1");
}
public static function getByCode(string $code): ?array
{
$db = App::getInstance()->db();
return $db->selectOne("SELECT * FROM treasuries WHERE code = ? AND is_active = 1", [$code]);
}
public static function getMainTreasury(): ?array
{
$db = App::getInstance()->db();
......@@ -164,9 +176,10 @@ final class TreasuryService
$where .= " AND pr.status IN ('pending','processing')";
}
// Filter by sports-related payment types for sub-treasury
if ($treasury['type'] === 'sub') {
if ($treasury['code'] === 'SUB_SA') {
$where .= " AND pr.payment_type IN ('activity_subscription','hourly_booking','sports_registration')";
} elseif ($treasury['code'] === 'SUB_MEM') {
$where .= " AND pr.payment_type IN ('form_fee','membership_fee','down_payment','addition_fee','annual_subscription','divorce_fee','death_fee','waiver_fee','separation_fee','fine','carnet_replacement','seasonal_fee')";
}
$search = trim($filters['search'] ?? '');
......@@ -208,8 +221,14 @@ final class TreasuryService
[$treasuryId]
);
$treasury = self::find($treasuryId);
$typeFilter = "('activity_subscription','hourly_booking','sports_registration')";
if ($treasury && $treasury['code'] === 'SUB_MEM') {
$typeFilter = "('form_fee','membership_fee','down_payment','addition_fee','annual_subscription','divorce_fee','death_fee','waiver_fee','separation_fee','fine','carnet_replacement','seasonal_fee')";
}
$pendingQueue = $db->selectOne(
"SELECT COUNT(*) as count FROM payment_requests WHERE status IN ('pending','processing') AND is_voided = 0 AND payment_type IN ('activity_subscription','hourly_booking','sports_registration')"
"SELECT COUNT(*) as count FROM payment_requests WHERE status IN ('pending','processing') AND is_voided = 0 AND payment_type IN {$typeFilter}"
);
return [
......
......@@ -89,7 +89,7 @@
<tr>
<td style="font-family:monospace;font-size:12px;"><?= e($r['request_number']) ?></td>
<td>
<div style="font-weight:600;"><?= e($r['member_name'] ?? '—') ?></div>
<div style="font-weight:600;"><?= e($r['member_name'] ?? $r['description_ar'] ?? '—') ?></div>
<div style="font-size:11px;color:#9CA3AF;"><?= e($r['form_number'] ?? '') ?></div>
</td>
<td><?= e($typeLabel) ?></td>
......
......@@ -93,12 +93,28 @@ class TutorialController extends Controller
],
'enroll-player' => [
'title' => 'تسجيل لاعب في مجموعة',
'subtitle' => 'التحاق لاعب بمجموعة تدريبية',
'subtitle' => 'التحاق لاعب بمجموعة تدريبية مع طلب الدفع الإجباري',
'icon' => 'user-plus',
'color' => '#10B981',
'category' => 'academy',
'order' => 11,
],
'enrollment-payment' => [
'title' => 'الدفع الإجباري عند التسجيل',
'subtitle' => 'لا يتم تفعيل التسجيل بدون دفع أول اشتراك من خزنة الأنشطة',
'icon' => 'shield-check',
'color' => '#059669',
'category' => 'financial',
'order' => 12,
],
'sports-payment-queue' => [
'title' => 'طابور دفع الأنشطة الرياضية',
'subtitle' => 'عرض ومعالجة طلبات الدفع في خزنة الأنشطة الفرعية',
'icon' => 'list-ordered',
'color' => '#2563EB',
'category' => 'financial',
'order' => 13,
],
'generate-training-bookings' => [
'title' => 'توليد حجوزات التمارين',
'subtitle' => 'إنشاء حجوزات تلقائية من جدول المجموعة',
......
This diff is collapsed.
......@@ -22,10 +22,10 @@
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح تسجيل اللاعبين</h3><div class="tut-step-body">من صفحة المجموعة > <span class="field">تسجيل لاعب</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">البحث عن اللاعب</h3><div class="tut-step-body">ابحث بالاسم أو الرقم التسلسلي أو الرقم القومي.</div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">التحقق التلقائي</h3><div class="tut-step-body">النظام يتحقق من:<ul><li>الحالة الطبية: لازم تكون <span class="field">fit</span> أو <span class="field">conditional</span></li><li>السعة: المجموعة لسه فيها مكان</li><li>العمر: ضمن الفئة العمرية للبرنامج</li><li>عدم التكرار: مش مسجل بالفعل</li></ul><span class="warn">إذا اللاعب حالته الطبية "pending" أو "unfit" أو "expired"، التسجيل مرفوض تلقائياً.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">نتيجة التسجيل</h3><div class="tut-step-body">إذا كل الشروط تمام → يتم التسجيل. إذا المجموعة ممتلئة → يُعرض خيار "قائمة الانتظار".<span class="success">بعد التسجيل بنجاح، اللاعب يظهر في كشف الحضور اليومي ويتم توليد اشتراك شهري له.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">نتيجة التسجيل</h3><div class="tut-step-body">إذا كل الشروط تمام → يُسجل اللاعب بحالة <span class="field">pending_payment</span> (في انتظار الدفع). إذا المجموعة ممتلئة → يُعرض خيار "قائمة الانتظار".<span class="info">التسجيل لا يُفعّل مباشرة — يجب دفع أول اشتراك من خزنة الأنشطة أولاً. انظر: <a href="/tutorials/sports-activity/enrollment-payment" style="color:#1E40AF;font-weight:600;">الدفع الإجباري عند التسجيل</a>.</span><span class="success">بعد التحصيل: اللاعب يظهر في كشف الحضور اليومي ويتم توليد اشتراك شهري له.</span></div></div>
<div class="tut-nav">
<a href="/tutorials/sports-activity/create-group"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> إنشاء مجموعة تدريبية</a>
<a href="/tutorials/sports-activity/generate-training-bookings">توليد حجوزات التمارين <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a>
<a href="/tutorials/sports-activity/enrollment-payment">الدفع الإجباري عند التسجيل <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a>
</div>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>شرح: الدفع الإجباري عند التسجيل<?php $__template->endSection(); ?>
<?php $__template->section('styles'); ?>
<style>
.tut-page{max-width:860px;margin:0 auto}.tut-breadcrumb{display:flex;align-items:center;gap:8px;margin-bottom:20px;font-size:13px}.tut-breadcrumb a{color:#6B7280;text-decoration:none}.tut-breadcrumb a:hover{color:#8B5CF6}.tut-breadcrumb span{color:#9CA3AF}.tut-header{display:flex;align-items:center;gap:18px;margin-bottom:30px;padding:24px;background:linear-gradient(135deg,#05966908,#05966904);border-radius:16px;border:1px solid #05966920}.tut-header-icon{width:56px;height:56px;background:#059669;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0}.tut-header h1{font-size:22px;font-weight:800;color:#1A1A2E;margin:0 0 4px}.tut-header p{font-size:14px;color:#6B7280;margin:0}.tut-step{position:relative;padding:20px 20px 20px 60px;margin-bottom:16px;background:#fff;border:1px solid #E5E7EB;border-radius:12px;transition:border-color .2s}.tut-step:hover{border-color:#05966960}.tut-step-num{position:absolute;right:18px;top:20px;width:32px;height:32px;background:#05966915;color:#059669;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:800}.tut-step-title{font-size:15px;font-weight:700;color:#1A1A2E;margin:0 0 6px}.tut-step-body{font-size:13.5px;color:#374151;line-height:1.8}.tut-step-body ul{margin:8px 0;padding-right:18px}.tut-step-body li{margin-bottom:4px}.tut-step-body .field{display:inline-block;background:#F3F4F6;color:#1A1A2E;padding:1px 8px;border-radius:4px;font-size:12px;font-weight:600;font-family:monospace;direction:ltr}.tut-step-body .warn{display:block;background:#FEF3C7;border:1px solid #F59E0B30;border-radius:8px;padding:10px 14px;margin:8px 0;font-size:12.5px;color:#92400E}.tut-step-body .info{display:block;background:#DBEAFE;border:1px solid #3B82F630;border-radius:8px;padding:10px 14px;margin:8px 0;font-size:12.5px;color:#1E40AF}.tut-step-body .success{display:block;background:#ECFDF5;border:1px solid #05966930;border-radius:8px;padding:10px 14px;margin:8px 0;font-size:12.5px;color:#065F46}.tut-nav{display:flex;justify-content:space-between;align-items:center;margin-top:32px;padding-top:20px;border-top:1px solid #E5E7EB}.tut-nav a{display:inline-flex;align-items:center;gap:6px;padding:10px 16px;background:#F9FAFB;border:1px solid #E5E7EB;border-radius:10px;text-decoration:none;color:#374151;font-size:13px;font-weight:600;transition:all .15s}.tut-nav a:hover{background:#EDE9FE;border-color:#8B5CF6;color:#7C3AED}.tut-diagram{background:#F8FAFC;border:1px solid #E2E8F0;border-radius:12px;padding:16px 20px;margin:12px 0;font-family:monospace;font-size:12px;direction:ltr;text-align:left;line-height:1.6;overflow-x:auto}
</style>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="tut-page">
<div class="tut-breadcrumb">
<a href="/tutorials"><i data-lucide="graduation-cap" style="width:14px;height:14px;"></i></a>
<span>/</span>
<a href="/tutorials/sports-activity">النشاط الرياضي</a>
<span>/</span>
<span style="color:#1A1A2E;font-weight:600;">الدفع الإجباري عند التسجيل</span>
</div>
<div class="tut-header">
<div class="tut-header-icon"><i data-lucide="shield-check" style="width:28px;height:28px;color:#fff;"></i></div>
<div><h1>الدفع الإجباري عند التسجيل</h1><p>لا يتم تفعيل التسجيل بدون دفع أول اشتراك من خزنة الأنشطة</p></div>
</div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">كيف يعمل النظام؟</h3><div class="tut-step-body">عند تسجيل لاعب في مجموعة، لا يُفعّل التسجيل مباشرة. بدلاً من ذلك:<ul><li>يُسجل اللاعب بحالة <span class="field">pending_payment</span> (في انتظار الدفع)</li><li>يُنشئ النظام <strong>طلب دفع</strong> تلقائياً في طابور خزنة الأنشطة</li><li>المبلغ = رسم الاشتراك الشهري للمجموعة (حسب نوع اللاعب: عضو أو غير عضو)</li></ul><span class="info">هذا يضمن عدم تفعيل أي تسجيل بدون سداد — لا استثناءات إلا عبر الإعفاء الرسمي.</span></div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">حالة اللاعب بعد التسجيل</h3><div class="tut-step-body">في صفحة المجموعة، اللاعب يظهر بخلفية صفراء وبادج <span class="field">في انتظار الدفع</span>.<ul><li>لا يُحسب ضمن العدد الفعلي للمجموعة (السعة)</li><li>لا يظهر في كشوف الحضور</li><li>لا يُولد له اشتراك شهري</li></ul><span class="warn">إذا أُلغي طلب الدفع بدون تحصيل — التسجيل يبقى معلقاً ولا يُحذف تلقائياً.</span></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">تحصيل الدفعة</h3><div class="tut-step-body">أمين خزنة الأنشطة يفتح طابور الدفع ويرى الطلب بنوع <span class="field">sports_registration</span>. يقوم بالتحصيل بالطريقة المعتادة (نقدي/فيزا/شيك).<div class="tut-diagram">تسجيل لاعب → طلب دفع (pending) → تحصيل من الخزنة → تفعيل التسجيل (active)</div></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">التفعيل التلقائي</h3><div class="tut-step-body">بعد التحصيل الناجح، النظام يقوم تلقائياً بـ:<ul><li>تغيير حالة التسجيل من <span class="field">pending_payment</span> إلى <span class="field">active</span></li><li>زيادة عدد اللاعبين الفعليين في المجموعة</li><li>إنشاء أول اشتراك شهري بحالة <span class="field">paid</span></li><li>طباعة إيصال الدفع</li></ul><span class="success">من هذه اللحظة، اللاعب يظهر في كشوف الحضور ويُولد له اشتراك شهري كل شهر.</span></div></div>
<div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">ماذا لو أُلغيت الدفعة؟</h3><div class="tut-step-body">إذا تم إلغاء (void) دفعة تسجيل بعد التحصيل:<ul><li>يعود التسجيل لحالة <span class="field">pending_payment</span></li><li>يتم خفض عدد اللاعبين في المجموعة</li><li>يحتاج اللاعب لدفعة جديدة لإعادة التفعيل</li></ul><span class="warn">الإلغاء يُرجع حالة التسجيل فقط — لا يحذف سجل اللاعب من المجموعة.</span></div></div>
<div class="tut-nav">
<a href="/tutorials/sports-activity/enroll-player"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> تسجيل لاعب في مجموعة</a>
<a href="/tutorials/sports-activity/sports-payment-queue">طابور دفع الأنشطة الرياضية <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a>
</div>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>شرح: طابور دفع الأنشطة الرياضية<?php $__template->endSection(); ?>
<?php $__template->section('styles'); ?>
<style>
.tut-page{max-width:860px;margin:0 auto}.tut-breadcrumb{display:flex;align-items:center;gap:8px;margin-bottom:20px;font-size:13px}.tut-breadcrumb a{color:#6B7280;text-decoration:none}.tut-breadcrumb a:hover{color:#8B5CF6}.tut-breadcrumb span{color:#9CA3AF}.tut-header{display:flex;align-items:center;gap:18px;margin-bottom:30px;padding:24px;background:linear-gradient(135deg,#2563EB08,#2563EB04);border-radius:16px;border:1px solid #2563EB20}.tut-header-icon{width:56px;height:56px;background:#2563EB;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0}.tut-header h1{font-size:22px;font-weight:800;color:#1A1A2E;margin:0 0 4px}.tut-header p{font-size:14px;color:#6B7280;margin:0}.tut-step{position:relative;padding:20px 20px 20px 60px;margin-bottom:16px;background:#fff;border:1px solid #E5E7EB;border-radius:12px;transition:border-color .2s}.tut-step:hover{border-color:#2563EB60}.tut-step-num{position:absolute;right:18px;top:20px;width:32px;height:32px;background:#2563EB15;color:#2563EB;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:800}.tut-step-title{font-size:15px;font-weight:700;color:#1A1A2E;margin:0 0 6px}.tut-step-body{font-size:13.5px;color:#374151;line-height:1.8}.tut-step-body ul{margin:8px 0;padding-right:18px}.tut-step-body li{margin-bottom:4px}.tut-step-body .field{display:inline-block;background:#F3F4F6;color:#1A1A2E;padding:1px 8px;border-radius:4px;font-size:12px;font-weight:600;font-family:monospace;direction:ltr}.tut-step-body .warn{display:block;background:#FEF3C7;border:1px solid #F59E0B30;border-radius:8px;padding:10px 14px;margin:8px 0;font-size:12.5px;color:#92400E}.tut-step-body .info{display:block;background:#DBEAFE;border:1px solid #3B82F630;border-radius:8px;padding:10px 14px;margin:8px 0;font-size:12.5px;color:#1E40AF}.tut-step-body .success{display:block;background:#ECFDF5;border:1px solid #05966930;border-radius:8px;padding:10px 14px;margin:8px 0;font-size:12.5px;color:#065F46}.tut-nav{display:flex;justify-content:space-between;align-items:center;margin-top:32px;padding-top:20px;border-top:1px solid #E5E7EB}.tut-nav a{display:inline-flex;align-items:center;gap:6px;padding:10px 16px;background:#F9FAFB;border:1px solid #E5E7EB;border-radius:10px;text-decoration:none;color:#374151;font-size:13px;font-weight:600;transition:all .15s}.tut-nav a:hover{background:#EDE9FE;border-color:#8B5CF6;color:#7C3AED}.tut-diagram{background:#F8FAFC;border:1px solid #E2E8F0;border-radius:12px;padding:16px 20px;margin:12px 0;font-family:monospace;font-size:12px;direction:ltr;text-align:left;line-height:1.6;overflow-x:auto}
</style>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="tut-page">
<div class="tut-breadcrumb">
<a href="/tutorials"><i data-lucide="graduation-cap" style="width:14px;height:14px;"></i></a>
<span>/</span>
<a href="/tutorials/sports-activity">النشاط الرياضي</a>
<span>/</span>
<span style="color:#1A1A2E;font-weight:600;">طابور دفع الأنشطة الرياضية</span>
</div>
<div class="tut-header">
<div class="tut-header-icon"><i data-lucide="list-ordered" style="width:28px;height:28px;color:#fff;"></i></div>
<div><h1>طابور دفع الأنشطة الرياضية</h1><p>عرض ومعالجة طلبات الدفع في خزنة الأنشطة الفرعية</p></div>
</div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح الطابور</h3><div class="tut-step-body">من القائمة الجانبية: <span class="field">خزنة الأنشطة</span> > <span class="field">طابور الدفع</span>. يظهر جميع طلبات الدفع المعلقة والجارية المرتبطة بالأنشطة الرياضية.<span class="info">الطابور يتحدث تلقائياً كل 30 ثانية. أنواع الطلبات: تسجيل رياضي، اشتراك نشاط، حجز ساعة، إيجار لوكر.</span></div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">التأكد من وجود وردية مفتوحة</h3><div class="tut-step-body">قبل التحصيل — يجب أن تكون هناك وردية مفتوحة. إذا لم تكن موجودة:<ul><li>يظهر شريط أصفر: «لا توجد وردية مفتوحة»</li><li>اضغط <span class="field">فتح وردية</span> لبدء العمل</li></ul><span class="warn">لا يمكن تحصيل أي دفعة بدون وردية مفتوحة — الزر «تحصيل» لن يعمل.</span></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">اختيار طلب الدفع</h3><div class="tut-step-body">اضغط <span class="field">تحصيل</span> بجانب الطلب المطلوب. تُفتح صفحة التحصيل بمعلومات:<ul><li>اسم اللاعب/العضو</li><li>نوع الدفعة (تسجيل رياضي، اشتراك شهري، حجز ساعة)</li><li>المبلغ المطلوب</li><li>تفاصيل إضافية (المجموعة، الوحدة، التاريخ)</li></ul></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">اختيار طريقة الدفع والتحصيل</h3><div class="tut-step-body">حدد <span class="field">طريقة الدفع</span>: نقدي، فيزا، شيك، أو تحويل بنكي. لكل طريقة حقول إضافية (رقم الشيك، مرجع التحويل...). ثم اضغط <span class="field">تأكيد التحصيل</span>.<div class="tut-diagram">طلب دفع (pending) → تحصيل → إيصال + قيد محاسبي + تفعيل الخدمة</div><span class="success">بعد التحصيل: يُطبع الإيصال تلقائياً، ويُربط المبلغ بالوردية الحالية وعهدة أمين الخزنة.</span></div></div>
<div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">إغلاق الوردية والتسوية</h3><div class="tut-step-body">بعد الانتهاء من التحصيلات:<ul><li>اضغط <span class="field">إغلاق الوردية</span> — يُحفظ إجمالي التحصيلات</li><li>أنشئ <span class="field">تسوية</span> لترحيل المبالغ للخزنة الرئيسية</li><li>أمين الخزنة الرئيسية يستلم ويؤكد</li></ul><span class="info">شريط الوردية يعرض دائماً: رقم الوردية، عدد الإيصالات، إجمالي التحصيلات، ورصيد العهدة الحالي.</span></div></div>
<div class="tut-nav">
<a href="/tutorials/sports-activity/enrollment-payment"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> الدفع الإجباري عند التسجيل</a>
<a href="/tutorials/sports-activity/generate-training-bookings">توليد حجوزات التمارين <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a>
</div>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `sa_group_players`
ADD COLUMN `payment_request_id` BIGINT UNSIGNED NULL AFTER `created_by`,
ADD COLUMN `activated_by_payment_id` BIGINT UNSIGNED NULL AFTER `payment_request_id`,
MODIFY COLUMN `status` VARCHAR(20) NOT NULL DEFAULT 'pending_payment' COMMENT 'pending_payment, active, paused, transferred, withdrawn'
",
'down' => "
ALTER TABLE `sa_group_players`
DROP COLUMN `payment_request_id`,
DROP COLUMN `activated_by_payment_id`,
MODIFY COLUMN `status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active, paused, transferred, withdrawn'
",
];
<?php
declare(strict_types=1);
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
// 1. GL Account for Membership Sub-Treasury
$glExists = $db->selectOne(
"SELECT 1 FROM chart_of_accounts WHERE account_code = ?",
['12060103']
);
if (!$glExists) {
$db->insert('chart_of_accounts', [
'account_code' => '12060103',
'name_ar' => 'صندوق الخزنة الفرعية - العضويات',
'name_en' => 'Sub-Treasury Cash - Memberships',
'parent_code' => '120601',
'level' => 5,
'account_nature' => 'debit',
'is_header' => 0,
'is_active' => 1,
'current_balance' => '0.00',
'created_at' => $now,
'updated_at' => $now,
]);
}
// 2. Treasury record for SUB_MEM
$treasuryExists = $db->selectOne("SELECT 1 FROM treasuries WHERE code = ?", ['SUB_MEM']);
if (!$treasuryExists) {
$db->insert('treasuries', [
'code' => 'SUB_MEM',
'name_ar' => 'خزنة العضويات الفرعية',
'name_en' => 'Memberships Sub-Treasury',
'type' => 'sub',
'account_code' => '12060103',
'branch_id' => null,
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
};
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$now = date('Y-m-d H:i:s');
// ─── New Roles ──────────────────────────────────────────────────────────────
$newRoles = [
[
'role_code' => 'membership_cashier',
'name_ar' => 'أمين خزنة العضويات',
'name_en' => 'Membership Cashier',
'description_ar' => 'تحصيل مدفوعات العضويات وإدارة الوردية',
'is_system' => 1,
'category' => 'finance',
'level' => 2,
'permissions' => [
'cashier.view_queue', 'cashier.process_payment', 'cashier.cancel_request',
'cashier.view_custody',
'treasury.open_session', 'treasury.close_session', 'treasury.initiate_settlement',
'treasury.view_custody', 'treasury.view_sessions',
'member.view', 'member.search',
'receipts.print',
],
],
[
'role_code' => 'sports_cashier',
'name_ar' => 'أمين خزنة الأنشطة',
'name_en' => 'Sports Cashier',
'description_ar' => 'تحصيل مدفوعات الأنشطة الرياضية وإدارة الوردية',
'is_system' => 1,
'category' => 'finance',
'level' => 2,
'permissions' => [
'treasury.view_dashboard', 'treasury.collect_payment',
'treasury.open_session', 'treasury.close_session', 'treasury.initiate_settlement',
'treasury.view_custody', 'treasury.view_sessions', 'treasury.view_settlements',
'sa.subscription.collect',
'member.view', 'member.search',
'receipts.print',
],
],
[
'role_code' => 'main_cashier',
'name_ar' => 'أمين الخزنة الرئيسية',
'name_en' => 'Main Cashier',
'description_ar' => 'استلام التسويات من الخزن الفرعية وإيداعات البنك',
'is_system' => 1,
'category' => 'finance',
'level' => 1,
'permissions' => [
'cashier.view_queue', 'cashier.receive_settlement',
'cashier.manage_deposits', 'cashier.confirm_deposit', 'cashier.view_custody',
'treasury.view_settlements',
'member.view', 'member.search',
'receipts.print',
],
],
[
'role_code' => 'sports_officer',
'name_ar' => 'موظف الأنشطة الرياضية',
'name_en' => 'Sports Officer',
'description_ar' => 'إدارة اللاعبين والمجموعات والحجوزات',
'is_system' => 1,
'category' => 'operations',
'level' => 2,
'permissions' => [
'sa.dashboard', 'sa.player.view', 'sa.player.manage',
'sa.group.view', 'sa.group.manage', 'sa.group.enroll',
'sa.schedule.view', 'sa.schedule.manage',
'sa.booking.view', 'sa.booking.create', 'sa.booking.manage',
'sa.subscription.view', 'sa.subscription.generate',
'sa.attendance.view', 'sa.attendance.manage',
'sa.waitlist.view', 'sa.waitlist.manage',
'sa.locker.view', 'sa.locker.manage',
'sa.locker_rental.view', 'sa.locker_rental.create',
],
],
[
'role_code' => 'sports_director',
'name_ar' => 'مدير الأنشطة الرياضية',
'name_en' => 'Sports Director',
'description_ar' => 'إدارة كاملة للأنشطة الرياضية والتقارير والإعفاءات',
'is_system' => 1,
'category' => 'management',
'level' => 1,
'permissions' => [
'sa.dashboard', 'sa.player.view', 'sa.player.manage',
'sa.group.view', 'sa.group.manage', 'sa.group.enroll',
'sa.schedule.view', 'sa.schedule.manage',
'sa.booking.view', 'sa.booking.create', 'sa.booking.manage',
'sa.subscription.view', 'sa.subscription.generate', 'sa.subscription.collect', 'sa.subscription.exempt',
'sa.attendance.view', 'sa.attendance.manage',
'sa.waitlist.view', 'sa.waitlist.manage',
'sa.discipline.view', 'sa.discipline.manage',
'sa.facility.view', 'sa.facility.manage',
'sa.coach.view', 'sa.coach.manage',
'sa.program.view', 'sa.program.manage',
'sa.pricing.view', 'sa.pricing.manage',
'sa.academy.view', 'sa.academy.manage',
'sa.contract.view', 'sa.contract.manage', 'sa.contract.approve',
'sa.medical.approve',
'sa.locker.view', 'sa.locker.manage',
'sa.locker_rental.view', 'sa.locker_rental.create', 'sa.locker_rental.manage', 'sa.locker_rental.evict',
'sa.mirror.view', 'sa.pool-grid.manage',
'report.view_operations',
],
],
];
foreach ($newRoles as $roleDef) {
$permissions = $roleDef['permissions'];
unset($roleDef['permissions']);
$existing = $db->selectOne("SELECT id FROM roles WHERE role_code = ?", [$roleDef['role_code']]);
if ($existing) {
continue;
}
$roleId = $db->insert('roles', array_merge($roleDef, [
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]));
foreach ($permissions as $permKey) {
$db->insert('role_permissions', [
'role_id' => $roleId,
'permission_key' => $permKey,
'granted_at' => $now,
]);
}
}
// ─── Update Existing Roles ──────────────────────────────────────────────────
$updates = [
'treasury_manager' => [
'cashier.receive_settlement', 'cashier.manage_deposits', 'cashier.confirm_deposit',
'cashier.view_custody', 'treasury.view_dashboard',
'treasury.view_custody', 'treasury.view_sessions', 'treasury.view_settlements',
],
'treasury_officer' => [
'cashier.view_queue', 'cashier.process_payment',
'treasury.open_session', 'treasury.close_session', 'treasury.view_custody',
],
'auditor' => [
'treasury.view_dashboard', 'treasury.view_custody', 'treasury.view_sessions', 'treasury.view_settlements',
'cashier.view_queue', 'cashier.view_custody',
'sa.dashboard', 'sa.subscription.view', 'sa.booking.view',
'sa.locker.view', 'sa.locker_rental.view',
],
];
foreach ($updates as $roleCode => $permsToAdd) {
$role = $db->selectOne("SELECT id FROM roles WHERE role_code = ?", [$roleCode]);
if (!$role) continue;
$roleId = (int) $role['id'];
foreach ($permsToAdd as $permKey) {
$exists = $db->selectOne(
"SELECT 1 FROM role_permissions WHERE role_id = ? AND permission_key = ?",
[$roleId, $permKey]
);
if (!$exists) {
$db->insert('role_permissions', [
'role_id' => $roleId,
'permission_key' => $permKey,
'granted_at' => $now,
]);
}
}
}
};
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