Commit 7537971e authored by Administrator's avatar Administrator

Update 34 files via Son of Anton

parent 1e79566a
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Pricing\Models\PricingConfig;
class PricingController extends Controller
{
public function index(Request $request): Response
{
$configs = PricingConfig::allWithDetails();
return $this->view('Pricing.Views.index', ['configs' => $configs]);
}
public function edit(Request $request, string $id): Response
{
$config = PricingConfig::find((int) $id);
if (!$config) {
return $this->redirect('/pricing')->withError('التسعير غير موجود');
}
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
$quals = $db->select("SELECT id, name_ar FROM qualifications WHERE is_active = 1 ORDER BY sort_order");
return $this->view('Pricing.Views.edit', ['config' => $config, 'branches' => $branches, 'quals' => $quals]);
}
public function update(Request $request, string $id): Response
{
$config = PricingConfig::find((int) $id);
if (!$config) {
return $this->redirect('/pricing')->withError('التسعير غير موجود');
}
$data = $this->validate($request->all(), [
'price' => 'required|numeric|min:0',
'effective_from' => 'required|date',
'effective_to' => 'nullable|date',
'notes' => 'nullable|string|max:1000',
]);
$config->update([
'price' => $data['price'],
'effective_from' => $data['effective_from'],
'effective_to' => $data['effective_to'] ?? null,
'notes' => $data['notes'] ?? null,
]);
return $this->redirect('/pricing')->withSuccess('تم تحديث التسعير بنجاح');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Models;
use App\Core\Model;
use App\Core\App;
class DiscountRule extends Model
{
protected static string $table = 'discount_rules';
protected static bool $softDelete = false;
protected static bool $timestamps = true;
protected static array $fillable = [
'discount_code', 'name_ar', 'name_en', 'discount_type', 'value',
'min_quantity', 'max_quantity', 'stackable', 'max_discount_percentage',
'branch_id', 'effective_from', 'effective_to', 'is_active',
];
public static function allActive(): array
{
$db = App::getInstance()->db();
return $db->select("SELECT * FROM discount_rules WHERE is_active = 1 AND effective_from <= CURDATE() AND (effective_to IS NULL OR effective_to >= CURDATE()) ORDER BY discount_code");
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Models;
use App\Core\Model;
use App\Core\App;
class PricingConfig extends Model
{
protected static string $table = 'pricing_configs';
protected static bool $softDelete = false;
protected static bool $timestamps = true;
protected static array $fillable = [
'branch_id', 'qualification_id', 'membership_type', 'price', 'currency',
'effective_from', 'effective_to', 'is_active', 'notes',
];
public static function allWithDetails(): array
{
$db = App::getInstance()->db();
return $db->select("
SELECT pc.*, b.name_ar as branch_name, q.name_ar as qualification_name
FROM pricing_configs pc
JOIN branches b ON b.id = pc.branch_id
JOIN qualifications q ON q.id = pc.qualification_id
WHERE pc.is_active = 1
ORDER BY b.name_ar, q.sort_order, pc.effective_from DESC
");
}
}
\ No newline at end of file
<?php
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'],
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Rules\Services\RuleEngine;
final class PricingEngine
{
public static function getMembershipPrice(int $branchId, string $qualificationCode, ?string $date = null): array
{
$date = $date ?? date('Y-m-d');
$db = App::getInstance()->db();
$qual = $db->selectOne("SELECT id FROM qualifications WHERE code = ?", [$qualificationCode]);
if (!$qual) {
return ['price' => '0.00', 'currency' => 'EGP', 'config_id' => null, 'error' => 'Qualification not found'];
}
$config = $db->selectOne(
"SELECT * FROM pricing_configs WHERE branch_id = ? AND qualification_id = ? AND membership_type = 'working' AND is_active = 1 AND effective_from <= ? AND (effective_to IS NULL OR effective_to >= ?) ORDER BY effective_from DESC LIMIT 1",
[$branchId, $qual['id'], $date, $date]
);
if (!$config) {
return ['price' => '0.00', 'currency' => 'EGP', 'config_id' => null, 'error' => 'No pricing config found'];
}
return [
'price' => $config['price'],
'currency' => $config['currency'],
'config_id' => (int) $config['id'],
'error' => null,
];
}
public static function calculateChildFee(string $membershipValue, int $childAge, int $childOrder): array
{
$maxIncluded = RuleEngine::getValue('CHILD_INCLUDED_MAX_COUNT', 'value') ?? 3;
$maxIncludedAge = RuleEngine::getValue('CHILD_INCLUDED_MAX_AGE', 'value') ?? 18;
if ($childAge < $maxIncludedAge && $childOrder <= $maxIncluded) {
return ['fee' => '0.00', 'rule_applied' => 'included', 'percentage' => '0.00', 'classification' => 'included'];
}
if ($childAge < $maxIncludedAge && $childOrder > $maxIncluded) {
$data = RuleEngine::get('CHILD_4TH_UNDER_18_FEE');
$pct = $data['percentage'] ?? '5.00';
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['fee' => $fee, 'rule_applied' => 'CHILD_4TH_UNDER_18_FEE', 'percentage' => $pct, 'classification' => 'dependent_with_fee'];
}
$ruleMap = [18 => 'CHILD_FEE_AGE_18', 19 => 'CHILD_FEE_AGE_19', 20 => 'CHILD_FEE_AGE_20'];
if (isset($ruleMap[$childAge])) {
$data = RuleEngine::get($ruleMap[$childAge]);
$pct = $data['percentage'] ?? '10.00';
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['fee' => $fee, 'rule_applied' => $ruleMap[$childAge], 'percentage' => $pct, 'classification' => $data['type'] ?? 'regular'];
}
if ($childAge >= 21 && $childAge < 25) {
$data = RuleEngine::get('CHILD_FEE_AGE_21');
$pct = $data['percentage'] ?? '15.00';
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['fee' => $fee, 'rule_applied' => 'CHILD_FEE_AGE_21', 'percentage' => $pct, 'classification' => 'temporary'];
}
return ['fee' => '0.00', 'rule_applied' => 'CHILD_AUTO_DELETE_AGE', 'percentage' => '0.00', 'classification' => 'not_accepted', 'error' => 'Child age 25+ not accepted'];
}
public static function calculateSpouseFee(string $membershipValue, int $spouseOrder, string $nationality, string $marriageDate, string $membershipAcquisitionDate, string $memberType): array
{
if ($spouseOrder === 1) {
return ['percentage_fee' => '0.00', 'annual_fee' => '0.00', 'total' => '0.00', 'years_count' => 0, 'rule_applied' => 'included'];
}
if (strtolower($nationality) !== 'مصري' && strtolower($nationality) !== 'egyptian' && strtolower($nationality) !== 'egy') {
$data = RuleEngine::get('SPOUSE_FOREIGN_FEE');
$pct = $data['percentage'] ?? '15.00';
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['percentage_fee' => $fee, 'annual_fee' => '0.00', 'total' => $fee, 'years_count' => 0, 'rule_applied' => 'SPOUSE_FOREIGN_FEE'];
}
if ($memberType === 'acquired') {
$data = RuleEngine::get('SPOUSE_ACQUIRED_MEMBER_FEE');
$pct = $data['percentage'] ?? '50.00';
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['percentage_fee' => $fee, 'annual_fee' => '0.00', 'total' => $fee, 'years_count' => 0, 'rule_applied' => 'SPOUSE_ACQUIRED_MEMBER_FEE'];
}
$ruleMap = [2 => 'SPOUSE_2ND_FEE', 3 => 'SPOUSE_3RD_FEE', 4 => 'SPOUSE_4TH_FEE'];
$ruleCode = $ruleMap[$spouseOrder] ?? 'SPOUSE_4TH_FEE';
$data = RuleEngine::get($ruleCode);
if (!$data) {
return ['percentage_fee' => '0.00', 'annual_fee' => '0.00', 'total' => '0.00', 'years_count' => 0, 'rule_applied' => 'error', 'error' => 'Rule not found'];
}
$pct = $data['percentage'] ?? '10.00';
$annualFlat = $data['annual_flat'] ?? '150.00';
$percentageFee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
$startDate = max($marriageDate, $membershipAcquisitionDate);
$start = new \DateTime($startDate);
$now = new \DateTime();
$diff = $now->diff($start);
$yearsCount = $diff->y;
if ($diff->m > 0 || $diff->d > 0) {
$yearsCount++;
}
$yearsCount = max(1, $yearsCount);
$annualFee = bcmul((string) $yearsCount, $annualFlat, 2);
$total = bcadd($percentageFee, $annualFee, 2);
return [
'percentage_fee' => $percentageFee,
'annual_fee' => $annualFee,
'total' => $total,
'years_count' => $yearsCount,
'rule_applied' => $ruleCode,
];
}
public static function calculateSeparationFee(string $newMembershipValue, int $yearsSinceAcquisition): array
{
$ruleMap = [
1 => 'SEPARATION_FEE_YEAR_1',
2 => 'SEPARATION_FEE_YEAR_2',
3 => 'SEPARATION_FEE_YEAR_3',
4 => 'SEPARATION_FEE_YEAR_4',
5 => 'SEPARATION_FEE_YEAR_5',
];
$ruleCode = $ruleMap[$yearsSinceAcquisition] ?? 'SEPARATION_FEE_YEAR_6_PLUS';
$data = RuleEngine::get($ruleCode);
$pct = $data['percentage'] ?? '2.50';
$fee = bcmul($newMembershipValue, bcdiv($pct, '100', 4), 2);
return [
'fee' => $fee,
'percentage' => $pct,
'rule_applied' => $ruleCode,
'years' => $yearsSinceAcquisition,
];
}
public static function calculateInstallmentPlan(string $totalAmount, string $downPayment, int $months): array
{
$rateData = RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$annualRate = $rateData['percentage'] ?? '22.00';
$monthlyRate = bcdiv($annualRate, '1200', 8);
$remaining = bcsub($totalAmount, $downPayment, 2);
$totalInterest = bcmul($remaining, bcmul($monthlyRate, (string) $months, 8), 2);
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$monthlyPayment = bcdiv($totalWithInterest, (string) $months, 2);
$schedule = [];
$balance = $remaining;
$startDate = new \DateTime();
$startDate->modify('+1 month');
for ($i = 1; $i <= $months; $i++) {
$interest = bcmul($balance, $monthlyRate, 2);
$principal = bcsub($monthlyPayment, $interest, 2);
if ($i === $months) {
$principal = $balance;
$monthlyPayment = bcadd($principal, $interest, 2);
}
$balance = bcsub($balance, $principal, 2);
if (bccomp($balance, '0', 2) < 0) {
$balance = '0.00';
}
$dueDate = clone $startDate;
$dueDate->modify('+' . ($i - 1) . ' months');
$schedule[] = [
'number' => $i,
'due_date' => $dueDate->format('Y-m-d'),
'amount' => $monthlyPayment,
'principal' => $principal,
'interest' => $interest,
'remaining' => $balance,
];
}
return [
'total_amount' => $totalAmount,
'down_payment' => $downPayment,
'remaining' => $remaining,
'annual_rate' => $annualRate,
'total_interest' => $totalInterest,
'total_with_interest' => $totalWithInterest,
'monthly_payment' => bcdiv($totalWithInterest, (string) $months, 2),
'months' => $months,
'schedule' => $schedule,
];
}
public static function getServiceFee(string $serviceCode, ?int $branchId = null): ?array
{
$db = App::getInstance()->db();
$service = null;
if ($branchId) {
$service = $db->selectOne(
"SELECT * FROM service_catalog WHERE service_code = ? AND branch_id = ? AND is_active = 1 AND effective_from <= CURDATE() AND (effective_to IS NULL OR effective_to >= CURDATE())",
[$serviceCode, $branchId]
);
}
if (!$service) {
$service = $db->selectOne(
"SELECT * FROM service_catalog WHERE service_code = ? AND branch_id IS NULL AND is_active = 1 AND effective_from <= CURDATE() AND (effective_to IS NULL OR effective_to >= CURDATE())",
[$serviceCode]
);
}
return $service;
}
public static function getFormFee(string $formType): string
{
$codeMap = [
'NEW_MEMBERSHIP' => 'SVC_NEW_FORM',
'TRANSFER_SEPARATION' => 'SVC_TRANSFER_FORM',
'ADDITION' => 'SVC_ADDITION_FORM',
];
$code = $codeMap[$formType] ?? $formType;
$service = self::getServiceFee($code);
return $service ? ($service['base_amount'] ?? '0.00') : '0.00';
}
}
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل التسعير<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/pricing/<?= (int) $config->id ?>">
<?= csrf_field() ?>
<div class="card" style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">السعر (ج.م) <span style="color:#DC2626;">*</span></label>
<input type="number" name="price" value="<?= e($config->price) ?>" class="form-input" step="0.01" min="0" required>
</div>
<div class="form-group">
<label class="form-label">تاريخ السريان <span style="color:#DC2626;">*</span></label>
<input type="date" name="effective_from" value="<?= e($config->effective_from) ?>" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">تاريخ الانتهاء</label>
<input type="date" name="effective_to" value="<?= e($config->effective_to ?? '') ?>" class="form-input">
</div>
<div class="form-group" style="grid-column:1/-1;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-textarea" rows="3"><?= e($config->notes ?? '') ?></textarea>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary" style="margin-top:20px;">حفظ</button>
<a href="/pricing" class="btn btn-outline" style="margin-top:20px;">إلغاء</a>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>التسعير<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr><th>الفرع</th><th>المؤهل</th><th>النوع</th><th>السعر</th><th>من</th><th>إلى</th><th>الإجراءات</th></tr>
</thead>
<tbody>
<?php foreach ($configs as $c): ?>
<tr>
<td><?= e($c['branch_name']) ?></td>
<td><?= e($c['qualification_name']) ?></td>
<td><?= e($c['membership_type']) ?></td>
<td style="font-weight:700;direction:ltr;text-align:right;"><?= money($c['price']) ?></td>
<td style="font-size:12px;"><?= e($c['effective_from']) ?></td>
<td style="font-size:12px;"><?= e($c['effective_to'] ?? '—') ?></td>
<td><a href="/pricing/<?= (int) $c['id'] ?>/edit" class="btn btn-sm btn-outline">تعديل</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($configs)): ?>
<tr><td colspan="7" style="text-align:center;padding:40px;color:#6B7280;">لا توجد تسعيرات</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
// Menu and permissions registered in Rules/bootstrap.php (shared group)
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Rules\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Rules\Models\BusinessRule;
use App\Modules\Rules\Models\RuleVersion;
use App\Modules\Rules\Services\RuleEngine;
class RuleController extends Controller
{
public function index(Request $request): Response
{
$category = $request->get('category', '');
$categories = BusinessRule::getCategories();
$db = App::getInstance()->db();
$where = 'is_active = 1';
$params = [];
if ($category !== '') {
$where .= ' AND category = ?';
$params[] = $category;
}
$rules = $db->select("SELECT * FROM business_rules WHERE {$where} ORDER BY category, rule_code", $params);
return $this->view('Rules.Views.index', [
'rules' => $rules,
'categories' => $categories,
'category' => $category,
]);
}
public function edit(Request $request, string $id): Response
{
$rule = BusinessRule::find((int) $id);
if (!$rule) {
return $this->redirect('/rules')->withError('القاعدة غير موجودة');
}
return $this->view('Rules.Views.edit', ['rule' => $rule]);
}
public function update(Request $request, string $id): Response
{
$rule = BusinessRule::find((int) $id);
if (!$rule) {
return $this->redirect('/rules')->withError('القاعدة غير موجودة');
}
$newValueJson = trim((string) $request->post('current_value_json', ''));
$reason = trim((string) $request->post('change_reason', ''));
if ($newValueJson === '' || $reason === '') {
return $this->redirect("/rules/{$id}/edit")->withError('يرجى ملء جميع الحقول المطلوبة');
}
$decoded = json_decode($newValueJson, true);
if ($decoded === null && $newValueJson !== 'null') {
return $this->redirect("/rules/{$id}/edit")->withError('قيمة JSON غير صالحة');
}
try {
RuleEngine::update($rule->rule_code, $newValueJson, $reason, $rule->branch_id ? (int) $rule->branch_id : null);
return $this->redirect('/rules')->withSuccess('تم تحديث القاعدة بنجاح');
} catch (\Throwable $e) {
return $this->redirect("/rules/{$id}/edit")->withError('فشل تحديث القاعدة: ' . $e->getMessage());
}
}
public function history(Request $request, string $id): Response
{
$rule = BusinessRule::find((int) $id);
if (!$rule) {
return $this->redirect('/rules')->withError('القاعدة غير موجودة');
}
$versions = RuleVersion::getForRule((int) $id);
return $this->view('Rules.Views.history', ['rule' => $rule, 'versions' => $versions]);
}
public function simulate(Request $request, string $id): Response
{
$rule = BusinessRule::find((int) $id);
if (!$rule) {
return $this->redirect('/rules')->withError('القاعدة غير موجودة');
}
return $this->view('Rules.Views.simulate', ['rule' => $rule]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Rules\Models;
use App\Core\Model;
use App\Core\App;
class BusinessRule extends Model
{
protected static string $table = 'business_rules';
protected static bool $softDelete = false;
protected static bool $timestamps = true;
protected static array $fillable = [
'rule_code', 'category', 'name_ar', 'name_en', 'description_ar', 'description_en',
'parameters_json', 'current_value_json', 'data_type', 'branch_id',
'effective_from', 'effective_to', 'is_active', 'version',
];
public function getValue(): array
{
return json_decode($this->current_value_json ?? '{}', true) ?? [];
}
public function getParameters(): array
{
return json_decode($this->parameters_json ?? '{}', true) ?? [];
}
public static function allByCategory(string $category, ?int $branchId = null): array
{
$db = App::getInstance()->db();
if ($branchId) {
return $db->select(
"SELECT * FROM business_rules WHERE category = ? AND (branch_id IS NULL OR branch_id = ?) AND is_active = 1 ORDER BY rule_code",
[$category, $branchId]
);
}
return $db->select(
"SELECT * FROM business_rules WHERE category = ? AND is_active = 1 ORDER BY rule_code",
[$category]
);
}
public static function getCategories(): array
{
$db = App::getInstance()->db();
$rows = $db->select("SELECT DISTINCT category FROM business_rules WHERE is_active = 1 ORDER BY category");
return array_column($rows, 'category');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Rules\Models;
use App\Core\Model;
use App\Core\App;
class RuleOverride extends Model
{
protected static string $table = 'rule_overrides';
protected static bool $softDelete = false;
protected static bool $timestamps = false;
protected static array $fillable = [
'rule_id', 'entity_type', 'entity_id', 'override_value_json',
'reason', 'approved_by', 'effective_from', 'effective_to', 'is_active',
];
public static function getForEntity(string $entityType, int $entityId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT ro.*, br.rule_code, br.name_ar FROM rule_overrides ro JOIN business_rules br ON br.id = ro.rule_id WHERE ro.entity_type = ? AND ro.entity_id = ? AND ro.is_active = 1 AND (ro.effective_to IS NULL OR ro.effective_to >= CURDATE()) ORDER BY ro.created_at DESC",
[$entityType, $entityId]
);
}
public static function findOverride(int $ruleId, string $entityType, int $entityId): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM rule_overrides WHERE rule_id = ? AND entity_type = ? AND entity_id = ? AND is_active = 1 AND effective_from <= CURDATE() AND (effective_to IS NULL OR effective_to >= CURDATE())",
[$ruleId, $entityType, $entityId]
);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Rules\Models;
use App\Core\Model;
use App\Core\App;
class RuleVersion extends Model
{
protected static string $table = 'rule_versions';
protected static bool $softDelete = false;
protected static bool $timestamps = false;
protected static array $fillable = [
'rule_id', 'version_number', 'old_value_json', 'new_value_json', 'changed_by', 'change_reason',
];
public static function getForRule(int $ruleId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT rv.*, e.full_name_ar as changed_by_name FROM rule_versions rv LEFT JOIN employees e ON e.id = rv.changed_by WHERE rv.rule_id = ? ORDER BY rv.version_number DESC",
[$ruleId]
);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/rules', 'Rules\Controllers\RuleController@index', ['auth'], 'rules.view'],
['GET', '/rules/{id:\d+}/edit', 'Rules\Controllers\RuleController@edit', ['auth'], 'rules.edit'],
['POST', '/rules/{id:\d+}', 'Rules\Controllers\RuleController@update', ['auth', 'csrf'], 'rules.edit'],
['GET', '/rules/{id:\d+}/history', 'Rules\Controllers\RuleController@history', ['auth'], 'rules.view'],
['GET', '/rules/{id:\d+}/simulate', 'Rules\Controllers\RuleController@simulate', ['auth'], 'rules.view'],
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Rules\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Rules\Models\BusinessRule;
use App\Modules\Rules\Models\RuleVersion;
use App\Modules\Rules\Models\RuleOverride;
final class RuleEngine
{
private static array $cache = [];
public static function get(string $ruleCode, ?int $branchId = null, ?string $date = null): mixed
{
$date = $date ?? date('Y-m-d');
$cacheKey = "{$ruleCode}_{$branchId}_{$date}";
if (isset(self::$cache[$cacheKey])) {
return self::$cache[$cacheKey];
}
$db = App::getInstance()->db();
// Try branch-specific first
$rule = null;
if ($branchId !== null) {
$rule = $db->selectOne(
"SELECT * FROM business_rules WHERE rule_code = ? AND branch_id = ? AND is_active = 1 AND effective_from <= ? AND (effective_to IS NULL OR effective_to >= ?)",
[$ruleCode, $branchId, $date, $date]
);
}
// Fallback to global
if (!$rule) {
$rule = $db->selectOne(
"SELECT * FROM business_rules WHERE rule_code = ? AND branch_id IS NULL AND is_active = 1 AND effective_from <= ? AND (effective_to IS NULL OR effective_to >= ?)",
[$ruleCode, $date, $date]
);
}
if (!$rule) {
Logger::warning("Rule not found: {$ruleCode}", ['branch_id' => $branchId, 'date' => $date]);
self::$cache[$cacheKey] = null;
return null;
}
$value = json_decode($rule['current_value_json'], true);
self::$cache[$cacheKey] = $value;
return $value;
}
public static function getValue(string $ruleCode, string $key = 'value', ?int $branchId = null): mixed
{
$data = self::get($ruleCode, $branchId);
if ($data === null) {
return null;
}
return $data[$key] ?? ($data['percentage'] ?? ($data['amount'] ?? ($data['value'] ?? null)));
}
public static function getAll(string $category, ?int $branchId = null): array
{
return BusinessRule::allByCategory($category, $branchId);
}
public static function update(string $ruleCode, string $newValueJson, string $reason, ?int $branchId = null): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$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';
}
$rule = $db->selectOne("SELECT * FROM business_rules WHERE {$where}", $params);
if (!$rule) {
throw new \RuntimeException("Rule not found: {$ruleCode}");
}
$newVersion = (int) $rule['version'] + 1;
// Create version history
$db->insert('rule_versions', [
'rule_id' => (int) $rule['id'],
'version_number' => $newVersion,
'old_value_json' => $rule['current_value_json'],
'new_value_json' => $newValueJson,
'changed_by' => $employee ? (int) $employee->id : null,
'changed_at' => date('Y-m-d H:i:s'),
'change_reason' => $reason,
]);
// Update current value
$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']]);
// Clear cache
self::$cache = [];
Logger::info("Rule updated: {$ruleCode}", ['version' => $newVersion, 'reason' => $reason]);
}
public static function override(string $ruleCode, string $entityType, int $entityId, string $overrideValueJson, string $reason): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$rule = $db->selectOne("SELECT id FROM business_rules WHERE rule_code = ? AND branch_id IS NULL AND is_active = 1", [$ruleCode]);
if (!$rule) {
throw new \RuntimeException("Rule not found: {$ruleCode}");
}
$db->insert('rule_overrides', [
'rule_id' => (int) $rule['id'],
'entity_type' => $entityType,
'entity_id' => $entityId,
'override_value_json' => $overrideValueJson,
'reason' => $reason,
'approved_by' => $employee ? (int) $employee->id : null,
'effective_from' => date('Y-m-d'),
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
]);
}
public static function getEffective(string $ruleCode, string $entityType, int $entityId, ?int $branchId = null): mixed
{
$db = App::getInstance()->db();
$rule = $db->selectOne("SELECT id FROM business_rules WHERE rule_code = ? AND branch_id IS NULL AND is_active = 1", [$ruleCode]);
if ($rule) {
$override = RuleOverride::findOverride((int) $rule['id'], $entityType, $entityId);
if ($override) {
return json_decode($override['override_value_json'], true);
}
}
return self::get($ruleCode, $branchId);
}
public static function clearCache(): void
{
self::$cache = [];
}
}
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل القاعدة: <?= e($rule->name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/rules/<?= (int) $rule->id ?>">
<?= csrf_field() ?>
<div class="card" style="padding:20px;margin-bottom:20px;">
<table style="width:100%;font-size:14px;margin-bottom:20px;">
<tr><td style="padding:6px 0;color:#6B7280;width:30%;">كود القاعدة</td><td><code><?= e($rule->rule_code) ?></code></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">التصنيف</td><td><?= e($rule->category) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">النوع</td><td><?= e($rule->data_type) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">الإصدار الحالي</td><td><?= (int) $rule->version ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">تاريخ السريان</td><td><?= e($rule->effective_from) ?></td></tr>
</table>
<div class="form-group" style="margin-bottom:20px;">
<label class="form-label">القيمة الحالية (JSON) <span style="color:#DC2626;">*</span></label>
<textarea name="current_value_json" class="form-textarea" rows="6" required style="font-family:monospace;direction:ltr;text-align:left;"><?= e($rule->current_value_json) ?></textarea>
<?php $params = json_decode($rule->parameters_json ?? '{}', true); ?>
<?php if (!empty($params)): ?>
<small style="color:#6B7280;">المعاملات: <?php foreach ($params as $k => $v): ?><code><?= e($k) ?></code> (<?= e($v) ?>) <?php endforeach; ?></small>
<?php endif; ?>
</div>
<div class="form-group">
<label class="form-label">سبب التعديل <span style="color:#DC2626;">*</span></label>
<textarea name="change_reason" class="form-textarea" rows="3" required placeholder="اذكر سبب تعديل هذه القاعدة..."></textarea>
</div>
</div>
<button type="submit" class="btn btn-primary">حفظ التعديل</button>
<a href="/rules" class="btn btn-outline">إلغاء</a>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سجل تعديلات: <?= e($rule->name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/rules" class="btn btn-outline">← العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<?php if (empty($versions)): ?>
<div style="padding:40px;text-align:center;color:#6B7280;">لا توجد تعديلات سابقة</div>
<?php else: ?>
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>الإصدار</th><th>التاريخ</th><th>بواسطة</th><th>القيمة القديمة</th><th>القيمة الجديدة</th><th>السبب</th></tr></thead>
<tbody>
<?php foreach ($versions as $v): ?>
<tr>
<td style="text-align:center;font-weight:700;"><?= (int) $v['version_number'] ?></td>
<td style="font-size:12px;"><?= e($v['changed_at']) ?></td>
<td><?= e($v['changed_by_name'] ?? '—') ?></td>
<td style="font-size:11px;direction:ltr;text-align:left;max-width:200px;overflow:hidden;"><code><?= e(mb_substr($v['old_value_json'] ?? '—', 0, 100)) ?></code></td>
<td style="font-size:11px;direction:ltr;text-align:left;max-width:200px;overflow:hidden;"><code><?= e(mb_substr($v['new_value_json'], 0, 100)) ?></code></td>
<td style="font-size:13px;color:#6B7280;"><?= e($v['change_reason'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>محرك القواعد<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/rules" style="display:flex;gap:10px;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">التصنيف</label>
<select name="category" class="form-select" style="min-width:150px;">
<option value="">الكل</option>
<?php foreach ($categories as $c): ?>
<option value="<?= e($c) ?>" <?= $category === $c ? 'selected' : '' ?>><?= e($c) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline">تصفية</button>
<a href="/rules" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>كود القاعدة</th>
<th>الاسم</th>
<th>التصنيف</th>
<th>النوع</th>
<th>القيمة الحالية</th>
<th>الإصدار</th>
<th>الفرع</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($rules as $r): ?>
<tr>
<td><code style="font-size:11px;"><?= e($r['rule_code']) ?></code></td>
<td><?= e($r['name_ar']) ?></td>
<td><span style="background:#F3F4F6;padding:2px 8px;border-radius:4px;font-size:12px;"><?= e($r['category']) ?></span></td>
<td style="font-size:12px;"><?= e($r['data_type']) ?></td>
<td style="font-size:12px;max-width:200px;overflow:hidden;text-overflow:ellipsis;">
<?php $val = json_decode($r['current_value_json'], true); ?>
<?php if (is_array($val)): ?>
<?php foreach ($val as $k => $v): ?>
<span style="color:#6B7280;"><?= e($k) ?>:</span> <strong><?= e((string)$v) ?></strong><?= $k !== array_key_last($val) ? ', ' : '' ?>
<?php endforeach; ?>
<?php else: ?>
<?= e($r['current_value_json']) ?>
<?php endif; ?>
</td>
<td style="text-align:center;"><?= (int) $r['version'] ?></td>
<td style="font-size:12px;"><?= $r['branch_id'] ? '#' . (int) $r['branch_id'] : 'عام' ?></td>
<td>
<div style="display:flex;gap:5px;">
<a href="/rules/<?= (int) $r['id'] ?>/edit" class="btn btn-sm btn-outline">تعديل</a>
<a href="/rules/<?= (int) $r['id'] ?>/history" class="btn btn-sm btn-outline">السجل</a>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rules)): ?>
<tr><td colspan="8" style="text-align:center;padding:40px;color:#6B7280;">لا توجد قواعد</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>محاكاة تعديل: <?= e($rule->name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="padding:20px;">
<p style="color:#6B7280;margin-bottom:15px;">محاكاة تأثير تغيير هذه القاعدة على البيانات الحالية. هذه الميزة ستكون متاحة بالكامل بعد إضافة وحدة الأعضاء.</p>
<table style="width:100%;font-size:14px;">
<tr><td style="padding:6px 0;color:#6B7280;width:30%;">كود القاعدة</td><td><code><?= e($rule->rule_code) ?></code></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">القيمة الحالية</td><td style="direction:ltr;text-align:left;"><code><?= e($rule->current_value_json) ?></code></td></tr>
</table>
<div style="margin-top:20px;padding:20px;background:#FFF7ED;border:1px solid #FED7AA;border-radius:8px;">
<strong style="color:#D97706;">⚠ ملاحظة:</strong> المحاكاة الكاملة ستتوفر عند إنشاء بيانات الأعضاء (المرحلة 8). حالياً يمكنك مراجعة القيمة الحالية والمعاملات المتاحة.
</div>
</div>
<a href="/rules" class="btn btn-outline" style="margin-top:20px;">← العودة</a>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('rules_pricing', [
'label_ar' => 'القواعد والتسعير',
'label_en' => 'Rules & Pricing',
'icon' => '⚙️',
'route' => '/rules',
'permission' => 'rules.view',
'order' => 150,
'children' => [
['label_ar' => 'محرك القواعد', 'label_en' => 'Rules Engine', 'route' => '/rules', 'permission' => 'rules.view', 'order' => 1],
['label_ar' => 'التسعير', 'label_en' => 'Pricing', 'route' => '/pricing', 'permission' => 'pricing.view', 'order' => 2],
['label_ar' => 'كتالوج الخدمات', 'label_en' => 'Service Catalog', 'route' => '/catalog', 'permission' => 'pricing.view', 'order' => 3],
],
]);
PermissionRegistry::register('rules', [
'rules.view' => ['ar' => 'عرض القواعد', 'en' => 'View Rules'],
'rules.edit' => ['ar' => 'تعديل القواعد', 'en' => 'Edit Rules'],
'rules.create' => ['ar' => 'إنشاء قاعدة', 'en' => 'Create Rule'],
'rules.deactivate' => ['ar' => 'تعطيل قاعدة', 'en' => 'Deactivate Rule'],
]);
PermissionRegistry::register('pricing', [
'pricing.view' => ['ar' => 'عرض التسعير', 'en' => 'View Pricing'],
'pricing.edit' => ['ar' => 'تعديل التسعير', 'en' => 'Edit Pricing'],
'pricing.create' => ['ar' => 'إنشاء تسعير', 'en' => 'Create Pricing'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\ServiceCatalog\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\ServiceCatalog\Models\ServicePrice;
class CatalogController extends Controller
{
public function index(Request $request): Response
{
$services = ServicePrice::allActive();
return $this->view('ServiceCatalog.Views.index', ['services' => $services]);
}
public function edit(Request $request, string $id): Response
{
$service = ServicePrice::find((int) $id);
if (!$service) {
return $this->redirect('/catalog')->withError('الخدمة غير موجودة');
}
return $this->view('ServiceCatalog.Views.edit', ['service' => $service]);
}
public function update(Request $request, string $id): Response
{
$service = ServicePrice::find((int) $id);
if (!$service) {
return $this->redirect('/catalog')->withError('الخدمة غير موجودة');
}
$data = $this->validate($request->all(), [
'base_amount' => 'nullable|numeric|min:0',
'percentage' => 'nullable|numeric|min:0|max:100',
'annual_amount' => 'nullable|numeric|min:0',
]);
$service->update([
'base_amount' => $data['base_amount'] ?? null,
'percentage' => $data['percentage'] ?? null,
'annual_amount' => $data['annual_amount'] ?? null,
]);
return $this->redirect('/catalog')->withSuccess('تم تحديث الخدمة بنجاح');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\ServiceCatalog\Models;
use App\Core\Model;
use App\Core\App;
class ServicePrice extends Model
{
protected static string $table = 'service_catalog';
protected static bool $softDelete = false;
protected static bool $timestamps = true;
protected static array $fillable = [
'service_code', 'name_ar', 'name_en', 'price_type', 'base_amount',
'percentage', 'annual_amount', 'currency', 'applies_to', 'branch_id',
'effective_from', 'effective_to', 'is_active',
];
public static function allActive(): array
{
$db = App::getInstance()->db();
return $db->select("
SELECT sc.*, b.name_ar as branch_name
FROM service_catalog sc
LEFT JOIN branches b ON b.id = sc.branch_id
WHERE sc.is_active = 1
ORDER BY sc.service_code
");
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/catalog', 'ServiceCatalog\Controllers\CatalogController@index', ['auth'], 'pricing.view'],
['GET', '/catalog/{id:\d+}/edit', 'ServiceCatalog\Controllers\CatalogController@edit', ['auth'], 'pricing.edit'],
['POST', '/catalog/{id:\d+}', 'ServiceCatalog\Controllers\CatalogController@update', ['auth', 'csrf'], 'pricing.edit'],
];
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل الخدمة: <?= e($service->name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/catalog/<?= (int) $service->id ?>">
<?= csrf_field() ?>
<div class="card" style="padding:20px;">
<table style="width:100%;font-size:14px;margin-bottom:20px;">
<tr><td style="padding:6px 0;color:#6B7280;width:30%;">كود الخدمة</td><td><code><?= e($service->service_code) ?></code></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">الاسم</td><td><?= e($service->name_ar) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">نوع السعر</td><td><?= e($service->price_type) ?></td></tr>
</table>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">المبلغ الأساسي (ج.م)</label>
<input type="number" name="base_amount" value="<?= e($service->base_amount ?? '') ?>" class="form-input" step="0.01" min="0">
</div>
<div class="form-group">
<label class="form-label">النسبة (%)</label>
<input type="number" name="percentage" value="<?= e($service->percentage ?? '') ?>" class="form-input" step="0.01" min="0" max="100">
</div>
<div class="form-group">
<label class="form-label">المبلغ السنوي (ج.م)</label>
<input type="number" name="annual_amount" value="<?= e($service->annual_amount ?? '') ?>" class="form-input" step="0.01" min="0">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary" style="margin-top:20px;">حفظ</button>
<a href="/catalog" class="btn btn-outline" style="margin-top:20px;">إلغاء</a>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>كتالوج الخدمات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr><th>الكود</th><th>الخدمة</th><th>النوع</th><th>المبلغ الأساسي</th><th>النسبة</th><th>سنوي</th><th>الفرع</th><th>الإجراءات</th></tr>
</thead>
<tbody>
<?php foreach ($services as $s): ?>
<tr>
<td><code style="font-size:11px;"><?= e($s['service_code']) ?></code></td>
<td><?= e($s['name_ar']) ?></td>
<td><span style="background:#F3F4F6;padding:2px 8px;border-radius:4px;font-size:12px;"><?= e($s['price_type']) ?></span></td>
<td style="direction:ltr;text-align:right;"><?= $s['base_amount'] ? money($s['base_amount']) : '—' ?></td>
<td><?= $s['percentage'] ? percentage($s['percentage']) : '—' ?></td>
<td style="direction:ltr;text-align:right;"><?= $s['annual_amount'] ? money($s['annual_amount']) : '—' ?></td>
<td style="font-size:12px;"><?= e($s['branch_name'] ?? 'عام') ?></td>
<td><a href="/catalog/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-outline">تعديل</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($services)): ?>
<tr><td colspan="8" style="text-align:center;padding:40px;color:#6B7280;">لا توجد خدمات</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
// Menu and permissions registered in Rules/bootstrap.php (shared group)
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `business_rules` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`rule_code` VARCHAR(100) NOT NULL,
`category` VARCHAR(50) NOT NULL,
`name_ar` VARCHAR(300) NOT NULL,
`name_en` VARCHAR(300) NULL,
`description_ar` TEXT NULL,
`description_en` TEXT NULL,
`parameters_json` JSON NULL,
`current_value_json` JSON NOT NULL,
`data_type` VARCHAR(50) NOT NULL DEFAULT 'string',
`branch_id` BIGINT UNSIGNED NULL,
`effective_from` DATE NOT NULL,
`effective_to` DATE NULL DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`version` INT UNSIGNED NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_business_rules_code_branch` (`rule_code`, `branch_id`),
INDEX `idx_business_rules_category` (`category`),
INDEX `idx_business_rules_branch` (`branch_id`),
INDEX `idx_business_rules_active` (`is_active`),
INDEX `idx_business_rules_effective` (`effective_from`, `effective_to`),
CONSTRAINT `fk_business_rules_branch` FOREIGN KEY (`branch_id`) REFERENCES `branches`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `business_rules`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `rule_versions` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`rule_id` BIGINT UNSIGNED NOT NULL,
`version_number` INT UNSIGNED NOT NULL,
`old_value_json` JSON NULL,
`new_value_json` JSON NOT NULL,
`changed_by` BIGINT UNSIGNED NULL,
`changed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`change_reason` TEXT NULL,
INDEX `idx_rule_versions_rule` (`rule_id`),
CONSTRAINT `fk_rule_versions_rule` FOREIGN KEY (`rule_id`) REFERENCES `business_rules`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `rule_versions`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `pricing_configs` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`branch_id` BIGINT UNSIGNED NOT NULL,
`qualification_id` BIGINT UNSIGNED NOT NULL,
`membership_type` VARCHAR(50) NOT NULL DEFAULT 'working',
`price` DECIMAL(15,2) NOT NULL,
`currency` VARCHAR(3) NOT NULL DEFAULT 'EGP',
`effective_from` DATE NOT NULL,
`effective_to` DATE NULL DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`notes` TEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
INDEX `idx_pricing_branch` (`branch_id`),
INDEX `idx_pricing_qual` (`qualification_id`),
INDEX `idx_pricing_type` (`membership_type`),
INDEX `idx_pricing_effective` (`effective_from`, `effective_to`),
CONSTRAINT `fk_pricing_branch` FOREIGN KEY (`branch_id`) REFERENCES `branches`(`id`),
CONSTRAINT `fk_pricing_qual` FOREIGN KEY (`qualification_id`) REFERENCES `qualifications`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `pricing_configs`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `service_catalog` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`service_code` VARCHAR(50) NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NULL,
`price_type` VARCHAR(50) NOT NULL DEFAULT 'fixed',
`base_amount` DECIMAL(15,2) NULL,
`percentage` DECIMAL(5,2) NULL,
`annual_amount` DECIMAL(15,2) NULL,
`currency` VARCHAR(3) NOT NULL DEFAULT 'EGP',
`applies_to` VARCHAR(100) NULL,
`branch_id` BIGINT UNSIGNED NULL,
`effective_from` DATE NOT NULL,
`effective_to` DATE NULL DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_service_catalog_code_branch` (`service_code`, `branch_id`),
INDEX `idx_service_catalog_active` (`is_active`),
CONSTRAINT `fk_service_catalog_branch` FOREIGN KEY (`branch_id`) REFERENCES `branches`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `service_catalog`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `discount_rules` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`discount_code` VARCHAR(50) NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NULL,
`discount_type` VARCHAR(30) NOT NULL DEFAULT 'percentage',
`value` DECIMAL(15,2) NOT NULL,
`min_quantity` INT UNSIGNED NULL,
`max_quantity` INT UNSIGNED NULL,
`stackable` TINYINT(1) NOT NULL DEFAULT 0,
`max_discount_percentage` DECIMAL(5,2) NULL,
`branch_id` BIGINT UNSIGNED NULL,
`effective_from` DATE NOT NULL,
`effective_to` DATE NULL DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
INDEX `idx_discount_rules_code` (`discount_code`),
INDEX `idx_discount_rules_active` (`is_active`),
CONSTRAINT `fk_discount_rules_branch` FOREIGN KEY (`branch_id`) REFERENCES `branches`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `discount_rules`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `rule_overrides` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`rule_id` BIGINT UNSIGNED NOT NULL,
`entity_type` VARCHAR(100) NOT NULL,
`entity_id` BIGINT UNSIGNED NOT NULL,
`override_value_json` JSON NOT NULL,
`reason` TEXT NOT NULL,
`approved_by` BIGINT UNSIGNED NULL,
`effective_from` DATE NOT NULL,
`effective_to` DATE NULL DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_rule_overrides_rule` (`rule_id`),
INDEX `idx_rule_overrides_entity` (`entity_type`, `entity_id`),
CONSTRAINT `fk_rule_overrides_rule` FOREIGN KEY (`rule_id`) REFERENCES `business_rules`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `rule_overrides`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$now = date('Y-m-d');
$ts = date('Y-m-d H:i:s');
$rules = [
// ── Age Rules ──
['rule_code' => 'MIN_WORKING_AGE', 'category' => 'age', 'name_ar' => 'الحد الأدنى لسن العضو العامل', 'name_en' => 'Minimum Working Member Age', 'data_type' => 'integer', 'current_value_json' => '{"value":21}', 'parameters_json' => '{"min_age":"integer"}'],
['rule_code' => 'CHILD_INCLUDED_MAX_AGE', 'category' => 'age', 'name_ar' => 'الحد الأقصى لسن الأبناء المشمولين', 'name_en' => 'Max Included Child Age', 'data_type' => 'integer', 'current_value_json' => '{"value":18}', 'parameters_json' => '{"max_age":"integer"}'],
['rule_code' => 'CHILD_INCLUDED_MAX_COUNT', 'category' => 'age', 'name_ar' => 'الحد الأقصى لعدد الأبناء المشمولين', 'name_en' => 'Max Included Children', 'data_type' => 'integer', 'current_value_json' => '{"value":3}', 'parameters_json' => '{"max_count":"integer"}'],
['rule_code' => 'SPOUSE_WORKING_AGE_THRESHOLD', 'category' => 'age', 'name_ar' => 'سن تصنيف الزوجة كعضو عامل', 'name_en' => 'Spouse Working Age', 'data_type' => 'integer', 'current_value_json' => '{"value":21}', 'parameters_json' => '{"threshold":"integer"}'],
['rule_code' => 'MALE_CHILD_FREEZE_AGE', 'category' => 'age', 'name_ar' => 'سن تجميد عضوية الذكور', 'name_en' => 'Male Freeze Age', 'data_type' => 'integer', 'current_value_json' => '{"value":25}', 'parameters_json' => '{"freeze_age":"integer"}'],
['rule_code' => 'TEMP_MEMBER_MAX_AGE', 'category' => 'age', 'name_ar' => 'الحد الأقصى لسن العضو المؤقت', 'name_en' => 'Max Temp Member Age', 'data_type' => 'integer', 'current_value_json' => '{"value":25}', 'parameters_json' => '{"max_age":"integer"}'],
['rule_code' => 'SISTER_MAX_AGE', 'category' => 'age', 'name_ar' => 'الحد الأقصى لسن الشقيقة المؤقتة', 'name_en' => 'Max Sister Age', 'data_type' => 'integer', 'current_value_json' => '{"value":25}', 'parameters_json' => '{"max_age":"integer"}'],
['rule_code' => 'STEPCHILD_MAX_AGE', 'category' => 'age', 'name_ar' => 'الحد الأقصى لسن ابن الزوجة/الزوج', 'name_en' => 'Max Stepchild Age', 'data_type' => 'integer', 'current_value_json' => '{"value":25}', 'parameters_json' => '{"max_age":"integer"}'],
['rule_code' => 'ORPHAN_MAX_AGE', 'category' => 'age', 'name_ar' => 'الحد الأقصى لسن الطفل اليتيم', 'name_en' => 'Max Orphan Age', 'data_type' => 'integer', 'current_value_json' => '{"value":25}', 'parameters_json' => '{"max_age":"integer"}'],
['rule_code' => 'SEASONAL_MIN_AGE', 'category' => 'age', 'name_ar' => 'الحد الأدنى لسن العضو الموسمي', 'name_en' => 'Min Seasonal Age', 'data_type' => 'integer', 'current_value_json' => '{"value":0}', 'parameters_json' => '{"min_age":"integer"}'],
// ── Children Fee Rules ──
['rule_code' => 'CHILD_4TH_UNDER_18_FEE', 'category' => 'children_fee', 'name_ar' => 'رسوم الابن الرابع تحت 18', 'name_en' => '4th Child Under 18 Fee', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"5.00","base":"membership_value"}', 'parameters_json' => '{"percentage":"decimal","base":"string"}'],
['rule_code' => 'CHILD_FEE_AGE_18', 'category' => 'children_fee', 'name_ar' => 'رسوم ابن 18 سنة', 'name_en' => 'Child Fee Age 18', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"10.00","type":"regular"}', 'parameters_json' => '{"percentage":"decimal","type":"string"}'],
['rule_code' => 'CHILD_FEE_AGE_19', 'category' => 'children_fee', 'name_ar' => 'رسوم ابن 19 سنة', 'name_en' => 'Child Fee Age 19', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"15.00","type":"regular"}', 'parameters_json' => '{"percentage":"decimal","type":"string"}'],
['rule_code' => 'CHILD_FEE_AGE_20', 'category' => 'children_fee', 'name_ar' => 'رسوم ابن 20 سنة', 'name_en' => 'Child Fee Age 20', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"20.00","type":"regular"}', 'parameters_json' => '{"percentage":"decimal","type":"string"}'],
['rule_code' => 'CHILD_FEE_AGE_21', 'category' => 'children_fee', 'name_ar' => 'رسوم ابن 21 سنة (مؤقت)', 'name_en' => 'Child Fee Age 21 Temp', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"15.00","type":"temporary","until_age":25}', 'parameters_json' => '{"percentage":"decimal","type":"string","until_age":"integer"}'],
['rule_code' => 'CHILD_AUTO_DELETE_AGE', 'category' => 'children_fee', 'name_ar' => 'سن الحذف التلقائي', 'name_en' => 'Auto Delete Age', 'data_type' => 'integer', 'current_value_json' => '{"value":25}', 'parameters_json' => '{"age":"integer"}'],
// ── Spouse Fee Rules ──
['rule_code' => 'SPOUSE_2ND_FEE', 'category' => 'spouse_fee', 'name_ar' => 'رسوم الزوجة الثانية', 'name_en' => '2nd Spouse Fee', 'data_type' => 'compound', 'current_value_json' => '{"percentage":"10.00","annual_flat":"150.00","base_date_rule":"later_of_marriage_or_membership","partial_year_rule":"round_up"}', 'parameters_json' => '{"percentage":"decimal","annual_flat":"decimal","base_date_rule":"string","partial_year_rule":"string"}'],
['rule_code' => 'SPOUSE_3RD_FEE', 'category' => 'spouse_fee', 'name_ar' => 'رسوم الزوجة الثالثة', 'name_en' => '3rd Spouse Fee', 'data_type' => 'compound', 'current_value_json' => '{"percentage":"20.00","annual_flat":"200.00","base_date_rule":"later_of_marriage_or_membership","partial_year_rule":"round_up"}', 'parameters_json' => '{"percentage":"decimal","annual_flat":"decimal","base_date_rule":"string","partial_year_rule":"string"}'],
['rule_code' => 'SPOUSE_4TH_FEE', 'category' => 'spouse_fee', 'name_ar' => 'رسوم الزوجة الرابعة', 'name_en' => '4th Spouse Fee', 'data_type' => 'compound', 'current_value_json' => '{"percentage":"30.00","annual_flat":"300.00","base_date_rule":"later_of_marriage_or_membership","partial_year_rule":"round_up"}', 'parameters_json' => '{"percentage":"decimal","annual_flat":"decimal","base_date_rule":"string","partial_year_rule":"string"}'],
['rule_code' => 'SPOUSE_FOREIGN_FEE', 'category' => 'spouse_fee', 'name_ar' => 'رسوم زوج/زوجة أجنبي', 'name_en' => 'Foreign Spouse Fee', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"15.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'SPOUSE_ACQUIRED_MEMBER_FEE', 'category' => 'spouse_fee', 'name_ar' => 'رسوم إضافة زوج لعضو مكتسب', 'name_en' => 'Acquired Member Spouse Fee', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"50.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'SPOUSE_BASE_MEMBER_FEE', 'category' => 'spouse_fee', 'name_ar' => 'رسوم إضافة زوج لعضو أساسي', 'name_en' => 'Base Member Spouse Fee', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"15.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
// ── Separation/Transfer Fee Rules ──
['rule_code' => 'SEPARATION_FEE_YEAR_1', 'category' => 'separation_fee', 'name_ar' => 'رسوم فصل السنة الأولى', 'name_en' => 'Separation Year 1', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"30.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'SEPARATION_FEE_YEAR_2', 'category' => 'separation_fee', 'name_ar' => 'رسوم فصل السنة الثانية', 'name_en' => 'Separation Year 2', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"20.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'SEPARATION_FEE_YEAR_3', 'category' => 'separation_fee', 'name_ar' => 'رسوم فصل السنة الثالثة', 'name_en' => 'Separation Year 3', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"15.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'SEPARATION_FEE_YEAR_4', 'category' => 'separation_fee', 'name_ar' => 'رسوم فصل السنة الرابعة', 'name_en' => 'Separation Year 4', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"10.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'SEPARATION_FEE_YEAR_5', 'category' => 'separation_fee', 'name_ar' => 'رسوم فصل السنة الخامسة', 'name_en' => 'Separation Year 5', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"5.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'SEPARATION_FEE_YEAR_6_PLUS', 'category' => 'separation_fee', 'name_ar' => 'رسوم فصل السنة السادسة وما بعد', 'name_en' => 'Separation Year 6+', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"2.50"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'WAIVER_FEE', 'category' => 'separation_fee', 'name_ar' => 'رسوم التنازل عن العضوية', 'name_en' => 'Waiver Fee', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"30.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'SPORTS_CONVERSION_FEE', 'category' => 'separation_fee', 'name_ar' => 'رسوم تحويل رياضي لعامل', 'name_en' => 'Sports Conversion Fee', 'data_type' => 'compound', 'current_value_json' => '{"percentage":"50.00","min_years":8}', 'parameters_json' => '{"percentage":"decimal","min_years":"integer"}'],
// ── Divorce Rules ──
['rule_code' => 'DIVORCE_REQUEST_WINDOW', 'category' => 'divorce', 'name_ar' => 'مهلة تقديم طلب الفصل بعد الطلاق', 'name_en' => 'Divorce Request Window', 'data_type' => 'integer', 'current_value_json' => '{"max_years":1}', 'parameters_json' => '{"max_years":"integer"}'],
['rule_code' => 'DIVORCE_MIN_MEMBERSHIP_YEARS', 'category' => 'divorce', 'name_ar' => 'الحد الأدنى لسنوات العضوية', 'name_en' => 'Divorce Min Membership Years', 'data_type' => 'compound', 'current_value_json' => '{"min_years":5,"waived_if_children":true}', 'parameters_json' => '{"min_years":"integer","waived_if_children":"boolean"}'],
['rule_code' => 'DIVORCE_BOTH_WORKING_FEE', 'category' => 'divorce', 'name_ar' => 'رسوم طلاق - كلاهما عامل', 'name_en' => 'Divorce Both Working', 'data_type' => 'string', 'current_value_json' => '{"fee_type":"annual_subscription_only"}', 'parameters_json' => '{"fee_type":"string"}'],
['rule_code' => 'DIVORCE_SAME_FORM_FEE', 'category' => 'divorce', 'name_ar' => 'رسوم طلاق - استمارة واحدة', 'name_en' => 'Divorce Same Form', 'data_type' => 'compound', 'current_value_json' => '{"percentage":"10.00","treat_as":"membership_basis"}', 'parameters_json' => '{"percentage":"decimal","treat_as":"string"}'],
['rule_code' => 'DIVORCE_JOINED_AFTER_FEE', 'category' => 'divorce', 'name_ar' => 'رسوم طلاق - انضمام بعد العضوية', 'name_en' => 'Divorce Joined After', 'data_type' => 'compound', 'current_value_json' => '{"percentage":"50.00","treat_as":"acquired_member"}', 'parameters_json' => '{"percentage":"decimal","treat_as":"string"}'],
['rule_code' => 'DIVORCE_CHILD_UNDER_12', 'category' => 'divorce', 'name_ar' => 'أبناء مكتسب تحت 12', 'name_en' => 'Divorce Child Under 12', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"15.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'DIVORCE_CHILD_12_TO_16', 'category' => 'divorce', 'name_ar' => 'أبناء مكتسب 12-16', 'name_en' => 'Divorce Child 12-16', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"20.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'DIVORCE_CHILD_16_TO_18', 'category' => 'divorce', 'name_ar' => 'أبناء مكتسب 16-18', 'name_en' => 'Divorce Child 16-18', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"25.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'DIVORCE_CHILD_OVER_18', 'category' => 'divorce', 'name_ar' => 'أبناء مكتسب فوق 18', 'name_en' => 'Divorce Child Over 18', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"30.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
// ── Financial Rules ──
['rule_code' => 'INSTALLMENT_MIN_DOWN_PAYMENT', 'category' => 'financial', 'name_ar' => 'الحد الأدنى للمقدم', 'name_en' => 'Min Down Payment', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"25.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'INSTALLMENT_MAX_MONTHS', 'category' => 'financial', 'name_ar' => 'الحد الأقصى لشهور التقسيط', 'name_en' => 'Max Installment Months', 'data_type' => 'integer', 'current_value_json' => '{"months":30}', 'parameters_json' => '{"months":"integer"}'],
['rule_code' => 'INSTALLMENT_INTEREST_RATE', 'category' => 'financial', 'name_ar' => 'نسبة الفائدة السنوية', 'name_en' => 'Annual Interest Rate', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"22.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'INSTALLMENT_INTEREST_BASE', 'category' => 'financial', 'name_ar' => 'أساس حساب الفائدة', 'name_en' => 'Interest Base', 'data_type' => 'string', 'current_value_json' => '{"base":"remaining_balance"}', 'parameters_json' => '{"base":"string"}'],
['rule_code' => 'CASH_PAYMENT_WINDOW', 'category' => 'financial', 'name_ar' => 'مهلة السداد الكاش بدون فائدة', 'name_en' => 'Cash Window Days', 'data_type' => 'integer', 'current_value_json' => '{"days":30}', 'parameters_json' => '{"days":"integer"}'],
['rule_code' => 'FORM_NEW_MEMBERSHIP_FEE', 'category' => 'financial', 'name_ar' => 'رسوم استمارة عضوية جديدة', 'name_en' => 'New Membership Form Fee', 'data_type' => 'compound', 'current_value_json' => '{"total":"505.00","form":"500.00","stamp":"5.00"}', 'parameters_json' => '{"total":"decimal","form":"decimal","stamp":"decimal"}'],
['rule_code' => 'FORM_TRANSFER_FEE', 'category' => 'financial', 'name_ar' => 'رسوم استمارة تحويل/فصل', 'name_en' => 'Transfer Form Fee', 'data_type' => 'amount', 'current_value_json' => '{"amount":"570.00"}', 'parameters_json' => '{"amount":"decimal"}'],
['rule_code' => 'FORM_ADDITION_FEE', 'category' => 'financial', 'name_ar' => 'رسوم استمارة إضافة', 'name_en' => 'Addition Form Fee', 'data_type' => 'amount', 'current_value_json' => '{"amount":"570.00"}', 'parameters_json' => '{"amount":"decimal"}'],
['rule_code' => 'DEVELOPMENT_FEE', 'category' => 'financial', 'name_ar' => 'رسوم تنمية', 'name_en' => 'Development Fee', 'data_type' => 'amount', 'current_value_json' => '{"amount":"35.00"}', 'parameters_json' => '{"amount":"decimal"}'],
// ── Penalty Rules ──
['rule_code' => 'LATE_SUB_FINE_YEAR_1', 'category' => 'penalty', 'name_ar' => 'غرامة تأخير سنة أولى', 'name_en' => 'Late Fine Year 1', 'data_type' => 'percentage', 'current_value_json' => '{"percentage_of_subscription":"100.00"}', 'parameters_json' => '{"percentage_of_subscription":"decimal"}'],
['rule_code' => 'LATE_SUB_FINE_YEAR_2', 'category' => 'penalty', 'name_ar' => 'غرامة تأخير سنة ثانية', 'name_en' => 'Late Fine Year 2', 'data_type' => 'percentage', 'current_value_json' => '{"percentage_of_subscription":"200.00"}', 'parameters_json' => '{"percentage_of_subscription":"decimal"}'],
['rule_code' => 'LATE_SUB_FINE_YEAR_3', 'category' => 'penalty', 'name_ar' => 'غرامة تأخير سنة ثالثة', 'name_en' => 'Late Fine Year 3', 'data_type' => 'percentage', 'current_value_json' => '{"percentage_of_subscription":"300.00"}', 'parameters_json' => '{"percentage_of_subscription":"decimal"}'],
['rule_code' => 'LATE_SUB_FINE_MAX_YEARS', 'category' => 'penalty', 'name_ar' => 'أقصى سنوات تراكم غرامات', 'name_en' => 'Max Fine Years', 'data_type' => 'integer', 'current_value_json' => '{"years":5}', 'parameters_json' => '{"years":"integer"}'],
['rule_code' => 'LATE_SUB_DROP_YEARS', 'category' => 'penalty', 'name_ar' => 'سنوات إسقاط العضوية', 'name_en' => 'Drop Years', 'data_type' => 'integer', 'current_value_json' => '{"years":5}', 'parameters_json' => '{"years":"integer"}'],
['rule_code' => 'VIOLATION_FINE_MIN', 'category' => 'penalty', 'name_ar' => 'الحد الأدنى للغرامة', 'name_en' => 'Min Violation Fine', 'data_type' => 'amount', 'current_value_json' => '{"amount":"1000.00"}', 'parameters_json' => '{"amount":"decimal"}'],
['rule_code' => 'VIOLATION_FINE_MAX', 'category' => 'penalty', 'name_ar' => 'الحد الأقصى للغرامة', 'name_en' => 'Max Violation Fine', 'data_type' => 'amount', 'current_value_json' => '{"amount":"10000.00"}', 'parameters_json' => '{"amount":"decimal"}'],
['rule_code' => 'APPEAL_WINDOW', 'category' => 'penalty', 'name_ar' => 'مهلة التظلم', 'name_en' => 'Appeal Window', 'data_type' => 'integer', 'current_value_json' => '{"days":15}', 'parameters_json' => '{"days":"integer"}'],
['rule_code' => 'REVIEW_AFTER_EXPULSION', 'category' => 'penalty', 'name_ar' => 'إعادة النظر بعد الفصل', 'name_en' => 'Review After Expulsion', 'data_type' => 'integer', 'current_value_json' => '{"months":6}', 'parameters_json' => '{"months":"integer"}'],
['rule_code' => 'REINSTATEMENT_WINDOW', 'category' => 'penalty', 'name_ar' => 'مهلة إعادة العضوية بعد الإسقاط', 'name_en' => 'Reinstatement Window', 'data_type' => 'integer', 'current_value_json' => '{"months":12}', 'parameters_json' => '{"months":"integer"}'],
// ── Discount Rules ──
['rule_code' => 'GROUP_DISCOUNT_TIER_1', 'category' => 'discount', 'name_ar' => 'خصم مجمع 5-10', 'name_en' => 'Group Discount 5-10', 'data_type' => 'compound', 'current_value_json' => '{"min":5,"max":10,"percentage":"3.00"}', 'parameters_json' => '{"min":"integer","max":"integer","percentage":"decimal"}'],
['rule_code' => 'GROUP_DISCOUNT_TIER_2', 'category' => 'discount', 'name_ar' => 'خصم مجمع 11-20', 'name_en' => 'Group Discount 11-20', 'data_type' => 'compound', 'current_value_json' => '{"min":11,"max":20,"percentage":"7.00"}', 'parameters_json' => '{"min":"integer","max":"integer","percentage":"decimal"}'],
['rule_code' => 'GROUP_DISCOUNT_TIER_3', 'category' => 'discount', 'name_ar' => 'خصم مجمع 21+', 'name_en' => 'Group Discount 21+', 'data_type' => 'compound', 'current_value_json' => '{"min":21,"max":9999,"percentage":"10.00"}', 'parameters_json' => '{"min":"integer","max":"integer","percentage":"decimal"}'],
['rule_code' => 'GROUP_DISCOUNT_MAX', 'category' => 'discount', 'name_ar' => 'الحد الأقصى للخصم المجمع', 'name_en' => 'Max Group Discount', 'data_type' => 'percentage', 'current_value_json' => '{"max_percentage":"10.00"}', 'parameters_json' => '{"max_percentage":"decimal"}'],
['rule_code' => 'CROSS_BRANCH_DISCOUNT', 'category' => 'discount', 'name_ar' => 'خصم العضوية عبر الفروع', 'name_en' => 'Cross Branch Discount', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"50.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'CROSS_BRANCH_BOTH_DISCOUNT', 'category' => 'discount', 'name_ar' => 'خصم عضوية فرعين للعاصمة', 'name_en' => 'Both Branches Capital Discount', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"25.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
// ── Form/Workflow Rules ──
['rule_code' => 'FORM_VALIDITY_DAYS', 'category' => 'workflow', 'name_ar' => 'صلاحية الاستمارة بعد الموافقة', 'name_en' => 'Form Validity Days', 'data_type' => 'integer', 'current_value_json' => '{"days":15}', 'parameters_json' => '{"days":"integer"}'],
['rule_code' => 'ADDRESS_CHANGE_NOTIFICATION_DAYS', 'category' => 'workflow', 'name_ar' => 'مهلة إبلاغ تغيير العنوان', 'name_en' => 'Address Change Notice', 'data_type' => 'integer', 'current_value_json' => '{"days":30}', 'parameters_json' => '{"days":"integer"}'],
['rule_code' => 'PASSPORT_MIN_VALIDITY_MONTHS', 'category' => 'workflow', 'name_ar' => 'الحد الأدنى لصلاحية جواز السفر', 'name_en' => 'Min Passport Validity', 'data_type' => 'integer', 'current_value_json' => '{"months":6}', 'parameters_json' => '{"months":"integer"}'],
['rule_code' => 'SUBSCRIPTION_COLLECTION_WINDOW', 'category' => 'workflow', 'name_ar' => 'مهلة تحصيل الاشتراك السنوي', 'name_en' => 'Collection Window', 'data_type' => 'integer', 'current_value_json' => '{"months":3}', 'parameters_json' => '{"months":"integer"}'],
['rule_code' => 'SUBSCRIPTION_DELAY_START', 'category' => 'workflow', 'name_ar' => 'بداية حساب التأخير', 'name_en' => 'Delay Start Month', 'data_type' => 'integer', 'current_value_json' => '{"month":10}', 'parameters_json' => '{"month":"integer"}'],
['rule_code' => 'MAX_ANNUAL_SUBSCRIPTION_INCREASE', 'category' => 'workflow', 'name_ar' => 'الحد الأقصى لزيادة الاشتراك السنوي', 'name_en' => 'Max Annual Increase', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"20.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
// ── Temporary Member Rules ──
['rule_code' => 'TEMP_MEMBER_FEE', 'category' => 'temporary', 'name_ar' => 'رسوم العضو المؤقت', 'name_en' => 'Temp Member Fee', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"10.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'TEMP_CHAMPIONSHIP_EXEMPT', 'category' => 'temporary', 'name_ar' => 'إعفاء بطولات جمهورية', 'name_en' => 'Championship Exempt', 'data_type' => 'boolean', 'current_value_json' => '{"exempt":true}', 'parameters_json' => '{"exempt":"boolean"}'],
['rule_code' => 'SEASONAL_FEE', 'category' => 'temporary', 'name_ar' => 'رسوم العضوية الموسمية', 'name_en' => 'Seasonal Fee', 'data_type' => 'percentage', 'current_value_json' => '{"percentage":"5.00"}', 'parameters_json' => '{"percentage":"decimal"}'],
['rule_code' => 'SEASONAL_MAX_MONTHS', 'category' => 'temporary', 'name_ar' => 'الحد الأقصى للمدة الموسمية', 'name_en' => 'Seasonal Max Months', 'data_type' => 'integer', 'current_value_json' => '{"months":6}', 'parameters_json' => '{"months":"integer"}'],
// ── Foreign/Special Membership Rules ──
['rule_code' => 'FOREIGN_MEMBER_FEE_SHERATON', 'category' => 'foreign', 'name_ar' => 'رسوم أجنبي شيراتون', 'name_en' => 'Foreign Sheraton', 'data_type' => 'amount', 'current_value_json' => '{"amount_usd":"10000.00"}', 'parameters_json' => '{"amount_usd":"decimal"}'],
['rule_code' => 'FOREIGN_MEMBER_FEE_CAPITAL', 'category' => 'foreign', 'name_ar' => 'رسوم أجنبي العاصمة', 'name_en' => 'Foreign Capital', 'data_type' => 'amount', 'current_value_json' => '{"amount_usd":"15000.00"}', 'parameters_json' => '{"amount_usd":"decimal"}'],
['rule_code' => 'FOREIGN_MEMBER_INCLUDES', 'category' => 'foreign', 'name_ar' => 'عدد الأفراد المشمولين', 'name_en' => 'Foreign Includes', 'data_type' => 'compound', 'current_value_json' => '{"spouse":1,"children":3}', 'parameters_json' => '{"spouse":"integer","children":"integer"}'],
['rule_code' => 'HONORARY_DURATION_YEARS', 'category' => 'foreign', 'name_ar' => 'مدة العضوية الشرفية', 'name_en' => 'Honorary Duration', 'data_type' => 'integer', 'current_value_json' => '{"years":1}', 'parameters_json' => '{"years":"integer"}'],
['rule_code' => 'HONORARY_RENEWABLE', 'category' => 'foreign', 'name_ar' => 'قابلة للتجديد', 'name_en' => 'Honorary Renewable', 'data_type' => 'boolean', 'current_value_json' => '{"renewable":true}', 'parameters_json' => '{"renewable":"boolean"}'],
['rule_code' => 'SPORTS_MIN_YEARS', 'category' => 'foreign', 'name_ar' => 'الحد الأدنى لسنوات اللاعب', 'name_en' => 'Sports Min Years', 'data_type' => 'integer', 'current_value_json' => '{"years":8}', 'parameters_json' => '{"years":"integer"}'],
// ── Death Rules ──
['rule_code' => 'DEATH_TRANSFER_TO', 'category' => 'death', 'name_ar' => 'نقل العضوية عند الوفاة', 'name_en' => 'Death Transfer To', 'data_type' => 'string', 'current_value_json' => '{"transfer_to":"spouse"}', 'parameters_json' => '{"transfer_to":"string"}'],
['rule_code' => 'DEATH_SAME_NUMBER', 'category' => 'death', 'name_ar' => 'نفس رقم العضوية', 'name_en' => 'Death Same Number', 'data_type' => 'boolean', 'current_value_json' => '{"same_number":true}', 'parameters_json' => '{"same_number":"boolean"}'],
['rule_code' => 'DEATH_FEE', 'category' => 'death', 'name_ar' => 'رسوم نقل الوفاة', 'name_en' => 'Death Fee Type', 'data_type' => 'string', 'current_value_json' => '{"fee_type":"form_fee_plus_annual"}', 'parameters_json' => '{"fee_type":"string"}'],
];
foreach ($rules as $rule) {
$existing = $db->selectOne("SELECT id FROM business_rules WHERE rule_code = ? AND branch_id IS NULL", [$rule['rule_code']]);
if ($existing) {
continue;
}
$db->insert('business_rules', [
'rule_code' => $rule['rule_code'],
'category' => $rule['category'],
'name_ar' => $rule['name_ar'],
'name_en' => $rule['name_en'] ?? null,
'description_ar' => $rule['description_ar'] ?? null,
'data_type' => $rule['data_type'],
'current_value_json' => $rule['current_value_json'],
'parameters_json' => $rule['parameters_json'] ?? null,
'branch_id' => null,
'effective_from' => $now,
'is_active' => 1,
'version' => 1,
'created_at' => $ts,
'updated_at' => $ts,
]);
}
};
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$ts = date('Y-m-d H:i:s');
$branches = $db->select("SELECT id, branch_code FROM branches");
$quals = $db->select("SELECT id, code FROM qualifications");
if (empty($branches) || empty($quals)) {
return;
}
$branchMap = [];
foreach ($branches as $b) {
$branchMap[$b['branch_code']] = (int) $b['id'];
}
$qualMap = [];
foreach ($quals as $q) {
$qualMap[$q['code']] = (int) $q['id'];
}
// Current prices from 1/7/2024
$prices = [
// Sheraton
['branch' => 'sheraton', 'qual' => 'high', 'price' => '150000.00', 'from' => '2024-07-01'],
['branch' => 'sheraton', 'qual' => 'medium', 'price' => '225000.00', 'from' => '2024-07-01'],
['branch' => 'sheraton', 'qual' => 'none', 'price' => '300000.00', 'from' => '2024-07-01'],
// 6th October
['branch' => 'sadis', 'qual' => 'high', 'price' => '150000.00', 'from' => '2024-07-01'],
['branch' => 'sadis', 'qual' => 'medium', 'price' => '225000.00', 'from' => '2024-07-01'],
['branch' => 'sadis', 'qual' => 'none', 'price' => '300000.00', 'from' => '2024-07-01'],
// New Capital
['branch' => 'new_capital', 'qual' => 'high', 'price' => '150000.00', 'from' => '2024-07-01'],
['branch' => 'new_capital', 'qual' => 'medium', 'price' => '225000.00', 'from' => '2024-07-01'],
['branch' => 'new_capital', 'qual' => 'none', 'price' => '300000.00', 'from' => '2024-07-01'],
// Previous prices before 1/7/2024
['branch' => 'sheraton', 'qual' => 'high', 'price' => '114000.00', 'from' => '2020-01-01', 'to' => '2024-06-30'],
['branch' => 'sheraton', 'qual' => 'medium', 'price' => '171000.00', 'from' => '2020-01-01', 'to' => '2024-06-30'],
['branch' => 'sheraton', 'qual' => 'none', 'price' => '228000.00', 'from' => '2020-01-01', 'to' => '2024-06-30'],
];
foreach ($prices as $p) {
$branchId = $branchMap[$p['branch']] ?? null;
$qualId = $qualMap[$p['qual']] ?? null;
if (!$branchId || !$qualId) {
continue;
}
$existing = $db->selectOne(
"SELECT id FROM pricing_configs WHERE branch_id = ? AND qualification_id = ? AND effective_from = ? AND membership_type = 'working'",
[$branchId, $qualId, $p['from']]
);
if ($existing) {
continue;
}
$db->insert('pricing_configs', [
'branch_id' => $branchId,
'qualification_id' => $qualId,
'membership_type' => 'working',
'price' => $p['price'],
'currency' => 'EGP',
'effective_from' => $p['from'],
'effective_to' => $p['to'] ?? null,
'is_active' => 1,
'created_at' => $ts,
'updated_at' => $ts,
]);
}
};
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$ts = date('Y-m-d H:i:s');
$now = date('Y-m-d');
$services = [
['code' => 'SVC_NEW_FORM', 'name_ar' => 'استمارة عضوية جديدة', 'name_en' => 'New Membership Form', 'price_type' => 'fixed', 'base_amount' => '505.00'],
['code' => 'SVC_TRANSFER_FORM', 'name_ar' => 'استمارة تحويل / فصل', 'name_en' => 'Transfer/Separation Form', 'price_type' => 'fixed', 'base_amount' => '570.00'],
['code' => 'SVC_ADDITION_FORM', 'name_ar' => 'استمارة إضافة', 'name_en' => 'Addition Form', 'price_type' => 'fixed', 'base_amount' => '570.00'],
['code' => 'SVC_DEV_FEE', 'name_ar' => 'رسوم تنمية', 'name_en' => 'Development Fee', 'price_type' => 'fixed', 'base_amount' => '35.00'],
['code' => 'SVC_MARTYRS_STAMP', 'name_ar' => 'طابع شهداء', 'name_en' => 'Martyrs Stamp', 'price_type' => 'fixed', 'base_amount' => '5.00'],
['code' => 'SVC_CARNET_REPLACE', 'name_ar' => 'بدل فاقد كارنيه', 'name_en' => 'Carnet Replacement', 'price_type' => 'fixed', 'base_amount' => '200.00'],
['code' => 'SVC_SEASONAL', 'name_ar' => 'عضوية موسمية', 'name_en' => 'Seasonal Membership', 'price_type' => 'percentage', 'percentage' => '5.00'],
['code' => 'SVC_TEMP_MEMBER', 'name_ar' => 'رسوم عضو مؤقت', 'name_en' => 'Temporary Member Fee', 'price_type' => 'percentage', 'percentage' => '10.00'],
['code' => 'SVC_CHILD_4TH', 'name_ar' => 'رسوم ابن رابع', 'name_en' => '4th Child Fee', 'price_type' => 'percentage', 'percentage' => '5.00'],
['code' => 'SVC_CHILD_18', 'name_ar' => 'رسوم ابن 18 سنة', 'name_en' => 'Child 18 Fee', 'price_type' => 'percentage', 'percentage' => '10.00'],
['code' => 'SVC_CHILD_19', 'name_ar' => 'رسوم ابن 19 سنة', 'name_en' => 'Child 19 Fee', 'price_type' => 'percentage', 'percentage' => '15.00'],
['code' => 'SVC_CHILD_20', 'name_ar' => 'رسوم ابن 20 سنة', 'name_en' => 'Child 20 Fee', 'price_type' => 'percentage', 'percentage' => '20.00'],
['code' => 'SVC_CHILD_21', 'name_ar' => 'رسوم ابن 21 سنة', 'name_en' => 'Child 21 Fee', 'price_type' => 'percentage', 'percentage' => '15.00'],
['code' => 'SVC_SPOUSE_2ND', 'name_ar' => 'رسوم زوجة ثانية', 'name_en' => '2nd Spouse Fee', 'price_type' => 'percentage_plus_annual', 'percentage' => '10.00', 'annual_amount' => '150.00'],
['code' => 'SVC_SPOUSE_3RD', 'name_ar' => 'رسوم زوجة ثالثة', 'name_en' => '3rd Spouse Fee', 'price_type' => 'percentage_plus_annual', 'percentage' => '20.00', 'annual_amount' => '200.00'],
['code' => 'SVC_SPOUSE_4TH', 'name_ar' => 'رسوم زوجة رابعة', 'name_en' => '4th Spouse Fee', 'price_type' => 'percentage_plus_annual', 'percentage' => '30.00', 'annual_amount' => '300.00'],
['code' => 'SVC_SPOUSE_FOREIGN', 'name_ar' => 'رسوم زوج أجنبي', 'name_en' => 'Foreign Spouse Fee', 'price_type' => 'percentage', 'percentage' => '15.00'],
['code' => 'SVC_SPOUSE_ACQUIRED', 'name_ar' => 'رسوم زوج مكتسب', 'name_en' => 'Acquired Spouse Fee', 'price_type' => 'percentage', 'percentage' => '50.00'],
['code' => 'SVC_SPOUSE_BASE', 'name_ar' => 'رسوم زوج أساسي', 'name_en' => 'Base Spouse Fee', 'price_type' => 'percentage', 'percentage' => '15.00'],
['code' => 'SVC_WAIVER', 'name_ar' => 'رسوم تنازل', 'name_en' => 'Waiver Fee', 'price_type' => 'percentage', 'percentage' => '30.00'],
['code' => 'SVC_SPORTS_CONV', 'name_ar' => 'رسوم تحويل رياضي', 'name_en' => 'Sports Conversion Fee', 'price_type' => 'percentage', 'percentage' => '50.00'],
['code' => 'SVC_ANNUAL_MEMBER', 'name_ar' => 'اشتراك سنوي - عضو', 'name_en' => 'Annual Sub - Member', 'price_type' => 'fixed', 'base_amount' => '492.00'],
['code' => 'SVC_ANNUAL_SPOUSE', 'name_ar' => 'اشتراك سنوي - زوجة', 'name_en' => 'Annual Sub - Spouse', 'price_type' => 'fixed', 'base_amount' => '492.00'],
['code' => 'SVC_ANNUAL_CHILD', 'name_ar' => 'اشتراك سنوي - ابن/ابنة', 'name_en' => 'Annual Sub - Child', 'price_type' => 'fixed', 'base_amount' => '222.00'],
['code' => 'SVC_ANNUAL_TEMP', 'name_ar' => 'اشتراك سنوي - مؤقت', 'name_en' => 'Annual Sub - Temp', 'price_type' => 'fixed', 'base_amount' => '222.00'],
];
foreach ($services as $s) {
$existing = $db->selectOne("SELECT id FROM service_catalog WHERE service_code = ? AND branch_id IS NULL", [$s['code']]);
if ($existing) {
continue;
}
$db->insert('service_catalog', [
'service_code' => $s['code'],
'name_ar' => $s['name_ar'],
'name_en' => $s['name_en'] ?? null,
'price_type' => $s['price_type'],
'base_amount' => $s['base_amount'] ?? null,
'percentage' => $s['percentage'] ?? null,
'annual_amount' => $s['annual_amount'] ?? null,
'currency' => 'EGP',
'applies_to' => $s['applies_to'] ?? null,
'branch_id' => null,
'effective_from' => $now,
'is_active' => 1,
'created_at' => $ts,
'updated_at' => $ts,
]);
}
};
\ No newline at end of file
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