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
public static function recordEntry(array $data): CarnetGuestEntry
{
$db = App::getInstance()->db();
$carnetId = (int) $data['carnet_id'];
$carnetId = (int) ($data['carnet_id'] ?? 0);
$entry = CarnetGuestEntry::create($data);
$guestCount = (int) ($data['guest_count'] ?? 1);
$db->query(
"UPDATE carnets SET used_invitations = used_invitations + ? WHERE id = ?",
[$guestCount, $carnetId]
);
if ($carnetId > 0) {
$guestCount = (int) ($data['guest_count'] ?? 1);
$db->query(
"UPDATE carnets SET used_invitations = used_invitations + ? WHERE id = ?",
[$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', [
'entry_id' => (int) $entry->id,
'carnet_id' => $carnetId,
'member_id' => (int) $data['member_id'],
'member_id' => (int) ($data['member_id'] ?? 0),
'guest_name' => $data['guest_name'],
'facility_id'=> $data['facility_id'] ?? null,
'activity' => $data['activity_type'],
'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;
}
......
......@@ -24,7 +24,7 @@ final class PaymentRequestService
$notes = $data['notes'] ?? null;
$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' => 'العضو مطلوب'];
}
if ($paymentType === '') {
......@@ -345,6 +345,8 @@ final class PaymentRequestService
'carnet_replacement' => 'بدل فاقد كارنيه',
'sports_registration' => 'تسجيل رياضي',
'sa_registration_fee' => 'رسوم تسجيل نشاط رياضي',
'sa_game_ticket' => 'تذكرة لعبة ترفيهية',
'sa_pool_ticket' => 'تذكرة حمام سباحة',
'activity_subscription' => 'اشتراك نشاط',
'seasonal_fee' => 'رسوم عضوية موسمية',
'foreign_membership_fee' => 'رسوم عضوية أجنبية',
......
......@@ -7,6 +7,7 @@ use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Cashier\Services\PaymentRequestService;
class GameController extends Controller
{
......@@ -171,7 +172,7 @@ class GameController extends Controller
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('الجلسة غير متاحة للتسجيل');
}
......@@ -206,20 +207,39 @@ class GameController extends Controller
$db->beginTransaction();
try {
$db->insert('sa_game_tickets', [
$ticketId = $db->insert('sa_game_tickets', [
'session_id' => (int) $sessionId,
'ticket_number' => $ticketNumber,
'player_id' => $playerIdInput ?: null,
'player_name' => $playerName ?: null,
'player_type' => $playerType,
'fee_charged' => $fee,
'payment_status' => 'unpaid',
'payment_status' => $fee > 0 ? 'unpaid' : 'paid',
'status' => 'active',
'created_by' => (int) (App::getInstance()->session()->get('employee_id') ?? 0),
'created_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', [
'current_players' => (int) $session['current_players'] + 1,
'updated_at' => date('Y-m-d H:i:s'),
......@@ -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}")->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 [
['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'],
['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
['GET', '/sa/booking-wizard', 'SportsActivity\Controllers\BookingWizardController@index', ['auth'], 'sa.booking_wizard.use'],
......
......@@ -98,6 +98,8 @@ final class SaConstants
const ENTITY_REGISTRATIONS = 'sa_registrations';
const ENTITY_REG_FORM = 'sa_registration_form';
const ENTITY_PLAYER_CARDS = 'sa_player_cards';
const ENTITY_GAME_TICKETS = 'sa_game_tickets';
const ENTITY_POOL_TICKETS = 'sa_pool_tickets';
// Group statuses
const GROUP_ACTIVE = 'active';
......
......@@ -53,6 +53,16 @@ final class SaEventListenerService
);
return;
}
if ($entityType === SaConstants::ENTITY_GAME_TICKETS) {
self::handleGameTicketPaid($data);
return;
}
if ($entityType === SaConstants::ENTITY_POOL_TICKETS) {
self::handlePoolTicketPaid($data);
return;
}
} catch (\Throwable $e) {
Logger::error('SA payment_request.completed listener failed: ' . $e->getMessage());
}
......@@ -91,6 +101,24 @@ final class SaEventListenerService
], '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(
"SELECT id FROM sa_group_players WHERE activated_by_payment_id = ? AND status = 'active'",
[$paymentId]
......@@ -181,6 +209,36 @@ final class SaEventListenerService
], '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
{
if ($enrollmentId < 1 || $paymentId < 1) {
......
......@@ -9,7 +9,7 @@
<!-- Session Info -->
<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(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>
......@@ -21,6 +21,26 @@
<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>
</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>
......
......@@ -5,14 +5,17 @@
## 1. مكتب الاشتراكات — تسجيل لاعب جديد
### الهدف
تسجيل لاعب (عضو أو غير عضو) في النشاط الرياضي، دفع رسوم التسجيل، التقاط صورة، واختيار الأنشطة المطلوبة.
### المسار
`/sa/registration`
### الخطوات
#### خطوة 1: إدخال البيانات
1. ادخل على **مكتب الخدمة****التسجيل الرياضي** من الشريط الجانبي
2. أدخل **رقم العضوية** (للأعضاء — يملأ الاسم والهاتف والرقم القومي تلقائياً)
3. أو أدخل **الرقم القومي** (14 رقم — يستخرج تاريخ الميلاد والنوع تلقائياً)
......@@ -22,6 +25,7 @@
> **ملاحظة**: إذا كان الشخص عضو في النادي (موجود في قاعدة البيانات)، يدفع **50 ج.م**. غير الأعضاء يدفعون **100 ج.م**.
#### خطوة 2: الدفع (Step 1 في الويزارد)
- تظهر شاشة الدفع بالمبلغ المطلوب
- اضغط **إرسال للخزينة**
- يتم إنشاء طلب دفع يظهر عند الخزينة في طابور الدفع
......@@ -30,20 +34,24 @@
> **لاعب عائد** (سبق ودفع رسوم التسجيل قبل كده): يتخطى هذه الخطوة مباشرة.
#### خطوة 3: التقاط الصورة (Step 2)
- افتح الكاميرا أو ارفع ملف صورة
- اضغط **التالي**
#### خطوة 4: اختيار الأنشطة (Step 3)
- اختر النشاط/الأنشطة المطلوبة (كرة قدم، سباحة، كاراتيه، إلخ)
- هذا لا يعيّن مجموعة — فقط يسجل رغبة اللاعب
- اضغط **حفظ الأنشطة المختارة**
#### خطوة 5: الاستلام (Step 4)
- **طباعة الاستمارة** — استمارة ورقية بالبيانات + QR
- **إنشاء الكارت** — كارت النشاط الرياضي (يعمل فقط بعد اكتمال الدفع)
- **طباعة الكارت** — بعد إنشائه
### النتيجة
اللاعب مسجل بحالة `completed` وينتظر **التقييم الفني من المدرب**.
---
......@@ -51,23 +59,28 @@
## 2. تقييم المدرب — تعيين لاعب في مجموعة
### الهدف
المدرب يقيّم اللاعب فنياً ويعيّنه في المجموعة المناسبة.
### المسار
`/sa/coach-assessment`
### من يستخدمه
المدرب أو الإدارة (صلاحية `sa.coach_assessment.view` + `sa.coach_assessment.manage`)
### الخطوات
#### خطوة 1: فتح شاشة التقييم
1. من الشريط الجانبي ← **تقييم اللاعبين** (تحت قسم "── تقييم المدربين ──")
2. تظهر قائمة اللاعبين المنتظرين للتقييم
3. إذا المدرب مرتبط بنشاط معين (مثلاً كاراتيه)، يرى فقط اللاعبين اللي اختاروا الكاراتيه
4. يمكن فلترة بنشاط معين من القائمة المنسدلة
#### خطوة 2: تقييم لاعب
1. اضغط **تقييم** بجانب اسم اللاعب
2. تفتح صفحة التقييم:
- بيانات اللاعب + الأنشطة المطلوبة ظاهرة
......@@ -77,12 +90,14 @@
3. اضغط **تأكيد التقييم والتعيين**
### ماذا يحدث بعد التأكيد
- اللاعب يُضاف للمجموعة بحالة `active`
- يُنشأ اشتراك شهري بمبلغ المجموعة (عضو/غير عضو)
- **يُرسل طلب دفع للخزينة** (اشتراك شهري) — يظهر في طابور الدفع
- حالة التسجيل تتحول إلى `assessed`
### الدورة الكاملة: تقييم ← خزينة
1. المدرب يقيّم ويعيّن ← تسجيل "assessed"
2. طلب دفع اشتراك يظهر في الخزينة
3. اللاعب يروح يدفع
......@@ -93,14 +108,17 @@
## 3. تعيين مدربين على المجموعات
### الهدف
إضافة مدرب أساسي + مدربين مساعدين على مجموعة واحدة.
### المسار
`/sa/groups` ← إنشاء أو تعديل مجموعة
### الخطوات
#### عند إنشاء مجموعة جديدة
1. اذهب إلى **المجموعات****إنشاء مجموعة**
2. اختر البرنامج + الاسم + السعة
3. **المدرب الأساسي** — اختره من القائمة (مطلوب)
......@@ -108,11 +126,13 @@
5. احفظ
#### تعديل مدربين مجموعة قائمة
1. اذهب إلى **المجموعات** ← اختر المجموعة ← **تعديل**
2. غيّر المدرب الأساسي أو أضف/أزل مساعدين
3. احفظ
### الأدوار
- `primary` — المدرب الأساسي (واحد فقط)
- `assistant` — مدرب مساعد (عدد غير محدود)
......@@ -121,32 +141,39 @@
## 4. حضور التدريب
### الهدف
تسجيل حضور/غياب اللاعبين في التدريبات اليومية.
### المسار
`/sa/training-attendance`
### من يستخدمه
المدرب أو الإدارة (صلاحية `sa.attendance.manage`)
### الخطوات
#### خطوة 1: اختيار اليوم والمجموعة
1. من الشريط الجانبي ← **حضور التدريب**
2. اختر التاريخ (افتراضي: اليوم)
3. تظهر المجموعات المجدولة لهذا اليوم (حسب `sa_group_schedule`)
4. اضغط **تسجيل حضور** بجانب المجموعة
#### خطوة 2: تسجيل الحضور
1. تظهر قائمة لاعبي المجموعة النشطين
2. لكل لاعب اختر: **حاضر** / **غائب** / **معذور** / **متأخر** / **تعويضي**
3. اضغط **حفظ**
### التنبيهات الآلية
- إذا تجاوز لاعب حد الغياب (افتراضي 5 مرات/شهر) ← SMS لولي الأمر تلقائياً
- يمكن رؤية التقرير من `/sa/training-attendance/report`
### معلومات المجموعة
- كل مجموعة تظهر: اسمها + المدرب + عدد اللاعبين
- من صفحة المجموعة (`/sa/groups/{id}`) يمكن رؤية:
- المدرب الأساسي + المساعدين
......@@ -158,19 +185,23 @@
## 5. مدربين السباحة (Freelance) — حجز حارات
### الهدف
إدارة مدربين السباحة المستقلين وحجز حارات لهم.
### المسار
`/sa/swimming/coaches` — إدارة المدربين
`/sa/mirror/pool/{facility_id}` — مراية حمام السباحة (حجز الحارات)
### إضافة مدرب سباحة
1. من الشريط الجانبي ← **مدربين السباحة** (تحت قسم "── السباحة ──")
2. اضغط **إضافة مدرب**
3. أدخل: الاسم + الرقم القومي + الهاتف + نوع التعاقد (freelance/staff/contract)
4. يُربط تلقائياً بنشاط "السباحة"
### حجز حارة لمدرب Freelance
1. اذهب إلى **المراية****مراية حمام السباحة** (أو `/sa/mirror/pool/{id}`)
2. تظهر شبكة الحارات × المواعيد (grid)
3. حدد الخانات (حارة + وقت) المطلوبة
......@@ -179,6 +210,7 @@
6. احفظ
### التحقق من التوفر
- الشبكة تظهر الخانات المحجوزة بألوان مختلفة:
- أخضر = تدريب (training)
- أزرق = حجز ساعي (hourly) — freelance
......@@ -188,11 +220,13 @@
- المراية في real-time — كل حجز يظهر فوراً
### عدد أفراد الحارة
- نظام Open Access (`open_access`) فيه `max_occupancy` لكل منطقة
- إذا المنطقة وصلت الحد الأقصى → لا يمكن إصدار تذاكر إضافية
- تذاكر السباحة (`/sa/pool-tickets`) تتحكم في الإشغال
### تذاكر الدخول (Booking Passes)
- عند حجز ساعي بنمط "passes" ← تُنشأ تذاكر دخول لكل فرد
- رقم التذكرة: `{رقم_الحجز}-P01`, `P02`, ...
- عند البوابة: يُمسح رقم التذكرة ← تُعلّم "مستخدمة"
......@@ -203,26 +237,31 @@
## 6. الشهادات الطبية
### الهدف
كل لاعب يحتاج شهادة طبية سارية للتدريب. بدونها يُوقف الاشتراك.
### المسار
`/medical-board` — لجنة الفحص الطبي
### أنواع الشهادات
| النوع | المدة |
|-------|-------|
| النوع | المدة |
| --------------------- | ------ |
| ترفيهي (recreational) | 12 شهر |
| أكاديمي (academy) | 6 شهور |
| دولي (international) | 3 شهور |
| أكاديمي (academy) | 6 شهور |
| دولي (international) | 3 شهور |
### الدورة الكاملة
#### خطوة 1: تقديم الشهادة
- من صفحة اللاعب ← رفع شهادة طبية (صورة/PDF)
- أو من تطبيق الموبايل (Player API)
- الحالة: `pending`
#### خطوة 2: المراجعة والاعتماد
1. اذهب إلى **لجنة الفحص الطبي** (`/medical-board`)
2. تظهر الشهادات المنتظرة من كلا المصدرين (عضوية + نشاط رياضي)
3. لكل شهادة:
......@@ -230,10 +269,12 @@
- **رفض**`medical_status = unfit` ← اللاعب لا يمكنه التدريب
#### خطوة 3: التأثير على الاشتراكات
- عند توليد اشتراكات شهرية ← يتم التحقق من الشهادة الطبية
- إذا منتهية: الاشتراك يُنشأ لكن `medical_verified = 0`
#### خطوة 4: التذكيرات والإيقاف (Cron — يومياً)
- **قبل 30 يوم من الانتهاء** ← تذكير SMS
- **بعد الانتهاء**`medical_status = expired`
- **بعد انتهاء فترة السماح** ← إيقاف التسجيل في المجموعة + إنقاص عداد المجموعة
......@@ -243,23 +284,28 @@
## 7. تجديد الاشتراك الشهري
### الهدف
كل أول شهر يُنشأ اشتراك جديد لكل لاعب نشط في مجموعاته.
### المسار
`/sa/subscriptions` → زر **توليد اشتراكات**
### من يستخدمه
الإدارة (صلاحية `sa.subscription.generate`)
### الخطوات
#### خطوة 1: توليد الاشتراكات
1. اذهب إلى **الاشتراكات** (`/sa/subscriptions`)
2. اضغط **توليد اشتراكات**
3. اختر الشهر (مثلاً `2026-08`)
4. اضغط **توليد**
#### ماذا يحدث
- يتم جلب كل لاعب نشط (`sa_group_players.status = active`) في مجموعة نشطة
- يُتحقق من عدم وجود اشتراك مسبق لنفس الشهر
- يُتحقق من حالة الشهادة الطبية
......@@ -270,12 +316,14 @@
- لو أول شهر للاعب ودخل بعد يوم 15 ← المبلغ ينخفض للنصف
#### خطوة 2: التحصيل
- من شاشة الاشتراكات ← فلتر حسب الشهر/المجموعة/حالة الدفع
- اضغط على الاشتراك ← **تحصيل**
- اختر طريقة الدفع (كاش/فيزا/شيك/تحويل)
- أو **إعفاء** مع سبب
### الاشتراك المتأخر
- Cron يومي يعلّم الاشتراكات المنتهية (`period_end < today`) بحالة `overdue`
- يرسل SMS لولي الأمر
......@@ -284,6 +332,7 @@
## 8. دورة الدفع بعد تعيين المدرب — من التقييم للخزينة
### الهدف
بعد ما المدرب يعيّن لاعب في مجموعة ← اللاعب يروح الخزينة يدفع اشتراك الشهر.
### الدورة
......@@ -315,20 +364,426 @@
6. **Event Listener** (`SaEventListenerService::handleSubscriptionPaid`) يعلّم الاشتراك `paid`
### ملاحظة مهمة
اللاعب يكون نشط في المجموعة فوراً بعد التقييم (لا ينتظر الدفع). الدفع يحصل بالتوازي ولا يمنع الحضور.
---
## ملخص المسارات
| الخطوة | من يفعلها | أين |
|--------|----------|-----|
| تسجيل لاعب جديد | مكتب الاشتراكات | `/sa/registration` |
| دفع رسوم التسجيل | الخزينة | طابور الدفع |
| تقييم فني + تعيين مجموعة | المدرب | `/sa/coach-assessment` |
| دفع اشتراك شهري | الخزينة | طابور الدفع |
| تسجيل حضور يومي | المدرب | `/sa/training-attendance` |
| توليد اشتراكات شهرية | الإدارة | `/sa/subscriptions` → توليد |
| اعتماد شهادة طبية | لجنة الفحص | `/medical-board` |
| حجز حارة سباحة | الإدارة | `/sa/mirror/pool/{id}` |
| إدارة مدربين سباحة | الإدارة | `/sa/swimming/coaches` |
| الخطوة | من يفعلها | أين |
| ------------------------ | --------------- | --------------------------- |
| تسجيل لاعب جديد | مكتب الاشتراكات | `/sa/registration` |
| دفع رسوم التسجيل | الخزينة | طابور الدفع |
| تقييم فني + تعيين مجموعة | المدرب | `/sa/coach-assessment` |
| دفع اشتراك شهري | الخزينة | طابور الدفع |
| تسجيل حضور يومي | المدرب | `/sa/training-attendance` |
| توليد اشتراكات شهرية | الإدارة | `/sa/subscriptions` → توليد |
| اعتماد شهادة طبية | لجنة الفحص | `/medical-board` |
| حجز حارة سباحة | الإدارة | `/sa/mirror/pool/{id}` |
| إدارة مدربين سباحة | الإدارة | `/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