Commit d1e9d443 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(games): integrate game tickets with cashier payment queue + session lifecycle

- Game tickets now create payment requests (sa_game_ticket type) sent to
  the cashier sub-queue, matching pool ticket and booking patterns
- Added session lifecycle actions: start, complete, cancel with UI buttons
- Fixed session status bug: allow issuing tickets during 'in_progress' sessions
- Fixed GuestEntryService crash when no carnet exists (skip UPDATE on id=0)
- Added payment completion/void handlers for game and pool tickets
- Extended PaymentRequestService whitelist for sa_game_ticket/sa_pool_ticket
- Added comprehensive tutorial sections 9-14 covering entertainment workflows
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 92bc017a
...@@ -51,35 +51,37 @@ class GuestEntryService ...@@ -51,35 +51,37 @@ class GuestEntryService
public static function recordEntry(array $data): CarnetGuestEntry public static function recordEntry(array $data): CarnetGuestEntry
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$carnetId = (int) $data['carnet_id']; $carnetId = (int) ($data['carnet_id'] ?? 0);
$entry = CarnetGuestEntry::create($data); $entry = CarnetGuestEntry::create($data);
if ($carnetId > 0) {
$guestCount = (int) ($data['guest_count'] ?? 1); $guestCount = (int) ($data['guest_count'] ?? 1);
$db->query( $db->query(
"UPDATE carnets SET used_invitations = used_invitations + ? WHERE id = ?", "UPDATE carnets SET used_invitations = used_invitations + ? WHERE id = ?",
[$guestCount, $carnetId] [$guestCount, $carnetId]
); );
$remaining = self::getRemainingInvitations($carnetId);
if ($remaining <= 2 && $remaining > 0) {
EventBus::dispatch('carnet.low_balance', [
'carnet_id' => $carnetId,
'member_id' => (int) ($data['member_id'] ?? 0),
'remaining' => $remaining,
]);
}
}
EventBus::dispatch('carnet_guest.entry_recorded', [ EventBus::dispatch('carnet_guest.entry_recorded', [
'entry_id' => (int) $entry->id, 'entry_id' => (int) $entry->id,
'carnet_id' => $carnetId, 'carnet_id' => $carnetId,
'member_id' => (int) $data['member_id'], 'member_id' => (int) ($data['member_id'] ?? 0),
'guest_name' => $data['guest_name'], 'guest_name' => $data['guest_name'],
'facility_id'=> $data['facility_id'] ?? null, 'facility_id'=> $data['facility_id'] ?? null,
'activity' => $data['activity_type'], 'activity' => $data['activity_type'],
'amount' => (float) ($data['amount_paid'] ?? 0), 'amount' => (float) ($data['amount_paid'] ?? 0),
]); ]);
$remaining = self::getRemainingInvitations($carnetId);
if ($remaining <= 2 && $remaining > 0) {
EventBus::dispatch('carnet.low_balance', [
'carnet_id' => $carnetId,
'member_id' => (int) $data['member_id'],
'remaining' => $remaining,
]);
}
return $entry; return $entry;
} }
......
...@@ -24,7 +24,7 @@ final class PaymentRequestService ...@@ -24,7 +24,7 @@ final class PaymentRequestService
$notes = $data['notes'] ?? null; $notes = $data['notes'] ?? null;
$currency = $data['currency'] ?? 'EGP'; $currency = $data['currency'] ?? 'EGP';
if ($memberId <= 0 && !in_array($paymentType, ['sports_registration', 'hourly_booking', 'sa_form_fee', 'sa_subscription', 'sports_subscription', 'activity_subscription', 'sa_registration_fee'], true)) { if ($memberId <= 0 && !in_array($paymentType, ['sports_registration', 'hourly_booking', 'sa_form_fee', 'sa_subscription', 'sports_subscription', 'activity_subscription', 'sa_registration_fee', 'sa_game_ticket', 'sa_pool_ticket'], true)) {
return ['success' => false, 'error' => 'العضو مطلوب']; return ['success' => false, 'error' => 'العضو مطلوب'];
} }
if ($paymentType === '') { if ($paymentType === '') {
...@@ -345,6 +345,8 @@ final class PaymentRequestService ...@@ -345,6 +345,8 @@ final class PaymentRequestService
'carnet_replacement' => 'بدل فاقد كارنيه', 'carnet_replacement' => 'بدل فاقد كارنيه',
'sports_registration' => 'تسجيل رياضي', 'sports_registration' => 'تسجيل رياضي',
'sa_registration_fee' => 'رسوم تسجيل نشاط رياضي', 'sa_registration_fee' => 'رسوم تسجيل نشاط رياضي',
'sa_game_ticket' => 'تذكرة لعبة ترفيهية',
'sa_pool_ticket' => 'تذكرة حمام سباحة',
'activity_subscription' => 'اشتراك نشاط', 'activity_subscription' => 'اشتراك نشاط',
'seasonal_fee' => 'رسوم عضوية موسمية', 'seasonal_fee' => 'رسوم عضوية موسمية',
'foreign_membership_fee' => 'رسوم عضوية أجنبية', 'foreign_membership_fee' => 'رسوم عضوية أجنبية',
......
...@@ -7,6 +7,7 @@ use App\Core\Controller; ...@@ -7,6 +7,7 @@ use App\Core\Controller;
use App\Core\Request; use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Modules\Cashier\Services\PaymentRequestService;
class GameController extends Controller class GameController extends Controller
{ {
...@@ -171,7 +172,7 @@ class GameController extends Controller ...@@ -171,7 +172,7 @@ class GameController extends Controller
return $this->redirect('/sa/games/' . $id)->withError('الجلسة غير موجودة'); return $this->redirect('/sa/games/' . $id)->withError('الجلسة غير موجودة');
} }
if ($session['status'] !== 'open') { if (!in_array($session['status'], ['open', 'in_progress'], true)) {
return $this->redirect("/sa/games/{$id}/sessions/{$sessionId}")->withError('الجلسة غير متاحة للتسجيل'); return $this->redirect("/sa/games/{$id}/sessions/{$sessionId}")->withError('الجلسة غير متاحة للتسجيل');
} }
...@@ -206,20 +207,39 @@ class GameController extends Controller ...@@ -206,20 +207,39 @@ class GameController extends Controller
$db->beginTransaction(); $db->beginTransaction();
try { try {
$db->insert('sa_game_tickets', [ $ticketId = $db->insert('sa_game_tickets', [
'session_id' => (int) $sessionId, 'session_id' => (int) $sessionId,
'ticket_number' => $ticketNumber, 'ticket_number' => $ticketNumber,
'player_id' => $playerIdInput ?: null, 'player_id' => $playerIdInput ?: null,
'player_name' => $playerName ?: null, 'player_name' => $playerName ?: null,
'player_type' => $playerType, 'player_type' => $playerType,
'fee_charged' => $fee, 'fee_charged' => $fee,
'payment_status' => 'unpaid', 'payment_status' => $fee > 0 ? 'unpaid' : 'paid',
'status' => 'active', 'status' => 'active',
'created_by' => (int) (App::getInstance()->session()->get('employee_id') ?? 0), 'created_by' => (int) (App::getInstance()->session()->get('employee_id') ?? 0),
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]); ]);
if ($fee > 0) {
$description = 'تذكرة ' . ($game['name_ar'] ?? 'لعبة') . ' — ' . ($playerName ?: 'لاعب');
$payResult = PaymentRequestService::createRequest([
'member_id' => 0,
'payment_type' => 'sa_game_ticket',
'amount' => (string) $fee,
'description_ar' => $description,
'related_entity_type' => 'sa_game_tickets',
'related_entity_id' => $ticketId,
]);
if ($payResult['success']) {
$db->update('sa_game_tickets', [
'payment_id' => (int) $payResult['request_id'],
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$ticketId]);
}
}
$db->update('sa_game_sessions', [ $db->update('sa_game_sessions', [
'current_players' => (int) $session['current_players'] + 1, 'current_players' => (int) $session['current_players'] + 1,
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
...@@ -236,6 +256,73 @@ class GameController extends Controller ...@@ -236,6 +256,73 @@ class GameController extends Controller
return $this->redirect("/sa/games/{$id}/sessions/{$sessionId}")->withError('فشل إصدار التذكرة: ' . $e->getMessage()); return $this->redirect("/sa/games/{$id}/sessions/{$sessionId}")->withError('فشل إصدار التذكرة: ' . $e->getMessage());
} }
return $this->redirect("/sa/games/{$id}/sessions/{$sessionId}")->withSuccess("تم إصدار تذكرة {$ticketNumber} بنجاح"); $msg = "تم إصدار تذكرة {$ticketNumber} بنجاح";
if ($fee > 0) {
$msg .= ' — في انتظار التحصيل من الخزنة';
}
return $this->redirect("/sa/games/{$id}/sessions/{$sessionId}")->withSuccess($msg);
}
public function startSession(Request $request, string $id, string $sessionId): Response
{
$db = App::getInstance()->db();
$session = $db->selectOne("SELECT * FROM sa_game_sessions WHERE id = ? AND game_id = ?", [(int) $sessionId, (int) $id]);
if (!$session) {
return $this->redirect('/sa/games/' . $id)->withError('الجلسة غير موجودة');
}
if ($session['status'] !== 'open') {
return $this->redirect("/sa/games/{$id}/sessions/{$sessionId}")->withError('لا يمكن بدء الجلسة — الحالة الحالية: ' . $session['status']);
}
$db->update('sa_game_sessions', [
'status' => 'in_progress',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $sessionId]);
return $this->redirect("/sa/games/{$id}/sessions/{$sessionId}")->withSuccess('تم بدء الجلسة');
}
public function completeSession(Request $request, string $id, string $sessionId): Response
{
$db = App::getInstance()->db();
$session = $db->selectOne("SELECT * FROM sa_game_sessions WHERE id = ? AND game_id = ?", [(int) $sessionId, (int) $id]);
if (!$session) {
return $this->redirect('/sa/games/' . $id)->withError('الجلسة غير موجودة');
}
if (!in_array($session['status'], ['open', 'in_progress', 'full'], true)) {
return $this->redirect("/sa/games/{$id}/sessions/{$sessionId}")->withError('لا يمكن إنهاء الجلسة');
}
$db->update('sa_game_sessions', [
'status' => 'completed',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $sessionId]);
return $this->redirect("/sa/games/{$id}/sessions/{$sessionId}")->withSuccess('تم إنهاء الجلسة');
}
public function cancelSession(Request $request, string $id, string $sessionId): Response
{
$db = App::getInstance()->db();
$session = $db->selectOne("SELECT * FROM sa_game_sessions WHERE id = ? AND game_id = ?", [(int) $sessionId, (int) $id]);
if (!$session) {
return $this->redirect('/sa/games/' . $id)->withError('الجلسة غير موجودة');
}
if ($session['status'] === 'completed') {
return $this->redirect("/sa/games/{$id}/sessions/{$sessionId}")->withError('لا يمكن إلغاء جلسة مكتملة');
}
$db->update('sa_game_sessions', [
'status' => 'cancelled',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $sessionId]);
return $this->redirect("/sa/games/{$id}/sessions/{$sessionId}")->withSuccess('تم إلغاء الجلسة');
} }
} }
...@@ -121,6 +121,9 @@ return [ ...@@ -121,6 +121,9 @@ return [
['POST', '/sa/games/{id:\d+}/sessions', 'SportsActivity\Controllers\GameController@createSession', ['auth', 'csrf'], 'sa.game.manage'], ['POST', '/sa/games/{id:\d+}/sessions', 'SportsActivity\Controllers\GameController@createSession', ['auth', 'csrf'], 'sa.game.manage'],
['GET', '/sa/games/{id:\d+}/sessions/{sid:\d+}', 'SportsActivity\Controllers\GameController@sessionDetail', ['auth'], 'sa.game.view'], ['GET', '/sa/games/{id:\d+}/sessions/{sid:\d+}', 'SportsActivity\Controllers\GameController@sessionDetail', ['auth'], 'sa.game.view'],
['POST', '/sa/games/{id:\d+}/sessions/{sid:\d+}/ticket', 'SportsActivity\Controllers\GameController@issueTicket', ['auth', 'csrf'], 'sa.game.manage'], ['POST', '/sa/games/{id:\d+}/sessions/{sid:\d+}/ticket', 'SportsActivity\Controllers\GameController@issueTicket', ['auth', 'csrf'], 'sa.game.manage'],
['POST', '/sa/games/{id:\d+}/sessions/{sid:\d+}/start', 'SportsActivity\Controllers\GameController@startSession', ['auth', 'csrf'], 'sa.game.manage'],
['POST', '/sa/games/{id:\d+}/sessions/{sid:\d+}/complete', 'SportsActivity\Controllers\GameController@completeSession', ['auth', 'csrf'], 'sa.game.manage'],
['POST', '/sa/games/{id:\d+}/sessions/{sid:\d+}/cancel', 'SportsActivity\Controllers\GameController@cancelSession', ['auth', 'csrf'], 'sa.game.manage'],
// Booking Wizard // Booking Wizard
['GET', '/sa/booking-wizard', 'SportsActivity\Controllers\BookingWizardController@index', ['auth'], 'sa.booking_wizard.use'], ['GET', '/sa/booking-wizard', 'SportsActivity\Controllers\BookingWizardController@index', ['auth'], 'sa.booking_wizard.use'],
......
...@@ -98,6 +98,8 @@ final class SaConstants ...@@ -98,6 +98,8 @@ final class SaConstants
const ENTITY_REGISTRATIONS = 'sa_registrations'; const ENTITY_REGISTRATIONS = 'sa_registrations';
const ENTITY_REG_FORM = 'sa_registration_form'; const ENTITY_REG_FORM = 'sa_registration_form';
const ENTITY_PLAYER_CARDS = 'sa_player_cards'; const ENTITY_PLAYER_CARDS = 'sa_player_cards';
const ENTITY_GAME_TICKETS = 'sa_game_tickets';
const ENTITY_POOL_TICKETS = 'sa_pool_tickets';
// Group statuses // Group statuses
const GROUP_ACTIVE = 'active'; const GROUP_ACTIVE = 'active';
......
...@@ -53,6 +53,16 @@ final class SaEventListenerService ...@@ -53,6 +53,16 @@ final class SaEventListenerService
); );
return; return;
} }
if ($entityType === SaConstants::ENTITY_GAME_TICKETS) {
self::handleGameTicketPaid($data);
return;
}
if ($entityType === SaConstants::ENTITY_POOL_TICKETS) {
self::handlePoolTicketPaid($data);
return;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
Logger::error('SA payment_request.completed listener failed: ' . $e->getMessage()); Logger::error('SA payment_request.completed listener failed: ' . $e->getMessage());
} }
...@@ -91,6 +101,24 @@ final class SaEventListenerService ...@@ -91,6 +101,24 @@ final class SaEventListenerService
], 'id = ?', [(int) $bk['id']]); ], 'id = ?', [(int) $bk['id']]);
} }
$gameTicket = $db->selectOne("SELECT id FROM sa_game_tickets WHERE payment_id = ?", [$paymentId]);
if ($gameTicket) {
$db->update('sa_game_tickets', [
'payment_status' => SaConstants::PAYMENT_UNPAID,
'payment_id' => null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $gameTicket['id']]);
}
$poolTicket = $db->selectOne("SELECT id FROM sa_pool_tickets WHERE payment_id = ?", [$paymentId]);
if ($poolTicket) {
$db->update('sa_pool_tickets', [
'payment_status' => SaConstants::PAYMENT_UNPAID,
'payment_id' => null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $poolTicket['id']]);
}
$enrollment = $db->selectOne( $enrollment = $db->selectOne(
"SELECT id FROM sa_group_players WHERE activated_by_payment_id = ? AND status = 'active'", "SELECT id FROM sa_group_players WHERE activated_by_payment_id = ? AND status = 'active'",
[$paymentId] [$paymentId]
...@@ -181,6 +209,36 @@ final class SaEventListenerService ...@@ -181,6 +209,36 @@ final class SaEventListenerService
], 'id = ?', [(int) ($data['related_entity_id'] ?? 0)]); ], 'id = ?', [(int) ($data['related_entity_id'] ?? 0)]);
} }
private static function handleGameTicketPaid(array $data): void
{
$db = App::getInstance()->db();
$ticketId = (int) ($data['related_entity_id'] ?? 0);
if ($ticketId < 1) {
return;
}
$db->update('sa_game_tickets', [
'payment_status' => SaConstants::PAYMENT_PAID,
'payment_id' => $data['payment_id'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$ticketId]);
}
private static function handlePoolTicketPaid(array $data): void
{
$db = App::getInstance()->db();
$ticketId = (int) ($data['related_entity_id'] ?? 0);
if ($ticketId < 1) {
return;
}
$db->update('sa_pool_tickets', [
'payment_status' => SaConstants::PAYMENT_PAID,
'payment_id' => $data['payment_id'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$ticketId]);
}
private static function handleEnrollmentPaid(int $enrollmentId, int $paymentId, array $data): void private static function handleEnrollmentPaid(int $enrollmentId, int $paymentId, array $data): void
{ {
if ($enrollmentId < 1 || $paymentId < 1) { if ($enrollmentId < 1 || $paymentId < 1) {
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
<!-- Session Info --> <!-- Session Info -->
<div class="card" style="padding:15px 20px;margin-bottom:20px;"> <div class="card" style="padding:15px 20px;margin-bottom:20px;">
<div style="display:flex;gap:20px;font-size:13px;flex-wrap:wrap;"> <div style="display:flex;gap:20px;font-size:13px;flex-wrap:wrap;align-items:center;">
<div><strong>التاريخ:</strong> <?= e($session['session_date']) ?></div> <div><strong>التاريخ:</strong> <?= e($session['session_date']) ?></div>
<div><strong>الوقت:</strong> <?= e(substr($session['start_time'], 0, 5)) ?> - <?= e(substr($session['end_time'], 0, 5)) ?></div> <div><strong>الوقت:</strong> <?= e(substr($session['start_time'], 0, 5)) ?> - <?= e(substr($session['end_time'], 0, 5)) ?></div>
<div><strong>اللاعبون:</strong> <?= (int) $session['current_players'] ?> / <?= (int) $session['max_players'] ?></div> <div><strong>اللاعبون:</strong> <?= (int) $session['current_players'] ?> / <?= (int) $session['max_players'] ?></div>
...@@ -21,6 +21,26 @@ ...@@ -21,6 +21,26 @@
<div> <div>
<span style="padding:3px 10px;border-radius:8px;font-size:12px;font-weight:600;background:<?= $statusColors[$ss] ?? '#6B7280' ?>15;color:<?= $statusColors[$ss] ?? '#6B7280' ?>;"><?= $statusLabels[$ss] ?? $ss ?></span> <span style="padding:3px 10px;border-radius:8px;font-size:12px;font-weight:600;background:<?= $statusColors[$ss] ?? '#6B7280' ?>15;color:<?= $statusColors[$ss] ?? '#6B7280' ?>;"><?= $statusLabels[$ss] ?? $ss ?></span>
</div> </div>
<div style="margin-right:auto;display:flex;gap:8px;">
<?php if ($ss === 'open'): ?>
<form method="POST" action="/sa/games/<?= (int) $game['id'] ?>/sessions/<?= (int) $session['id'] ?>/start" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="background:#2563EB;color:#fff;padding:5px 12px;font-size:12px;border-radius:6px;">بدء الجلسة</button>
</form>
<?php endif; ?>
<?php if (in_array($ss, ['open', 'in_progress', 'full'])): ?>
<form method="POST" action="/sa/games/<?= (int) $game['id'] ?>/sessions/<?= (int) $session['id'] ?>/complete" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="background:#059669;color:#fff;padding:5px 12px;font-size:12px;border-radius:6px;">إنهاء الجلسة</button>
</form>
<?php endif; ?>
<?php if ($ss !== 'completed' && $ss !== 'cancelled'): ?>
<form method="POST" action="/sa/games/<?= (int) $game['id'] ?>/sessions/<?= (int) $session['id'] ?>/cancel" style="display:inline;" onsubmit="return confirm('هل أنت متأكد من إلغاء الجلسة؟')">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="background:#DC2626;color:#fff;padding:5px 12px;font-size:12px;border-radius:6px;">إلغاء</button>
</form>
<?php endif; ?>
</div>
</div> </div>
</div> </div>
......
...@@ -5,14 +5,17 @@ ...@@ -5,14 +5,17 @@
## 1. مكتب الاشتراكات — تسجيل لاعب جديد ## 1. مكتب الاشتراكات — تسجيل لاعب جديد
### الهدف ### الهدف
تسجيل لاعب (عضو أو غير عضو) في النشاط الرياضي، دفع رسوم التسجيل، التقاط صورة، واختيار الأنشطة المطلوبة. تسجيل لاعب (عضو أو غير عضو) في النشاط الرياضي، دفع رسوم التسجيل، التقاط صورة، واختيار الأنشطة المطلوبة.
### المسار ### المسار
`/sa/registration` `/sa/registration`
### الخطوات ### الخطوات
#### خطوة 1: إدخال البيانات #### خطوة 1: إدخال البيانات
1. ادخل على **مكتب الخدمة****التسجيل الرياضي** من الشريط الجانبي 1. ادخل على **مكتب الخدمة****التسجيل الرياضي** من الشريط الجانبي
2. أدخل **رقم العضوية** (للأعضاء — يملأ الاسم والهاتف والرقم القومي تلقائياً) 2. أدخل **رقم العضوية** (للأعضاء — يملأ الاسم والهاتف والرقم القومي تلقائياً)
3. أو أدخل **الرقم القومي** (14 رقم — يستخرج تاريخ الميلاد والنوع تلقائياً) 3. أو أدخل **الرقم القومي** (14 رقم — يستخرج تاريخ الميلاد والنوع تلقائياً)
...@@ -22,6 +25,7 @@ ...@@ -22,6 +25,7 @@
> **ملاحظة**: إذا كان الشخص عضو في النادي (موجود في قاعدة البيانات)، يدفع **50 ج.م**. غير الأعضاء يدفعون **100 ج.م**. > **ملاحظة**: إذا كان الشخص عضو في النادي (موجود في قاعدة البيانات)، يدفع **50 ج.م**. غير الأعضاء يدفعون **100 ج.م**.
#### خطوة 2: الدفع (Step 1 في الويزارد) #### خطوة 2: الدفع (Step 1 في الويزارد)
- تظهر شاشة الدفع بالمبلغ المطلوب - تظهر شاشة الدفع بالمبلغ المطلوب
- اضغط **إرسال للخزينة** - اضغط **إرسال للخزينة**
- يتم إنشاء طلب دفع يظهر عند الخزينة في طابور الدفع - يتم إنشاء طلب دفع يظهر عند الخزينة في طابور الدفع
...@@ -30,20 +34,24 @@ ...@@ -30,20 +34,24 @@
> **لاعب عائد** (سبق ودفع رسوم التسجيل قبل كده): يتخطى هذه الخطوة مباشرة. > **لاعب عائد** (سبق ودفع رسوم التسجيل قبل كده): يتخطى هذه الخطوة مباشرة.
#### خطوة 3: التقاط الصورة (Step 2) #### خطوة 3: التقاط الصورة (Step 2)
- افتح الكاميرا أو ارفع ملف صورة - افتح الكاميرا أو ارفع ملف صورة
- اضغط **التالي** - اضغط **التالي**
#### خطوة 4: اختيار الأنشطة (Step 3) #### خطوة 4: اختيار الأنشطة (Step 3)
- اختر النشاط/الأنشطة المطلوبة (كرة قدم، سباحة، كاراتيه، إلخ) - اختر النشاط/الأنشطة المطلوبة (كرة قدم، سباحة، كاراتيه، إلخ)
- هذا لا يعيّن مجموعة — فقط يسجل رغبة اللاعب - هذا لا يعيّن مجموعة — فقط يسجل رغبة اللاعب
- اضغط **حفظ الأنشطة المختارة** - اضغط **حفظ الأنشطة المختارة**
#### خطوة 5: الاستلام (Step 4) #### خطوة 5: الاستلام (Step 4)
- **طباعة الاستمارة** — استمارة ورقية بالبيانات + QR - **طباعة الاستمارة** — استمارة ورقية بالبيانات + QR
- **إنشاء الكارت** — كارت النشاط الرياضي (يعمل فقط بعد اكتمال الدفع) - **إنشاء الكارت** — كارت النشاط الرياضي (يعمل فقط بعد اكتمال الدفع)
- **طباعة الكارت** — بعد إنشائه - **طباعة الكارت** — بعد إنشائه
### النتيجة ### النتيجة
اللاعب مسجل بحالة `completed` وينتظر **التقييم الفني من المدرب**. اللاعب مسجل بحالة `completed` وينتظر **التقييم الفني من المدرب**.
--- ---
...@@ -51,23 +59,28 @@ ...@@ -51,23 +59,28 @@
## 2. تقييم المدرب — تعيين لاعب في مجموعة ## 2. تقييم المدرب — تعيين لاعب في مجموعة
### الهدف ### الهدف
المدرب يقيّم اللاعب فنياً ويعيّنه في المجموعة المناسبة. المدرب يقيّم اللاعب فنياً ويعيّنه في المجموعة المناسبة.
### المسار ### المسار
`/sa/coach-assessment` `/sa/coach-assessment`
### من يستخدمه ### من يستخدمه
المدرب أو الإدارة (صلاحية `sa.coach_assessment.view` + `sa.coach_assessment.manage`) المدرب أو الإدارة (صلاحية `sa.coach_assessment.view` + `sa.coach_assessment.manage`)
### الخطوات ### الخطوات
#### خطوة 1: فتح شاشة التقييم #### خطوة 1: فتح شاشة التقييم
1. من الشريط الجانبي ← **تقييم اللاعبين** (تحت قسم "── تقييم المدربين ──") 1. من الشريط الجانبي ← **تقييم اللاعبين** (تحت قسم "── تقييم المدربين ──")
2. تظهر قائمة اللاعبين المنتظرين للتقييم 2. تظهر قائمة اللاعبين المنتظرين للتقييم
3. إذا المدرب مرتبط بنشاط معين (مثلاً كاراتيه)، يرى فقط اللاعبين اللي اختاروا الكاراتيه 3. إذا المدرب مرتبط بنشاط معين (مثلاً كاراتيه)، يرى فقط اللاعبين اللي اختاروا الكاراتيه
4. يمكن فلترة بنشاط معين من القائمة المنسدلة 4. يمكن فلترة بنشاط معين من القائمة المنسدلة
#### خطوة 2: تقييم لاعب #### خطوة 2: تقييم لاعب
1. اضغط **تقييم** بجانب اسم اللاعب 1. اضغط **تقييم** بجانب اسم اللاعب
2. تفتح صفحة التقييم: 2. تفتح صفحة التقييم:
- بيانات اللاعب + الأنشطة المطلوبة ظاهرة - بيانات اللاعب + الأنشطة المطلوبة ظاهرة
...@@ -77,12 +90,14 @@ ...@@ -77,12 +90,14 @@
3. اضغط **تأكيد التقييم والتعيين** 3. اضغط **تأكيد التقييم والتعيين**
### ماذا يحدث بعد التأكيد ### ماذا يحدث بعد التأكيد
- اللاعب يُضاف للمجموعة بحالة `active` - اللاعب يُضاف للمجموعة بحالة `active`
- يُنشأ اشتراك شهري بمبلغ المجموعة (عضو/غير عضو) - يُنشأ اشتراك شهري بمبلغ المجموعة (عضو/غير عضو)
- **يُرسل طلب دفع للخزينة** (اشتراك شهري) — يظهر في طابور الدفع - **يُرسل طلب دفع للخزينة** (اشتراك شهري) — يظهر في طابور الدفع
- حالة التسجيل تتحول إلى `assessed` - حالة التسجيل تتحول إلى `assessed`
### الدورة الكاملة: تقييم ← خزينة ### الدورة الكاملة: تقييم ← خزينة
1. المدرب يقيّم ويعيّن ← تسجيل "assessed" 1. المدرب يقيّم ويعيّن ← تسجيل "assessed"
2. طلب دفع اشتراك يظهر في الخزينة 2. طلب دفع اشتراك يظهر في الخزينة
3. اللاعب يروح يدفع 3. اللاعب يروح يدفع
...@@ -93,14 +108,17 @@ ...@@ -93,14 +108,17 @@
## 3. تعيين مدربين على المجموعات ## 3. تعيين مدربين على المجموعات
### الهدف ### الهدف
إضافة مدرب أساسي + مدربين مساعدين على مجموعة واحدة. إضافة مدرب أساسي + مدربين مساعدين على مجموعة واحدة.
### المسار ### المسار
`/sa/groups` ← إنشاء أو تعديل مجموعة `/sa/groups` ← إنشاء أو تعديل مجموعة
### الخطوات ### الخطوات
#### عند إنشاء مجموعة جديدة #### عند إنشاء مجموعة جديدة
1. اذهب إلى **المجموعات****إنشاء مجموعة** 1. اذهب إلى **المجموعات****إنشاء مجموعة**
2. اختر البرنامج + الاسم + السعة 2. اختر البرنامج + الاسم + السعة
3. **المدرب الأساسي** — اختره من القائمة (مطلوب) 3. **المدرب الأساسي** — اختره من القائمة (مطلوب)
...@@ -108,11 +126,13 @@ ...@@ -108,11 +126,13 @@
5. احفظ 5. احفظ
#### تعديل مدربين مجموعة قائمة #### تعديل مدربين مجموعة قائمة
1. اذهب إلى **المجموعات** ← اختر المجموعة ← **تعديل** 1. اذهب إلى **المجموعات** ← اختر المجموعة ← **تعديل**
2. غيّر المدرب الأساسي أو أضف/أزل مساعدين 2. غيّر المدرب الأساسي أو أضف/أزل مساعدين
3. احفظ 3. احفظ
### الأدوار ### الأدوار
- `primary` — المدرب الأساسي (واحد فقط) - `primary` — المدرب الأساسي (واحد فقط)
- `assistant` — مدرب مساعد (عدد غير محدود) - `assistant` — مدرب مساعد (عدد غير محدود)
...@@ -121,32 +141,39 @@ ...@@ -121,32 +141,39 @@
## 4. حضور التدريب ## 4. حضور التدريب
### الهدف ### الهدف
تسجيل حضور/غياب اللاعبين في التدريبات اليومية. تسجيل حضور/غياب اللاعبين في التدريبات اليومية.
### المسار ### المسار
`/sa/training-attendance` `/sa/training-attendance`
### من يستخدمه ### من يستخدمه
المدرب أو الإدارة (صلاحية `sa.attendance.manage`) المدرب أو الإدارة (صلاحية `sa.attendance.manage`)
### الخطوات ### الخطوات
#### خطوة 1: اختيار اليوم والمجموعة #### خطوة 1: اختيار اليوم والمجموعة
1. من الشريط الجانبي ← **حضور التدريب** 1. من الشريط الجانبي ← **حضور التدريب**
2. اختر التاريخ (افتراضي: اليوم) 2. اختر التاريخ (افتراضي: اليوم)
3. تظهر المجموعات المجدولة لهذا اليوم (حسب `sa_group_schedule`) 3. تظهر المجموعات المجدولة لهذا اليوم (حسب `sa_group_schedule`)
4. اضغط **تسجيل حضور** بجانب المجموعة 4. اضغط **تسجيل حضور** بجانب المجموعة
#### خطوة 2: تسجيل الحضور #### خطوة 2: تسجيل الحضور
1. تظهر قائمة لاعبي المجموعة النشطين 1. تظهر قائمة لاعبي المجموعة النشطين
2. لكل لاعب اختر: **حاضر** / **غائب** / **معذور** / **متأخر** / **تعويضي** 2. لكل لاعب اختر: **حاضر** / **غائب** / **معذور** / **متأخر** / **تعويضي**
3. اضغط **حفظ** 3. اضغط **حفظ**
### التنبيهات الآلية ### التنبيهات الآلية
- إذا تجاوز لاعب حد الغياب (افتراضي 5 مرات/شهر) ← SMS لولي الأمر تلقائياً - إذا تجاوز لاعب حد الغياب (افتراضي 5 مرات/شهر) ← SMS لولي الأمر تلقائياً
- يمكن رؤية التقرير من `/sa/training-attendance/report` - يمكن رؤية التقرير من `/sa/training-attendance/report`
### معلومات المجموعة ### معلومات المجموعة
- كل مجموعة تظهر: اسمها + المدرب + عدد اللاعبين - كل مجموعة تظهر: اسمها + المدرب + عدد اللاعبين
- من صفحة المجموعة (`/sa/groups/{id}`) يمكن رؤية: - من صفحة المجموعة (`/sa/groups/{id}`) يمكن رؤية:
- المدرب الأساسي + المساعدين - المدرب الأساسي + المساعدين
...@@ -158,19 +185,23 @@ ...@@ -158,19 +185,23 @@
## 5. مدربين السباحة (Freelance) — حجز حارات ## 5. مدربين السباحة (Freelance) — حجز حارات
### الهدف ### الهدف
إدارة مدربين السباحة المستقلين وحجز حارات لهم. إدارة مدربين السباحة المستقلين وحجز حارات لهم.
### المسار ### المسار
`/sa/swimming/coaches` — إدارة المدربين `/sa/swimming/coaches` — إدارة المدربين
`/sa/mirror/pool/{facility_id}` — مراية حمام السباحة (حجز الحارات) `/sa/mirror/pool/{facility_id}` — مراية حمام السباحة (حجز الحارات)
### إضافة مدرب سباحة ### إضافة مدرب سباحة
1. من الشريط الجانبي ← **مدربين السباحة** (تحت قسم "── السباحة ──") 1. من الشريط الجانبي ← **مدربين السباحة** (تحت قسم "── السباحة ──")
2. اضغط **إضافة مدرب** 2. اضغط **إضافة مدرب**
3. أدخل: الاسم + الرقم القومي + الهاتف + نوع التعاقد (freelance/staff/contract) 3. أدخل: الاسم + الرقم القومي + الهاتف + نوع التعاقد (freelance/staff/contract)
4. يُربط تلقائياً بنشاط "السباحة" 4. يُربط تلقائياً بنشاط "السباحة"
### حجز حارة لمدرب Freelance ### حجز حارة لمدرب Freelance
1. اذهب إلى **المراية****مراية حمام السباحة** (أو `/sa/mirror/pool/{id}`) 1. اذهب إلى **المراية****مراية حمام السباحة** (أو `/sa/mirror/pool/{id}`)
2. تظهر شبكة الحارات × المواعيد (grid) 2. تظهر شبكة الحارات × المواعيد (grid)
3. حدد الخانات (حارة + وقت) المطلوبة 3. حدد الخانات (حارة + وقت) المطلوبة
...@@ -179,6 +210,7 @@ ...@@ -179,6 +210,7 @@
6. احفظ 6. احفظ
### التحقق من التوفر ### التحقق من التوفر
- الشبكة تظهر الخانات المحجوزة بألوان مختلفة: - الشبكة تظهر الخانات المحجوزة بألوان مختلفة:
- أخضر = تدريب (training) - أخضر = تدريب (training)
- أزرق = حجز ساعي (hourly) — freelance - أزرق = حجز ساعي (hourly) — freelance
...@@ -188,11 +220,13 @@ ...@@ -188,11 +220,13 @@
- المراية في real-time — كل حجز يظهر فوراً - المراية في real-time — كل حجز يظهر فوراً
### عدد أفراد الحارة ### عدد أفراد الحارة
- نظام Open Access (`open_access`) فيه `max_occupancy` لكل منطقة - نظام Open Access (`open_access`) فيه `max_occupancy` لكل منطقة
- إذا المنطقة وصلت الحد الأقصى → لا يمكن إصدار تذاكر إضافية - إذا المنطقة وصلت الحد الأقصى → لا يمكن إصدار تذاكر إضافية
- تذاكر السباحة (`/sa/pool-tickets`) تتحكم في الإشغال - تذاكر السباحة (`/sa/pool-tickets`) تتحكم في الإشغال
### تذاكر الدخول (Booking Passes) ### تذاكر الدخول (Booking Passes)
- عند حجز ساعي بنمط "passes" ← تُنشأ تذاكر دخول لكل فرد - عند حجز ساعي بنمط "passes" ← تُنشأ تذاكر دخول لكل فرد
- رقم التذكرة: `{رقم_الحجز}-P01`, `P02`, ... - رقم التذكرة: `{رقم_الحجز}-P01`, `P02`, ...
- عند البوابة: يُمسح رقم التذكرة ← تُعلّم "مستخدمة" - عند البوابة: يُمسح رقم التذكرة ← تُعلّم "مستخدمة"
...@@ -203,14 +237,17 @@ ...@@ -203,14 +237,17 @@
## 6. الشهادات الطبية ## 6. الشهادات الطبية
### الهدف ### الهدف
كل لاعب يحتاج شهادة طبية سارية للتدريب. بدونها يُوقف الاشتراك. كل لاعب يحتاج شهادة طبية سارية للتدريب. بدونها يُوقف الاشتراك.
### المسار ### المسار
`/medical-board` — لجنة الفحص الطبي `/medical-board` — لجنة الفحص الطبي
### أنواع الشهادات ### أنواع الشهادات
| النوع | المدة | | النوع | المدة |
|-------|-------| | --------------------- | ------ |
| ترفيهي (recreational) | 12 شهر | | ترفيهي (recreational) | 12 شهر |
| أكاديمي (academy) | 6 شهور | | أكاديمي (academy) | 6 شهور |
| دولي (international) | 3 شهور | | دولي (international) | 3 شهور |
...@@ -218,11 +255,13 @@ ...@@ -218,11 +255,13 @@
### الدورة الكاملة ### الدورة الكاملة
#### خطوة 1: تقديم الشهادة #### خطوة 1: تقديم الشهادة
- من صفحة اللاعب ← رفع شهادة طبية (صورة/PDF) - من صفحة اللاعب ← رفع شهادة طبية (صورة/PDF)
- أو من تطبيق الموبايل (Player API) - أو من تطبيق الموبايل (Player API)
- الحالة: `pending` - الحالة: `pending`
#### خطوة 2: المراجعة والاعتماد #### خطوة 2: المراجعة والاعتماد
1. اذهب إلى **لجنة الفحص الطبي** (`/medical-board`) 1. اذهب إلى **لجنة الفحص الطبي** (`/medical-board`)
2. تظهر الشهادات المنتظرة من كلا المصدرين (عضوية + نشاط رياضي) 2. تظهر الشهادات المنتظرة من كلا المصدرين (عضوية + نشاط رياضي)
3. لكل شهادة: 3. لكل شهادة:
...@@ -230,10 +269,12 @@ ...@@ -230,10 +269,12 @@
- **رفض**`medical_status = unfit` ← اللاعب لا يمكنه التدريب - **رفض**`medical_status = unfit` ← اللاعب لا يمكنه التدريب
#### خطوة 3: التأثير على الاشتراكات #### خطوة 3: التأثير على الاشتراكات
- عند توليد اشتراكات شهرية ← يتم التحقق من الشهادة الطبية - عند توليد اشتراكات شهرية ← يتم التحقق من الشهادة الطبية
- إذا منتهية: الاشتراك يُنشأ لكن `medical_verified = 0` - إذا منتهية: الاشتراك يُنشأ لكن `medical_verified = 0`
#### خطوة 4: التذكيرات والإيقاف (Cron — يومياً) #### خطوة 4: التذكيرات والإيقاف (Cron — يومياً)
- **قبل 30 يوم من الانتهاء** ← تذكير SMS - **قبل 30 يوم من الانتهاء** ← تذكير SMS
- **بعد الانتهاء**`medical_status = expired` - **بعد الانتهاء**`medical_status = expired`
- **بعد انتهاء فترة السماح** ← إيقاف التسجيل في المجموعة + إنقاص عداد المجموعة - **بعد انتهاء فترة السماح** ← إيقاف التسجيل في المجموعة + إنقاص عداد المجموعة
...@@ -243,23 +284,28 @@ ...@@ -243,23 +284,28 @@
## 7. تجديد الاشتراك الشهري ## 7. تجديد الاشتراك الشهري
### الهدف ### الهدف
كل أول شهر يُنشأ اشتراك جديد لكل لاعب نشط في مجموعاته. كل أول شهر يُنشأ اشتراك جديد لكل لاعب نشط في مجموعاته.
### المسار ### المسار
`/sa/subscriptions` → زر **توليد اشتراكات** `/sa/subscriptions` → زر **توليد اشتراكات**
### من يستخدمه ### من يستخدمه
الإدارة (صلاحية `sa.subscription.generate`) الإدارة (صلاحية `sa.subscription.generate`)
### الخطوات ### الخطوات
#### خطوة 1: توليد الاشتراكات #### خطوة 1: توليد الاشتراكات
1. اذهب إلى **الاشتراكات** (`/sa/subscriptions`) 1. اذهب إلى **الاشتراكات** (`/sa/subscriptions`)
2. اضغط **توليد اشتراكات** 2. اضغط **توليد اشتراكات**
3. اختر الشهر (مثلاً `2026-08`) 3. اختر الشهر (مثلاً `2026-08`)
4. اضغط **توليد** 4. اضغط **توليد**
#### ماذا يحدث #### ماذا يحدث
- يتم جلب كل لاعب نشط (`sa_group_players.status = active`) في مجموعة نشطة - يتم جلب كل لاعب نشط (`sa_group_players.status = active`) في مجموعة نشطة
- يُتحقق من عدم وجود اشتراك مسبق لنفس الشهر - يُتحقق من عدم وجود اشتراك مسبق لنفس الشهر
- يُتحقق من حالة الشهادة الطبية - يُتحقق من حالة الشهادة الطبية
...@@ -270,12 +316,14 @@ ...@@ -270,12 +316,14 @@
- لو أول شهر للاعب ودخل بعد يوم 15 ← المبلغ ينخفض للنصف - لو أول شهر للاعب ودخل بعد يوم 15 ← المبلغ ينخفض للنصف
#### خطوة 2: التحصيل #### خطوة 2: التحصيل
- من شاشة الاشتراكات ← فلتر حسب الشهر/المجموعة/حالة الدفع - من شاشة الاشتراكات ← فلتر حسب الشهر/المجموعة/حالة الدفع
- اضغط على الاشتراك ← **تحصيل** - اضغط على الاشتراك ← **تحصيل**
- اختر طريقة الدفع (كاش/فيزا/شيك/تحويل) - اختر طريقة الدفع (كاش/فيزا/شيك/تحويل)
- أو **إعفاء** مع سبب - أو **إعفاء** مع سبب
### الاشتراك المتأخر ### الاشتراك المتأخر
- Cron يومي يعلّم الاشتراكات المنتهية (`period_end < today`) بحالة `overdue` - Cron يومي يعلّم الاشتراكات المنتهية (`period_end < today`) بحالة `overdue`
- يرسل SMS لولي الأمر - يرسل SMS لولي الأمر
...@@ -284,6 +332,7 @@ ...@@ -284,6 +332,7 @@
## 8. دورة الدفع بعد تعيين المدرب — من التقييم للخزينة ## 8. دورة الدفع بعد تعيين المدرب — من التقييم للخزينة
### الهدف ### الهدف
بعد ما المدرب يعيّن لاعب في مجموعة ← اللاعب يروح الخزينة يدفع اشتراك الشهر. بعد ما المدرب يعيّن لاعب في مجموعة ← اللاعب يروح الخزينة يدفع اشتراك الشهر.
### الدورة ### الدورة
...@@ -315,6 +364,7 @@ ...@@ -315,6 +364,7 @@
6. **Event Listener** (`SaEventListenerService::handleSubscriptionPaid`) يعلّم الاشتراك `paid` 6. **Event Listener** (`SaEventListenerService::handleSubscriptionPaid`) يعلّم الاشتراك `paid`
### ملاحظة مهمة ### ملاحظة مهمة
اللاعب يكون نشط في المجموعة فوراً بعد التقييم (لا ينتظر الدفع). الدفع يحصل بالتوازي ولا يمنع الحضور. اللاعب يكون نشط في المجموعة فوراً بعد التقييم (لا ينتظر الدفع). الدفع يحصل بالتوازي ولا يمنع الحضور.
--- ---
...@@ -322,7 +372,7 @@ ...@@ -322,7 +372,7 @@
## ملخص المسارات ## ملخص المسارات
| الخطوة | من يفعلها | أين | | الخطوة | من يفعلها | أين |
|--------|----------|-----| | ------------------------ | --------------- | --------------------------- |
| تسجيل لاعب جديد | مكتب الاشتراكات | `/sa/registration` | | تسجيل لاعب جديد | مكتب الاشتراكات | `/sa/registration` |
| دفع رسوم التسجيل | الخزينة | طابور الدفع | | دفع رسوم التسجيل | الخزينة | طابور الدفع |
| تقييم فني + تعيين مجموعة | المدرب | `/sa/coach-assessment` | | تقييم فني + تعيين مجموعة | المدرب | `/sa/coach-assessment` |
...@@ -332,3 +382,408 @@ ...@@ -332,3 +382,408 @@
| اعتماد شهادة طبية | لجنة الفحص | `/medical-board` | | اعتماد شهادة طبية | لجنة الفحص | `/medical-board` |
| حجز حارة سباحة | الإدارة | `/sa/mirror/pool/{id}` | | حجز حارة سباحة | الإدارة | `/sa/mirror/pool/{id}` |
| إدارة مدربين سباحة | الإدارة | `/sa/swimming/coaches` | | إدارة مدربين سباحة | الإدارة | `/sa/swimming/coaches` |
| تذكرة نشاط (مكتب الخدمة) | مكتب الخدمة | `/sa/service-desk#ticket` |
| تذكرة لعبة ترفيهية | مكتب الخدمة | `/sa/games/{id}/sessions/{sid}` |
| حجز ملعب (معالج الحجز) | مكتب الخدمة | `/sa/booking-wizard` |
---
## 9. تذكرة نشاط — مكتب الخدمة (دخول سريع)
### الهدف
إصدار تذكرة دخول سريعة لشخص (عضو أو ضيف) لأي نشاط ترفيهي بدون حجز مسبق. مناسب للأنشطة اللحظية مثل: سباحة حرة، بنج بونج، بلاي ستيشن، إلخ.
### المسار
`/sa/service-desk` ← تبويب **تذكرة نشاط**
### من يستخدمه
موظف مكتب الخدمة (صلاحية `sa.registration.manage`)
### الخطوات
#### خطوة 1: البحث (اختياري)
1. أدخل **الرقم القومي** في شريط البحث أعلى الصفحة
2. اضغط **بحث**
3. إذا كان عضو ← تظهر بيانات العضوية + اسمه + كارنيه (إن وُجد)
4. يتم ملء اسم الضيف تلقائياً من بيانات العضو
#### خطوة 2: ملء البيانات
1. اختر **نوع النشاط** من القائمة:
- سباحة حرة (`free_swim`)
- حجز ملعب (`court_booking`)
- ترفيه (`recreation`)
- بنج بونج (`ping_pong`)
- بولينج (`bowling`)
- بلاي ستيشن (`playstation`)
- تنس (`tennis`)
- بادل (`paddle`)
- جيم (`gym`)
2. أدخل **اسم الضيف** (يُملأ تلقائياً لو عملت بحث)
3. حدد **عدد الأشخاص** (افتراضي: 1)
4. أدخل **المبلغ** المطلوب (يدوي — مثلاً 50 أو 100 ج.م)
5. اختر **المرفق** (اختياري — حمام سباحة، ملعب تنس، إلخ)
6. ملاحظات (اختياري)
#### خطوة 3: الإصدار
1. اضغط **إصدار التذكرة**
2. يظهر تأكيد "تم إصدار التذكرة بنجاح" ✓
### ماذا يحدث داخلياً
- يُنشأ سجل في `carnet_guest_entries` بالبيانات المُدخلة
- إذا العضو عنده **كارنيه ضيافة** ← يُخصم من رصيد دعواته تلقائياً
- إذا ما عندوش كارنيه ← التذكرة تُسجّل بدون خصم (الدفع مباشر)
- المبلغ يُسجّل كـ `amount_paid` مباشرة (لا يذهب لطابور الدفع)
### الحالات الخاصة
| الحالة | السلوك |
|--------|--------|
| عضو بكارنيه + رصيد متاح | يُخصم من رصيد الكارنيه |
| عضو بكارنيه + رصيد منتهي | خطأ: "تم استنفاذ جميع الدعوات" |
| عضو بدون كارنيه | يمر عادي — المبلغ يُدفع مباشر |
| ضيف (غير عضو) | لا بحث مسبق — أدخل الاسم يدوي |
| عضو + رصيد كارنيه = 2 أو أقل | تنبيه "رصيد منخفض" يُطلق |
### متى تستخدم هذه الطريقة
- دخول سريع لنشاط واحد (لا حاجة لجلسة أو حجز)
- ضيوف عابرين (مرة واحدة)
- تسجيل دخول سريع لأنشطة بسيطة (جيم، بنج بونج)
### متى لا تستخدمها
- حجز ملعب بوقت محدد ← استخدم **معالج الحجز** (القسم 11)
- بولينج/بلاي ستيشن مع تتبع جلسة وسعة ← استخدم **تذكرة لعبة ترفيهية** (القسم 10)
---
## 10. تذكرة لعبة ترفيهية — بولينج / بلاي ستيشن / إلخ
### الهدف
إدارة الألعاب الترفيهية بنظام الجلسات: فتح جلسة ← إصدار تذاكر للاعبين ← تتبع السعة ← إغلاق الجلسة. كل تذكرة تُرسل للخزنة الفرعية في طابور الدفع.
### المسار
`/sa/games` — قائمة الألعاب
`/sa/games/{id}` — تفاصيل لعبة + جلساتها
`/sa/games/{id}/sessions/{sid}` — جلسة محددة + إصدار تذاكر
### من يستخدمه
مشرف الألعاب (صلاحية `sa.game.view` + `sa.game.manage`)
### التسلسل الكامل
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ إنشاء لعبة │───▶│ فتح جلسة │───▶│ إصدار تذاكر │───▶│ إنهاء الجلسة │
│ (مرة واحدة) │ │ (يومياً) │ │ (لكل لاعب) │ │ (بعد اللعب) │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
┌──────────────────────┐
│ طلب دفع → الخزنة │
│ الفرعية (طابور الدفع)│
└──────────────────────┘
```
### خطوة 1: إنشاء لعبة (مرة واحدة)
1. اذهب إلى `/sa/games`**إنشاء لعبة**
2. أدخل:
- **اسم اللعبة** (عربي) — مثلاً "بولينج" أو "بلاي ستيشن"
- **اسم إنجليزي** (اختياري)
- **نوع التسعير**: `per_game` (سعر ثابت للمباراة) أو `per_hour` (سعر × ساعات الجلسة)
- **سعر الأعضاء** (fee_member)
- **سعر غير الأعضاء** (fee_nonmember)
- **أقصى عدد لاعبين** في الجلسة
- **مدة الجلسة** بالدقائق (افتراضي 60)
- **المرفق** المرتبط (اختياري — مثلاً "صالة البولينج")
3. احفظ
> **مثال**: بولينج → per_game, fee_member=650, fee_nonmember=900, max_players=6
### خطوة 2: فتح جلسة جديدة (يومياً)
1. من صفحة اللعبة (`/sa/games/{id}`)
2. أدخل: **التاريخ** + **وقت البدء** + **وقت الانتهاء**
3. اضغط **إنشاء جلسة**
4. الجلسة تُنشأ بحالة `open`
### خطوة 3: إصدار تذاكر (لكل لاعب)
1. ادخل على الجلسة (`/sa/games/{id}/sessions/{sid}`)
2. في نموذج "إصدار تذكرة":
- أدخل **اسم اللاعب/الضيف**
- اختر **النوع**: ضيف / عضو / غير عضو
3. اضغط **إصدار تذكرة**
4. النظام:
- يحسب الرسوم تلقائياً حسب نوع التسعير ونوع اللاعب
- يُنشئ تذكرة برقم فريد (GT-XXXXXXXX)
- **يُنشئ طلب دفع** يظهر في طابور الخزنة الفرعية
- يزيد عداد اللاعبين في الجلسة
- إذا الجلسة امتلأت ← تتحول لحالة `full` تلقائياً
5. تظهر رسالة: "تم إصدار تذكرة GT-XXXXXXXX بنجاح — في انتظار التحصيل من الخزنة"
### خطوة 4: إدارة حالة الجلسة
من صفحة الجلسة تتوفر أزرار:
| الزر | الشرط | الأثر |
|------|-------|-------|
| **بدء الجلسة** | حالة = `open` | تتحول إلى `in_progress` |
| **إنهاء الجلسة** | حالة = `open`/`in_progress`/`full` | تتحول إلى `completed` |
| **إلغاء** | حالة ≠ `completed` | تتحول إلى `cancelled` |
> **ملاحظة**: يمكن إصدار تذاكر أثناء حالة `open` أو `in_progress`.
### حساب الرسوم
| نوع التسعير | الحساب |
|-------------|--------|
| `per_game` | fee_member أو fee_nonmember (مبلغ ثابت) |
| `per_hour` | fee × عدد ساعات الجلسة (مقرّب لأعلى) |
**مثال**: بولينج per_game, fee_nonmember=900 ← كل تذكرة لغير عضو = 900 ج.م
**مثال**: بلاي ستيشن per_hour, fee_member=50, جلسة من 14:00-16:00 (ساعتين) ← 50×2 = 100 ج.م
### دورة الدفع
```
إصدار تذكرة ← طلب دفع (payment_requests) بنوع sa_game_ticket
يظهر في الخزنة الفرعية (طابور الدفع)
الخزينة تحصّل ← event: payment_request.completed
التذكرة تتحول payment_status = 'paid' تلقائياً
```
### الحالات الخاصة
| الحالة | السلوك |
|--------|--------|
| الجلسة ممتلئة | لا يمكن إصدار تذاكر إضافية |
| الجلسة مكتملة أو ملغية | لا يمكن إصدار تذاكر |
| رسوم = 0 | التذكرة تُنشأ بحالة `paid` مباشرة (بدون طلب دفع) |
| تذكرة لعضو | يستخدم `fee_member` |
| تذكرة لضيف أو غير عضو | يستخدم `fee_nonmember` |
---
## 11. حجز ملعب/مرفق — معالج الحجز
### الهدف
حجز مرفق (ملعب تنس، كورت بادل، ملعب كرة، جيم، إلخ) لوقت محدد مع حساب السعر التلقائي وإرسال طلب الدفع للخزنة الفرعية.
### المسار
`/sa/booking-wizard` — معالج حجز تفاعلي (4 خطوات)
### من يستخدمه
موظف مكتب الخدمة أو أي مستخدم بصلاحية `sa.booking_wizard.use`
### التسلسل الكامل
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1. الشخص │───▶│ 2. المرفق │───▶│ 3. الموعد │───▶│ 4. التأكيد │
│ (بحث/يدوي) │ │ (اختيار) │ │ (التوقيت) │ │ → الخزنة │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
```
### خطوة 1: تحديد الشخص
1. ابحث بـ **رقم العضوية** أو **الرقم القومي** أو **الاسم**
2. إذا وُجد عضو ← يُملأ الاسم والنوع تلقائياً (`booker_type = member`)
3. إذا لم يُوجد ← أدخل الاسم يدوياً (`booker_type = guest`)
4. يمكن أيضاً إدخال اسم مؤسسة (`organization`)
### خطوة 2: اختيار المرفق
1. تظهر جميع الوحدات المتاحة مجمّعة حسب المرفق
2. كل وحدة تظهر:
- اسمها (مثلاً "كورت 1")
- نمط الحجز: `exclusive` (حجز كامل) أو `shared` (سعة مشتركة)
- السعة القصوى
3. اضغط على الوحدة المطلوبة
### خطوة 3: اختيار التاريخ والوقت
1. اختر **التاريخ** من التقويم
2. تظهر **شبكة المواعيد** (time grid) مقسمة بالساعة:
- 🟢 أخضر = متاح
- 🟡 أصفر = جزئي (shared فيه أماكن)
- 🔴 أحمر = محجوز بالكامل
- ⚫ أسود = مغلق (blackout)
3. اضغط على الخانة الزمنية المطلوبة
4. يظهر **معاينة السعر** مباشرة:
- سعر الشخص × عدد المشاركين × عدد الساعات = الإجمالي
- نوع الفترة (peak/off-peak)
### خطوة 4: التأكيد والإرسال للخزنة
1. تظهر ملخص الحجز:
- الشخص + المرفق + الوحدة + التاريخ + الوقت + عدد المشاركين + المبلغ
2. اضغط **تأكيد وإرسال للخزينة**
3. النظام:
- يُنشئ حجز في `sa_bookings` بحالة `payment_status = pending`
- يُنشئ طلب دفع (`payment_requests`) بنوع `hourly_booking`
- يظهر طلب الدفع في **طابور الخزنة الفرعية**
4. تظهر شاشة النجاح: "تم الحجز — في انتظار التحصيل من الخزينة"
### نظام التسعير
السعر يُحسب تلقائياً بناءً على:
1. **فترة زمنية** (Time Bracket) — يحدد هل الوقت peak أو عادي
2. **قاعدة التسعير** (Pricing Rule) — السعر لكل شخص حسب الوحدة + الفترة + نوع الحاجز
| العامل | كيف يؤثر |
|--------|----------|
| نوع الحاجز (عضو/ضيف/مؤسسة) | سعر مختلف لكل فئة |
| الفترة (peak/off-peak) | Peak أغلى |
| عدد المشاركين | المبلغ × العدد |
| مدة الحجز (ساعات) | المبلغ × الساعات |
**المعادلة**: `الإجمالي = سعر_الشخص × عدد_المشاركين × عدد_الساعات`
### فحص التوفر (Availability Check)
النظام يتحقق تلقائياً قبل أي حجز من:
1. **الوحدة نشطة** — غير مؤرشفة
2. **لا يوجد إغلاق** (blackout) — على مستوى المرفق أو الوحدة
3. **نمط exclusive**: لا يوجد حجز آخر يتداخل مع نفس الوقت
4. **نمط shared**: مجموع الأماكن المحجوزة + المطلوبة ≤ السعة القصوى
5. **تذاكر السباحة**: إذا المرفق حمام سباحة ← يتحقق من شبكة الحارات
### دورة الدفع
```
حجز جديد ← طلب دفع بنوع hourly_booking
يظهر في الخزنة الفرعية (طابور الدفع)
الخزينة تحصّل ← event: payment_request.completed
الحجز يتحول payment_status = 'paid' تلقائياً
```
### بعد الحجز
| الإجراء | المسار | الشرط |
|---------|--------|-------|
| إلغاء | `/sa/bookings/{id}/cancel` | قبل وقت البدء |
| تأجيل | `/sa/bookings/{id}/postpone` | تغيير التاريخ/الوقت |
| تسجيل دخول | `/sa/bookings/{id}/checkin` | عند الحضور |
| تسجيل خروج | `/sa/bookings/{id}/checkout` | عند المغادرة |
### الحالات الخاصة
| الحالة | السلوك |
|--------|--------|
| حجز exclusive + يوجد حجز آخر | خطأ: "الوقت غير متاح" |
| حجز shared + سعة ممتلئة | خطأ: "متبقي X أماكن فقط" |
| تاريخ مغلق (blackout) | الخانة تظهر مقفلة ولا يمكن الضغط عليها |
| مبلغ = 0 | الحجز يُنشأ بحالة `paid` مباشرة |
| إلغاء حجز مدفوع (خلال 24 ساعة) | يُلغى طلب الدفع تلقائياً |
---
## 12. متى تستخدم كل نظام — دليل الاختيار
### للدخول السريع (بدون وقت محدد):
**مكتب الخدمة — تذكرة نشاط** (القسم 9)
مناسب لـ: جيم، بنج بونج، سباحة حرة، أي نشاط "ادخل الآن"
### للألعاب الترفيهية (بولينج، بلاي ستيشن):
**تذاكر الألعاب** (القسم 10)
مناسب لـ: أي نشاط بسعة محدودة + جلسة بوقت + تسعير تلقائي + تحصيل عبر الخزنة
### لحجز ملعب/مرفق بوقت محدد:
**معالج الحجز** (القسم 11)
مناسب لـ: ملاعب تنس، بادل، سكواش، كرة قدم، أي شيء محجوز بالساعة
### جدول المقارنة
| المعيار | تذكرة نشاط (§9) | تذكرة لعبة (§10) | حجز ملعب (§11) |
|---------|-----------------|------------------|----------------|
| يحتاج وقت محدد؟ | لا | نعم (جلسة) | نعم (slot) |
| يتتبع السعة؟ | لا | نعم (max_players) | نعم (availability) |
| يذهب للخزنة؟ | لا (مباشر) | نعم ✓ | نعم ✓ |
| يُحسب السعر تلقائياً؟ | لا (يدوي) | نعم ✓ | نعم ✓ |
| يدعم الأعضاء + الضيوف؟ | نعم | نعم | نعم |
| يحتاج كارنيه؟ | اختياري | لا | لا |
| جدول بصري (grid)؟ | لا | لا | نعم ✓ |
---
## 13. إعداد الألعاب الترفيهية — دليل المشرف
### لإضافة لعبة جديدة (مثلاً: بلاي ستيشن)
1. اذهب إلى `/sa/games/create`
2. أدخل:
- اسم: "بلاي ستيشن"
- نوع التسعير: `per_hour` (أو `per_game` حسب السياسة)
- سعر أعضاء: 50 ج.م
- سعر غير أعضاء: 75 ج.م
- أقصى عدد: 4 لاعبين
- مدة: 60 دقيقة
3. احفظ
### العمليات اليومية
1. **الصباح**: فتح جلسات جديدة للألعاب المطلوبة
2. **عند الطلب**: إصدار تذاكر لكل لاعب يأتي
3. **نهاية اليوم**: إنهاء/إلغاء الجلسات المفتوحة
### نصائح
- يمكن فتح عدة جلسات لنفس اللعبة في نفس اليوم (مثلاً جلسة صباحية + مسائية)
- الجلسة المكتملة لا يمكن إلغاؤها
- إذا لاعب لم يدفع ← التذكرة تبقى `unpaid` والخزنة تتابع التحصيل
---
## 14. إعداد المرافق والتسعير — دليل المشرف
### لإضافة مرفق جديد
1. `/sa/facilities/create` ← أدخل الاسم + النوع (pool/court/pitch/gym/track/multipurpose)
2. أضف وحدات: `/sa/facilities/{id}/units/create`
- مثلاً: "كورت بادل 1", booking_mode=exclusive, max_capacity=4
3. أضف فترات زمنية: `/sa/facilities/{id}/brackets`
- مثلاً: فترة صباحية 06:00-12:00 (off-peak), فترة مسائية 16:00-22:00 (peak)
4. أضف قواعد تسعير: `/sa/pricing/rules`
- مثلاً: كورت بادل + peak + member = 200 ج.م/شخص/ساعة
### إغلاق مرفق مؤقتاً (Blackout)
1. من صفحة المرفق ← **إضافة فترة إغلاق**
2. حدد: التاريخ من/إلى + السبب
3. خلال هذه الفترة لن يظهر المرفق كمتاح في المعالج
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