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>
......
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