Commit c674d6f6 authored by Mahmoud Aglan's avatar Mahmoud Aglan

dsfghgjdfgtkjdgfkj

parent 7c9f8efb
......@@ -241,6 +241,18 @@ class BookingWizardController extends Controller
{
$this->authorize('sa.booking_wizard.use');
$errors = $this->validate($request->allPost(), [
'unit_id' => 'required|integer',
'date' => 'required|date',
'start_time' => 'required|string',
'end_time' => 'required|string',
'participants' => 'required|integer|min:1',
'booker_name' => 'required|string|min:2|max:200',
]);
if ($errors) {
return $this->json(['success' => false, 'error' => 'بيانات الحجز غير مكتملة']);
}
$unitId = (int) $request->post('unit_id', 0);
$date = trim((string) $request->post('date', ''));
$startTime = trim((string) $request->post('start_time', ''));
......@@ -251,13 +263,6 @@ class BookingWizardController extends Controller
$isMember = (bool) $request->post('is_member', false);
$memberId = (int) $request->post('member_id', 0);
if ($unitId <= 0 || $date === '' || $startTime === '' || $endTime === '') {
return $this->json(['success' => false, 'error' => 'بيانات الحجز غير مكتملة']);
}
if ($bookerName === '') {
return $this->json(['success' => false, 'error' => 'اسم الحاجز مطلوب']);
}
$branch = App::getInstance()->currentBranch();
$bookerType = $isMember ? 'member' : 'guest';
......
......@@ -10,84 +10,44 @@ use App\Core\App;
class DashboardController extends Controller
{
/**
* Main SportsActivity dashboard with summary cards.
*/
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
// Total active disciplines
$activeDisciplines = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_disciplines WHERE is_active = 1",
[]
)['cnt'] ?? 0);
// Total active facilities + units
$activeFacilities = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_facilities WHERE is_active = 1 AND is_archived = 0",
[]
)['cnt'] ?? 0);
$activeUnits = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_facility_units WHERE is_active = 1",
[]
)['cnt'] ?? 0);
// Total active coaches
$activeCoaches = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_coaches WHERE is_active = 1 AND is_archived = 0",
[]
)['cnt'] ?? 0);
// Total registered players (not archived)
$totalPlayers = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_players WHERE is_archived = 0",
[]
)['cnt'] ?? 0);
// Total active groups + enrolled players
$activeGroups = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_groups WHERE status = 'active' AND is_archived = 0",
[]
)['cnt'] ?? 0);
$enrolledPlayers = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_group_players WHERE status = 'active'",
[]
)['cnt'] ?? 0);
// Today's bookings count
$todayBookings = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_bookings WHERE booking_date = ? AND status NOT IN ('cancelled', 'no_show')",
[$today]
)['cnt'] ?? 0);
// Pending medical approvals
$pendingMedical = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_player_documents WHERE document_type = 'medical_cert' AND approval_status = 'pending'",
[]
)['cnt'] ?? 0);
// Overdue subscriptions
$overdueSubscriptions = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_subscriptions WHERE payment_status IN ('unpaid', 'overdue') AND period_end < ?",
[$today]
)['cnt'] ?? 0);
$branchScope = '';
$branchParams = [];
if (function_exists('branch_scope')) {
$branchScope = branch_scope('', 'branch_id');
$branchParams = branch_params();
}
$stats = $db->selectOne("
SELECT
(SELECT COUNT(*) FROM sa_disciplines WHERE is_active = 1) as active_disciplines,
(SELECT COUNT(*) FROM sa_facilities WHERE is_active = 1 AND is_archived = 0) as active_facilities,
(SELECT COUNT(*) FROM sa_facility_units WHERE is_active = 1) as active_units,
(SELECT COUNT(*) FROM sa_coaches WHERE is_active = 1 AND is_archived = 0) as active_coaches,
(SELECT COUNT(*) FROM sa_players WHERE is_archived = 0{$branchScope}) as total_players,
(SELECT COUNT(*) FROM sa_groups WHERE status = 'active' AND is_archived = 0) as active_groups,
(SELECT COUNT(*) FROM sa_group_players WHERE status = 'active') as enrolled_players,
(SELECT COUNT(*) FROM sa_bookings WHERE booking_date = ? AND status NOT IN ('cancelled','no_show'){$branchScope}) as today_bookings,
(SELECT COUNT(*) FROM sa_player_documents WHERE document_type = 'medical_cert' AND approval_status = 'pending') as pending_medical,
(SELECT COUNT(*) FROM sa_subscriptions WHERE payment_status IN ('unpaid','overdue') AND period_end < ?) as overdue_subscriptions
", array_merge([$today], $branchParams, $branchParams, [$today]));
return $this->view('SportsActivity.Views.dashboard', [
'stats' => [
'active_disciplines' => $activeDisciplines,
'active_facilities' => $activeFacilities,
'active_units' => $activeUnits,
'active_coaches' => $activeCoaches,
'total_players' => $totalPlayers,
'active_groups' => $activeGroups,
'enrolled_players' => $enrolledPlayers,
'today_bookings' => $todayBookings,
'pending_medical' => $pendingMedical,
'overdue_subscriptions' => $overdueSubscriptions,
'active_disciplines' => (int) ($stats['active_disciplines'] ?? 0),
'active_facilities' => (int) ($stats['active_facilities'] ?? 0),
'active_units' => (int) ($stats['active_units'] ?? 0),
'active_coaches' => (int) ($stats['active_coaches'] ?? 0),
'total_players' => (int) ($stats['total_players'] ?? 0),
'active_groups' => (int) ($stats['active_groups'] ?? 0),
'enrolled_players' => (int) ($stats['enrolled_players'] ?? 0),
'today_bookings' => (int) ($stats['today_bookings'] ?? 0),
'pending_medical' => (int) ($stats['pending_medical'] ?? 0),
'overdue_subscriptions' => (int) ($stats['overdue_subscriptions'] ?? 0),
],
'today' => $today,
]);
......
......@@ -11,6 +11,7 @@ use App\Core\Pagination;
use App\Modules\SportsActivity\Models\Group;
use App\Modules\SportsActivity\Models\Program;
use App\Modules\SportsActivity\Services\EnrollmentService;
use App\Modules\SportsActivity\Services\GroupTransferService;
use App\Modules\SportsActivity\Services\ScheduleGeneratorService;
class GroupController extends Controller
......@@ -436,6 +437,25 @@ class GroupController extends Controller
return $this->redirect('/sa/groups/' . $id)->withSuccess("تم حفظ الجدول ({$inserted} حصة)");
}
public function transferPlayer(Request $request, string $id): Response
{
$playerId = (int) $request->post('player_id', 0);
$toGroupId = (int) $request->post('to_group_id', 0);
$reason = trim((string) $request->post('reason', ''));
if ($playerId === 0 || $toGroupId === 0) {
return $this->redirect('/sa/groups/' . $id)->withError('يرجى تحديد اللاعب والمجموعة المنقول إليها');
}
$result = GroupTransferService::transfer($playerId, (int) $id, $toGroupId, $reason);
if ($result['success']) {
return $this->redirect('/sa/groups/' . $id)->withSuccess('تم نقل اللاعب بنجاح');
}
return $this->redirect('/sa/groups/' . $id)->withError($result['error']);
}
public function generateSessions(Request $request, string $id): Response
{
$group = Group::find((int) $id);
......
......@@ -9,6 +9,7 @@ use App\Core\Response;
use App\Core\App;
use App\Core\Pagination;
use App\Modules\Carnets\Services\QRCodeGenerator;
use App\Modules\SportsActivity\Services\CardRenewalService;
class SaCardController extends Controller
{
......@@ -239,6 +240,26 @@ class SaCardController extends Controller
]);
}
public function renew(Request $request, string $id): Response
{
$months = (int) $request->post('months', 0);
if ($months < 1 || $months > 24) {
return $this->redirect('/sa/cards/' . $id)->withError('يرجى تحديد مدة تجديد صالحة (1-24 شهر)');
}
$result = CardRenewalService::renewCard((int) $id, $months);
if ($result['success']) {
$msg = 'تم إنشاء طلب تجديد الكارت';
if (!empty($result['request_number'])) {
$msg .= ' (طلب دفع: ' . $result['request_number'] . ')';
}
return $this->redirect('/sa/cards/' . $id)->withSuccess($msg);
}
return $this->redirect('/sa/cards/' . $id)->withError($result['error']);
}
public function report(Request $request): Response
{
$db = App::getInstance()->db();
......
......@@ -94,6 +94,7 @@ return [
['POST', '/sa/groups/{id:\d+}', 'SportsActivity\Controllers\GroupController@update', ['auth', 'csrf'], 'sa.group.manage'],
['POST', '/sa/groups/{id:\d+}/enroll', 'SportsActivity\Controllers\GroupController@enroll', ['auth', 'csrf'], 'sa.group.enroll'],
['POST', '/sa/groups/{id:\d+}/remove-player', 'SportsActivity\Controllers\GroupController@removePlayer', ['auth', 'csrf'], 'sa.group.manage'],
['POST', '/sa/groups/{id:\d+}/transfer-player', 'SportsActivity\Controllers\GroupController@transferPlayer', ['auth', 'csrf'], 'sa.group.manage'],
['POST', '/sa/groups/{id:\d+}/schedule', 'SportsActivity\Controllers\GroupController@saveSchedule', ['auth', 'csrf'], 'sa.group.manage'],
['POST', '/sa/groups/{id:\d+}/generate-sessions', 'SportsActivity\Controllers\GroupController@generateSessions', ['auth', 'csrf'], 'sa.schedule.manage'],
......@@ -216,6 +217,7 @@ return [
['POST', '/sa/cards/{id:\d+}/suspend', 'SportsActivity\Controllers\SaCardController@suspend', ['auth', 'csrf'], 'sa.card.manage'],
['POST', '/sa/cards/{id:\d+}/revoke', 'SportsActivity\Controllers\SaCardController@revoke', ['auth', 'csrf'], 'sa.card.manage'],
['POST', '/sa/cards/{id:\d+}/reactivate', 'SportsActivity\Controllers\SaCardController@reactivate', ['auth', 'csrf'], 'sa.card.manage'],
['POST', '/sa/cards/{id:\d+}/renew', 'SportsActivity\Controllers\SaCardController@renew', ['auth', 'csrf'], 'sa.card.manage'],
['GET', '/sa/cards/{id:\d+}/print', 'SportsActivity\Controllers\SaCardController@print', ['auth'], 'sa.card.print'],
// ─── Gate Access ────────────────────────────────────────────────────────────
......
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity;
final class SaConstants
{
// Enrollment statuses (sa_group_players.status)
const STATUS_ACTIVE = 'active';
const STATUS_PENDING_PAYMENT = 'pending_payment';
const STATUS_WITHDRAWN = 'withdrawn';
const STATUS_TRANSFERRED = 'transferred';
// Booking statuses (sa_bookings.status)
const BOOKING_CONFIRMED = 'confirmed';
const BOOKING_CANCELLED = 'cancelled';
const BOOKING_NO_SHOW = 'no_show';
const BOOKING_COMPLETED = 'completed';
// Booking types
const BOOKING_TYPE_HOURLY = 'hourly';
const BOOKING_TYPE_TRAINING = 'training';
// Payment statuses
const PAYMENT_UNPAID = 'unpaid';
const PAYMENT_PENDING = 'pending';
const PAYMENT_PAID = 'paid';
const PAYMENT_OVERDUE = 'overdue';
const PAYMENT_REFUNDED = 'refunded';
// Player types
const PLAYER_MEMBER = 'member';
const PLAYER_NON_MEMBER = 'non_member';
// Booker types
const BOOKER_MEMBER = 'member';
const BOOKER_GUEST = 'guest';
// Medical statuses
const MEDICAL_FIT = 'fit';
const MEDICAL_CONDITIONAL = 'conditional';
const MEDICAL_PENDING = 'pending';
const MEDICAL_EXPIRED = 'expired';
const MEDICAL_UNFIT = 'unfit';
// Card statuses
const CARD_ACTIVE = 'active';
const CARD_TEMPORARY = 'temporary';
const CARD_SUSPENDED = 'suspended';
const CARD_REVOKED = 'revoked';
const CARD_EXPIRED = 'expired';
// Pool grid actions
const GRID_TRAINING = 'training';
const GRID_BLOCKED = 'blocked';
const GRID_MAINTENANCE = 'maintenance';
const GRID_HOURLY = 'hourly';
// Registration statuses
const REG_IN_PROGRESS = 'in_progress';
const REG_PENDING_PAYMENT = 'pending_payment';
const REG_COMPLETED = 'completed';
const REG_CANCELLED = 'cancelled';
// Payment types (used with PaymentRequestService)
const PAY_TYPE_SPORTS_REG = 'sports_registration';
const PAY_TYPE_HOURLY_BOOKING = 'hourly_booking';
const PAY_TYPE_FORM_FEE = 'sa_form_fee';
const PAY_TYPE_SPORTS_SUB = 'sports_subscription';
const PAY_TYPE_CARD_RENEWAL = 'sa_card_renewal';
// Entity types (related_entity_type)
const ENTITY_GROUP_PLAYERS = 'sa_group_players';
const ENTITY_SUBSCRIPTIONS = 'sa_subscriptions';
const ENTITY_BOOKINGS = 'sa_bookings';
const ENTITY_REGISTRATIONS = 'sa_registrations';
const ENTITY_REG_FORM = 'sa_registration_form';
const ENTITY_PLAYER_CARDS = 'sa_player_cards';
// Group statuses
const GROUP_ACTIVE = 'active';
const GROUP_PAUSED = 'paused';
const GROUP_COMPLETED = 'completed';
const GROUP_CANCELLED = 'cancelled';
// Cancelled booking statuses (for exclusion)
const EXCLUDED_STATUSES = ['cancelled', 'no_show'];
}
......@@ -35,43 +35,13 @@ final class AcademyPricingService
$basePrice = $isMember ? (float) $rule['price_member'] : (float) $rule['price_nonmember'];
$totalBeforeDiscount = $basePrice * $months;
$discounts = [];
$totalDiscount = 0.0;
// 15% discount for 3-month advance payment
if ($months >= 3) {
$advanceDiscount = self::getDiscountRule('ADVANCE_3MONTHS_15PCT');
if ($advanceDiscount && (int) $advanceDiscount['is_active']) {
$discountAmount = $totalBeforeDiscount * ((float) $advanceDiscount['discount_value'] / 100);
$totalDiscount += $discountAmount;
$discounts[] = [
'code' => 'ADVANCE_3MONTHS_15PCT',
'name_ar' => $advanceDiscount['name_ar'],
'amount' => round($discountAmount, 2),
'percentage' => (float) $advanceDiscount['discount_value'],
];
}
}
// Sibling discount (chess academy)
if ($hasSibling) {
$siblingDiscount = self::getDiscountRule('CHESS_SIBLING_10PCT');
if ($siblingDiscount && (int) $siblingDiscount['is_active']) {
$siblingAcademy = $siblingDiscount['academy_code'];
if (!$siblingAcademy || $siblingAcademy === $academyCode) {
$discountAmount = ($totalBeforeDiscount - $totalDiscount) * ((float) $siblingDiscount['discount_value'] / 100);
$totalDiscount += $discountAmount;
$discounts[] = [
'code' => 'CHESS_SIBLING_10PCT',
'name_ar' => $siblingDiscount['name_ar'],
'amount' => round($discountAmount, 2),
'percentage' => (float) $siblingDiscount['discount_value'],
];
}
}
}
$finalTotal = round($totalBeforeDiscount - $totalDiscount, 2);
$discountResult = DiscountCalculatorService::calculateDiscounts(
$totalBeforeDiscount,
$months,
$hasSibling,
$academyCode
);
return [
'found' => true,
......@@ -82,9 +52,9 @@ final class AcademyPricingService
'base_price_monthly' => $basePrice,
'months' => $months,
'subtotal' => $totalBeforeDiscount,
'discounts' => $discounts,
'total_discount' => round($totalDiscount, 2),
'final_total' => $finalTotal,
'discounts' => $discountResult['discounts'],
'total_discount' => $discountResult['total_discount'],
'final_total' => $discountResult['final_amount'],
'sessions_per_week' => $rule['sessions_per_week'] ? (int) $rule['sessions_per_week'] : null,
'session_duration_minutes' => $rule['session_duration_minutes'] ? (int) $rule['session_duration_minutes'] : null,
'includes_fitness' => (bool) (int) ($rule['includes_fitness'] ?? 0),
......
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
final class AttendanceRuleService
{
private const DEFAULT_ABSENCE_THRESHOLD = 3;
public static function checkAbsenceThreshold(int $playerId, int $groupId): ?array
{
$db = App::getInstance()->db();
$monthStart = date('Y-m-01');
$today = date('Y-m-d');
$absences = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_attendance
WHERE player_id = ? AND group_id = ? AND status = 'absent'
AND attendance_date BETWEEN ? AND ?",
[$playerId, $groupId, $monthStart, $today]
)['cnt'] ?? 0);
$thresholdRow = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'sa.absence_threshold'",
[]
);
$threshold = (int) ($thresholdRow['config_value'] ?? self::DEFAULT_ABSENCE_THRESHOLD);
if ($absences >= $threshold) {
return [
'player_id' => $playerId,
'group_id' => $groupId,
'absences' => $absences,
'threshold' => $threshold,
'month' => date('Y-m'),
];
}
return null;
}
public static function getAttendanceRate(int $playerId, int $groupId, string $from, string $to): float
{
$db = App::getInstance()->db();
$total = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_attendance
WHERE player_id = ? AND group_id = ? AND attendance_date BETWEEN ? AND ?",
[$playerId, $groupId, $from, $to]
)['cnt'] ?? 0);
if ($total === 0) {
return 0.0;
}
$present = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_attendance
WHERE player_id = ? AND group_id = ? AND status IN ('present', 'late')
AND attendance_date BETWEEN ? AND ?",
[$playerId, $groupId, $from, $to]
)['cnt'] ?? 0);
return round(($present / $total) * 100, 1);
}
public static function processMonthlyAbsenceReport(): array
{
$db = App::getInstance()->db();
$lastMonth = date('Y-m-01', strtotime('-1 month'));
$lastMonthEnd = date('Y-m-t', strtotime('-1 month'));
$processed = 0;
$thresholdRow = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'sa.absence_threshold'",
[]
);
$threshold = (int) ($thresholdRow['config_value'] ?? self::DEFAULT_ABSENCE_THRESHOLD);
$absentees = $db->select(
"SELECT a.player_id, a.group_id, COUNT(*) as absence_count,
p.full_name_ar, p.phone, p.guardian_phone, g.name_ar as group_name
FROM sa_attendance a
JOIN sa_players p ON p.id = a.player_id
JOIN sa_groups g ON g.id = a.group_id
WHERE a.status = 'absent'
AND a.attendance_date BETWEEN ? AND ?
GROUP BY a.player_id, a.group_id, p.full_name_ar, p.phone, p.guardian_phone, g.name_ar
HAVING COUNT(*) >= ?",
[$lastMonth, $lastMonthEnd, $threshold]
);
foreach ($absentees as $row) {
EventBus::dispatch('sa.player.absence_threshold', [
'player_id' => (int) $row['player_id'],
'group_id' => (int) $row['group_id'],
'player_name' => $row['full_name_ar'],
'group_name' => $row['group_name'],
'phone' => $row['guardian_phone'] ?: $row['phone'],
'absence_count' => (int) $row['absence_count'],
'threshold' => $threshold,
'month' => date('Y-m', strtotime('-1 month')),
]);
$processed++;
}
Logger::info("SA Attendance report: {$processed} players exceeded absence threshold");
return ['processed' => $processed, 'threshold' => $threshold];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Cashier\Services\PaymentRequestService;
use App\Modules\SportsActivity\SaConstants;
final class CardRenewalService
{
public static function renewCard(int $cardId, int $months = 1): array
{
$db = App::getInstance()->db();
$card = $db->selectOne(
"SELECT c.*, p.full_name_ar, p.member_id, p.player_type
FROM sa_player_cards c
JOIN sa_players p ON p.id = c.player_id
WHERE c.id = ? AND c.is_archived = 0",
[$cardId]
);
if (!$card) {
return ['success' => false, 'error' => 'الكارت غير موجود'];
}
if (!in_array($card['status'], [SaConstants::CARD_EXPIRED, SaConstants::CARD_ACTIVE], true)) {
return ['success' => false, 'error' => 'حالة الكارت لا تسمح بالتجديد (' . $card['status'] . ')'];
}
$feeRow = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = ?",
['sa.card_renewal_fee']
);
$fee = (float) ($feeRow['config_value'] ?? '25.00');
if ($fee <= 0) {
return self::applyRenewal($cardId, $months, null);
}
$memberId = (int) ($card['member_id'] ?? 0);
$description = 'تجديد كارت نشاط رياضي — ' . ($card['full_name_ar'] ?? '');
$result = PaymentRequestService::createRequest([
'member_id' => $memberId,
'payment_type' => SaConstants::PAY_TYPE_CARD_RENEWAL,
'amount' => (string) $fee,
'description_ar' => $description,
'related_entity_type' => SaConstants::ENTITY_PLAYER_CARDS,
'related_entity_id' => $cardId,
]);
if (!$result['success']) {
return $result;
}
return [
'success' => true,
'request_id' => $result['request_id'],
'request_number' => $result['request_number'],
'fee' => $fee,
'months' => $months,
];
}
public static function completeRenewal(int $cardId, int $paymentId): array
{
return self::applyRenewal($cardId, 1, $paymentId);
}
private static function applyRenewal(int $cardId, int $months, ?int $paymentId): array
{
$db = App::getInstance()->db();
$card = $db->selectOne(
"SELECT * FROM sa_player_cards WHERE id = ? AND is_archived = 0",
[$cardId]
);
if (!$card) {
return ['success' => false, 'error' => 'الكارت غير موجود'];
}
$baseDate = ($card['valid_until'] && $card['valid_until'] >= date('Y-m-d'))
? $card['valid_until']
: date('Y-m-d');
$newValidUntil = date('Y-m-d', strtotime("+{$months} months", strtotime($baseDate)));
$db->update('sa_player_cards', [
'status' => SaConstants::CARD_ACTIVE,
'valid_until' => $newValidUntil,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$cardId]);
$db->update('sa_players', [
'card_status' => SaConstants::CARD_ACTIVE,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $card['player_id']]);
EventBus::dispatch('sa.card.renewed', [
'card_id' => $cardId,
'player_id' => (int) $card['player_id'],
'valid_until' => $newValidUntil,
'payment_id' => $paymentId,
]);
return [
'success' => true,
'card_id' => $cardId,
'valid_until' => $newValidUntil,
];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
final class DiscountCalculatorService
{
/**
* Calculate all applicable discounts for a subscription amount.
*/
public static function calculateDiscounts(
float $subscriptionAmount,
int $months,
bool $hasSibling,
?string $academyCode = null
): array {
$discounts = [];
$totalDiscount = 0.0;
if ($months >= 3) {
$advance = self::getAdvanceDiscount($subscriptionAmount);
if ($advance) {
$totalDiscount += $advance['amount'];
$discounts[] = $advance;
}
}
if ($hasSibling) {
$remaining = $subscriptionAmount - $totalDiscount;
$sibling = self::getSiblingDiscount($remaining, $academyCode);
if ($sibling) {
$totalDiscount += $sibling['amount'];
$discounts[] = $sibling;
}
}
return [
'discounts' => $discounts,
'total_discount' => round($totalDiscount, 2),
'final_amount' => round($subscriptionAmount - $totalDiscount, 2),
];
}
private static function getAdvanceDiscount(float $amount): ?array
{
$db = App::getInstance()->db();
$rule = $db->selectOne(
"SELECT * FROM sa_discount_rules WHERE rule_code = 'ADVANCE_3MONTHS_15PCT' AND is_active = 1",
[]
);
if (!$rule) {
return null;
}
$discountAmount = $amount * ((float) $rule['discount_value'] / 100);
return [
'code' => 'ADVANCE_3MONTHS_15PCT',
'name_ar' => $rule['name_ar'],
'amount' => round($discountAmount, 2),
'percentage' => (float) $rule['discount_value'],
];
}
private static function getSiblingDiscount(float $remainingAmount, ?string $academyCode): ?array
{
$db = App::getInstance()->db();
$rule = $db->selectOne(
"SELECT * FROM sa_discount_rules WHERE condition_type = 'sibling' AND is_active = 1",
[]
);
if (!$rule) {
return null;
}
$ruleAcademy = $rule['academy_code'] ?? null;
if ($ruleAcademy && $academyCode && $ruleAcademy !== $academyCode) {
return null;
}
$discountAmount = $remainingAmount * ((float) $rule['discount_value'] / 100);
return [
'code' => $rule['rule_code'],
'name_ar' => $rule['name_ar'],
'amount' => round($discountAmount, 2),
'percentage' => (float) $rule['discount_value'],
];
}
}
......@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\EventBus;
final class GateAccessService
{
......@@ -29,6 +30,7 @@ final class GateAccessService
}
if ($card['status'] === 'suspended') {
self::dispatchDenialEvent($cardNumber, $card, 'الكارت موقوف');
return [
'granted' => false,
'reason' => 'الكارت موقوف',
......@@ -37,6 +39,7 @@ final class GateAccessService
}
if ($card['status'] === 'revoked') {
self::dispatchDenialEvent($cardNumber, $card, 'الكارت ملغى');
return [
'granted' => false,
'reason' => 'الكارت ملغى',
......@@ -45,6 +48,7 @@ final class GateAccessService
}
if ($card['status'] === 'expired') {
self::dispatchDenialEvent($cardNumber, $card, 'الكارت منتهي الصلاحية');
return [
'granted' => false,
'reason' => 'الكارت منتهي الصلاحية',
......@@ -58,6 +62,7 @@ final class GateAccessService
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $card['id']]);
self::dispatchDenialEvent($cardNumber, $card, 'الكارت منتهي الصلاحية');
return [
'granted' => false,
'reason' => 'الكارت منتهي الصلاحية',
......@@ -66,11 +71,13 @@ final class GateAccessService
}
if (!in_array($card['status'], ['active', 'temporary'], true)) {
return [
$result = [
'granted' => false,
'reason' => 'حالة الكارت غير صالحة للدخول (' . $card['status'] . ')',
'card' => $card,
];
self::dispatchDenialEvent($cardNumber, $card, $result['reason']);
return $result;
}
return [
......@@ -83,6 +90,16 @@ final class GateAccessService
];
}
private static function dispatchDenialEvent(?string $cardNumber, ?array $card, string $reason): void
{
EventBus::dispatch('sa.gate.access_denied', [
'player_id' => (int) ($card['player_id'] ?? 0),
'player_name' => $card['full_name_ar'] ?? '',
'card_number' => $cardNumber ?? '',
'reason' => $reason,
]);
}
public static function recordEntry(int $playerId, ?int $cardId, string $accessPoint = ''): int
{
$db = App::getInstance()->db();
......
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\SportsActivity\SaConstants;
final class GroupTransferService
{
public static function transfer(int $playerId, int $fromGroupId, int $toGroupId, string $reason = ''): array
{
$db = App::getInstance()->db();
if ($fromGroupId === $toGroupId) {
return ['success' => false, 'error' => 'لا يمكن النقل لنفس المجموعة'];
}
$db->beginTransaction();
try {
$enrollment = $db->selectOne(
"SELECT id FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status = ?",
[$fromGroupId, $playerId, SaConstants::STATUS_ACTIVE]
);
if (!$enrollment) {
$db->rollBack();
return ['success' => false, 'error' => 'اللاعب غير مسجل في المجموعة المصدر'];
}
$toGroup = $db->selectOne(
"SELECT current_count, max_capacity FROM sa_groups WHERE id = ? AND status = ? AND is_archived = 0 FOR UPDATE",
[$toGroupId, SaConstants::GROUP_ACTIVE]
);
if (!$toGroup) {
$db->rollBack();
return ['success' => false, 'error' => 'المجموعة المستهدفة غير موجودة أو غير نشطة'];
}
if ((int) $toGroup['current_count'] >= (int) $toGroup['max_capacity']) {
$db->rollBack();
return ['success' => false, 'error' => 'المجموعة المستهدفة ممتلئة'];
}
$fromGroup = $db->selectOne(
"SELECT current_count FROM sa_groups WHERE id = ? FOR UPDATE",
[$fromGroupId]
);
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$now = date('Y-m-d H:i:s');
$db->update('sa_group_players', [
'status' => SaConstants::STATUS_TRANSFERRED,
'left_at' => date('Y-m-d'),
'transferred_to_group_id' => $toGroupId,
'transfer_reason' => $reason ?: null,
'updated_at' => $now,
], 'id = ?', [(int) $enrollment['id']]);
$newEnrollmentId = $db->insert('sa_group_players', [
'group_id' => $toGroupId,
'player_id' => $playerId,
'enrolled_at' => date('Y-m-d'),
'status' => SaConstants::STATUS_ACTIVE,
'created_at' => $now,
'updated_at' => $now,
'created_by' => $employeeId,
]);
$newFromCount = max(0, (int) $fromGroup['current_count'] - 1);
$db->update('sa_groups', [
'current_count' => $newFromCount,
'is_full' => 0,
'updated_at' => $now,
], 'id = ?', [$fromGroupId]);
$newToCount = (int) $toGroup['current_count'] + 1;
$db->update('sa_groups', [
'current_count' => $newToCount,
'is_full' => $newToCount >= (int) $toGroup['max_capacity'] ? 1 : 0,
'updated_at' => $now,
], 'id = ?', [$toGroupId]);
$db->commit();
EventBus::dispatch('sa.player.transferred', [
'player_id' => $playerId,
'from_group_id' => $fromGroupId,
'to_group_id' => $toGroupId,
'enrollment_id' => $newEnrollmentId,
'reason' => $reason,
]);
return [
'success' => true,
'enrollment_id' => $newEnrollmentId,
];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل عملية النقل: ' . $e->getMessage()];
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
final class NumberGeneratorService
{
public static function subscriptionNumber(): string
{
return self::generateWithRetry('sa_subscriptions', 'subscription_number', 'SUB');
}
public static function bookingNumber(): string
{
return self::generateWithRetry('sa_bookings', 'booking_number', 'BK');
}
private static function generateWithRetry(string $table, string $column, string $prefix, int $maxAttempts = 10): string
{
$db = App::getInstance()->db();
$datePrefix = $prefix . '-' . date('Ymd') . '-';
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
$number = $datePrefix . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$exists = $db->selectOne(
"SELECT id FROM `{$table}` WHERE `{$column}` = ?",
[$number]
);
if (!$exists) {
return $number;
}
}
return $datePrefix . str_pad((string) random_int(10000, 99999), 5, '0', STR_PAD_LEFT);
}
}
......@@ -148,6 +148,17 @@ final class PoolGridService
$label = $notes ?: 'حجز ساعة';
}
// Pre-fetch all active bookings for this facility+date to avoid per-cell SELECT
$allConflicts = $db->select(
"SELECT zone_row, zone_col, start_time FROM sa_pool_zone_bookings
WHERE facility_id = ? AND booking_date = ? AND status = 'active'",
[$facilityId, $date]
);
$conflictSet = [];
foreach ($allConflicts as $c) {
$conflictSet[$c['start_time'] . ':' . $c['zone_row'] . ':' . $c['zone_col']] = true;
}
$assigned = 0;
$skipped = 0;
......@@ -168,14 +179,8 @@ final class PoolGridService
continue;
}
$conflict = $db->selectOne(
"SELECT id FROM sa_pool_zone_bookings
WHERE facility_id = ? AND booking_date = ? AND start_time = ?
AND zone_row = ? AND zone_col = ? AND status = 'active'",
[$facilityId, $date, $startTimeFull, $row, $col]
);
if ($conflict) {
$key = $startTimeFull . ':' . $row . ':' . $col;
if (isset($conflictSet[$key])) {
$skipped++;
continue;
}
......@@ -203,6 +208,7 @@ final class PoolGridService
}
$db->insert('sa_pool_zone_bookings', $insertData);
$conflictSet[$key] = true;
$assigned++;
}
}
......@@ -220,6 +226,17 @@ final class PoolGridService
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$cleared = 0;
// Pre-fetch all active bookings for this facility+date
$allActive = $db->select(
"SELECT id, zone_row, zone_col, start_time FROM sa_pool_zone_bookings
WHERE facility_id = ? AND booking_date = ? AND status = 'active'",
[$facilityId, $date]
);
$activeMap = [];
foreach ($allActive as $a) {
$activeMap[$a['start_time'] . ':' . $a['zone_row'] . ':' . $a['zone_col']] = (int) $a['id'];
}
foreach ($slots as $slot) {
$startTime = $slot['start_time'] ?? '';
if (!$startTime) continue;
......@@ -231,20 +248,14 @@ final class PoolGridService
if ($row < 0 || $col < 0) continue;
$existing = $db->selectOne(
"SELECT id FROM sa_pool_zone_bookings
WHERE facility_id = ? AND booking_date = ? AND start_time = ?
AND zone_row = ? AND zone_col = ? AND status = 'active'",
[$facilityId, $date, $startTimeFull, $row, $col]
);
if ($existing) {
$key = $startTimeFull . ':' . $row . ':' . $col;
if (isset($activeMap[$key])) {
$db->update('sa_pool_zone_bookings', [
'status' => 'cancelled',
'cancelled_by' => $employeeId,
'cancelled_at' => $ts,
'updated_at' => $ts,
], 'id = ?', [(int) $existing['id']]);
], 'id = ?', [$activeMap[$key]]);
$cleared++;
}
}
......
......@@ -197,50 +197,25 @@ final class RegistrationWizardService
: (float) $group['monthly_fee_nonmember'];
$subscriptionAmount = $monthlyFee * max(1, $months);
$discounts = [];
$totalDiscount = 0.0;
// 15% discount for 3-month advance payment
if ($months >= 3) {
$advanceRule = $db->selectOne(
"SELECT * FROM sa_discount_rules WHERE rule_code = 'ADVANCE_3MONTHS_15PCT' AND is_active = 1",
[]
);
if ($advanceRule) {
$discountAmt = $subscriptionAmount * ((float) $advanceRule['discount_value'] / 100);
$totalDiscount += $discountAmt;
$discounts[] = ['code' => 'ADVANCE_3MONTHS_15PCT', 'name_ar' => $advanceRule['name_ar'], 'amount' => round($discountAmt, 2)];
}
}
// Sibling discount (if applicable to this academy)
if ($hasSibling) {
$siblingRule = $db->selectOne(
"SELECT * FROM sa_discount_rules WHERE condition_type = 'sibling' AND is_active = 1",
[]
);
if ($siblingRule) {
$acadCode = $siblingRule['academy_code'];
$applyDiscount = true;
if ($acadCode) {
$program = $db->selectOne("SELECT p.academy_id FROM sa_programs p WHERE p.id = ?", [(int) $group['program_id']]);
if ($program) {
$academy = $db->selectOne("SELECT code FROM sa_academies WHERE id = ?", [(int) $program['academy_id']]);
if (!$academy || $academy['code'] !== $acadCode) {
$applyDiscount = false;
}
}
}
if ($applyDiscount) {
$remaining = $subscriptionAmount - $totalDiscount;
$discountAmt = $remaining * ((float) $siblingRule['discount_value'] / 100);
$totalDiscount += $discountAmt;
$discounts[] = ['code' => $siblingRule['rule_code'], 'name_ar' => $siblingRule['name_ar'], 'amount' => round($discountAmt, 2)];
}
// Resolve academy code for discount scoping
$academyCode = null;
if (!empty($group['program_id'])) {
$program = $db->selectOne("SELECT academy_id FROM sa_programs WHERE id = ?", [(int) $group['program_id']]);
if ($program) {
$academy = $db->selectOne("SELECT code FROM sa_academies WHERE id = ?", [(int) $program['academy_id']]);
$academyCode = $academy['code'] ?? null;
}
}
$subscriptionAfterDiscount = round($subscriptionAmount - $totalDiscount, 2);
$discountResult = DiscountCalculatorService::calculateDiscounts(
$subscriptionAmount,
$months,
$hasSibling,
$academyCode
);
$subscriptionAfterDiscount = $discountResult['final_amount'];
$totalFees = (float) $registration['registration_fee']
+ (float) $registration['card_fee']
......@@ -261,8 +236,8 @@ final class RegistrationWizardService
'monthly_fee' => $monthlyFee,
'months' => $months,
'subscription_before_discount' => $subscriptionAmount,
'discounts' => $discounts,
'total_discount' => round($totalDiscount, 2),
'discounts' => $discountResult['discounts'],
'total_discount' => $discountResult['total_discount'],
'subscription_amount' => $subscriptionAfterDiscount,
'total_fees' => $totalFees,
];
......@@ -490,46 +465,55 @@ final class RegistrationWizardService
return ['success' => false, 'error' => 'التسجيل غير موجود'];
}
$db->update('sa_registrations', [
'status' => 'completed',
'payment_status' => 'paid',
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$registrationId]);
$db->update('sa_players', [
'registration_fee_paid' => 1,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $registration['player_id']]);
$groupId = (int) ($registration['group_id'] ?? 0);
if ($groupId > 0) {
$existingEnrollment = $db->selectOne(
"SELECT id FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status IN ('active','pending_payment')",
[$groupId, (int) $registration['player_id']]
);
if (!$existingEnrollment) {
$employeeId = (int) ($registration['created_by'] ?? 0);
$enrollmentId = $db->insert('sa_group_players', [
'group_id' => $groupId,
'player_id' => (int) $registration['player_id'],
'enrolled_at' => date('Y-m-d'),
'status' => 'active',
'activated_by_payment_id' => $paymentId,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employeeId,
]);
$group = $db->selectOne("SELECT current_count, max_capacity FROM sa_groups WHERE id = ?", [$groupId]);
$newCount = (int) $group['current_count'] + 1;
$db->update('sa_groups', [
'current_count' => $newCount,
'is_full' => $newCount >= (int) $group['max_capacity'] ? 1 : 0,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$groupId]);
$db->beginTransaction();
try {
$db->update('sa_registrations', [
'status' => 'completed',
'payment_status' => 'paid',
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$registrationId]);
$db->update('sa_players', [
'registration_fee_paid' => 1,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $registration['player_id']]);
if ($groupId > 0) {
$existingEnrollment = $db->selectOne(
"SELECT id FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status IN ('active','pending_payment')",
[$groupId, (int) $registration['player_id']]
);
if (!$existingEnrollment) {
$employeeId = (int) ($registration['created_by'] ?? 0);
$db->insert('sa_group_players', [
'group_id' => $groupId,
'player_id' => (int) $registration['player_id'],
'enrolled_at' => date('Y-m-d'),
'status' => 'active',
'activated_by_payment_id' => $paymentId,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employeeId,
]);
$group = $db->selectOne("SELECT current_count, max_capacity FROM sa_groups WHERE id = ? FOR UPDATE", [$groupId]);
$newCount = (int) $group['current_count'] + 1;
$db->update('sa_groups', [
'current_count' => $newCount,
'is_full' => $newCount >= (int) $group['max_capacity'] ? 1 : 0,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$groupId]);
}
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل إتمام التسجيل: ' . $e->getMessage()];
}
EventBus::dispatch('sa.registration.completed', [
......
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\SportsActivity\SaConstants;
final class SaEventListenerService
{
public static function handlePaymentCompleted(array $data): void
{
$entityType = $data['related_entity_type'] ?? '';
try {
if ($entityType === SaConstants::ENTITY_REG_FORM) {
self::handleRegistrationFormPaid((int) ($data['related_entity_id'] ?? 0));
return;
}
if ($entityType === SaConstants::ENTITY_REGISTRATIONS) {
self::handleRegistrationPaid(
(int) ($data['related_entity_id'] ?? 0),
(int) ($data['payment_id'] ?? 0)
);
return;
}
if ($entityType === SaConstants::ENTITY_SUBSCRIPTIONS) {
self::handleSubscriptionPaid($data);
return;
}
if ($entityType === SaConstants::ENTITY_BOOKINGS) {
self::handleBookingPaid($data);
return;
}
if ($entityType === SaConstants::ENTITY_GROUP_PLAYERS) {
self::handleEnrollmentPaid(
(int) ($data['related_entity_id'] ?? 0),
(int) ($data['payment_id'] ?? 0),
$data
);
return;
}
if ($entityType === SaConstants::ENTITY_PLAYER_CARDS) {
CardRenewalService::completeRenewal(
(int) ($data['related_entity_id'] ?? 0),
(int) ($data['payment_id'] ?? 0)
);
return;
}
} catch (\Throwable $e) {
Logger::error('SA payment_request.completed listener failed: ' . $e->getMessage());
}
}
public static function handlePaymentVoided(array $data): void
{
try {
$db = App::getInstance()->db();
$paymentId = (int) ($data['payment_id'] ?? 0);
if ($paymentId < 1) {
return;
}
$sub = $db->selectOne("SELECT id FROM sa_subscriptions WHERE payment_id = ?", [$paymentId]);
if ($sub) {
$db->update('sa_subscriptions', [
'payment_status' => SaConstants::PAYMENT_UNPAID,
'paid_at' => null,
'paid_amount' => null,
'payment_id' => null,
'receipt_id' => null,
'receipt_number' => null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $sub['id']]);
}
$bk = $db->selectOne("SELECT id FROM sa_bookings WHERE payment_id = ?", [$paymentId]);
if ($bk) {
$db->update('sa_bookings', [
'payment_status' => SaConstants::PAYMENT_UNPAID,
'payment_id' => null,
'receipt_id' => null,
'receipt_number' => null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $bk['id']]);
}
$enrollment = $db->selectOne(
"SELECT id FROM sa_group_players WHERE activated_by_payment_id = ? AND status = 'active'",
[$paymentId]
);
if ($enrollment) {
EnrollmentService::deactivateEnrollment((int) $enrollment['id']);
}
} catch (\Throwable $e) {
Logger::error('SA payment.voided listener failed: ' . $e->getMessage());
}
}
private static function handleRegistrationFormPaid(int $registrationId): void
{
if ($registrationId < 1) {
return;
}
$db = App::getInstance()->db();
$db->update('sa_registrations', [
'form_payment_status' => SaConstants::PAYMENT_PAID,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$registrationId]);
}
private static function handleRegistrationPaid(int $registrationId, int $paymentId): void
{
if ($registrationId < 1) {
return;
}
RegistrationWizardService::completeRegistration($registrationId, $paymentId);
}
private static function handleSubscriptionPaid(array $data): void
{
$db = App::getInstance()->db();
$db->update('sa_subscriptions', [
'payment_status' => SaConstants::PAYMENT_PAID,
'paid_at' => date('Y-m-d H:i:s'),
'paid_amount' => $data['amount'] ?? 0,
'payment_id' => $data['payment_id'] ?? null,
'receipt_id' => $data['receipt_id'] ?? null,
'receipt_number' => $data['receipt_number'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) ($data['related_entity_id'] ?? 0)]);
}
private static function handleBookingPaid(array $data): void
{
$db = App::getInstance()->db();
$db->update('sa_bookings', [
'payment_status' => SaConstants::PAYMENT_PAID,
'payment_id' => $data['payment_id'] ?? null,
'receipt_id' => $data['receipt_id'] ?? null,
'receipt_number' => $data['receipt_number'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) ($data['related_entity_id'] ?? 0)]);
}
private static function handleEnrollmentPaid(int $enrollmentId, int $paymentId, array $data): void
{
if ($enrollmentId < 1 || $paymentId < 1) {
return;
}
$db = App::getInstance()->db();
EnrollmentService::activateEnrollment($enrollmentId, $paymentId);
$enrollment = $db->selectOne(
"SELECT gp.player_id, gp.group_id, g.name_ar as group_name
FROM sa_group_players gp
LEFT JOIN sa_groups g ON g.id = gp.group_id
WHERE gp.id = ?",
[$enrollmentId]
);
if ($enrollment) {
$periodStart = date('Y-m-d');
$periodEnd = date('Y-m-t');
$subNumber = NumberGeneratorService::subscriptionNumber();
$db->insert('sa_subscriptions', [
'subscription_number' => $subNumber,
'player_id' => (int) $enrollment['player_id'],
'group_id' => (int) $enrollment['group_id'],
'period_start' => $periodStart,
'period_end' => $periodEnd,
'amount' => $data['amount'] ?? '0.00',
'discount_amount' => '0.00',
'final_amount' => $data['amount'] ?? '0.00',
'payment_status' => SaConstants::PAYMENT_PAID,
'paid_at' => date('Y-m-d H:i:s'),
'paid_amount' => $data['amount'] ?? '0.00',
'payment_id' => $paymentId,
'receipt_id' => $data['receipt_id'] ?? null,
'receipt_number' => $data['receipt_number'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
}
......@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Modules\SportsActivity\SaConstants;
final class SubscriptionGeneratorService
{
......@@ -14,63 +15,74 @@ final class SubscriptionGeneratorService
$periodStart = $yearMonth . '-01';
$periodEnd = date('Y-m-t', strtotime($periodStart));
$groups = $db->select(
"SELECT g.*, p.name_ar as program_name
FROM sa_groups g
JOIN sa_programs p ON p.id = g.program_id
WHERE g.status = 'active' AND g.is_archived = 0",
[]
$groupPlayers = $db->select(
"SELECT gp.player_id, gp.group_id, gp.enrolled_at,
sp.player_type,
g.monthly_fee_member, g.monthly_fee_nonmember
FROM sa_group_players gp
JOIN sa_players sp ON sp.id = gp.player_id
JOIN sa_groups g ON g.id = gp.group_id
WHERE gp.status = ?
AND g.status = ? AND g.is_archived = 0",
[SaConstants::STATUS_ACTIVE, SaConstants::GROUP_ACTIVE]
);
$existingSubs = $db->select(
"SELECT player_id, group_id FROM sa_subscriptions WHERE period_start = ?",
[$periodStart]
);
$existingSet = [];
foreach ($existingSubs as $es) {
$existingSet[$es['player_id'] . ':' . $es['group_id']] = true;
}
$generated = 0;
$skipped = 0;
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
foreach ($groups as $group) {
$players = $db->select(
"SELECT gp.*, sp.player_type, sp.full_name_ar
FROM sa_group_players gp
JOIN sa_players sp ON sp.id = gp.player_id
WHERE gp.group_id = ? AND gp.status = 'active'",
[(int) $group['id']]
);
foreach ($groupPlayers as $gp) {
$key = $gp['player_id'] . ':' . $gp['group_id'];
if (isset($existingSet[$key])) {
$skipped++;
continue;
}
foreach ($players as $player) {
$existing = $db->selectOne(
"SELECT id FROM sa_subscriptions
WHERE player_id = ? AND group_id = ? AND period_start = ?",
[(int) $player['player_id'], (int) $group['id'], $periodStart]
);
$amount = $gp['player_type'] === SaConstants::PLAYER_MEMBER
? (float) $gp['monthly_fee_member']
: (float) $gp['monthly_fee_nonmember'];
if ($existing) {
$skipped++;
continue;
}
// Proration: first month + enrolled after 15th = half fee
$isFirstMonth = !$db->selectOne(
"SELECT id FROM sa_subscriptions WHERE player_id = ? AND group_id = ? AND period_start < ? LIMIT 1",
[(int) $gp['player_id'], (int) $gp['group_id'], $periodStart]
);
$amount = $player['player_type'] === 'member'
? (float) $group['monthly_fee_member']
: (float) $group['monthly_fee_nonmember'];
if ($isFirstMonth && !empty($gp['enrolled_at'])) {
$enrollmentDay = (int) date('j', strtotime($gp['enrolled_at']));
if ($enrollmentDay > 15) {
$amount = round($amount / 2, 2);
}
}
$subNumber = 'SUB-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$subNumber = NumberGeneratorService::subscriptionNumber();
$db->insert('sa_subscriptions', [
'subscription_number' => $subNumber,
'player_id' => (int) $player['player_id'],
'group_id' => (int) $group['id'],
'period_start' => $periodStart,
'period_end' => $periodEnd,
'amount' => $amount,
'discount_amount' => 0.00,
'final_amount' => $amount,
'payment_status' => 'unpaid',
'paid_amount' => 0.00,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employeeId,
]);
$db->insert('sa_subscriptions', [
'subscription_number' => $subNumber,
'player_id' => (int) $gp['player_id'],
'group_id' => (int) $gp['group_id'],
'period_start' => $periodStart,
'period_end' => $periodEnd,
'amount' => $amount,
'discount_amount' => 0.00,
'final_amount' => $amount,
'payment_status' => SaConstants::PAYMENT_UNPAID,
'paid_amount' => 0.00,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employeeId,
]);
$generated++;
}
$generated++;
}
return [
......
......@@ -98,131 +98,63 @@ PermissionRegistry::register('sports_activity', [
// ─── Event Listeners ────────────────────────────────────────────────────────
EventBus::listen('payment_request.completed', function (array $data): void {
$entityType = $data['related_entity_type'] ?? '';
try {
if ($entityType === 'sa_registration_form') {
$registrationId = (int) ($data['related_entity_id'] ?? 0);
if ($registrationId > 0) {
$db = App::getInstance()->db();
$db->update('sa_registrations', [
'form_payment_status' => 'paid',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$registrationId]);
}
return;
}
EventBus::listen('payment_request.completed', [\App\Modules\SportsActivity\Services\SaEventListenerService::class, 'handlePaymentCompleted'], 60);
if ($entityType === 'sa_registrations') {
$registrationId = (int) ($data['related_entity_id'] ?? 0);
$paymentId = (int) ($data['payment_id'] ?? 0);
if ($registrationId > 0) {
\App\Modules\SportsActivity\Services\RegistrationWizardService::completeRegistration($registrationId, $paymentId);
}
return;
}
EventBus::listen('payment.voided', [\App\Modules\SportsActivity\Services\SaEventListenerService::class, 'handlePaymentVoided'], 60);
$db = App::getInstance()->db();
if ($entityType === 'sa_subscriptions') {
$db->update('sa_subscriptions', [
'payment_status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
'paid_amount' => $data['amount'] ?? 0,
'payment_id' => $data['payment_id'] ?? null,
'receipt_id' => $data['receipt_id'] ?? null,
'receipt_number' => $data['receipt_number'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) ($data['related_entity_id'] ?? 0)]);
} elseif ($entityType === 'sa_bookings') {
$db->update('sa_bookings', [
'payment_status' => 'paid',
'payment_id' => $data['payment_id'] ?? null,
'receipt_id' => $data['receipt_id'] ?? null,
'receipt_number' => $data['receipt_number'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) ($data['related_entity_id'] ?? 0)]);
} elseif ($entityType === 'sa_group_players') {
$enrollmentId = (int) ($data['related_entity_id'] ?? 0);
$paymentId = (int) ($data['payment_id'] ?? 0);
if ($enrollmentId < 1 || $paymentId < 1) return;
// ─── Notification Listeners ─────────────────────────────────────────────────
\App\Modules\SportsActivity\Services\EnrollmentService::activateEnrollment($enrollmentId, $paymentId);
$enrollment = $db->selectOne(
"SELECT gp.player_id, gp.group_id, g.name_ar as group_name
FROM sa_group_players gp
LEFT JOIN sa_groups g ON g.id = gp.group_id
WHERE gp.id = ?",
[$enrollmentId]
);
if ($enrollment) {
$periodStart = date('Y-m-d');
$periodEnd = date('Y-m-t');
$subNumber = 'SUB-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$db->insert('sa_subscriptions', [
'subscription_number' => $subNumber,
'player_id' => (int) $enrollment['player_id'],
'group_id' => (int) $enrollment['group_id'],
'period_start' => $periodStart,
'period_end' => $periodEnd,
'amount' => $data['amount'] ?? '0.00',
'discount_amount' => '0.00',
'final_amount' => $data['amount'] ?? '0.00',
'payment_status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
'paid_amount' => $data['amount'] ?? '0.00',
'payment_id' => $paymentId,
'receipt_id' => $data['receipt_id'] ?? null,
'receipt_number' => $data['receipt_number'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
} catch (\Throwable $e) {
Logger::error('SA payment_request.completed listener failed: ' . $e->getMessage());
EventBus::listen('sa.card.expiry_reminder', function (array $data): void {
$phone = $data['phone'] ?? '';
if ($phone !== '') {
\App\Modules\Notifications\Services\SmsNotificationService::send(
$phone,
'تنبيه: كارت النشاط الرياضي للاعب ' . ($data['player_name'] ?? '') .
' سينتهي بتاريخ ' . ($data['expiry_date'] ?? '') . '. يرجى التجديد.'
);
}
}, 60);
}, 80);
EventBus::listen('payment.voided', function (array $data): void {
try {
$db = App::getInstance()->db();
$paymentId = (int) ($data['payment_id'] ?? 0);
if ($paymentId < 1) return;
$sub = $db->selectOne("SELECT id FROM sa_subscriptions WHERE payment_id = ?", [$paymentId]);
if ($sub) {
$db->update('sa_subscriptions', [
'payment_status' => 'unpaid',
'paid_at' => null,
'paid_amount' => null,
'payment_id' => null,
'receipt_id' => null,
'receipt_number' => null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $sub['id']]);
}
EventBus::listen('sa.subscription.overdue', function (array $data): void {
$phone = $data['phone'] ?? '';
if ($phone !== '') {
\App\Modules\Notifications\Services\SmsNotificationService::send(
$phone,
'تنبيه: اشتراك النشاط الرياضي للاعب ' . ($data['player_name'] ?? '') .
' متأخر السداد. المبلغ المطلوب: ' . ($data['amount'] ?? '0') . ' جنيه.'
);
}
}, 80);
$bk = $db->selectOne("SELECT id FROM sa_bookings WHERE payment_id = ?", [$paymentId]);
if ($bk) {
$db->update('sa_bookings', [
'payment_status' => 'unpaid',
'payment_id' => null,
'receipt_id' => null,
'receipt_number' => null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $bk['id']]);
}
EventBus::listen('sa.player.transferred', function (array $data): void {
$db = App::getInstance()->db();
$player = $db->selectOne(
"SELECT full_name_ar, phone, guardian_phone FROM sa_players WHERE id = ?",
[(int) ($data['player_id'] ?? 0)]
);
if (!$player) return;
$phone = $player['guardian_phone'] ?: $player['phone'] ?? '';
if ($phone !== '') {
$toGroup = $db->selectOne("SELECT name_ar FROM sa_groups WHERE id = ?", [(int) ($data['to_group_id'] ?? 0)]);
\App\Modules\Notifications\Services\SmsNotificationService::send(
$phone,
'تم نقل اللاعب ' . $player['full_name_ar'] . ' إلى مجموعة ' . ($toGroup['name_ar'] ?? '') . ' بنجاح.'
);
}
}, 80);
$enrollment = $db->selectOne(
"SELECT id FROM sa_group_players WHERE activated_by_payment_id = ? AND status = 'active'",
[$paymentId]
EventBus::listen('sa.player.absence_threshold', function (array $data): void {
$phone = $data['phone'] ?? '';
if ($phone !== '') {
\App\Modules\Notifications\Services\SmsNotificationService::send(
$phone,
'تنبيه: اللاعب ' . ($data['player_name'] ?? '') .
' تجاوز حد الغياب (' . ($data['absence_count'] ?? 0) . ' مرات) في مجموعة ' .
($data['group_name'] ?? '') . '. يرجى المتابعة.'
);
if ($enrollment) {
\App\Modules\SportsActivity\Services\EnrollmentService::deactivateEnrollment((int) $enrollment['id']);
}
} catch (\Throwable $e) {
Logger::error('SA payment.voided listener failed: ' . $e->getMessage());
}
}, 60);
}, 80);
EventBus::listen('sa.gate.access_denied', function (array $data): void {
Logger::warning('SA Gate denial: ' . ($data['player_name'] ?? 'unknown') . ' - ' . ($data['reason'] ?? ''), $data);
}, 50);
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\EventBus;
use App\Core\Logger;
use App\Core\App;
class SaAttendanceReportJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
return (int) date('j') === 1;
}
public function run(): array
{
App::getInstance()->setDb($this->db);
$lastMonth = date('Y-m', strtotime('-1 month'));
$from = $lastMonth . '-01';
$to = date('Y-m-t', strtotime($from));
$threshold = (int) ($this->db->selectOne(
"SELECT value FROM system_config WHERE `key` = 'sa.absence_threshold'",
[]
)['value'] ?? 3);
$players = $this->db->select(
"SELECT gp.player_id, gp.group_id, p.full_name_ar, p.phone, p.guardian_phone,
g.name_ar as group_name,
(SELECT COUNT(*) FROM sa_attendance a
WHERE a.player_id = gp.player_id AND a.group_id = gp.group_id
AND a.session_date BETWEEN ? AND ? AND a.status = 'absent') as absence_count
FROM sa_group_players gp
INNER JOIN sa_players p ON p.id = gp.player_id
INNER JOIN sa_groups g ON g.id = gp.group_id
WHERE gp.status = 'active'
HAVING absence_count >= ?",
[$from, $to, $threshold]
);
$notified = 0;
foreach ($players as $player) {
$phone = $player['guardian_phone'] ?: ($player['phone'] ?? '');
if ($phone === '') {
continue;
}
EventBus::dispatch('sa.player.absence_threshold', [
'player_id' => (int) $player['player_id'],
'player_name' => $player['full_name_ar'],
'group_name' => $player['group_name'],
'absence_count' => (int) $player['absence_count'],
'month' => $lastMonth,
'phone' => $phone,
]);
$notified++;
}
Logger::info("SaAttendanceReportJob for {$lastMonth}: {$notified} players exceeded threshold ({$threshold})");
return ['month' => $lastMonth, 'threshold' => $threshold, 'notified' => $notified];
}
}
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\EventBus;
use App\Core\Logger;
class SaCardExpiryJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
return true;
}
public function run(): array
{
$ts = date('Y-m-d H:i:s');
$today = date('Y-m-d');
$reminderDate = date('Y-m-d', strtotime('+7 days'));
$expired = $this->expireCards($today, $ts);
$reminded = $this->sendExpiryReminders($today, $reminderDate);
Logger::info("SaCardExpiryJob: {$expired} expired, {$reminded} reminders sent");
return ['expired' => $expired, 'reminded' => $reminded];
}
private function expireCards(string $today, string $ts): int
{
$cards = $this->db->select(
"SELECT id, player_id FROM sa_player_cards
WHERE status = 'active' AND valid_until IS NOT NULL AND valid_until < ?
AND is_archived = 0",
[$today]
);
foreach ($cards as $card) {
$this->db->update('sa_player_cards', [
'status' => 'expired',
'updated_at' => $ts,
], 'id = ?', [(int) $card['id']]);
}
return count($cards);
}
private function sendExpiryReminders(string $today, string $reminderDate): int
{
$cards = $this->db->select(
"SELECT c.id, c.card_number, c.valid_until, c.player_id,
p.full_name_ar, p.phone, p.guardian_phone
FROM sa_player_cards c
INNER JOIN sa_players p ON p.id = c.player_id
WHERE c.status = 'active' AND c.valid_until BETWEEN ? AND ?
AND c.is_archived = 0",
[$today, $reminderDate]
);
$count = 0;
foreach ($cards as $card) {
$phone = $card['guardian_phone'] ?: ($card['phone'] ?? '');
if ($phone === '') {
continue;
}
EventBus::dispatch('sa.card.expiry_reminder', [
'player_id' => (int) $card['player_id'],
'player_name' => $card['full_name_ar'],
'card_number' => $card['card_number'],
'expiry_date' => $card['valid_until'],
'phone' => $phone,
]);
$count++;
}
return $count;
}
}
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\EventBus;
use App\Core\Logger;
class SaOverdueSubscriptionJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
return true;
}
public function run(): array
{
$ts = date('Y-m-d H:i:s');
$today = date('Y-m-d');
$marked = $this->markOverdue($today, $ts);
$notified = $this->notifyOverdue();
Logger::info("SaOverdueSubscriptionJob: {$marked} marked overdue, {$notified} notified");
return ['marked_overdue' => $marked, 'notified' => $notified];
}
private function markOverdue(string $today, string $ts): int
{
$subs = $this->db->select(
"SELECT id FROM sa_subscriptions
WHERE payment_status = 'unpaid' AND period_end < ?",
[$today]
);
foreach ($subs as $sub) {
$this->db->update('sa_subscriptions', [
'payment_status' => 'overdue',
'updated_at' => $ts,
], 'id = ?', [(int) $sub['id']]);
}
return count($subs);
}
private function notifyOverdue(): int
{
$subs = $this->db->select(
"SELECT s.id, s.player_id, s.amount, s.period_start, s.period_end,
p.full_name_ar, p.phone, p.guardian_phone
FROM sa_subscriptions s
INNER JOIN sa_players p ON p.id = s.player_id
WHERE s.payment_status = 'overdue' AND s.overdue_notified_at IS NULL",
[]
);
$count = 0;
$ts = date('Y-m-d H:i:s');
foreach ($subs as $sub) {
$phone = $sub['guardian_phone'] ?: ($sub['phone'] ?? '');
if ($phone === '') {
$this->db->update('sa_subscriptions', ['overdue_notified_at' => $ts], 'id = ?', [(int) $sub['id']]);
continue;
}
EventBus::dispatch('sa.subscription.overdue', [
'player_id' => (int) $sub['player_id'],
'player_name' => $sub['full_name_ar'],
'amount' => $sub['amount'],
'period' => $sub['period_start'] . ' - ' . $sub['period_end'],
'phone' => $phone,
]);
$this->db->update('sa_subscriptions', ['overdue_notified_at' => $ts], 'id = ?', [(int) $sub['id']]);
$count++;
}
return $count;
}
}
<?php
declare(strict_types=1);
return [
'up' => "ALTER TABLE `sa_groups`
ADD COLUMN `min_age` TINYINT UNSIGNED NULL AFTER `monthly_fee_nonmember`,
ADD COLUMN `max_age` TINYINT UNSIGNED NULL AFTER `min_age`;
ALTER TABLE `sa_subscriptions`
ADD COLUMN `overdue_notified_at` TIMESTAMP NULL AFTER `receipt_number`;
ALTER TABLE `sa_group_players`
ADD COLUMN `transferred_to_group_id` BIGINT UNSIGNED NULL AFTER `notes`,
ADD COLUMN `transfer_reason` VARCHAR(500) NULL AFTER `transferred_to_group_id`",
'down' => "ALTER TABLE `sa_groups`
DROP COLUMN `max_age`,
DROP COLUMN `min_age`;
ALTER TABLE `sa_subscriptions`
DROP COLUMN `overdue_notified_at`;
ALTER TABLE `sa_group_players`
DROP COLUMN `transfer_reason`,
DROP COLUMN `transferred_to_group_id`",
];
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