Commit d14f0c10 authored by Mahmoud Aglan's avatar Mahmoud Aglan

multiple fixes

parent f834d38b
......@@ -20,15 +20,16 @@ final class DivorceFeeCalculator
public static function getAnnualSubscription(): string
{
$month = (int) date('n');
$year = (int) date('Y');
$fy = $month >= 7 ? $year . '/' . ($year + 1) : ($year - 1) . '/' . $year;
$db = App::getInstance()->db();
$childRate = $db->selectOne(
"SELECT base_amount FROM service_catalog WHERE service_code LIKE 'SVC_ANNUAL_CHILD%' AND is_active = 1 ORDER BY effective_from DESC LIMIT 1"
);
$devFeeData = RuleEngine::get('DEVELOPMENT_FEE');
$ratesData = RuleEngine::get('membership.annual_rates.' . $fy);
$childRate = $ratesData['child'] ?? '222';
$devFee = $ratesData['dev'] ?? '35';
$rate = $childRate ? $childRate['base_amount'] : '222.00';
$dev = $devFeeData['amount'] ?? '35.00';
return bcadd($childRate, $devFee, 2);
return bcadd($rate, $dev, 2);
}
/**
......
......@@ -211,9 +211,15 @@ $statusLabel = $statusLabels[$case['status']] ?? $case['status'];
document.addEventListener('DOMContentLoaded', function() {
var percInput = document.querySelector('input[name="fee_percentage"]');
if (!percInput) return;
<?php
$__formFeeData = \App\Modules\Rules\Services\RuleEngine::get('FORM_TRANSFER_FEE');
$__devFeeData = \App\Modules\Rules\Services\RuleEngine::get('DEVELOPMENT_FEE');
$__db = \App\Core\App::getInstance()->db();
$__childRate = $__db->selectOne("SELECT base_amount FROM service_catalog WHERE service_code LIKE 'SVC_ANNUAL_CHILD%' AND is_active = 1 ORDER BY effective_from DESC LIMIT 1");
?>
var membershipValue = <?= json_encode((float) ($case['membership_value'] ?? 0)) ?>;
var formFee = 570;
var annualSub = 257;
var formFee = <?= json_encode((float) ($__formFeeData['amount'] ?? '570')) ?>;
var annualSub = <?= json_encode((float) ($__childRate['base_amount'] ?? '222') + (float) ($__devFeeData['amount'] ?? '35')) ?>;
percInput.addEventListener('input', function() {
var perc = parseFloat(this.value) || 0;
......
......@@ -68,15 +68,16 @@ final class FormFeeService
public static function getAnnualSubscriptionForAddition(): string
{
$month = (int) date('n');
$year = (int) date('Y');
$fy = $month >= 7 ? $year . '/' . ($year + 1) : ($year - 1) . '/' . $year;
$db = App::getInstance()->db();
$childRate = $db->selectOne(
"SELECT base_amount FROM service_catalog WHERE service_code LIKE 'SVC_ANNUAL_CHILD%' AND is_active = 1 ORDER BY effective_from DESC LIMIT 1"
);
$devFeeData = RuleEngine::get('DEVELOPMENT_FEE');
$ratesData = RuleEngine::get('membership.annual_rates.' . $fy);
$childRate = $ratesData['child'] ?? '222';
$devFee = $ratesData['dev'] ?? '35';
$rate = $childRate ? $childRate['base_amount'] : '222.00';
$dev = $devFeeData['amount'] ?? '35.00';
return bcadd($childRate, $devFee, 2);
return bcadd($rate, $dev, 2);
}
/**
......
......@@ -121,10 +121,54 @@ final class PaymentLifecycleService
$paymentType = $data['payment_type'] ?? '';
$entityType = $data['related_entity_type'] ?? null;
$entityId = (int) ($data['related_entity_id'] ?? 0);
$memberId = (int) ($data['member_id'] ?? 0);
if ($paymentType === 'addition_fee' && $entityType && $entityId > 0) {
self::archiveCancelledDependent($entityType, $entityId, $memberId);
return;
}
self::revertLifeEventOnVoid($paymentType, $entityType, $entityId);
}
/**
* Archive a dependent whose addition fee was cancelled.
* The member addition is considered withdrawn — archive the record.
*/
private static function archiveCancelledDependent(string $entityType, int $entityId, int $memberId): void
{
$validTables = ['spouses', 'children', 'temporary_members'];
if (!in_array($entityType, $validTables, true)) {
return;
}
$db = App::getInstance()->db();
$entity = $db->selectOne(
"SELECT id, status FROM `{$entityType}` WHERE id = ? AND member_id = ? AND is_archived = 0",
[$entityId, $memberId]
);
if (!$entity) {
return;
}
// Only archive if still pending — if active via another payment, leave it
if (!in_array($entity['status'], ['pending_payment', 'inactive'], true)) {
return;
}
$employee = App::getInstance()->currentEmployee();
$db->update($entityType, [
'is_archived' => 1,
'status' => 'inactive',
'archived_at' => date('Y-m-d H:i:s'),
'archived_by' => $employee ? (int) $employee->id : null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$entityId]);
Logger::info("PaymentLifecycleService: archived {$entityType} #{$entityId} after addition_fee cancelled for member #{$memberId}");
}
/**
* Revert life-event entity status when its fee payment is voided/cancelled.
*/
......
This diff is collapsed.
......@@ -2,9 +2,21 @@
declare(strict_types=1);
return [
['GET', '/pricing', 'Pricing\Controllers\PricingController@index', ['auth'], 'pricing.view'],
['GET', '/pricing/{id:\d+}/edit', 'Pricing\Controllers\PricingController@edit', ['auth'], 'pricing.edit'],
['POST', '/pricing/{id:\d+}', 'Pricing\Controllers\PricingController@update', ['auth', 'csrf'], 'pricing.edit'],
// Pricing Dashboard
['GET', '/pricing', 'Pricing\Controllers\PricingDashboardController@index', ['auth'], 'pricing.view'],
['POST', '/pricing/section/{category}', 'Pricing\Controllers\PricingDashboardController@saveSection', ['auth', 'csrf'], 'pricing.edit'],
['POST', '/pricing/annual-rates', 'Pricing\Controllers\PricingDashboardController@saveAnnualRates', ['auth', 'csrf'], 'pricing.edit'],
['POST', '/pricing/annual-rates/add-year', 'Pricing\Controllers\PricingDashboardController@addYear', ['auth', 'csrf'], 'pricing.edit'],
['POST', '/pricing/membership-prices', 'Pricing\Controllers\PricingDashboardController@saveMembershipPrices', ['auth', 'csrf'], 'pricing.edit'],
// Rule versioning
['GET', '/pricing/rule/{ruleCode}/versions', 'Pricing\Controllers\PricingDashboardController@ruleVersions', ['auth'], 'pricing.view'],
['POST', '/pricing/rule/{ruleCode}/add-version', 'Pricing\Controllers\PricingDashboardController@addRuleVersion', ['auth', 'csrf'], 'pricing.edit'],
// Legacy pricing configs
['GET', '/pricing/configs', 'Pricing\Controllers\PricingController@index', ['auth'], 'pricing.view'],
['GET', '/pricing/configs/{id:\d+}/edit', 'Pricing\Controllers\PricingController@edit', ['auth'], 'pricing.edit'],
['POST', '/pricing/configs/{id:\d+}', 'Pricing\Controllers\PricingController@update', ['auth', 'csrf'], 'pricing.edit'],
// Special Discounts
['GET', '/pricing/special-discounts', 'Pricing\Controllers\SpecialDiscountController@index', ['auth'], 'pricing.special_discounts.view'],
......
This diff is collapsed.
This diff is collapsed.
<?php
declare(strict_types=1);
// Menu and permissions registered in Rules/bootstrap.php (shared group)
\ No newline at end of file
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('pricing', [
'label_ar' => 'التسعير',
'label_en' => 'Pricing',
'icon' => 'banknote',
'route' => '/pricing',
'permission' => 'pricing.view',
'parent' => null,
'order' => 850,
'children' => [
['label_ar' => 'لوحة التسعير', 'label_en' => 'Pricing Dashboard', 'route' => '/pricing', 'permission' => 'pricing.view', 'order' => 1],
['label_ar' => 'الخصومات الخاصة', 'label_en' => 'Special Discounts', 'route' => '/pricing/special-discounts', 'permission' => 'pricing.special_discounts.view', 'order' => 2],
],
]);
PermissionRegistry::register('pricing', [
'pricing.view' => ['ar' => 'عرض لوحة التسعير', 'en' => 'View Pricing Dashboard'],
'pricing.edit' => ['ar' => 'تعديل الأسعار والرسوم', 'en' => 'Edit Prices & Fees'],
'pricing.special_discounts.view' => ['ar' => 'عرض الخصومات الخاصة', 'en' => 'View Special Discounts'],
'pricing.special_discounts.create' => ['ar' => 'إنشاء خصم خاص', 'en' => 'Create Special Discount'],
'pricing.special_discounts.edit' => ['ar' => 'تعديل الخصومات الخاصة','en' => 'Edit Special Discounts'],
]);
......@@ -66,13 +66,14 @@ final class RuleEngine
return BusinessRule::allByCategory($category, $branchId);
}
public static function update(string $ruleCode, string $newValueJson, string $reason, ?int $branchId = null): void
public static function update(string $ruleCode, string $newValueJson, string $reason, ?int $branchId = null, ?string $effectiveFrom = null): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$effectiveFrom = $effectiveFrom ?? date('Y-m-d');
$where = 'rule_code = ? AND is_active = 1';
$params = [$ruleCode];
$where = 'rule_code = ? AND is_active = 1 AND effective_from <= ? AND (effective_to IS NULL OR effective_to >= ?)';
$params = [$ruleCode, $effectiveFrom, $effectiveFrom];
if ($branchId !== null) {
$where .= ' AND branch_id = ?';
$params[] = $branchId;
......@@ -80,14 +81,20 @@ final class RuleEngine
$where .= ' AND branch_id IS NULL';
}
$rule = $db->selectOne("SELECT * FROM business_rules WHERE {$where}", $params);
$rule = $db->selectOne("SELECT * FROM business_rules WHERE {$where} ORDER BY effective_from DESC LIMIT 1", $params);
if (!$rule) {
throw new \RuntimeException("Rule not found: {$ruleCode}");
$fallback = $db->selectOne(
"SELECT * FROM business_rules WHERE rule_code = ? AND is_active = 1" . ($branchId !== null ? " AND branch_id = ?" : " AND branch_id IS NULL") . " ORDER BY effective_from DESC LIMIT 1",
$branchId !== null ? [$ruleCode, $branchId] : [$ruleCode]
);
if (!$fallback) {
throw new \RuntimeException("Rule not found: {$ruleCode}");
}
$rule = $fallback;
}
$newVersion = (int) $rule['version'] + 1;
// Create version history
$db->insert('rule_versions', [
'rule_id' => (int) $rule['id'],
'version_number' => $newVersion,
......@@ -98,18 +105,105 @@ final class RuleEngine
'change_reason' => $reason,
]);
// Update current value
$db->update('business_rules', [
'current_value_json' => $newValueJson,
'version' => $newVersion,
if ($rule['effective_from'] === $effectiveFrom) {
$db->update('business_rules', [
'current_value_json' => $newValueJson,
'version' => $newVersion,
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [$rule['id']]);
} else {
$yesterday = date('Y-m-d', strtotime($effectiveFrom . ' -1 day'));
$db->update('business_rules', [
'effective_to' => $yesterday,
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [$rule['id']]);
$db->insert('business_rules', [
'rule_code' => $rule['rule_code'],
'category' => $rule['category'],
'name_ar' => $rule['name_ar'],
'name_en' => $rule['name_en'],
'description_ar' => $rule['description_ar'],
'description_en' => $rule['description_en'],
'parameters_json' => $rule['parameters_json'],
'current_value_json' => $newValueJson,
'data_type' => $rule['data_type'],
'branch_id' => $rule['branch_id'],
'effective_from' => $effectiveFrom,
'effective_to' => $rule['effective_to'],
'is_active' => 1,
'version' => $newVersion,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
'updated_by' => $employee ? (int) $employee->id : null,
]);
}
self::$cache = [];
Logger::info("Rule updated: {$ruleCode}", ['version' => $newVersion, 'effective_from' => $effectiveFrom, 'reason' => $reason]);
}
public static function addHistoricalVersion(string $ruleCode, string $valueJson, string $effectiveFrom, string $effectiveTo, string $reason, ?int $branchId = null): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$existing = $db->selectOne(
"SELECT * FROM business_rules WHERE rule_code = ? AND is_active = 1" . ($branchId !== null ? " AND branch_id = ?" : " AND branch_id IS NULL") . " LIMIT 1",
$branchId !== null ? [$ruleCode, $branchId] : [$ruleCode]
);
if (!$existing) {
throw new \RuntimeException("Rule not found: {$ruleCode}");
}
$overlap = $db->selectOne(
"SELECT id FROM business_rules WHERE rule_code = ? AND is_active = 1 AND effective_from <= ? AND (effective_to IS NULL OR effective_to >= ?)" . ($branchId !== null ? " AND branch_id = ?" : " AND branch_id IS NULL"),
$branchId !== null ? [$ruleCode, $effectiveTo, $effectiveFrom, $branchId] : [$ruleCode, $effectiveTo, $effectiveFrom]
);
if ($overlap) {
throw new \RuntimeException("Date range overlaps with existing version for rule: {$ruleCode}");
}
$db->insert('business_rules', [
'rule_code' => $existing['rule_code'],
'category' => $existing['category'],
'name_ar' => $existing['name_ar'],
'name_en' => $existing['name_en'],
'description_ar' => $existing['description_ar'],
'description_en' => $existing['description_en'],
'parameters_json' => $existing['parameters_json'],
'current_value_json' => $valueJson,
'data_type' => $existing['data_type'],
'branch_id' => $existing['branch_id'],
'effective_from' => $effectiveFrom,
'effective_to' => $effectiveTo,
'is_active' => 1,
'version' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [$rule['id']]);
]);
// Clear cache
self::$cache = [];
Logger::info("Historical version added: {$ruleCode}", ['from' => $effectiveFrom, 'to' => $effectiveTo, 'reason' => $reason]);
}
Logger::info("Rule updated: {$ruleCode}", ['version' => $newVersion, 'reason' => $reason]);
public static function getVersionHistory(string $ruleCode, ?int $branchId = null): array
{
$db = App::getInstance()->db();
$where = 'rule_code = ? AND is_active = 1';
$params = [$ruleCode];
if ($branchId !== null) {
$where .= ' AND branch_id = ?';
$params[] = $branchId;
} else {
$where .= ' AND branch_id IS NULL';
}
return $db->select("SELECT * FROM business_rules WHERE {$where} ORDER BY effective_from DESC", $params);
}
public static function override(string $ruleCode, string $entityType, int $entityId, string $overrideValueJson, string $reason): void
......
......@@ -22,7 +22,7 @@
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-groups.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">إضافة مجموعة</h3><div class="tut-step-body">من القائمة: <span class="field">الأنشطة الرياضية</span> > <span class="field">المجموعات</span> > إضافة مجموعة.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">البيانات والربط</h3><div class="tut-step-body"><ul><li><span class="field">code / name_ar</span></li><li><span class="field">program_id</span> — البرنامج (يحدد اللعبة والمستوى تلقائياً)</li><li><span class="field">coach_id</span> — المدرب (يظهر فقط المتخصصين في نفس اللعبة)</li><li><span class="field">min_capacity / max_capacity</span> — الحد الأدنى والأقصى</li><li><span class="field">monthly_fee_member / monthly_fee_nonmember</span> — الرسوم الشهرية</li><li><span class="field">season_start / season_end</span> — مدة الموسم</li></ul></div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">البيانات والربط</h3><div class="tut-step-body"><ul><li><span class="field">code / name_ar / name_en</span> — كود واسم المجموعة</li><li><span class="field">النشاط الرياضي</span> — اختياري لتصفية البرامج والمدربين</li><li><span class="field">program_id</span> — البرنامج (يحدد المستوى والفئة العمرية)</li><li><span class="field">coach_id</span> — المدرب (يتم تصفيته حسب النشاط المختار)</li><li><span class="field">الحد الأدنى / الحد الأقصى</span> — سعة المجموعة</li><li><span class="field">رسوم شهرية (أعضاء) / رسوم شهرية (غير أعضاء)</span> — الرسوم</li><li><span class="field">بداية الموسم / نهاية الموسم</span> — مدة الموسم</li></ul><span class="info">اختيار النشاط الرياضي اختياري — لكنه يصفي قائمة البرامج والمدربين المتاحة تلقائياً.</span></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">إعداد الجدول الأسبوعي</h3><div class="tut-step-body">من صفحة المجموعة > الجدول > إضافة حصة:<ul><li><span class="field">day_of_week</span> — اليوم</li><li><span class="field">facility_unit_id</span> — الوحدة/الحارة/الملعب</li><li><span class="field">start_time / end_time</span> — الوقت</li></ul>مثال: أحد + ثلاثاء + خميس — حارة 3 — من 16:00 إلى 17:00<span class="warn">الجدول الأسبوعي يتحقق من التعارض — لو الوحدة محجوزة في نفس الوقت، النظام يرفض ويوضح التعارض.</span><span class="success">بعد إنشاء الجدول، يمكنك استخدام "توليد الحجوزات" لإنشاء حجوزات تلقائية لكل حصة.</span></div></div>
<div class="tut-nav">
<a href="/tutorials/sports-activity/create-program"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> إنشاء برنامج تدريبي</a>
......
......@@ -21,11 +21,11 @@
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-booking-wizard.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">إنشاء حجز جديد</h3><div class="tut-step-body">من القائمة: <span class="field">الحجوزات</span> > <span class="field">حجز جديد</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">اختيار الوحدة والوقت</h3><div class="tut-step-body"><ul><li><span class="field">facility_unit_id</span> — الوحدة (ملعب تنس، كورت...)</li><li><span class="field">booking_date</span> — التاريخ</li><li><span class="field">start_time / end_time</span> — الوقت</li></ul></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">الفحص التلقائي</h3><div class="tut-step-body">النظام يتحقق: هل الوحدة فاضية؟ هل في blackout؟ هل الوقت ضمن ساعات التشغيل؟<span class="warn">للوحدات exclusive: حجز واحد فقط يأخذ الوحدة بالكامل. لو في تمرين مجدول — الحجز مرفوض.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">حساب السعر</h3><div class="tut-step-body">النظام يحدد time_bracket تلقائياً → يجلب pricing_rule → يحسب السعر الإجمالي.<span class="info">السعر يتم حسابه لحظياً — أي تغيير في الوقت أو عدد المشاركين يعيد الحساب فوراً.</span></div></div>
<div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">تأكيد الحجز</h3><div class="tut-step-body">تأكيد → <span class="field">status = confirmed</span> ويتولد <span class="field">booking_number</span> تلقائياً.<span class="success">بعد الحجز، يظهر في المراية كـ "محجوز" (booked) بلون مميز عن التمارين (training).</span></div></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح معالج الحجز</h3><div class="tut-step-body">من القائمة الجانبية: <span class="field">الأنشطة الرياضية</span> > <span class="field">معالج الحجز</span>. أو من لوحة التحكم اضغط زر <span class="field">حجز جديد</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">الخطوة 1: بيانات الحاجز</h3><div class="tut-step-body">المعالج يبدأ بـ 4 خطوات تتبعية. في الخطوة الأولى:<ul><li>أدخل <span class="field">رقم العضوية</span> لملء البيانات تلقائياً (للأعضاء)</li><li>أو أدخل <span class="field">الرقم القومي</span> والاسم يدوياً (للزوار)</li></ul><span class="info">إذا أدخلت رقم العضوية، النظام يسحب الاسم والنوع والسن تلقائياً.</span></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">الخطوة 2: اختيار المرفق</h3><div class="tut-step-body">اختر المرفق (ملعب تنس، كورت، حمام سباحة...) ثم اختر الوحدة المحددة.<span class="warn">للوحدات exclusive: حجز واحد فقط يأخذ الوحدة بالكامل. لو في تمرين مجدول — الحجز مرفوض.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">الخطوة 3: اختيار الموعد</h3><div class="tut-step-body">حدد التاريخ والوقت. النظام يعرض فقط الأوقات المتاحة ويتحقق تلقائياً من:<ul><li>هل الوحدة فاضية؟</li><li>هل في blackout (صيانة)؟</li><li>هل الوقت ضمن ساعات التشغيل؟</li></ul>السعر يتم حسابه تلقائياً بناءً على <span class="field">time_bracket</span> و<span class="field">pricing_rule</span>.<span class="info">السعر يتم حسابه لحظياً — أي تغيير في الوقت أو عدد المشاركين يعيد الحساب فوراً.</span></div></div>
<div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">الخطوة 4: التأكيد والحجز</h3><div class="tut-step-body">راجع الملخص (الشخص، المرفق، الموعد، السعر) ثم اضغط <span class="field">تأكيد الحجز</span>.<br>يتولد <span class="field">booking_number</span> تلقائياً وطلب دفع يُرسل لخزنة الأنشطة.<span class="success">بعد الحجز، يظهر في المراية كـ "محجوز" (booked) بلون مميز عن التمارين (training).</span></div></div>
<div class="tut-nav">
<a href="/tutorials/sports-activity/generate-training-bookings"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> توليد حجوزات التمارين</a>
<a href="/tutorials/sports-activity/booking-shared-lane">حجز حارة سباحة <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a>
......
......@@ -21,7 +21,7 @@
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-subscriptions.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح التوليد</h3><div class="tut-step-body">من القائمة: <span class="field">الاشتراكات</span> > <span class="field">توليد اشتراكات شهرية</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح التوليد</h3><div class="tut-step-body">من صفحة الاشتراكات الشهرية، اضغط زر <span class="field">توليد الاشتراكات</span> أعلى الصفحة. أو يمكنك الضغط على <span class="field">معاينة الشهر القادم</span> أولاً لرؤية ما سيتم توليده.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">اختيار الشهر</h3><div class="tut-step-body">اختر الشهر والسنة (مثلاً: يونيو 2026).</div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">المعالجة</h3><div class="tut-step-body">لكل لاعب active في كل مجموعة نشطة:<ul><li>يتحقق: هل سبق توليد اشتراك لنفس الفترة؟</li><li>يحدد المبلغ حسب <span class="field">player_type</span></li><li>ينشئ سجل بحالة <span class="field">unpaid</span></li></ul><div class="tut-diagram">المجموعة "سباحة صباحي":
├── أحمد (عضو) 300 ج → اشتراك #SUB-001 unpaid
......
......@@ -21,8 +21,9 @@
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-mirror.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح المراية</h3><div class="tut-step-body">من القائمة: <span class="field">الأنشطة الرياضية</span> > <span class="field">المراية</span> > اختر المرفق.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">العرض الشبكي</h3><div class="tut-step-body">شبكة = الوحدات (أعمدة) × الفترات الزمنية (صفوف):<div class="tut-diagram">المراية — حمام السباحة — 2026-05-18
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح المراية</h3><div class="tut-step-body">من القائمة الجانبية: <span class="field">الأنشطة الرياضية</span> > <span class="field">المراية</span>. أو من لوحة التحكم اضغط زر <span class="field">المراية</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">العرض الرئيسي — حالة المرافق</h3><div class="tut-step-body">الصفحة الرئيسية تعرض جميع المرافق مجمّعة حسب النشاط الرياضي. كل مرفق يظهر كبطاقة تحتوي على:<ul><li>اسم المرفق ونوعه (ملعب / صالة / حمام سباحة)</li><li>عدد الوحدات</li></ul>يمكنك التصفية بالضغط على اسم الرياضة في شريط <span class="field">تصفية بالرياضة</span> أعلى الصفحة.</div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">العرض التفصيلي — شبكة المرفق</h3><div class="tut-step-body">عند الضغط على بطاقة مرفق معين، تنتقل لشبكة الوقت الخاصة به:<div class="tut-diagram">المراية — حمام السباحة — اليوم
الوقت | حارة 1 | حارة 2 | حارة 3 | حارة 4
─────────┼───────────┼───────────┼───────────┼──────────
......@@ -30,8 +31,8 @@
07:00-08 | 🟡 4/8 | 🟢 free | 🟣 تمرين | 🟢 free
08:00-09 | 🔴 full | 🔵 حجز | 🟢 free | 🟢 free
09:00-10 | 🟢 free | 🟢 free | 🟢 free | ⚫ صيانة</div></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">الحالات والألوان</h3><div class="tut-step-body"><ul><li>🟢 <span class="field">free</span> — فارغة ومتاحة</li><li>🔵 <span class="field">booked</span> — محجوزة بالساعة</li><li>🟣 <span class="field">training</span> — تمرين مجموعة</li><li>🟡 <span class="field">partial</span> — محجوزة جزئياً (shared)</li><li>🔴 <span class="field">full</span> — ممتلئة</li><li><span class="field">blocked</span> — صيانة</li></ul></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">ملاحظات</h3><div class="tut-step-body"><span class="info">المراية للعرض فقط — لا يوجد فيها أي نموذج إدخال. تُعرض عادة على شاشة كبيرة في الاستقبال.</span><span class="warn">تعرض فقط الحجوزات الفعالة. الملغية لا تظهر. تتحدث كل 30 ثانية تلقائياً.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">الحالات والألوان</h3><div class="tut-step-body"><ul><li>🟢 <span class="field">free</span> — فارغة ومتاحة</li><li>🔵 <span class="field">booked</span> — محجوزة بالساعة</li><li>🟣 <span class="field">training</span> — تمرين مجموعة</li><li>🟡 <span class="field">partial</span> — محجوزة جزئياً (shared)</li><li>🔴 <span class="field">full</span> — ممتلئة</li><li><span class="field">blocked</span> — صيانة</li></ul></div></div>
<div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">ملاحظات</h3><div class="tut-step-body"><span class="info">المراية للعرض فقط — لا يوجد فيها أي نموذج إدخال. تُعرض عادة على شاشة كبيرة في الاستقبال.</span><span class="warn">تعرض فقط الحجوزات الفعالة. الملغية لا تظهر. تتحدث كل 30 ثانية تلقائياً.</span></div></div>
<div class="tut-nav">
<a href="/tutorials/sports-activity/record-attendance"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> تسجيل الحضور</a>
<a href="/tutorials/sports-activity/waitlist-management">إدارة قائمة الانتظار <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a>
......
......@@ -21,8 +21,8 @@
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-attendance.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح الحضور</h3><div class="tut-step-body">من القائمة: <span class="field">الأنشطة الرياضية</span> > <span class="field">الحضور</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">اختيار التمرين</h3><div class="tut-step-body">اختر التاريخ والمجموعة. النظام يعرض كل اللاعبين المسجلين.</div></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح الحضور</h3><div class="tut-step-body">من القائمة الجانبية: <span class="field">الأنشطة الرياضية</span> > <span class="field">الحضور</span>. تعرض الصفحة حصص التدريب اليوم مع إمكانية التصفية بالنشاط الرياضي والمدرب.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">اختيار الحصة</h3><div class="tut-step-body">اضغط زر <span class="field">تسجيل الحضور</span> بجانب الحصة المطلوبة. يعرض الجدول: الوقت، المجموعة، المدرب، المرفق، والوحدة. النظام يعرض كل اللاعبين المسجلين في تلك المجموعة.</div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">تسجيل الحالة</h3><div class="tut-step-body">لكل لاعب:<ul><li><span class="field">present</span> — حاضر (مع check_in_time)</li><li><span class="field">absent</span> — غائب</li><li><span class="field">late</span> — متأخر (مع الوقت الفعلي)</li><li><span class="field">excused</span> — معتذر</li></ul><span class="info">الحضور يرتبط بحجز التمرين — لازم يكون في حجز training لهذا اليوم والمجموعة.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">التقارير</h3><div class="tut-step-body"><span class="success">تقارير متاحة لكل لاعب (نسبة حضوره) ولكل مجموعة (متوسط الحضور). المدرب يقدر يشوف أنماط الغياب.</span></div></div>
<div class="tut-nav">
......
......@@ -21,13 +21,20 @@
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-players.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح صفحة اللاعبين</h3><div class="tut-step-body">من القائمة: <span class="field">الأنشطة الرياضية</span> > <span class="field">اللاعبين</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">تسجيل لاعب جديد</h3><div class="tut-step-body">اضغط <span class="field">تسجيل لاعب جديد</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">نوع اللاعب</h3><div class="tut-step-body"><span class="field">player_type</span>: عضو (member) — يتم ربطه بعضوية موجودة، أو غير عضو (non_member).<span class="info">لو اللاعب عضو، بيتم سحب بياناته الأساسية من سجل العضوية تلقائياً.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">البيانات الشخصية</h3><div class="tut-step-body"><ul><li>الاسم الكامل بالعربي والإنجليزي</li><li>الرقم القومي، تاريخ الميلاد، النوع</li><li>الهاتف، البريد الإلكتروني</li></ul></div></div>
<div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">بيانات ولي الأمر</h3><div class="tut-step-body">للأطفال: اسم ولي الأمر، هاتفه، رقمه القومي، صلة القرابة.</div></div>
<div class="tut-step"><div class="tut-step-num">6</div><h3 class="tut-step-title">الحالة الطبية والكارنيه</h3><div class="tut-step-body">الحالة الطبية تبدأ <span class="field">pending</span> — لازم يتم رفع شهادة طبية واعتمادها. الكارنيه يبدأ <span class="field">inactive</span>.<span class="warn">اللاعب مش هيقدر يتسجل في أي مجموعة أو يحجز ساعة إلا لما حالته الطبية تبقى "fit" أو "conditional".</span></div></div>
<div class="tut-step"><div class="tut-step-num">7</div><h3 class="tut-step-title">حفظ</h3><div class="tut-step-body">يتم توليد <span class="field">registration_serial</span> تلقائياً. الخطوة التالية: رفع الشهادة الطبية.</div></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح صفحة اللاعبين</h3><div class="tut-step-body">من القائمة الجانبية: <span class="field">الأنشطة الرياضية</span> > <span class="field">اللاعبين</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">إضافة لاعب جديد</h3><div class="tut-step-body">اضغط زر <span class="field">إضافة لاعب جديد</span> أعلى الصفحة. ستظهر نموذج التسجيل في صفحة واحدة مقسمة لأقسام.</div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">البيانات الأساسية</h3><div class="tut-step-body">
<ul>
<li><span class="field">نوع اللاعب</span> — عضو (member) أو غير عضو (non_member). إذا اخترت عضو، أدخل رقم العضوية وستُملأ البيانات تلقائياً</li>
<li><span class="field">الاسم بالعربي</span> — إلزامي</li>
<li><span class="field">الاسم بالإنجليزي</span> — اختياري</li>
<li><span class="field">الرقم القومي</span></li>
<li><span class="field">تاريخ الميلاد</span> والنوع</li>
<li><span class="field">الهاتف</span> و<span class="field">البريد الإلكتروني</span></li>
</ul>
</div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">بيانات ولي الأمر</h3><div class="tut-step-body">للأطفال: <span class="field">اسم ولي الأمر</span>، <span class="field">هاتف ولي الأمر</span>، <span class="field">الرقم القومي لولي الأمر</span>، <span class="field">صلة القرابة</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">ملاحظات وحفظ</h3><div class="tut-step-body">يمكنك إضافة ملاحظات إضافية، ثم اضغط <span class="field">حفظ</span>.<br>الحالة الطبية تبدأ <span class="field">pending</span> — لازم يتم رفع شهادة طبية واعتمادها.<span class="warn">اللاعب مش هيقدر يتسجل في أي مجموعة أو يحجز ساعة إلا لما حالته الطبية تبقى "fit" أو "conditional".</span><span class="success">بعد الحفظ: يتم توليد <span class="field">registration_serial</span> تلقائياً. الخطوة التالية: رفع الشهادة الطبية.</span></div></div>
<div class="tut-nav">
<a href="/tutorials/sports-activity/register-coach"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> تسجيل مدرب جديد</a>
<a href="/tutorials/sports-activity/medical-workflow">دورة الموافقة الطبية <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a>
......
......@@ -31,9 +31,9 @@
<li><span class="field">name_ar</span> — الاسم بالعربي (مثل: كرة القدم)</li>
<li><span class="field">name_en</span> — الاسم بالإنجليزي (اختياري)</li>
<li><span class="field">category</span> — تصنيف اللعبة</li>
<li><span class="field">icon</span> — أيقونة العرض</li>
<li><span class="field">sort_order</span> — ترتيب العرض في القوائم</li>
<li><span class="field">icon</span> — أيقونة العرض (مع معاينة مباشرة)</li>
<li><span class="field">description_ar</span> — وصف مختصر</li>
<li><span class="field">config_json</span> — إعدادات إضافية (فئات عمرية، مستويات مهارة)</li>
</ul>
<span class="info">التصنيفات المتاحة: فردي (individual) — جماعي (team) — مضرب (racket) — مائي (aquatic) — قتالي (combat) — ترفيهي (leisure)</span>
</div></div>
......
<?php
declare(strict_types=1);
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
$rules = [
[
'rule_code' => 'ACQUIRED_CHILD_FEE_UNDER_12',
'category' => 'children_fee',
'name_ar' => 'رسوم ابن المنضم - أقل من 12 سنة',
'name_en' => 'Acquired Member Child Fee - Under 12',
'data_type' => 'percentage',
'current_value_json' => '{"percentage":"15.00","base":"membership_value"}',
'parameters_json' => '{"percentage":"decimal","base":"string"}',
],
[
'rule_code' => 'ACQUIRED_CHILD_FEE_12_TO_16',
'category' => 'children_fee',
'name_ar' => 'رسوم ابن المنضم - 12 إلى 16 سنة',
'name_en' => 'Acquired Member Child Fee - 12 to 16',
'data_type' => 'percentage',
'current_value_json' => '{"percentage":"20.00","base":"membership_value"}',
'parameters_json' => '{"percentage":"decimal","base":"string"}',
],
[
'rule_code' => 'ACQUIRED_CHILD_FEE_16_TO_18',
'category' => 'children_fee',
'name_ar' => 'رسوم ابن المنضم - 16 إلى 18 سنة',
'name_en' => 'Acquired Member Child Fee - 16 to 18',
'data_type' => 'percentage',
'current_value_json' => '{"percentage":"25.00","base":"membership_value"}',
'parameters_json' => '{"percentage":"decimal","base":"string"}',
],
[
'rule_code' => 'ACQUIRED_CHILD_FEE_OVER_18',
'category' => 'children_fee',
'name_ar' => 'رسوم ابن المنضم - أكبر من 18 سنة',
'name_en' => 'Acquired Member Child Fee - Over 18',
'data_type' => 'percentage',
'current_value_json' => '{"percentage":"30.00","base":"membership_value"}',
'parameters_json' => '{"percentage":"decimal","base":"string"}',
],
[
'rule_code' => 'ACQUIRED_SPOUSE_FEE_2ND_PLUS',
'category' => 'spouse_fee',
'name_ar' => 'رسوم زوجة المنضم - الثانية فأكثر',
'name_en' => 'Acquired Member Spouse Fee - 2nd+',
'data_type' => 'percentage',
'current_value_json' => '{"percentage":"75.00","base":"membership_value"}',
'parameters_json' => '{"percentage":"decimal","base":"string"}',
],
];
foreach ($rules as $rule) {
$exists = $db->selectOne(
"SELECT id FROM business_rules WHERE rule_code = ? AND branch_id IS NULL",
[$rule['rule_code']]
);
if ($exists) {
continue;
}
$db->insert('business_rules', array_merge($rule, [
'effective_from' => '2024-01-01',
'is_active' => 1,
'version' => 1,
'created_at' => $now,
'updated_at' => $now,
]));
}
};
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `business_rules` DROP INDEX `uq_business_rules_code_branch`;
ALTER TABLE `business_rules` ADD UNIQUE KEY `uq_business_rules_code_branch_date` (`rule_code`, `branch_id`, `effective_from`)
",
'down' => "
ALTER TABLE `business_rules` DROP INDEX `uq_business_rules_code_branch_date`;
ALTER TABLE `business_rules` ADD UNIQUE KEY `uq_business_rules_code_branch` (`rule_code`, `branch_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