Commit 0f1cba7f authored by Mahmoud Aglan's avatar Mahmoud Aglan

xdgxdgng

parent 4bd5dfab
This diff is collapsed.
...@@ -11,6 +11,9 @@ use App\Core\EventBus; ...@@ -11,6 +11,9 @@ use App\Core\EventBus;
use App\Modules\Carnets\Models\Carnet; use App\Modules\Carnets\Models\Carnet;
use App\Modules\Carnets\Services\CarnetPrintService; use App\Modules\Carnets\Services\CarnetPrintService;
use App\Modules\Carnets\Services\QRCodeGenerator; use App\Modules\Carnets\Services\QRCodeGenerator;
use App\Modules\Pricing\Services\PricingEngine;
use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Cashier\Services\PaymentRequestService;
class CarnetController extends Controller class CarnetController extends Controller
{ {
...@@ -141,9 +144,19 @@ class CarnetController extends Controller ...@@ -141,9 +144,19 @@ class CarnetController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود'); return $this->redirect('/members')->withError('العضو غير موجود');
} }
// Lost carnet replacement — requires fee payment $svc = PricingEngine::getServiceFee('SVC_CARNET_REPLACEMENT');
// For now, issue new carnet (fee handled separately via payment module) $fee = $svc ? ($svc['base_amount'] ?? '200.00') : (RuleEngine::getValue('CARNET_REPLACEMENT_FEE', 'amount') ?? '200.00');
return $this->issue($request, $memberId);
PaymentRequestService::createRequest(
(int) $memberId,
'carnet_replacement',
$fee,
'رسوم بدل فاقد كارنيه',
'carnets',
null
);
return $this->redirect('/members/' . $memberId)->withSuccess('تم إرسال طلب دفع بدل فاقد الكارنيه (' . money($fee) . ') للخزينة');
} }
public function deactivate(Request $request, string $id): Response public function deactivate(Request $request, string $id): Response
......
...@@ -12,6 +12,7 @@ use App\Modules\Children\Models\Child; ...@@ -12,6 +12,7 @@ use App\Modules\Children\Models\Child;
use App\Modules\Children\Services\ChildFeeCalculator; use App\Modules\Children\Services\ChildFeeCalculator;
use App\Modules\Members\Services\NationalIdParser; use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Rules\Services\RuleEngine; use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Forms\Services\FormBridge;
class ChildController extends Controller class ChildController extends Controller
...@@ -153,6 +154,10 @@ class ChildController extends Controller ...@@ -153,6 +154,10 @@ class ChildController extends Controller
'remarks' => $data['remarks'] ?? null, 'remarks' => $data['remarks'] ?? null,
]); ]);
if (FormBridge::exists('ADDITION_CHILD')) {
FormBridge::submit('ADDITION_CHILD', $data, (int) $memberId, 'إضافة ابن: ' . trim($data['full_name_ar']));
}
EventBus::dispatch('child.added', [ EventBus::dispatch('child.added', [
'member_id' => (int) $memberId, 'member_id' => (int) $memberId,
'child_id' => (int) $child->id, 'child_id' => (int) $child->id,
......
...@@ -5,6 +5,7 @@ namespace App\Modules\Children\Models; ...@@ -5,6 +5,7 @@ namespace App\Modules\Children\Models;
use App\Core\Model; use App\Core\Model;
use App\Core\App; use App\Core\App;
use App\Modules\Rules\Services\RuleEngine;
class Child extends Model class Child extends Model
{ {
...@@ -45,9 +46,10 @@ class Child extends Model ...@@ -45,9 +46,10 @@ class Child extends Model
public static function countActiveUnder18ForMember(int $memberId): int public static function countActiveUnder18ForMember(int $memberId): int
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$maxAge = (int) (RuleEngine::getValue('CHILD_INCLUDED_MAX_AGE', 'value') ?? 18);
$row = $db->selectOne( $row = $db->selectOne(
"SELECT COUNT(*) as cnt FROM children WHERE member_id = ? AND is_archived = 0 AND age_years < 18", "SELECT COUNT(*) as cnt FROM children WHERE member_id = ? AND is_archived = 0 AND age_years < ?",
[$memberId] [$memberId, $maxAge]
); );
return (int) ($row['cnt'] ?? 0); return (int) ($row['cnt'] ?? 0);
} }
......
...@@ -40,8 +40,8 @@ final class ChildFeeCalculator ...@@ -40,8 +40,8 @@ final class ChildFeeCalculator
// Stepchildren (ابناء الزوجة/الزوج) always cost 10% — never counted as free // Stepchildren (ابناء الزوجة/الزوج) always cost 10% — never counted as free
if ($relationship === 'stepchild') { if ($relationship === 'stepchild') {
$stepData = RuleEngine::get('STEPCHILD_FEE'); $stepData = RuleEngine::require('STEPCHILD_FEE');
$stepPct = $stepData['percentage'] ?? '10.00'; $stepPct = $stepData['percentage'];
$stepFee = bcmul($membershipValue, bcdiv($stepPct, '100', 4), 2); $stepFee = bcmul($membershipValue, bcdiv($stepPct, '100', 4), 2);
$feeResult = [ $feeResult = [
'fee' => $stepFee, 'fee' => $stepFee,
......
...@@ -8,10 +8,12 @@ use App\Core\Request; ...@@ -8,10 +8,12 @@ use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Core\EventBus; use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Fines\Models\Fine; use App\Modules\Fines\Models\Fine;
use App\Modules\Fines\Models\Violation; use App\Modules\Fines\Models\Violation;
use App\Modules\Rules\Services\RuleEngine; use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Payments\Services\PaymentService; use App\Modules\Payments\Services\PaymentService;
use App\Modules\Workflow\Services\WorkflowEngine;
class FineController extends Controller class FineController extends Controller
{ {
...@@ -85,6 +87,16 @@ class FineController extends Controller ...@@ -85,6 +87,16 @@ class FineController extends Controller
$db->update('violations', ['status' => 'penalty_imposed', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [(int) $violationId]); $db->update('violations', ['status' => 'penalty_imposed', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [(int) $violationId]);
if (WorkflowEngine::hasDefinition('violation_penalty')) {
WorkflowEngine::createInstance('violation_penalty', 'fines', (int) $fine->id);
}
try {
WorkflowEngine::transitionByEntity('violations', (int) $violationId, 'impose_penalty');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for violation impose_penalty", ['violation_id' => (int) $violationId, 'error' => $e->getMessage()]);
}
EventBus::dispatch('fine.imposed', [ EventBus::dispatch('fine.imposed', [
'fine_id' => (int) $fine->id, 'fine_id' => (int) $fine->id,
'member_id' => (int) $violation['member_id'], 'member_id' => (int) $violation['member_id'],
...@@ -125,6 +137,12 @@ class FineController extends Controller ...@@ -125,6 +137,12 @@ class FineController extends Controller
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]); ], '`id` = ?', [(int) $id]);
try {
WorkflowEngine::transitionByEntity('fines', (int) $id, 'pay');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for fine pay", ['fine_id' => (int) $id, 'error' => $e->getMessage()]);
}
EventBus::dispatch('fine.paid', ['fine_id' => (int) $id, 'member_id' => (int) $fine['member_id'], 'amount' => $remaining]); EventBus::dispatch('fine.paid', ['fine_id' => (int) $id, 'member_id' => (int) $fine['member_id'], 'amount' => $remaining]);
return $this->redirect('/fines')->withSuccess('تم تسجيل دفع الغرامة — إيصال: ' . $result['receipt_number']); return $this->redirect('/fines')->withSuccess('تم تسجيل دفع الغرامة — إيصال: ' . $result['receipt_number']);
...@@ -156,6 +174,12 @@ class FineController extends Controller ...@@ -156,6 +174,12 @@ class FineController extends Controller
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]); ], '`id` = ?', [(int) $id]);
try {
WorkflowEngine::transitionByEntity('fines', (int) $id, 'appeal');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for fine appeal", ['fine_id' => (int) $id, 'error' => $e->getMessage()]);
}
EventBus::dispatch('fine.appealed', ['fine_id' => (int) $id, 'member_id' => (int) $fine['member_id']]); EventBus::dispatch('fine.appealed', ['fine_id' => (int) $id, 'member_id' => (int) $fine['member_id']]);
return $this->redirect('/fines')->withSuccess('تم تقديم التظلم'); return $this->redirect('/fines')->withSuccess('تم تقديم التظلم');
...@@ -193,6 +217,17 @@ class FineController extends Controller ...@@ -193,6 +217,17 @@ class FineController extends Controller
$db->update('fines', $updateData, '`id` = ?', [(int) $id]); $db->update('fines', $updateData, '`id` = ?', [(int) $id]);
$wfTransition = match ($decision) {
'upheld' => 'uphold_appeal',
'modified' => 'modify_appeal',
'cancelled' => 'cancel_appeal',
};
try {
WorkflowEngine::transitionByEntity('fines', (int) $id, $wfTransition);
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for fine {$wfTransition}", ['fine_id' => (int) $id, 'error' => $e->getMessage()]);
}
return $this->redirect('/fines')->withSuccess('تم البت في التظلم: ' . match($decision) { 'upheld' => 'تأييد العقوبة', 'modified' => 'تعديل العقوبة', 'cancelled' => 'إلغاء العقوبة' }); return $this->redirect('/fines')->withSuccess('تم البت في التظلم: ' . match($decision) { 'upheld' => 'تأييد العقوبة', 'modified' => 'تعديل العقوبة', 'cancelled' => 'إلغاء العقوبة' });
} }
...@@ -211,6 +246,12 @@ class FineController extends Controller ...@@ -211,6 +246,12 @@ class FineController extends Controller
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]); ], '`id` = ?', [(int) $id]);
try {
WorkflowEngine::transitionByEntity('fines', (int) $id, 'waive');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for fine waive", ['fine_id' => (int) $id, 'error' => $e->getMessage()]);
}
return $this->redirect('/fines')->withSuccess('تم الإعفاء من الغرامة'); return $this->redirect('/fines')->withSuccess('تم الإعفاء من الغرامة');
} }
} }
\ No newline at end of file
...@@ -8,7 +8,9 @@ use App\Core\Request; ...@@ -8,7 +8,9 @@ use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Core\EventBus; use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Fines\Models\Violation; use App\Modules\Fines\Models\Violation;
use App\Modules\Workflow\Services\WorkflowEngine;
class ViolationController extends Controller class ViolationController extends Controller
{ {
...@@ -60,6 +62,14 @@ class ViolationController extends Controller ...@@ -60,6 +62,14 @@ class ViolationController extends Controller
'status' => 'reported', 'status' => 'reported',
]); ]);
if (WorkflowEngine::hasDefinition('violation_penalty')) {
try {
WorkflowEngine::createInstance('violation_penalty', 'violations', (int) $violation->id);
} catch (\Throwable $e) {
Logger::warning("Workflow instance creation failed for violation", ['violation_id' => (int) $violation->id, 'error' => $e->getMessage()]);
}
}
EventBus::dispatch('violation.reported', ['violation_id' => (int) $violation->id, 'member_id' => (int) $memberId]); EventBus::dispatch('violation.reported', ['violation_id' => (int) $violation->id, 'member_id' => (int) $memberId]);
return $this->redirect('/violations')->withSuccess('تم تسجيل المخالفة'); return $this->redirect('/violations')->withSuccess('تم تسجيل المخالفة');
......
<?php
declare(strict_types=1);
namespace App\Modules\Forms\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Forms\Models\FormSchema;
use App\Modules\Forms\Models\FormSubmission;
/**
* Bridge layer for modules to render and validate schema-driven forms.
*
* Usage in a controller:
* $html = FormBridge::render('NEW_MEMBERSHIP', $prefillData);
* $result = FormBridge::validate('NEW_MEMBERSHIP', $request->all());
* $submission = FormBridge::submit('NEW_MEMBERSHIP', $data, $memberId);
*/
final class FormBridge
{
public static function render(string $formCode, ?array $prefillData = null, ?array $errors = null, bool $readOnly = false): string
{
$schema = FormSchema::findByCode($formCode);
if (!$schema) {
Logger::warning("FormBridge: schema not found", ['form_code' => $formCode]);
return '';
}
return FormRenderer::render($schema, $prefillData, $errors, $readOnly);
}
public static function validate(string $formCode, array $data): array
{
$schema = FormSchema::findByCode($formCode);
if (!$schema) {
return ['errors' => ['_form' => ['النموذج غير موجود']], 'validated' => [], 'passes' => false];
}
unset($data['_csrf_token']);
$result = FormValidator::validate($schema, $data);
return [
'errors' => $result['errors'] ?? [],
'validated' => $result['validated'] ?? $data,
'passes' => empty($result['errors']),
];
}
public static function submit(string $formCode, array $data, ?int $memberId = null, ?string $notes = null): array
{
$schema = FormSchema::findByCode($formCode);
if (!$schema) {
return ['success' => false, 'error' => 'النموذج غير موجود'];
}
unset($data['_csrf_token']);
$validation = FormValidator::validate($schema, $data);
if (!empty($validation['errors'])) {
return ['success' => false, 'errors' => $validation['errors']];
}
$employee = App::getInstance()->currentEmployee();
$formNumber = FormSubmission::generateFormNumber($formCode);
$expiresAt = null;
if ($schema->validity_days) {
$expiresAt = date('Y-m-d H:i:s', time() + ((int) $schema->validity_days * 86400));
}
$submission = FormSubmission::create([
'form_schema_id' => (int) $schema->id,
'schema_version' => (int) $schema->version,
'form_number' => $formNumber,
'submitted_data_json' => json_encode($data, JSON_UNESCAPED_UNICODE),
'status' => 'submitted',
'submitted_by_employee_id' => $employee ? (int) $employee->id : null,
'member_id' => $memberId,
'expires_at' => $expiresAt,
'notes' => $notes,
]);
EventBus::dispatch('form.submitted', [
'submission_id' => (int) $submission->id,
'form_code' => $formCode,
'form_number' => $formNumber,
'member_id' => $memberId,
'data' => $data,
]);
return [
'success' => true,
'submission_id' => (int) $submission->id,
'form_number' => $formNumber,
];
}
public static function exists(string $formCode): bool
{
return FormSchema::findByCode($formCode) !== null;
}
public static function getFee(string $formCode): string
{
$schema = FormSchema::findByCode($formCode);
if (!$schema || !$schema->form_fee) {
return '0.00';
}
return (string) $schema->form_fee;
}
}
...@@ -11,6 +11,7 @@ use App\Core\Logger; ...@@ -11,6 +11,7 @@ use App\Core\Logger;
use App\Modules\HR\Models\HrDisciplinaryAction; use App\Modules\HR\Models\HrDisciplinaryAction;
use App\Modules\HR\Models\HrEmployeeProfile; use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Services\DisciplinaryService; use App\Modules\HR\Services\DisciplinaryService;
use App\Modules\Workflow\Services\WorkflowEngine;
class DisciplinaryController extends Controller class DisciplinaryController extends Controller
{ {
...@@ -61,6 +62,14 @@ class DisciplinaryController extends Controller ...@@ -61,6 +62,14 @@ class DisciplinaryController extends Controller
$actionId = $db->insert('hr_disciplinary_actions', $data); $actionId = $db->insert('hr_disciplinary_actions', $data);
if (WorkflowEngine::hasDefinition('hr_disciplinary')) {
try {
WorkflowEngine::createInstance('hr_disciplinary', 'hr_disciplinary_actions', $actionId);
} catch (\Throwable $e) {
Logger::warning("Workflow instance creation failed for disciplinary", ['action_id' => $actionId, 'error' => $e->getMessage()]);
}
}
Logger::info("Disciplinary action created", ['action_id' => $actionId, 'profile_id' => $data['employee_profile_id']]); Logger::info("Disciplinary action created", ['action_id' => $actionId, 'profile_id' => $data['employee_profile_id']]);
return $this->redirect('/hr/disciplinary/' . $actionId)->withSuccess('تم إنشاء الإجراء التأديبي بنجاح'); return $this->redirect('/hr/disciplinary/' . $actionId)->withSuccess('تم إنشاء الإجراء التأديبي بنجاح');
...@@ -193,6 +202,12 @@ class DisciplinaryController extends Controller ...@@ -193,6 +202,12 @@ class DisciplinaryController extends Controller
$db->update('hr_disciplinary_actions', $updateData, '`id` = ?', [(int) $id]); $db->update('hr_disciplinary_actions', $updateData, '`id` = ?', [(int) $id]);
try {
WorkflowEngine::transitionByEntity('hr_disciplinary_actions', (int) $id, 'decide');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for disciplinary decide", ['action_id' => (int) $id, 'error' => $e->getMessage()]);
}
Logger::info("Disciplinary decision made", [ Logger::info("Disciplinary decision made", [
'action_id' => (int) $id, 'action_id' => (int) $id,
'penalty_type' => $penaltyType, 'penalty_type' => $penaltyType,
...@@ -229,6 +244,12 @@ class DisciplinaryController extends Controller ...@@ -229,6 +244,12 @@ class DisciplinaryController extends Controller
'updated_by' => $employee ? (int) $employee->id : null, 'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [(int) $id]); ], '`id` = ?', [(int) $id]);
try {
WorkflowEngine::transitionByEntity('hr_disciplinary_actions', (int) $id, 'appeal');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for disciplinary appeal", ['action_id' => (int) $id, 'error' => $e->getMessage()]);
}
return $this->redirect('/hr/disciplinary/' . $id)->withSuccess('تم تقديم التظلم بنجاح'); return $this->redirect('/hr/disciplinary/' . $id)->withSuccess('تم تقديم التظلم بنجاح');
} }
......
...@@ -11,6 +11,7 @@ use App\Core\Logger; ...@@ -11,6 +11,7 @@ use App\Core\Logger;
use App\Modules\HR\Models\HrEndOfService; use App\Modules\HR\Models\HrEndOfService;
use App\Modules\HR\Models\HrEmployeeProfile; use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Services\EndOfServiceService; use App\Modules\HR\Services\EndOfServiceService;
use App\Modules\Workflow\Services\WorkflowEngine;
class EndOfServiceController extends Controller class EndOfServiceController extends Controller
{ {
...@@ -86,6 +87,10 @@ class EndOfServiceController extends Controller ...@@ -86,6 +87,10 @@ class EndOfServiceController extends Controller
'created_by' => $employee ? (int) $employee->id : null, 'created_by' => $employee ? (int) $employee->id : null,
]); ]);
if (WorkflowEngine::hasDefinition('hr_end_of_service')) {
WorkflowEngine::createInstance('hr_end_of_service', 'hr_end_of_service', $recordId);
}
return $this->redirect('/hr/end-of-service/' . $recordId)->withSuccess('تم إنشاء سجل نهاية الخدمة بنجاح'); return $this->redirect('/hr/end-of-service/' . $recordId)->withSuccess('تم إنشاء سجل نهاية الخدمة بنجاح');
} }
...@@ -151,6 +156,12 @@ class EndOfServiceController extends Controller ...@@ -151,6 +156,12 @@ class EndOfServiceController extends Controller
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]); ], '`id` = ?', [(int) $id]);
try {
WorkflowEngine::transitionByEntity('hr_end_of_service', (int) $id, 'calculate');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for EOS calculate", ['id' => (int) $id, 'error' => $e->getMessage()]);
}
Logger::info("End of service calculated", ['record_id' => (int) $id, 'net_settlement' => $result['net_settlement']]); Logger::info("End of service calculated", ['record_id' => (int) $id, 'net_settlement' => $result['net_settlement']]);
return $this->redirect('/hr/end-of-service/' . $id)->withSuccess( return $this->redirect('/hr/end-of-service/' . $id)->withSuccess(
...@@ -188,6 +199,12 @@ class EndOfServiceController extends Controller ...@@ -188,6 +199,12 @@ class EndOfServiceController extends Controller
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ? AND `is_archived` = 0', [(int) $record->employee_profile_id]); ], '`id` = ? AND `is_archived` = 0', [(int) $record->employee_profile_id]);
try {
WorkflowEngine::transitionByEntity('hr_end_of_service', (int) $id, 'approve');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for EOS approve", ['id' => (int) $id, 'error' => $e->getMessage()]);
}
Logger::info("End of service approved", ['record_id' => (int) $id]); Logger::info("End of service approved", ['record_id' => (int) $id]);
return $this->redirect('/hr/end-of-service/' . $id)->withSuccess('تم اعتماد نهاية الخدمة'); return $this->redirect('/hr/end-of-service/' . $id)->withSuccess('تم اعتماد نهاية الخدمة');
...@@ -215,6 +232,12 @@ class EndOfServiceController extends Controller ...@@ -215,6 +232,12 @@ class EndOfServiceController extends Controller
'updated_by' => $employee ? (int) $employee->id : null, 'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [(int) $id]); ], '`id` = ?', [(int) $id]);
try {
WorkflowEngine::transitionByEntity('hr_end_of_service', (int) $id, 'mark_paid');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for EOS mark_paid", ['id' => (int) $id, 'error' => $e->getMessage()]);
}
Logger::info("End of service paid", ['record_id' => (int) $id, 'payment_date' => $paymentDate]); Logger::info("End of service paid", ['record_id' => (int) $id, 'payment_date' => $paymentDate]);
return $this->redirect('/hr/end-of-service/' . $id)->withSuccess('تم صرف مستحقات نهاية الخدمة'); return $this->redirect('/hr/end-of-service/' . $id)->withSuccess('تم صرف مستحقات نهاية الخدمة');
......
...@@ -13,6 +13,8 @@ use App\Modules\HR\Models\HrLeaveBalance; ...@@ -13,6 +13,8 @@ use App\Modules\HR\Models\HrLeaveBalance;
use App\Modules\HR\Models\HrEmployeeProfile; use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Models\HrDepartment; use App\Modules\HR\Models\HrDepartment;
use App\Modules\HR\Services\LeaveService; use App\Modules\HR\Services\LeaveService;
use App\Modules\Workflow\Services\WorkflowEngine;
use App\Core\Logger;
class LeaveController extends Controller class LeaveController extends Controller
{ {
...@@ -177,6 +179,12 @@ class LeaveController extends Controller ...@@ -177,6 +179,12 @@ class LeaveController extends Controller
'updated_by' => $employee ? (int) $employee->id : null, 'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [(int) $id]); ], '`id` = ?', [(int) $id]);
try {
WorkflowEngine::transitionByEntity('hr_leave_requests', (int) $id, 'cancel');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for leave cancel", ['request_id' => (int) $id, 'error' => $e->getMessage()]);
}
return $this->redirect('/hr/leaves/' . $id)->withSuccess('تم إلغاء طلب الإجازة'); return $this->redirect('/hr/leaves/' . $id)->withSuccess('تم إلغاء طلب الإجازة');
} }
......
...@@ -7,6 +7,7 @@ use App\Core\App; ...@@ -7,6 +7,7 @@ use App\Core\App;
use App\Core\EventBus; use App\Core\EventBus;
use App\Core\Logger; use App\Core\Logger;
use App\Modules\HR\Models\HrContract; use App\Modules\HR\Models\HrContract;
use App\Modules\Workflow\Services\WorkflowEngine;
/** /**
* Contract Management Service * Contract Management Service
...@@ -69,6 +70,10 @@ final class ContractService ...@@ -69,6 +70,10 @@ final class ContractService
$db->commit(); $db->commit();
if (WorkflowEngine::hasDefinition('hr_contract_approval')) {
WorkflowEngine::createInstance('hr_contract_approval', 'hr_contracts', $contractId);
}
Logger::info("Contract created", ['contract_id' => $contractId, 'profile_id' => $profileId]); Logger::info("Contract created", ['contract_id' => $contractId, 'profile_id' => $profileId]);
return ['success' => true, 'contract_id' => $contractId, 'contract_number' => $contractNumber]; return ['success' => true, 'contract_id' => $contractId, 'contract_number' => $contractNumber];
...@@ -122,6 +127,12 @@ final class ContractService ...@@ -122,6 +127,12 @@ final class ContractService
$db->commit(); $db->commit();
try {
WorkflowEngine::transitionByEntity('hr_contracts', $contractId, 'renew');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for contract renew", ['contract_id' => $contractId, 'error' => $e->getMessage()]);
}
EventBus::dispatch('hr.contract.renewed', [ EventBus::dispatch('hr.contract.renewed', [
'old_contract_id' => $contractId, 'old_contract_id' => $contractId,
'new_contract_id' => $newContractId, 'new_contract_id' => $newContractId,
...@@ -153,6 +164,12 @@ final class ContractService ...@@ -153,6 +164,12 @@ final class ContractService
'updated_by' => $employee ? (int) $employee->id : null, 'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [$contractId]); ], '`id` = ?', [$contractId]);
try {
WorkflowEngine::transitionByEntity('hr_contracts', $contractId, 'terminate');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for contract terminate", ['contract_id' => $contractId, 'error' => $e->getMessage()]);
}
EventBus::dispatch('hr.contract.terminated', [ EventBus::dispatch('hr.contract.terminated', [
'contract_id' => $contractId, 'contract_id' => $contractId,
'employee_profile_id'=> (int) $contract['employee_profile_id'], 'employee_profile_id'=> (int) $contract['employee_profile_id'],
......
...@@ -10,6 +10,7 @@ use App\Modules\HR\Models\HrLeaveBalance; ...@@ -10,6 +10,7 @@ use App\Modules\HR\Models\HrLeaveBalance;
use App\Modules\HR\Models\HrLeaveType; use App\Modules\HR\Models\HrLeaveType;
use App\Modules\HR\Models\HrLeaveRequest; use App\Modules\HR\Models\HrLeaveRequest;
use App\Modules\HR\Models\HrEmployeeProfile; use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\Workflow\Services\WorkflowEngine;
/** /**
* Leave Entitlement & Management Service * Leave Entitlement & Management Service
...@@ -139,6 +140,10 @@ final class LeaveService ...@@ -139,6 +140,10 @@ final class LeaveService
$db->commit(); $db->commit();
if (WorkflowEngine::hasDefinition('hr_leave_approval')) {
WorkflowEngine::createInstance('hr_leave_approval', 'hr_leave_requests', $requestId);
}
EventBus::dispatch('hr.leave.submitted', [ EventBus::dispatch('hr.leave.submitted', [
'request_id' => $requestId, 'request_id' => $requestId,
'employee_id' => $profile->employee_id, 'employee_id' => $profile->employee_id,
...@@ -187,6 +192,12 @@ final class LeaveService ...@@ -187,6 +192,12 @@ final class LeaveService
$db->commit(); $db->commit();
try {
WorkflowEngine::transitionByEntity('hr_leave_requests', $requestId, 'approve');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for leave approve", ['request_id' => $requestId, 'error' => $e->getMessage()]);
}
$profile = HrEmployeeProfile::find((int) $request['employee_profile_id']); $profile = HrEmployeeProfile::find((int) $request['employee_profile_id']);
EventBus::dispatch('hr.leave.approved', [ EventBus::dispatch('hr.leave.approved', [
'request_id' => $requestId, 'request_id' => $requestId,
...@@ -239,6 +250,12 @@ final class LeaveService ...@@ -239,6 +250,12 @@ final class LeaveService
$db->commit(); $db->commit();
try {
WorkflowEngine::transitionByEntity('hr_leave_requests', $requestId, 'reject');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for leave reject", ['request_id' => $requestId, 'error' => $e->getMessage()]);
}
$profile = HrEmployeeProfile::find((int) $request['employee_profile_id']); $profile = HrEmployeeProfile::find((int) $request['employee_profile_id']);
EventBus::dispatch('hr.leave.rejected', [ EventBus::dispatch('hr.leave.rejected', [
'request_id' => $requestId, 'request_id' => $requestId,
......
...@@ -9,6 +9,7 @@ use App\Core\Logger; ...@@ -9,6 +9,7 @@ use App\Core\Logger;
use App\Modules\HR\Models\HrEmployeeLoan; use App\Modules\HR\Models\HrEmployeeLoan;
use App\Modules\HR\Models\HrLoanInstallment; use App\Modules\HR\Models\HrLoanInstallment;
use App\Modules\HR\Services\HrNumberGenerator; use App\Modules\HR\Services\HrNumberGenerator;
use App\Modules\Workflow\Services\WorkflowEngine;
/** /**
* Loan & Salary Advance Service * Loan & Salary Advance Service
...@@ -86,6 +87,10 @@ final class LoanService ...@@ -86,6 +87,10 @@ final class LoanService
$db->commit(); $db->commit();
if (WorkflowEngine::hasDefinition('hr_loan_approval')) {
WorkflowEngine::createInstance('hr_loan_approval', 'hr_employee_loans', $loanId);
}
Logger::info("Loan created", ['loan_id' => $loanId, 'profile_id' => $profileId, 'amount' => $loanAmount]); Logger::info("Loan created", ['loan_id' => $loanId, 'profile_id' => $profileId, 'amount' => $loanAmount]);
return ['success' => true, 'loan_id' => $loanId]; return ['success' => true, 'loan_id' => $loanId];
...@@ -113,6 +118,12 @@ final class LoanService ...@@ -113,6 +118,12 @@ final class LoanService
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$loanId]); ], '`id` = ?', [$loanId]);
try {
WorkflowEngine::transitionByEntity('hr_employee_loans', $loanId, 'approve');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for loan approve", ['loan_id' => $loanId, 'error' => $e->getMessage()]);
}
EventBus::dispatch('hr.loan.approved', [ EventBus::dispatch('hr.loan.approved', [
'loan_id' => $loanId, 'loan_id' => $loanId,
'employee_id' => (int) $loan['employee_profile_id'], 'employee_id' => (int) $loan['employee_profile_id'],
...@@ -137,6 +148,12 @@ final class LoanService ...@@ -137,6 +148,12 @@ final class LoanService
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$loanId]); ], '`id` = ?', [$loanId]);
try {
WorkflowEngine::transitionByEntity('hr_employee_loans', $loanId, 'disburse');
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for loan disburse", ['loan_id' => $loanId, 'error' => $e->getMessage()]);
}
return ['success' => true]; return ['success' => true];
} }
...@@ -175,6 +192,14 @@ final class LoanService ...@@ -175,6 +192,14 @@ final class LoanService
], '`id` = ?', [$loanId]); ], '`id` = ?', [$loanId]);
$db->commit(); $db->commit();
$transitionName = ($newStatus === 'settled') ? 'settle' : 'deduct';
try {
WorkflowEngine::transitionByEntity('hr_employee_loans', $loanId, $transitionName);
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for loan {$transitionName}", ['loan_id' => $loanId, 'error' => $e->getMessage()]);
}
return ['success' => true, 'remaining' => $newRemaining, 'status' => $newStatus]; return ['success' => true, 'remaining' => $newRemaining, 'status' => $newStatus];
} catch (\Throwable $e) { } catch (\Throwable $e) {
......
...@@ -8,6 +8,7 @@ use App\Core\Request; ...@@ -8,6 +8,7 @@ use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Core\EventBus; use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Interviews\Models\Interview; use App\Modules\Interviews\Models\Interview;
use App\Modules\Workflow\Services\WorkflowEngine; use App\Modules\Workflow\Services\WorkflowEngine;
...@@ -80,12 +81,11 @@ class InterviewController extends Controller ...@@ -80,12 +81,11 @@ class InterviewController extends Controller
'status' => 'scheduled', 'status' => 'scheduled',
]); ]);
// Try to transition workflow
if ($member['workflow_instance_id']) { if ($member['workflow_instance_id']) {
try { try {
WorkflowEngine::transition((int) $member['workflow_instance_id'], 'schedule_interview', 'تم تحديد موعد المقابلة'); WorkflowEngine::transition((int) $member['workflow_instance_id'], 'schedule_interview', 'تم تحديد موعد المقابلة');
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Non-blocking — workflow might not support this transition from current state Logger::warning("Workflow transition failed for interview schedule", ['member_id' => (int) $memberId, 'error' => $e->getMessage()]);
} }
} }
...@@ -181,19 +181,16 @@ class InterviewController extends Controller ...@@ -181,19 +181,16 @@ class InterviewController extends Controller
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $interview['member_id']]); ], '`id` = ?', [(int) $interview['member_id']]);
// Transition workflow
if ($interview['workflow_instance_id']) { if ($interview['workflow_instance_id']) {
try {
$transitionName = $decision === 'accepted' ? 'accept' : 'reject'; $transitionName = $decision === 'accepted' ? 'accept' : 'reject';
try {
WorkflowEngine::transition((int) $interview['workflow_instance_id'], $transitionName, $decisionNotes ?: 'قرار مجلس الأمناء'); WorkflowEngine::transition((int) $interview['workflow_instance_id'], $transitionName, $decisionNotes ?: 'قرار مجلس الأمناء');
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Try alternate transition names Logger::warning("Workflow transition failed for interview decide", [
try { 'interview_id' => (int) $id,
$altName = $decision === 'accepted' ? 'payment_completed' : 'reject_from_review'; 'transition' => $transitionName,
WorkflowEngine::transition((int) $interview['workflow_instance_id'], $altName, $decisionNotes ?: 'قرار مجلس الأمناء'); 'error' => $e->getMessage(),
} catch (\Throwable $e2) { ]);
// Non-blocking
}
} }
} }
......
...@@ -17,6 +17,9 @@ use App\Modules\Payments\Services\PaymentService; ...@@ -17,6 +17,9 @@ use App\Modules\Payments\Services\PaymentService;
use App\Modules\Cashier\Services\PaymentRequestService; use App\Modules\Cashier\Services\PaymentRequestService;
use App\Modules\Rules\Services\RuleEngine; use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Pricing\Models\SpecialDiscount; use App\Modules\Pricing\Models\SpecialDiscount;
use App\Modules\Workflow\Services\WorkflowEngine;
use App\Modules\Forms\Services\FormBridge;
use App\Core\Logger;
class MemberController extends Controller class MemberController extends Controller
{ {
...@@ -100,8 +103,9 @@ class MemberController extends Controller ...@@ -100,8 +103,9 @@ class MemberController extends Controller
if ($dob) { $age = age_from_dob($dob); $ageYears = $age['years']; $ageMonths = $age['months']; } if ($dob) { $age = age_from_dob($dob); $ageYears = $age['years']; $ageMonths = $age['months']; }
$idType = 'passport'; $idType = 'passport';
} }
if ($ageYears !== null && $ageYears < 21) { $workingMinAge = (int) (RuleEngine::getValue('WORKING_MEMBER_MIN_AGE', 'value') ?? 21);
$errors[] = 'الحد الأدنى لسن العضوية العاملة 21 سنة (السن الحالي: ' . $ageYears . ')'; if ($ageYears !== null && $ageYears < $workingMinAge) {
$errors[] = 'الحد الأدنى لسن العضوية العاملة ' . $workingMinAge . ' سنة (السن الحالي: ' . $ageYears . ')';
} }
if (!empty($errors)) { $session = App::getInstance()->session(); $session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors)); $session->flash('_old_input', $request->all()); return $this->redirect('/members/create'); } if (!empty($errors)) { $session = App::getInstance()->session(); $session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors)); $session->flash('_old_input', $request->all()); return $this->redirect('/members/create'); }
...@@ -110,6 +114,14 @@ class MemberController extends Controller ...@@ -110,6 +114,14 @@ class MemberController extends Controller
if (!$formNumber) return $this->redirect('/members/create')->withError('يجب تحديد رقم بداية الاستمارات'); if (!$formNumber) return $this->redirect('/members/create')->withError('يجب تحديد رقم بداية الاستمارات');
$member = Member::create(['full_name_ar' => $fullNameAr, 'national_id' => $nationalId ?: null, 'passport_number' => $request->post('passport_number') ?: null, 'id_type' => $idType, 'date_of_birth' => $dob, 'age_years' => $ageYears, 'age_months' => $ageMonths, 'gender' => $gender, 'governorate_code' => $govCode, 'phone_mobile' => $phoneMobile, 'branch_id' => $branchId, 'nationality' => 'مصري', 'form_number' => (string) $formNumber, 'form_date' => date('Y-m-d'), 'status' => 'potential', 'membership_type' => 'working', 'member_category' => 'working_member']); $member = Member::create(['full_name_ar' => $fullNameAr, 'national_id' => $nationalId ?: null, 'passport_number' => $request->post('passport_number') ?: null, 'id_type' => $idType, 'date_of_birth' => $dob, 'age_years' => $ageYears, 'age_months' => $ageMonths, 'gender' => $gender, 'governorate_code' => $govCode, 'phone_mobile' => $phoneMobile, 'branch_id' => $branchId, 'nationality' => 'مصري', 'form_number' => (string) $formNumber, 'form_date' => date('Y-m-d'), 'status' => 'potential', 'membership_type' => 'working', 'member_category' => 'working_member']);
if (WorkflowEngine::hasDefinition('new_membership')) {
try {
$wfInstance = WorkflowEngine::createInstance('new_membership', 'members', (int) $member->id);
$member->update(['workflow_instance_id' => (int) $wfInstance->id]);
} catch (\Throwable $e) {
Logger::warning("Workflow instance creation failed for new member", ['member_id' => (int) $member->id, 'error' => $e->getMessage()]);
}
}
EventBus::dispatch('member.created', ['member_id' => (int) $member->id, 'form_number' => (string) $formNumber]); EventBus::dispatch('member.created', ['member_id' => (int) $member->id, 'form_number' => (string) $formNumber]);
return $this->redirect('/members/' . $member->id)->withSuccess('تم تسجيل العضو — استمارة رقم: ' . $formNumber); return $this->redirect('/members/' . $member->id)->withSuccess('تم تسجيل العضو — استمارة رقم: ' . $formNumber);
} }
...@@ -303,6 +315,9 @@ class MemberController extends Controller ...@@ -303,6 +315,9 @@ class MemberController extends Controller
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود'); if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
$formFeePaid = $db->selectOne("SELECT id FROM payments WHERE member_id = ? AND payment_type = 'form_fee' AND is_voided = 0 LIMIT 1", [(int) $id]); $formFeePaid = $db->selectOne("SELECT id FROM payments WHERE member_id = ? AND payment_type = 'form_fee' AND is_voided = 0 LIMIT 1", [(int) $id]);
if (!$formFeePaid) return $this->redirect('/members/' . $id)->withError('⚠ يجب دفع رسوم الاستمارة أولاً'); if (!$formFeePaid) return $this->redirect('/members/' . $id)->withError('⚠ يجب دفع رسوم الاستمارة أولاً');
$schemaHtml = FormBridge::render('NEW_MEMBERSHIP', $member->toArray());
return $this->view('Members.Views.fill-form', [ return $this->view('Members.Views.fill-form', [
'member' => $member, 'member' => $member,
'branches' => $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar"), 'branches' => $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar"),
...@@ -310,6 +325,7 @@ class MemberController extends Controller ...@@ -310,6 +325,7 @@ class MemberController extends Controller
'governorates' => $db->select("SELECT code, name_ar FROM governorates WHERE is_active = 1 ORDER BY name_ar"), 'governorates' => $db->select("SELECT code, name_ar FROM governorates WHERE is_active = 1 ORDER BY name_ar"),
'countries' => $db->select("SELECT nationality_ar FROM countries WHERE is_active = 1 ORDER BY name_ar"), 'countries' => $db->select("SELECT nationality_ar FROM countries WHERE is_active = 1 ORDER BY name_ar"),
'specialDiscounts' => SpecialDiscount::allActive(), 'specialDiscounts' => SpecialDiscount::allActive(),
'schemaHtml' => $schemaHtml,
]); ]);
} }
...@@ -345,6 +361,11 @@ class MemberController extends Controller ...@@ -345,6 +361,11 @@ class MemberController extends Controller
if ($member->status === 'potential') $update['status'] = 'under_review'; if ($member->status === 'potential') $update['status'] = 'under_review';
if (!empty($update)) $member->update($update); if (!empty($update)) $member->update($update);
if (FormBridge::exists('NEW_MEMBERSHIP')) {
FormBridge::submit('NEW_MEMBERSHIP', $data, (int) $id, 'ملء استمارة عضوية');
}
return $this->redirect('/members/' . $id)->withSuccess('تم ملء الاستمارة'); return $this->redirect('/members/' . $id)->withSuccess('تم ملء الاستمارة');
} }
...@@ -482,6 +503,11 @@ class MemberController extends Controller ...@@ -482,6 +503,11 @@ class MemberController extends Controller
$newStatus = trim((string) $request->post('status', '')); $newStatus = trim((string) $request->post('status', ''));
if (!in_array($newStatus, array_keys(Member::getStatusOptions()))) return $this->redirect('/members/' . $id)->withError('حالة غير صالحة'); if (!in_array($newStatus, array_keys(Member::getStatusOptions()))) return $this->redirect('/members/' . $id)->withError('حالة غير صالحة');
$old = $member->status; $member->update(['status' => $newStatus]); $old = $member->status; $member->update(['status' => $newStatus]);
try {
WorkflowEngine::transitionByEntity('members', (int) $id, $newStatus);
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for member status change", ['member_id' => (int) $id, 'transition' => $newStatus, 'error' => $e->getMessage()]);
}
EventBus::dispatch('member.status_changed', ['member_id' => (int) $id, 'old' => $old, 'new' => $newStatus]); EventBus::dispatch('member.status_changed', ['member_id' => (int) $id, 'old' => $old, 'new' => $newStatus]);
return $this->redirect('/members/' . $id)->withSuccess('تم تغيير الحالة'); return $this->redirect('/members/' . $id)->withSuccess('تم تغيير الحالة');
} }
......
...@@ -43,7 +43,10 @@ final class FormFeeService ...@@ -43,7 +43,10 @@ final class FormFeeService
} }
$feeData = RuleEngine::get('FORM_ADDITION_FEE'); $feeData = RuleEngine::get('FORM_ADDITION_FEE');
return $feeData['amount'] ?? ServicePrice::getPrice('SVC_ADDITION_FORM', '570.00'); if ($feeData && isset($feeData['amount'])) {
return $feeData['amount'];
}
return ServicePrice::getPrice('SVC_ADDITION_FORM', '570.00');
} }
/** /**
...@@ -55,7 +58,7 @@ final class FormFeeService ...@@ -55,7 +58,7 @@ final class FormFeeService
*/ */
public static function isSpouseFreeSlot(int $memberId, int $spouseOrder): bool public static function isSpouseFreeSlot(int $memberId, int $spouseOrder): bool
{ {
$maxFree = (int) (RuleEngine::getValue('INITIAL_FREE_SPOUSES_COUNT', 'value') ?? 1); $maxFree = (int) RuleEngine::requireValue('INITIAL_FREE_SPOUSES_COUNT', 'value');
return $spouseOrder <= $maxFree; return $spouseOrder <= $maxFree;
} }
...@@ -74,8 +77,8 @@ final class FormFeeService ...@@ -74,8 +77,8 @@ final class FormFeeService
*/ */
public static function isChildFreeSlot(int $memberId, int $childOrder, int $childAge): bool public static function isChildFreeSlot(int $memberId, int $childOrder, int $childAge): bool
{ {
$maxFreeCount = (int) (RuleEngine::getValue('INITIAL_FREE_CHILDREN_COUNT', 'value') ?? 2); $maxFreeCount = (int) RuleEngine::requireValue('INITIAL_FREE_CHILDREN_COUNT', 'value');
$maxFreeAge = (int) (RuleEngine::getValue('CHILD_INCLUDED_MAX_AGE', 'value') ?? 18); $maxFreeAge = (int) RuleEngine::requireValue('CHILD_INCLUDED_MAX_AGE', 'value');
return $childOrder <= $maxFreeCount && $childAge < $maxFreeAge; return $childOrder <= $maxFreeCount && $childAge < $maxFreeAge;
} }
......
...@@ -20,6 +20,19 @@ ...@@ -20,6 +20,19 @@
<form method="POST" action="/members/<?= (int) $member->id ?>/fill-form"> <form method="POST" action="/members/<?= (int) $member->id ?>/fill-form">
<?= csrf_field() ?> <?= csrf_field() ?>
<?php if (!empty($schemaHtml)): ?>
<!-- Schema-driven form (from /forms/builder) -->
<?= $schemaHtml ?>
<div style="display:flex;gap:10px;margin-top:20px;">
<button type="submit" class="btn btn-primary" style="padding:12px 30px;font-size:16px;">✓ حفظ الاستمارة</button>
<a href="/members/<?= (int) $member->id ?>" class="btn btn-outline" style="padding:12px 20px;">إلغاء</a>
</div>
</form>
<?php $__template->endSection(); ?>
<?php return; ?>
<?php endif; ?>
<!-- Section 1: Personal Details --> <!-- Section 1: Personal Details -->
<div class="card" style="margin-bottom:20px;"> <div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
......
...@@ -339,15 +339,9 @@ if ($formFilled && !$bill['membership_paid'] && !in_array($member->status, ['act ...@@ -339,15 +339,9 @@ if ($formFilled && !$bill['membership_paid'] && !in_array($member->status, ['act
foreach ($bill['items'] as $item) { foreach ($bill['items'] as $item) {
if (in_array($item['type'] ?? '', ['spouse_fee', 'child_fee', 'temp_fee'], true) && !$item['included'] && !$item['paid']) { if (in_array($item['type'] ?? '', ['spouse_fee', 'child_fee', 'temp_fee'], true) && !$item['included'] && !$item['paid']) {
$inQueue = !empty($item['in_queue']) || !empty($pendingMembership); $inQueue = !empty($item['in_queue']) || !empty($pendingMembership);
$statusText = $inQueue ? ' — في انتظار الخزينة' : ''; $statusText = $inQueue ? ' — في انتظار الخزينة' : ' — سيتم تحصيلها ضمن الفاتورة المجمعة';
$statusColor = $inQueue ? '#D97706' : '#DC2626'; $statusColor = $inQueue ? '#D97706' : '#DC2626';
$stepEntry = ['icon' => $inQueue ? '&#x1f4b3;' : '&#x26a0;', 'text' => ($inQueue ? '' : 'لم يتم سداد: ') . $item['label'] . $statusText, 'color' => $statusColor, 'done' => false]; $missingSteps[] = ['icon' => $inQueue ? '&#x1f4b3;' : '&#x26a0;', 'text' => $item['label'] . $statusText, 'color' => $statusColor, 'done' => false];
if (!$inQueue && empty($pendingMembership) && !empty($item['entity_type']) && !empty($item['entity_id']) && bccomp($item['amount'], '0.01', 2) >= 0) {
$stepEntry['entity_type'] = $item['entity_type'];
$stepEntry['entity_id'] = $item['entity_id'];
$stepEntry['amount'] = $item['amount'];
}
$missingSteps[] = $stepEntry;
} }
} }
...@@ -363,14 +357,6 @@ $hasIncomplete = !empty(array_filter($missingSteps, fn($s) => !$s['done'])); ...@@ -363,14 +357,6 @@ $hasIncomplete = !empty(array_filter($missingSteps, fn($s) => !$s['done']));
<div style="display:flex;align-items:center;gap:10px;padding:8px 0;<?= $step['done'] ? 'opacity:0.5;' : '' ?>border-bottom:1px solid #F9FAFB;"> <div style="display:flex;align-items:center;gap:10px;padding:8px 0;<?= $step['done'] ? 'opacity:0.5;' : '' ?>border-bottom:1px solid #F9FAFB;">
<span style="font-size:18px;"><?= $step['icon'] ?></span> <span style="font-size:18px;"><?= $step['icon'] ?></span>
<span style="flex:1;color:<?= $step['color'] ?>;font-weight:<?= $step['done'] ? '400' : '600' ?>;font-size:14px;<?= $step['done'] ? 'text-decoration:line-through;' : '' ?>"><?= e($step['text']) ?></span> <span style="flex:1;color:<?= $step['color'] ?>;font-weight:<?= $step['done'] ? '400' : '600' ?>;font-size:14px;<?= $step['done'] ? 'text-decoration:line-through;' : '' ?>"><?= e($step['text']) ?></span>
<?php if (!empty($step['entity_type'])): ?>
<form method="POST" action="/members/<?= (int) $member->id ?>/pay-addition" style="margin:0;">
<?= csrf_field() ?>
<input type="hidden" name="entity_type" value="<?= e($step['entity_type']) ?>">
<input type="hidden" name="entity_id" value="<?= (int) $step['entity_id'] ?>">
<button type="submit" class="btn btn-sm" style="background:#D97706;color:#fff;border:none;font-size:12px;padding:4px 12px;border-radius:6px;white-space:nowrap;" onclick="return confirm('إرسال رسوم <?= e(money($step['amount'])) ?> للخزينة؟')">📤 إرسال للخزينة</button>
</form>
<?php endif; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
......
...@@ -5,6 +5,7 @@ namespace App\Modules\PlayerAffairs\Models; ...@@ -5,6 +5,7 @@ namespace App\Modules\PlayerAffairs\Models;
use App\Core\Model; use App\Core\Model;
use App\Core\App; use App\Core\App;
use App\Modules\Rules\Services\RuleEngine;
class Player extends Model class Player extends Model
{ {
...@@ -176,7 +177,8 @@ class Player extends Model ...@@ -176,7 +177,8 @@ class Player extends Model
public function isMinor(): bool public function isMinor(): bool
{ {
$age = $this->getAge(); $age = $this->getAge();
return $age !== null && $age < 18; $minorAge = (int) (RuleEngine::getValue('MINOR_AGE_THRESHOLD', 'value') ?? 18);
return $age !== null && $age < $minorAge;
} }
/** /**
......
...@@ -38,16 +38,17 @@ final class PricingEngine ...@@ -38,16 +38,17 @@ final class PricingEngine
public static function calculateChildFee(string $membershipValue, int $childAge, int $childOrder): array public static function calculateChildFee(string $membershipValue, int $childAge, int $childOrder): array
{ {
$maxIncluded = (int) (RuleEngine::getValue('INITIAL_FREE_CHILDREN_COUNT', 'value') ?? RuleEngine::getValue('CHILD_INCLUDED_MAX_COUNT', 'value') ?? 2); $maxIncluded = (int) RuleEngine::requireValue('INITIAL_FREE_CHILDREN_COUNT', 'value');
$maxIncludedAge = (int) (RuleEngine::getValue('CHILD_INCLUDED_MAX_AGE', 'value') ?? 18); $maxIncludedAge = (int) RuleEngine::requireValue('CHILD_INCLUDED_MAX_AGE', 'value');
$maxChildAge = (int) (RuleEngine::getValue('CHILD_MAX_AGE', 'value') ?? 25);
if ($childAge < $maxIncludedAge && $childOrder <= $maxIncluded) { if ($childAge < $maxIncludedAge && $childOrder <= $maxIncluded) {
return ['fee' => '0.00', 'rule_applied' => 'included', 'percentage' => '0.00', 'classification' => 'included']; return ['fee' => '0.00', 'rule_applied' => 'included', 'percentage' => '0.00', 'classification' => 'included'];
} }
if ($childAge < $maxIncludedAge && $childOrder > $maxIncluded) { if ($childAge < $maxIncludedAge && $childOrder > $maxIncluded) {
$data = RuleEngine::get('CHILD_4TH_UNDER_18_FEE'); $data = RuleEngine::require('CHILD_4TH_UNDER_18_FEE');
$pct = $data['percentage'] ?? '5.00'; $pct = $data['percentage'];
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2); $fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['fee' => $fee, 'rule_applied' => 'CHILD_4TH_UNDER_18_FEE', 'percentage' => $pct, 'classification' => 'dependent_with_fee']; return ['fee' => $fee, 'rule_applied' => 'CHILD_4TH_UNDER_18_FEE', 'percentage' => $pct, 'classification' => 'dependent_with_fee'];
} }
...@@ -55,20 +56,20 @@ final class PricingEngine ...@@ -55,20 +56,20 @@ final class PricingEngine
$ruleMap = [18 => 'CHILD_FEE_AGE_18', 19 => 'CHILD_FEE_AGE_19', 20 => 'CHILD_FEE_AGE_20']; $ruleMap = [18 => 'CHILD_FEE_AGE_18', 19 => 'CHILD_FEE_AGE_19', 20 => 'CHILD_FEE_AGE_20'];
if (isset($ruleMap[$childAge])) { if (isset($ruleMap[$childAge])) {
$data = RuleEngine::get($ruleMap[$childAge]); $data = RuleEngine::require($ruleMap[$childAge]);
$pct = $data['percentage'] ?? '10.00'; $pct = $data['percentage'];
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2); $fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['fee' => $fee, 'rule_applied' => $ruleMap[$childAge], 'percentage' => $pct, 'classification' => $data['type'] ?? 'regular']; return ['fee' => $fee, 'rule_applied' => $ruleMap[$childAge], 'percentage' => $pct, 'classification' => $data['type'] ?? 'regular'];
} }
if ($childAge >= 21 && $childAge < 25) { if ($childAge >= 21 && $childAge < $maxChildAge) {
$data = RuleEngine::get('CHILD_FEE_AGE_21'); $data = RuleEngine::require('CHILD_FEE_AGE_21');
$pct = $data['percentage'] ?? '15.00'; $pct = $data['percentage'];
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2); $fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['fee' => $fee, 'rule_applied' => 'CHILD_FEE_AGE_21', 'percentage' => $pct, 'classification' => 'temporary']; 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']; return ['fee' => '0.00', 'rule_applied' => 'CHILD_AUTO_DELETE_AGE', 'percentage' => '0.00', 'classification' => 'not_accepted', 'error' => 'سن الابن/الابنة يتجاوز الحد المسموح (' . $maxChildAge . ' سنة)'];
} }
public static function calculateSpouseFee(string $membershipValue, int $spouseOrder, string $nationality, string $marriageDate, string $membershipAcquisitionDate, string $memberType): array public static function calculateSpouseFee(string $membershipValue, int $spouseOrder, string $nationality, string $marriageDate, string $membershipAcquisitionDate, string $memberType): array
...@@ -78,29 +79,25 @@ final class PricingEngine ...@@ -78,29 +79,25 @@ final class PricingEngine
} }
if (strtolower($nationality) !== 'مصري' && strtolower($nationality) !== 'egyptian' && strtolower($nationality) !== 'egy') { if (strtolower($nationality) !== 'مصري' && strtolower($nationality) !== 'egyptian' && strtolower($nationality) !== 'egy') {
$data = RuleEngine::get('SPOUSE_FOREIGN_FEE'); $data = RuleEngine::require('SPOUSE_FOREIGN_FEE');
$pct = $data['percentage'] ?? '15.00'; $pct = $data['percentage'];
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2); $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']; return ['percentage_fee' => $fee, 'annual_fee' => '0.00', 'total' => $fee, 'years_count' => 0, 'rule_applied' => 'SPOUSE_FOREIGN_FEE'];
} }
if ($memberType === 'acquired') { if ($memberType === 'acquired') {
$data = RuleEngine::get('SPOUSE_ACQUIRED_MEMBER_FEE'); $data = RuleEngine::require('SPOUSE_ACQUIRED_MEMBER_FEE');
$pct = $data['percentage'] ?? '50.00'; $pct = $data['percentage'];
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2); $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']; 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']; $ruleMap = [2 => 'SPOUSE_2ND_FEE', 3 => 'SPOUSE_3RD_FEE', 4 => 'SPOUSE_4TH_FEE'];
$ruleCode = $ruleMap[$spouseOrder] ?? 'SPOUSE_4TH_FEE'; $ruleCode = $ruleMap[$spouseOrder] ?? 'SPOUSE_4TH_FEE';
$data = RuleEngine::get($ruleCode); $data = RuleEngine::require($ruleCode);
if (!$data) { $pct = $data['percentage'];
return ['percentage_fee' => '0.00', 'annual_fee' => '0.00', 'total' => '0.00', 'years_count' => 0, 'rule_applied' => 'error', 'error' => 'Rule not found']; $annualFlat = $data['annual_flat'];
}
$pct = $data['percentage'] ?? '10.00';
$annualFlat = $data['annual_flat'] ?? '150.00';
$percentageFee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2); $percentageFee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
...@@ -142,8 +139,8 @@ final class PricingEngine ...@@ -142,8 +139,8 @@ final class PricingEngine
]; ];
$ruleCode = $ruleMap[$yearsSinceAcquisition] ?? 'SEPARATION_FEE_YEAR_6_PLUS'; $ruleCode = $ruleMap[$yearsSinceAcquisition] ?? 'SEPARATION_FEE_YEAR_6_PLUS';
$data = RuleEngine::get($ruleCode); $data = RuleEngine::require($ruleCode);
$pct = $data['percentage'] ?? '2.50'; $pct = $data['percentage'];
$fee = bcmul($newMembershipValue, bcdiv($pct, '100', 4), 2); $fee = bcmul($newMembershipValue, bcdiv($pct, '100', 4), 2);
...@@ -157,8 +154,8 @@ final class PricingEngine ...@@ -157,8 +154,8 @@ final class PricingEngine
public static function calculateInstallmentPlan(string $totalAmount, string $downPayment, int $months): array public static function calculateInstallmentPlan(string $totalAmount, string $downPayment, int $months): array
{ {
$rateData = RuleEngine::get('INSTALLMENT_INTEREST_RATE'); $rateData = RuleEngine::require('INSTALLMENT_INTEREST_RATE');
$annualRate = $rateData['percentage'] ?? '22.00'; $annualRate = $rateData['percentage'];
$monthlyRate = bcdiv($annualRate, '1200', 8); $monthlyRate = bcdiv($annualRate, '1200', 8);
$remaining = bcsub($totalAmount, $downPayment, 2); $remaining = bcsub($totalAmount, $downPayment, 2);
......
...@@ -148,6 +148,22 @@ final class RuleEngine ...@@ -148,6 +148,22 @@ final class RuleEngine
return self::get($ruleCode, $branchId); return self::get($ruleCode, $branchId);
} }
public static function require(string $ruleCode, ?int $branchId = null): array
{
$data = self::get($ruleCode, $branchId);
if ($data === null) {
Logger::error("Missing required business rule: {$ruleCode}", ['branch_id' => $branchId]);
throw new \RuntimeException("القاعدة '{$ruleCode}' غير مُعرّفة في النظام — يرجى مراجعة إعدادات القواعد");
}
return $data;
}
public static function requireValue(string $ruleCode, string $key = 'value', ?int $branchId = null): mixed
{
$data = self::require($ruleCode, $branchId);
return $data[$key] ?? ($data['percentage'] ?? ($data['amount'] ?? ($data['value'] ?? null)));
}
public static function clearCache(): void public static function clearCache(): void
{ {
self::$cache = []; self::$cache = [];
......
...@@ -72,25 +72,25 @@ final class SpouseFeeCalculator ...@@ -72,25 +72,25 @@ final class SpouseFeeCalculator
$ruleApplied = 'الزوجة الأولى — مشمولة (رسوم الاستمارة فقط ' . money($formFee) . ')'; $ruleApplied = 'الزوجة الأولى — مشمولة (رسوم الاستمارة فقط ' . money($formFee) . ')';
} }
} elseif ($isForeign) { } elseif ($isForeign) {
$data = RuleEngine::get('SPOUSE_FOREIGN_FEE'); $data = RuleEngine::require('SPOUSE_FOREIGN_FEE');
$percentage = $data['percentage'] ?? '15.00'; $percentage = $data['percentage'];
$ruleApplied = 'زوج/ة أجنبي — ' . $percentage . '% من قيمة العضوية'; $ruleApplied = 'زوج/ة أجنبي — ' . $percentage . '% من قيمة العضوية';
} elseif ($isAcquiredMember) { } elseif ($isAcquiredMember) {
$data = RuleEngine::get('SPOUSE_ACQUIRED_MEMBER_FEE'); $data = RuleEngine::require('SPOUSE_ACQUIRED_MEMBER_FEE');
$percentage = $data['percentage'] ?? '50.00'; $percentage = $data['percentage'];
$ruleApplied = 'إضافة زوج/ة لعضو مكتسب العضوية (فصل/طلاق/وفاة/تنازل) — ' . $percentage . '% من قيمة العضوية'; $ruleApplied = 'إضافة زوج/ة لعضو مكتسب العضوية (فصل/طلاق/وفاة/تنازل) — ' . $percentage . '% من قيمة العضوية';
} else { } else {
$data = RuleEngine::get('SPOUSE_BASE_MEMBER_FEE'); $data = RuleEngine::require('SPOUSE_BASE_MEMBER_FEE');
$percentage = $data['percentage'] ?? '15.00'; $percentage = $data['percentage'];
$ruleApplied = 'إضافة زوج/ة لعضو أساس العضوية (إضافة لاحقة) — ' . $percentage . '% من قيمة العضوية'; $ruleApplied = 'إضافة زوج/ة لعضو أساس العضوية (إضافة لاحقة) — ' . $percentage . '% من قيمة العضوية';
} }
$percentageFee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2); $percentageFee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2);
break; break;
case ($spouseOrder === 2): case ($spouseOrder === 2):
$data = RuleEngine::get('SPOUSE_2ND_FEE'); $data = RuleEngine::require('SPOUSE_2ND_FEE');
$percentage = $data['percentage'] ?? '10.00'; $percentage = $data['percentage'];
$annualPerYear = $data['annual_flat'] ?? '150.00'; $annualPerYear = $data['annual_flat'];
$ruleApplied = 'الزوجة الثانية — ' . $percentage . '% من قيمة العضوية + ' . $annualPerYear . ' ج.م عن كل سنة'; $ruleApplied = 'الزوجة الثانية — ' . $percentage . '% من قيمة العضوية + ' . $annualPerYear . ' ج.م عن كل سنة';
$percentageFee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2); $percentageFee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2);
$yearCount = self::calculateYears($marriageDate, $memberCreatedDate); $yearCount = self::calculateYears($marriageDate, $memberCreatedDate);
...@@ -98,9 +98,9 @@ final class SpouseFeeCalculator ...@@ -98,9 +98,9 @@ final class SpouseFeeCalculator
break; break;
case ($spouseOrder === 3): case ($spouseOrder === 3):
$data = RuleEngine::get('SPOUSE_3RD_FEE'); $data = RuleEngine::require('SPOUSE_3RD_FEE');
$percentage = $data['percentage'] ?? '20.00'; $percentage = $data['percentage'];
$annualPerYear = $data['annual_flat'] ?? '200.00'; $annualPerYear = $data['annual_flat'];
$ruleApplied = 'الزوجة الثالثة — ' . $percentage . '% من قيمة العضوية + ' . $annualPerYear . ' ج.م عن كل سنة'; $ruleApplied = 'الزوجة الثالثة — ' . $percentage . '% من قيمة العضوية + ' . $annualPerYear . ' ج.م عن كل سنة';
$percentageFee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2); $percentageFee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2);
$yearCount = self::calculateYears($marriageDate, $memberCreatedDate); $yearCount = self::calculateYears($marriageDate, $memberCreatedDate);
...@@ -108,9 +108,9 @@ final class SpouseFeeCalculator ...@@ -108,9 +108,9 @@ final class SpouseFeeCalculator
break; break;
case ($spouseOrder >= 4): case ($spouseOrder >= 4):
$data = RuleEngine::get('SPOUSE_4TH_FEE'); $data = RuleEngine::require('SPOUSE_4TH_FEE');
$percentage = $data['percentage'] ?? '30.00'; $percentage = $data['percentage'];
$annualPerYear = $data['annual_flat'] ?? '300.00'; $annualPerYear = $data['annual_flat'];
$ruleApplied = 'الزوجة الرابعة — ' . $percentage . '% من قيمة العضوية + ' . $annualPerYear . ' ج.م عن كل سنة'; $ruleApplied = 'الزوجة الرابعة — ' . $percentage . '% من قيمة العضوية + ' . $annualPerYear . ' ج.م عن كل سنة';
$percentageFee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2); $percentageFee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2);
$yearCount = self::calculateYears($marriageDate, $memberCreatedDate); $yearCount = self::calculateYears($marriageDate, $memberCreatedDate);
......
...@@ -13,6 +13,8 @@ use App\Modules\Transfers\Services\SeparationFeeCalculator; ...@@ -13,6 +13,8 @@ use App\Modules\Transfers\Services\SeparationFeeCalculator;
use App\Modules\Transfers\Services\TransferProcessor; use App\Modules\Transfers\Services\TransferProcessor;
use App\Modules\Payments\Services\PaymentService; use App\Modules\Payments\Services\PaymentService;
use App\Modules\Cashier\Services\PaymentRequestService; use App\Modules\Cashier\Services\PaymentRequestService;
use App\Modules\Forms\Services\FormBridge;
use App\Modules\Rules\Services\RuleEngine;
class TransferController extends Controller class TransferController extends Controller
{ {
...@@ -70,16 +72,17 @@ class TransferController extends Controller ...@@ -70,16 +72,17 @@ class TransferController extends Controller
return $this->redirect("/transfers/create/{$memberId}")->withError('يجب اختيار الابن/الابنة'); return $this->redirect("/transfers/create/{$memberId}")->withError('يجب اختيار الابن/الابنة');
} }
// Age validation: dependents under 25 cannot self-separate // Age validation: dependents under separation age cannot self-separate
if ($transferType === 'child_separation' && $childId) { if ($transferType === 'child_separation' && $childId) {
$child = $db->selectOne("SELECT * FROM children WHERE id = ? AND is_archived = 0", [$childId]); $child = $db->selectOne("SELECT * FROM children WHERE id = ? AND is_archived = 0", [$childId]);
if ($child && !empty($child['date_of_birth'])) { if ($child && !empty($child['date_of_birth'])) {
$dob = new \DateTime($child['date_of_birth']); $dob = new \DateTime($child['date_of_birth']);
$now = new \DateTime(); $now = new \DateTime();
$age = (int) $now->diff($dob)->y; $age = (int) $now->diff($dob)->y;
if ($age < 25) { $separationAge = (int) (RuleEngine::getValue('CHILD_MANDATORY_SEPARATION_AGE', 'value') ?? 25);
if ($age < $separationAge) {
return $this->redirect("/transfers/create/{$memberId}")->withError( return $this->redirect("/transfers/create/{$memberId}")->withError(
'لا يمكن فصل الملحق تحت سن 25 سنة. العمر الحالي: ' . $age . ' سنة. يتم الفصل الوجوبي عند بلوغ 25 سنة.' 'لا يمكن فصل الملحق تحت سن ' . $separationAge . ' سنة. العمر الحالي: ' . $age . ' سنة. يتم الفصل الوجوبي عند بلوغ ' . $separationAge . ' سنة.'
); );
} }
} }
...@@ -117,6 +120,10 @@ class TransferController extends Controller ...@@ -117,6 +120,10 @@ class TransferController extends Controller
'notes' => $notes ?: null, 'notes' => $notes ?: null,
]); ]);
if (FormBridge::exists('TRANSFER_SEPARATION')) {
FormBridge::submit('TRANSFER_SEPARATION', $request->all(), (int) $memberId, 'طلب فصل/تحويل');
}
EventBus::dispatch('transfer.requested', [ EventBus::dispatch('transfer.requested', [
'transfer_id' => (int) $transferReq->id, 'transfer_id' => (int) $transferReq->id,
'member_id' => (int) $memberId, 'member_id' => (int) $memberId,
......
...@@ -80,27 +80,20 @@ final class SeparationFeeCalculator ...@@ -80,27 +80,20 @@ final class SeparationFeeCalculator
public static function getFeePercentageByYear(int $year): string public static function getFeePercentageByYear(int $year): string
{ {
if ($year <= 1) { $ruleMap = [
$data = RuleEngine::get('SEPARATION_FEE_YEAR_1'); 1 => 'SEPARATION_FEE_YEAR_1',
return $data['percentage'] ?? '30.00'; 2 => 'SEPARATION_FEE_YEAR_2',
} 3 => 'SEPARATION_FEE_YEAR_3',
if ($year === 2) { 4 => 'SEPARATION_FEE_YEAR_4',
$data = RuleEngine::get('SEPARATION_FEE_YEAR_2'); 5 => 'SEPARATION_FEE_YEAR_5',
return $data['percentage'] ?? '20.00'; ];
}
if ($year === 3) { $ruleCode = $ruleMap[min($year, 5)] ?? 'SEPARATION_FEE_YEAR_6_PLUS';
$data = RuleEngine::get('SEPARATION_FEE_YEAR_3'); if ($year > 5) {
return $data['percentage'] ?? '15.00'; $ruleCode = 'SEPARATION_FEE_YEAR_6_PLUS';
}
if ($year === 4) {
$data = RuleEngine::get('SEPARATION_FEE_YEAR_4');
return $data['percentage'] ?? '10.00';
}
if ($year === 5) {
$data = RuleEngine::get('SEPARATION_FEE_YEAR_5');
return $data['percentage'] ?? '5.00';
} }
$data = RuleEngine::get('SEPARATION_FEE_YEAR_6_PLUS');
return $data['percentage'] ?? '2.50'; $data = RuleEngine::require($ruleCode);
return $data['percentage'];
} }
} }
\ No newline at end of file
...@@ -36,7 +36,7 @@ ...@@ -36,7 +36,7 @@
<?php if (in_array($waiver['status'], ['requested', 'approved']) && bccomp($waiver['waiver_fee_amount'] ?? '0', '0', 2) > 0): ?> <?php if (in_array($waiver['status'], ['requested', 'approved']) && bccomp($waiver['waiver_fee_amount'] ?? '0', '0', 2) > 0): ?>
<div class="card" style="padding:20px;margin-bottom:20px;background:#FFF7ED;border:2px solid #F59E0B;"> <div class="card" style="padding:20px;margin-bottom:20px;background:#FFF7ED;border:2px solid #F59E0B;">
<h4 style="margin:0 0 15px;color:#D97706;">💰 دفع رسوم التنازل (30% من قيمة العضوية)</h4> <h4 style="margin:0 0 15px;color:#D97706;">💰 دفع رسوم التنازل (<?= e($waiver['waiver_fee_percentage'] ?? '30') ?>% من قيمة العضوية)</h4>
<form method="POST" action="/waivers/<?= (int) $waiver['id'] ?>/pay"> <form method="POST" action="/waivers/<?= (int) $waiver['id'] ?>/pay">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="amount" value="<?= e($waiver['waiver_fee_amount']) ?>"> <input type="hidden" name="amount" value="<?= e($waiver['waiver_fee_amount']) ?>">
......
...@@ -243,6 +243,56 @@ final class WorkflowEngine ...@@ -243,6 +243,56 @@ final class WorkflowEngine
return $available; return $available;
} }
/**
* Execute a transition by entity type/ID (finds the active instance automatically).
*/
public static function transitionByEntity(
string $entityType,
int $entityId,
string $transitionName,
?string $notes = null,
string $triggerType = 'manual',
?string $workflowCode = null
): bool {
$instance = WorkflowInstance::findForEntity($entityType, $entityId, $workflowCode);
if (!$instance) {
throw new \RuntimeException("No active workflow instance for {$entityType}#{$entityId}");
}
return self::transition((int) $instance->id, $transitionName, $notes, $triggerType);
}
/**
* Get available transitions for an entity (without needing instance ID).
*/
public static function getAvailableTransitionsForEntity(string $entityType, int $entityId, ?string $workflowCode = null): array
{
$instance = WorkflowInstance::findForEntity($entityType, $entityId, $workflowCode);
if (!$instance) {
return [];
}
return self::getAvailableTransitions((int) $instance->id);
}
/**
* Check if a workflow definition exists for the given code.
*/
public static function hasDefinition(string $workflowCode): bool
{
return WorkflowDefinition::findByCode($workflowCode) !== null;
}
/**
* Ensure a workflow instance exists for an entity (create if missing).
*/
public static function ensureInstance(string $workflowCode, string $entityType, int $entityId): WorkflowInstance
{
$instance = WorkflowInstance::findForEntity($entityType, $entityId, $workflowCode);
if ($instance) {
return $instance;
}
return self::createInstance($workflowCode, $entityType, $entityId);
}
/** /**
* Get current state for an entity. * Get current state for an entity.
*/ */
......
<?php
declare(strict_types=1);
/**
* Phase 41: System Unification — Missing Business Rules
*
* Adds rules that were previously hardcoded in PHP code.
* After this seed, ALL variable values are editable via /rules admin.
*/
return function (\App\Core\Database $db) {
$now = date('Y-m-d H:i:s');
$today = date('Y-m-d');
$newRules = [
[
'rule_code' => 'MINOR_AGE_THRESHOLD',
'category' => 'age',
'name_ar' => 'سن القاصر (أقل من)',
'name_en' => 'Minor Age Threshold',
'data_type' => 'integer',
'current_value_json' => '{"value":18}',
'parameters_json' => '{"value":"integer"}',
],
[
'rule_code' => 'WORKING_MEMBER_MIN_AGE',
'category' => 'age',
'name_ar' => 'الحد الأدنى لسن العضوية العاملة',
'name_en' => 'Working Member Minimum Age',
'data_type' => 'integer',
'current_value_json' => '{"value":21}',
'parameters_json' => '{"value":"integer"}',
],
[
'rule_code' => 'CHILD_MANDATORY_SEPARATION_AGE',
'category' => 'age',
'name_ar' => 'سن الفصل الوجوبي للابن/الابنة',
'name_en' => 'Child Mandatory Separation Age',
'data_type' => 'integer',
'current_value_json' => '{"value":25}',
'parameters_json' => '{"value":"integer"}',
],
[
'rule_code' => 'CHILD_INCLUDED_MAX_AGE',
'category' => 'age',
'name_ar' => 'الحد الأقصى لسن الابن المشمول (بدون رسوم)',
'name_en' => 'Max Age for Included Child (free)',
'data_type' => 'integer',
'current_value_json' => '{"value":18}',
'parameters_json' => '{"value":"integer"}',
],
];
foreach ($newRules as $rule) {
$exists = $db->selectOne(
"SELECT id FROM business_rules WHERE rule_code = ? AND branch_id IS NULL",
[$rule['rule_code']]
);
if ($exists) {
continue;
}
$db->insert('business_rules', array_merge($rule, [
'branch_id' => null,
'effective_from' => $today,
'effective_to' => null,
'version' => 1,
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]));
}
};
<?php
declare(strict_types=1);
/**
* Phase 41: System Unification — Complete Service Catalog
*
* Adds missing service catalog entries for fees that were previously
* hardcoded or only in business_rules. After this seed, all fees
* are editable via /catalog admin with per-branch override support.
*/
return function (\App\Core\Database $db) {
$now = date('Y-m-d H:i:s');
$today = date('Y-m-d');
$services = [
[
'service_code' => 'SVC_CARNET_REPLACEMENT',
'name_ar' => 'رسوم بدل فاقد الكارنيه',
'name_en' => 'Carnet Replacement Fee',
'price_type' => 'fixed',
'base_amount' => '200.00',
'percentage' => null,
'annual_amount' => null,
'currency' => 'EGP',
'applies_to' => 'member',
],
[
'service_code' => 'SVC_SEASONAL_MEMBER',
'name_ar' => 'رسوم العضوية الموسمية',
'name_en' => 'Seasonal Membership Fee',
'price_type' => 'percentage',
'base_amount' => null,
'percentage' => '5.00',
'annual_amount' => null,
'currency' => 'EGP',
'applies_to' => 'member',
],
[
'service_code' => 'SVC_FOREIGN_MEMBER',
'name_ar' => 'رسوم عضوية الأجانب',
'name_en' => 'Foreign Member Fee',
'price_type' => 'fixed',
'base_amount' => '5000.00',
'percentage' => null,
'annual_amount' => null,
'currency' => 'EGP',
'applies_to' => 'member',
],
[
'service_code' => 'SVC_DIVORCE_FORM',
'name_ar' => 'استمارة انفصال / طلاق',
'name_en' => 'Divorce/Separation Form Fee',
'price_type' => 'fixed',
'base_amount' => '570.00',
'percentage' => null,
'annual_amount' => null,
'currency' => 'EGP',
'applies_to' => 'member',
],
[
'service_code' => 'SVC_DEATH_FORM',
'name_ar' => 'استمارة نقل عضوية (وفاة)',
'name_en' => 'Death Transfer Form Fee',
'price_type' => 'fixed',
'base_amount' => '570.00',
'percentage' => null,
'annual_amount' => null,
'currency' => 'EGP',
'applies_to' => 'member',
],
[
'service_code' => 'SVC_WAIVER_FORM',
'name_ar' => 'استمارة تنازل',
'name_en' => 'Waiver Form Fee',
'price_type' => 'fixed',
'base_amount' => '570.00',
'percentage' => null,
'annual_amount' => null,
'currency' => 'EGP',
'applies_to' => 'member',
],
];
foreach ($services as $svc) {
$exists = $db->selectOne(
"SELECT id FROM service_catalog WHERE service_code = ? AND branch_id IS NULL",
[$svc['service_code']]
);
if ($exists) {
continue;
}
$db->insert('service_catalog', array_merge($svc, [
'branch_id' => null,
'effective_from' => $today,
'effective_to' => null,
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]));
}
};
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