Commit 3f42fa01 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(pricing): implement regulatory discounts (Articles 97-102, 110)

Full-cycle implementation of club bylaw discount rules:
- Art 97: Cross-branch member discounts (50% Sheraton6th Oct, 25% →Admin Capital)
- Art 98: Government employees 50%, Ministry of Youth 62.5% at Admin Capital
- Art 99: Ministry of Youth 25% at Sheraton/6th Oct
- Art 100: Board of Trustees 50% + 2yr interest-free installment
- Art 101: Ministry employees installment-only (no discount)
- Art 102: Club employees (5+ yrs) up to 15%
- Art 110: Group membership tiered (5-10→3%, 11-20→7%, 21+→10%)

Includes: migration, seed data, model, service with eligibility engine,
controller (CRUD + eligibility check API + application workflow),
views (index, form, applications), routes, permissions, menu entry,
and PricingEngine integration.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 31130c75
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Models;
use App\Core\Model;
class RegulatoryDiscount extends Model
{
protected string $table = 'regulatory_discounts';
protected array $fillable = [
'article_number', 'name_ar', 'name_en', 'description',
'discount_percentage', 'max_discount_percentage',
'eligibility_type', 'source_branch_id', 'target_branch_id',
'min_service_years', 'allows_installment', 'installment_max_years',
'installment_interest_rate', 'applies_to', 'is_active',
'effective_from', 'effective_to', 'board_decision_number',
'board_decision_date', 'notes', 'created_by', 'updated_by',
];
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$query = static::query();
if (!empty($filters['search'])) {
$query->where('name_ar', 'LIKE', '%' . $filters['search'] . '%');
}
if ($filters['is_active'] !== '') {
$query->where('is_active', '=', (int) $filters['is_active']);
}
if (!empty($filters['eligibility_type'])) {
$query->where('eligibility_type', '=', $filters['eligibility_type']);
}
return $query->orderBy('article_number', 'ASC')->paginate($perPage, $page);
}
public static function getActiveByType(string $eligibilityType, ?int $targetBranchId = null): array
{
$db = \App\Core\App::getInstance()->db();
$sql = "SELECT * FROM regulatory_discounts WHERE eligibility_type = ? AND is_active = 1 AND (effective_from IS NULL OR effective_from <= CURDATE()) AND (effective_to IS NULL OR effective_to >= CURDATE())";
$params = [$eligibilityType];
if ($targetBranchId !== null) {
$sql .= " AND (target_branch_id = ? OR target_branch_id IS NULL)";
$params[] = $targetBranchId;
}
return $db->select($sql, $params);
}
}
......@@ -33,4 +33,16 @@ return [
['GET', '/pricing/special-discounts/{id:\d+}/edit', 'Pricing\Controllers\SpecialDiscountController@edit', ['auth'], 'pricing.special_discounts.edit'],
['POST', '/pricing/special-discounts/{id:\d+}', 'Pricing\Controllers\SpecialDiscountController@update', ['auth', 'csrf'], 'pricing.special_discounts.edit'],
['POST', '/pricing/special-discounts/{id:\d+}/toggle', 'Pricing\Controllers\SpecialDiscountController@toggleActive', ['auth', 'csrf'], 'pricing.special_discounts.edit'],
// Regulatory Discounts (Articles 97-102, 110)
['GET', '/pricing/regulatory-discounts', 'Pricing\Controllers\RegulatoryDiscountController@index', ['auth'], 'pricing.regulatory_discounts.view'],
['GET', '/pricing/regulatory-discounts/create', 'Pricing\Controllers\RegulatoryDiscountController@create', ['auth'], 'pricing.regulatory_discounts.create'],
['POST', '/pricing/regulatory-discounts', 'Pricing\Controllers\RegulatoryDiscountController@store', ['auth', 'csrf'], 'pricing.regulatory_discounts.create'],
['GET', '/pricing/regulatory-discounts/{id:\d+}/edit', 'Pricing\Controllers\RegulatoryDiscountController@edit', ['auth'], 'pricing.regulatory_discounts.edit'],
['POST', '/pricing/regulatory-discounts/{id:\d+}', 'Pricing\Controllers\RegulatoryDiscountController@update', ['auth', 'csrf'], 'pricing.regulatory_discounts.edit'],
['POST', '/pricing/regulatory-discounts/{id:\d+}/toggle', 'Pricing\Controllers\RegulatoryDiscountController@toggleActive', ['auth', 'csrf'], 'pricing.regulatory_discounts.edit'],
['POST', '/pricing/regulatory-discounts/check-eligibility', 'Pricing\Controllers\RegulatoryDiscountController@checkEligibility', ['auth', 'csrf'], 'pricing.regulatory_discounts.view'],
['GET', '/pricing/regulatory-discounts/applications', 'Pricing\Controllers\RegulatoryDiscountController@applications', ['auth'], 'pricing.regulatory_discounts.approve'],
['POST', '/pricing/regulatory-discounts/applications/{id:\d+}/approve','Pricing\Controllers\RegulatoryDiscountController@approveApplication', ['auth', 'csrf'], 'pricing.regulatory_discounts.approve'],
['POST', '/pricing/regulatory-discounts/applications/{id:\d+}/reject', 'Pricing\Controllers\RegulatoryDiscountController@rejectApplication', ['auth', 'csrf'], 'pricing.regulatory_discounts.approve'],
];
\ No newline at end of file
......@@ -213,6 +213,35 @@ final class PricingEngine
];
}
public static function applyRegulatoryDiscount(string $membershipFee, array $context): array
{
$best = RegulatoryDiscountService::getBestDiscount(array_merge($context, ['membership_fee' => $membershipFee]));
if (!$best) {
return [
'original_amount' => $membershipFee,
'discount_amount' => '0.00',
'final_amount' => $membershipFee,
'discount_percentage' => '0.00',
'rule_applied' => null,
'allows_installment' => false,
];
}
return [
'original_amount' => $best['original_amount'],
'discount_amount' => $best['discount_amount'],
'final_amount' => $best['final_amount'],
'discount_percentage' => $best['discount_percentage'],
'rule_applied' => $best['name_ar'],
'article_number' => $best['article_number'],
'rule_id' => $best['rule_id'],
'allows_installment' => $best['allows_installment'],
'installment_max_years' => $best['installment_max_years'],
'installment_interest_rate' => $best['installment_interest_rate'],
];
}
public static function getServiceFee(string $serviceCode, ?int $branchId = null): ?array
{
$db = App::getInstance()->db();
......
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Pricing\Models\RegulatoryDiscount;
final class RegulatoryDiscountService
{
/**
* Evaluate all applicable regulatory discounts for a membership application.
*
* @param array $context Keys: target_branch_id, source_branch_id (if existing member),
* is_government_employee, is_ministry_youth_employee,
* is_board_of_trustees, is_club_employee, employee_years,
* group_quantity, membership_fee
* @return array List of applicable discounts with calculated amounts
*/
public static function evaluateEligibility(array $context): array
{
$applicable = [];
$targetBranchId = (int) ($context['target_branch_id'] ?? 0);
$sourceBranchId = (int) ($context['source_branch_id'] ?? 0);
$membershipFee = (string) ($context['membership_fee'] ?? '0');
// Cross-branch member discount (Article 97)
if ($sourceBranchId > 0 && $targetBranchId > 0 && $sourceBranchId !== $targetBranchId) {
$rules = RegulatoryDiscount::getActiveByType('cross_branch_member', $targetBranchId);
foreach ($rules as $rule) {
if ((int) $rule['source_branch_id'] === $sourceBranchId) {
$applicable[] = self::buildResult($rule, $membershipFee);
}
}
}
// Government employee discount (Article 98)
if (!empty($context['is_government_employee'])) {
$rules = RegulatoryDiscount::getActiveByType('government_employee', $targetBranchId);
foreach ($rules as $rule) {
$applicable[] = self::buildResult($rule, $membershipFee);
}
}
// Ministry of Youth employee discount (Articles 98, 99)
if (!empty($context['is_ministry_youth_employee'])) {
$rules = RegulatoryDiscount::getActiveByType('ministry_youth_employee', $targetBranchId);
foreach ($rules as $rule) {
$applicable[] = self::buildResult($rule, $membershipFee);
}
}
// Board of Trustees discount (Article 100)
if (!empty($context['is_board_of_trustees'])) {
$rules = RegulatoryDiscount::getActiveByType('board_of_trustees', $targetBranchId);
foreach ($rules as $rule) {
$applicable[] = self::buildResult($rule, $membershipFee);
}
}
// Club employee discount (Article 102)
if (!empty($context['is_club_employee'])) {
$employeeYears = (int) ($context['employee_years'] ?? 0);
$rules = RegulatoryDiscount::getActiveByType('club_employee', $targetBranchId);
foreach ($rules as $rule) {
$minYears = (int) ($rule['min_service_years'] ?? 0);
if ($employeeYears >= $minYears) {
$applicable[] = self::buildResult($rule, $membershipFee);
}
}
}
// Group membership discount (Article 110)
$groupQty = (int) ($context['group_quantity'] ?? 0);
if ($groupQty >= 5) {
$rules = RegulatoryDiscount::getActiveByType('group_membership', $targetBranchId);
foreach ($rules as $rule) {
$tier = self::getGroupTier((int) $rule['id'], $groupQty);
if ($tier) {
$result = self::buildResult($rule, $membershipFee, (string) $tier['discount_percentage']);
$result['tier'] = $tier;
$result['group_quantity'] = $groupQty;
$applicable[] = $result;
}
}
}
return $applicable;
}
/**
* Get the best single discount for a given context (non-stackable — take highest).
*/
public static function getBestDiscount(array $context): ?array
{
$applicable = self::evaluateEligibility($context);
if (empty($applicable)) {
return null;
}
usort($applicable, function ($a, $b) {
return bccomp($b['discount_amount'], $a['discount_amount'], 2);
});
return $applicable[0];
}
/**
* Create an application record for audit trail.
*/
public static function createApplication(array $data): int
{
$db = App::getInstance()->db();
return $db->insert('regulatory_discount_applications', [
'regulatory_discount_id' => $data['regulatory_discount_id'],
'member_id' => $data['member_id'] ?? null,
'applicant_name' => $data['applicant_name'] ?? null,
'discount_percentage' => $data['discount_percentage'],
'original_amount' => $data['original_amount'],
'discount_amount' => $data['discount_amount'],
'final_amount' => $data['final_amount'],
'status' => $data['status'] ?? 'pending',
'verification_data' => isset($data['verification_data']) ? json_encode($data['verification_data']) : null,
'notes' => $data['notes'] ?? null,
'created_by' => $data['created_by'] ?? null,
]);
}
/**
* Approve a pending discount application.
*/
public static function approveApplication(int $applicationId, int $approvedBy): bool
{
$db = App::getInstance()->db();
$app = $db->selectOne("SELECT * FROM regulatory_discount_applications WHERE id = ?", [$applicationId]);
if (!$app || $app['status'] !== 'pending') {
return false;
}
$db->update('regulatory_discount_applications', [
'status' => 'approved',
'approved_by' => $approvedBy,
'approved_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$applicationId]);
return true;
}
/**
* Verify club employee eligibility by checking HR records.
*/
public static function verifyClubEmployee(int $employeeId): array
{
$db = App::getInstance()->db();
$profile = $db->selectOne(
"SELECT employee_id, first_name_ar, last_name_ar, hire_date, employment_status FROM hr_employee_profiles WHERE employee_id = ? AND is_archived = 0",
[$employeeId]
);
if (!$profile || $profile['employment_status'] !== 'active') {
return ['eligible' => false, 'reason' => 'الموظف غير نشط أو غير موجود'];
}
$hireDate = new \DateTime($profile['hire_date']);
$now = new \DateTime();
$years = (int) $now->diff($hireDate)->y;
return [
'eligible' => true,
'employee_name' => $profile['first_name_ar'] . ' ' . $profile['last_name_ar'],
'hire_date' => $profile['hire_date'],
'service_years' => $years,
];
}
/**
* Verify existing membership in another branch (for cross-branch discount).
*/
public static function verifyCrossBranchMembership(int $memberId, int $sourceBranchId): array
{
$db = App::getInstance()->db();
$member = $db->selectOne(
"SELECT m.id, m.member_number, m.full_name_ar, m.branch_id, b.name_ar as branch_name FROM members m LEFT JOIN branches b ON b.id = m.branch_id WHERE m.id = ? AND m.branch_id = ? AND m.status = 'active'",
[$memberId, $sourceBranchId]
);
if (!$member) {
return ['eligible' => false, 'reason' => 'لا توجد عضوية نشطة في الفرع المصدر'];
}
return [
'eligible' => true,
'member_number' => $member['member_number'],
'member_name' => $member['full_name_ar'],
'branch_name' => $member['branch_name'],
];
}
private static function buildResult(array $rule, string $membershipFee, ?string $overridePercentage = null): array
{
$pct = $overridePercentage ?? $rule['discount_percentage'];
if ($rule['max_discount_percentage'] !== null) {
$pct = min((float) $pct, (float) $rule['max_discount_percentage']);
$pct = number_format($pct, 2, '.', '');
}
$discountAmount = bcmul($membershipFee, bcdiv($pct, '100', 4), 2);
$finalAmount = bcsub($membershipFee, $discountAmount, 2);
return [
'rule_id' => (int) $rule['id'],
'article_number' => $rule['article_number'],
'name_ar' => $rule['name_ar'],
'eligibility_type' => $rule['eligibility_type'],
'discount_percentage' => $pct,
'original_amount' => $membershipFee,
'discount_amount' => $discountAmount,
'final_amount' => $finalAmount,
'allows_installment' => (bool) $rule['allows_installment'],
'installment_max_years' => $rule['installment_max_years'] ? (int) $rule['installment_max_years'] : null,
'installment_interest_rate' => $rule['installment_interest_rate'],
];
}
private static function getGroupTier(int $discountId, int $quantity): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM regulatory_discount_group_tiers WHERE regulatory_discount_id = ? AND is_active = 1 AND min_quantity <= ? AND (max_quantity IS NULL OR max_quantity >= ?) ORDER BY min_quantity DESC LIMIT 1",
[$discountId, $quantity, $quantity]
);
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>طلبات الخصم اللائحي<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/pricing/regulatory-discounts" class="btn btn-outline">
<i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة للخصومات
</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;padding:15px;">
<form method="GET" action="/pricing/regulatory-discounts/applications" style="display:flex;gap:10px;align-items:end;">
<div class="form-group" style="min-width:180px;">
<label class="form-label">الحالة</label>
<select name="status" class="form-input">
<option value="">— الكل —</option>
<option value="pending" <?= ($statusFilter ?? '') === 'pending' ? 'selected' : '' ?>>معلق</option>
<option value="approved" <?= ($statusFilter ?? '') === 'approved' ? 'selected' : '' ?>>معتمد</option>
<option value="rejected" <?= ($statusFilter ?? '') === 'rejected' ? 'selected' : '' ?>>مرفوض</option>
</select>
</div>
<button type="submit" class="btn btn-primary" style="padding:8px 20px;">
<i data-lucide="filter" style="width:14px;height:14px;vertical-align:middle;"></i> تصفية
</button>
</form>
</div>
<!-- Table -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="file-check" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">طلبات تطبيق الخصم اللائحي</h3>
<?php if (!empty($pagination['total'])): ?>
<span style="margin-right:auto;background:#F3F4F6;padding:2px 10px;border-radius:10px;font-size:12px;color:#6B7280;"><?= $pagination['total'] ?> طلب</span>
<?php endif; ?>
</div>
<?php if (!empty($rows)): ?>
<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>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row): ?>
<tr>
<td style="color:#9CA3AF;font-size:12px;"><?= (int) $row['id'] ?></td>
<td>
<div style="font-weight:600;font-size:13px;"><?= e($row['rule_name'] ?? '—') ?></div>
<div style="font-size:11px;color:#6B7280;">مادة <?= e($row['article_number'] ?? '') ?></div>
</td>
<td style="font-size:13px;"><?= e($row['applicant_name'] ?? '—') ?></td>
<td style="font-size:13px;direction:ltr;text-align:right;"><?= money($row['original_amount'] ?? 0) ?></td>
<td style="font-weight:700;color:#DC2626;font-size:13px;direction:ltr;text-align:right;">
-<?= money($row['discount_amount'] ?? 0) ?>
<div style="font-size:11px;color:#6B7280;">(<?= e($row['discount_percentage'] ?? 0) ?>%)</div>
</td>
<td style="font-weight:700;color:#059669;font-size:13px;direction:ltr;text-align:right;"><?= money($row['final_amount'] ?? 0) ?></td>
<td>
<?= match($row['status']) {
'pending' => '<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#FEF3C7;color:#92400E;">معلق</span>',
'approved' => '<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#ECFDF5;color:#059669;">معتمد</span>',
'rejected' => '<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#FEF2F2;color:#DC2626;">مرفوض</span>',
default => $row['status']
} ?>
</td>
<td style="font-size:12px;color:#6B7280;"><?= e(substr($row['created_at'] ?? '', 0, 10)) ?></td>
<td style="white-space:nowrap;">
<?php if ($row['status'] === 'pending'): ?>
<form method="POST" action="/pricing/regulatory-discounts/applications/<?= (int) $row['id'] ?>/approve" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="font-size:11px;padding:3px 8px;background:#ECFDF5;color:#059669;border:1px solid #A7F3D0;">اعتماد</button>
</form>
<button type="button" class="btn btn-sm" style="font-size:11px;padding:3px 8px;background:#FEF2F2;color:#DC2626;border:1px solid #FECACA;" onclick="rejectApp(<?= (int) $row['id'] ?>)">رفض</button>
<?php elseif ($row['status'] === 'rejected' && $row['rejection_reason']): ?>
<span title="<?= e($row['rejection_reason']) ?>" style="cursor:help;color:#DC2626;font-size:11px;">سبب الرفض</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($pagination['last_page'] > 1): ?>
<div style="padding:15px 20px;border-top:1px solid #E5E7EB;text-align:center;">
<?php for ($i = 1; $i <= $pagination['last_page']; $i++): ?>
<?php if ($i == $pagination['current_page']): ?>
<span style="display:inline-block;padding:4px 12px;background:#0D7377;color:#fff;border-radius:4px;font-size:13px;margin:0 2px;"><?= $i ?></span>
<?php else: ?>
<a href="?page=<?= $i ?>&status=<?= urlencode($statusFilter ?? '') ?>" style="display:inline-block;padding:4px 12px;color:#0D7377;text-decoration:none;font-size:13px;margin:0 2px;"><?= $i ?></a>
<?php endif; ?>
<?php endfor; ?>
</div>
<?php endif; ?>
<?php else: ?>
<div style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;"><i data-lucide="file-check" style="width:48px;height:48px;color:#D1D5DB;"></i></div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد طلبات خصم</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0;">تظهر هنا طلبات تطبيق الخصم اللائحي عند تقديمها من شاشة العضويات.</p>
</div>
<?php endif; ?>
</div>
<!-- Rejection Modal -->
<div id="reject-modal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;align-items:center;justify-content:center;">
<div style="background:#fff;border-radius:12px;padding:25px;max-width:400px;width:90%;">
<h4 style="margin:0 0 15px;color:#DC2626;">رفض طلب الخصم</h4>
<form method="POST" id="reject-form">
<?= csrf_field() ?>
<div class="form-group" style="margin-bottom:15px;">
<label class="form-label">سبب الرفض (اختياري)</label>
<textarea name="rejection_reason" class="form-input" rows="3" placeholder="أدخل سبب الرفض..."></textarea>
</div>
<div style="display:flex;gap:10px;">
<button type="submit" class="btn" style="background:#DC2626;color:#fff;padding:8px 20px;">تأكيد الرفض</button>
<button type="button" class="btn btn-outline" onclick="closeRejectModal()">إلغاء</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });
function rejectApp(id) {
document.getElementById('reject-form').action = '/pricing/regulatory-discounts/applications/' + id + '/reject';
document.getElementById('reject-modal').style.display = 'flex';
}
function closeRejectModal() {
document.getElementById('reject-modal').style.display = 'none';
}
</script>
<?php $__template->endSection(); ?>
This diff is collapsed.
This diff is collapsed.
......@@ -17,6 +17,7 @@ MenuRegistry::register('pricing', [
['label_ar' => 'لوحة التسعير', 'label_en' => 'Pricing Dashboard', 'route' => '/pricing', 'permission' => 'pricing.view', 'order' => 1],
['label_ar' => 'عروض مجلس الإدارة', 'label_en' => 'Board Offers', 'route' => '/pricing/board-offers', 'permission' => 'pricing.board_offers.view', 'order' => 2],
['label_ar' => 'الخصومات الخاصة', 'label_en' => 'Special Discounts', 'route' => '/pricing/special-discounts', 'permission' => 'pricing.special_discounts.view', 'order' => 3],
['label_ar' => 'الخصومات اللائحية', 'label_en' => 'Regulatory Discounts','route' => '/pricing/regulatory-discounts','permission' => 'pricing.regulatory_discounts.view', 'order' => 4],
],
]);
......@@ -29,6 +30,10 @@ PermissionRegistry::register('pricing', [
'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'],
'pricing.regulatory_discounts.view' => ['ar' => 'عرض الخصومات اللائحية', 'en' => 'View Regulatory Discounts'],
'pricing.regulatory_discounts.create' => ['ar' => 'إنشاء خصم لائحي', 'en' => 'Create Regulatory Discount'],
'pricing.regulatory_discounts.edit' => ['ar' => 'تعديل الخصومات اللائحية', 'en' => 'Edit Regulatory Discounts'],
'pricing.regulatory_discounts.approve' => ['ar' => 'اعتماد طلبات الخصم اللائحي','en' => 'Approve Regulatory Discount Applications'],
]);
// When a member is activated, check if their discount includes free subscription bonus
......
<?php
declare(strict_types=1);
return function (\App\Core\Database $db): void {
$db->raw("
CREATE TABLE IF NOT EXISTS `regulatory_discounts` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`article_number` VARCHAR(20) NOT NULL COMMENT 'رقم المادة في اللائحة',
`name_ar` VARCHAR(300) NOT NULL,
`name_en` VARCHAR(300) NULL,
`description` TEXT NULL,
`discount_percentage` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
`max_discount_percentage` DECIMAL(5,2) NULL COMMENT 'الحد الأقصى للخصم (لمادة 102)',
`eligibility_type` ENUM(
'cross_branch_member',
'government_employee',
'ministry_youth_employee',
'board_of_trustees',
'club_employee',
'group_membership'
) NOT NULL,
`source_branch_id` BIGINT UNSIGNED NULL COMMENT 'فرع العضو الحالي (لمادة 97)',
`target_branch_id` BIGINT UNSIGNED NULL COMMENT 'الفرع المستهدف بالخصم',
`min_service_years` INT UNSIGNED NULL COMMENT 'الحد الأدنى لسنوات الخدمة (لمادة 102)',
`allows_installment` TINYINT(1) NOT NULL DEFAULT 0,
`installment_max_years` INT UNSIGNED NULL COMMENT 'أقصى مدة تقسيط بالسنوات',
`installment_interest_rate` DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT 'نسبة الفائدة على التقسيط',
`applies_to` ENUM('membership_fee','subscription','all') NOT NULL DEFAULT 'membership_fee',
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`effective_from` DATE NULL,
`effective_to` DATE NULL,
`board_decision_number` VARCHAR(50) NULL,
`board_decision_date` DATE NULL,
`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_regulatory_eligibility` (`eligibility_type`, `is_active`),
INDEX `idx_regulatory_target_branch` (`target_branch_id`, `is_active`),
INDEX `idx_regulatory_article` (`article_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$db->raw("
CREATE TABLE IF NOT EXISTS `regulatory_discount_group_tiers` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`regulatory_discount_id` BIGINT UNSIGNED NOT NULL,
`min_quantity` INT UNSIGNED NOT NULL,
`max_quantity` INT UNSIGNED NULL,
`discount_percentage` DECIMAL(5,2) NOT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_group_tier_discount` (`regulatory_discount_id`),
CONSTRAINT `fk_group_tier_discount` FOREIGN KEY (`regulatory_discount_id`) REFERENCES `regulatory_discounts`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$db->raw("
CREATE TABLE IF NOT EXISTS `regulatory_discount_applications` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`regulatory_discount_id` BIGINT UNSIGNED NOT NULL,
`member_id` BIGINT UNSIGNED NULL,
`applicant_name` VARCHAR(200) NULL COMMENT 'الاسم (قبل إنشاء العضوية)',
`discount_percentage` DECIMAL(5,2) NOT NULL,
`original_amount` DECIMAL(15,2) NOT NULL,
`discount_amount` DECIMAL(15,2) NOT NULL,
`final_amount` DECIMAL(15,2) NOT NULL,
`status` ENUM('pending','approved','rejected','applied') NOT NULL DEFAULT 'pending',
`approved_by` BIGINT UNSIGNED NULL,
`approved_at` TIMESTAMP NULL,
`rejection_reason` TEXT NULL,
`verification_data` JSON NULL COMMENT 'بيانات التحقق (HR، فرع المصدر، إلخ)',
`installment_plan_id` BIGINT UNSIGNED NULL,
`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,
INDEX `idx_rda_discount` (`regulatory_discount_id`),
INDEX `idx_rda_member` (`member_id`),
INDEX `idx_rda_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
};
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment