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>
......
This diff is collapsed.
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