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;
use App\Modules\Carnets\Models\Carnet;
use App\Modules\Carnets\Services\CarnetPrintService;
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
{
......@@ -141,9 +144,19 @@ class CarnetController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود');
}
// Lost carnet replacement — requires fee payment
// For now, issue new carnet (fee handled separately via payment module)
return $this->issue($request, $memberId);
$svc = PricingEngine::getServiceFee('SVC_CARNET_REPLACEMENT');
$fee = $svc ? ($svc['base_amount'] ?? '200.00') : (RuleEngine::getValue('CARNET_REPLACEMENT_FEE', 'amount') ?? '200.00');
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
......
......@@ -12,6 +12,7 @@ use App\Modules\Children\Models\Child;
use App\Modules\Children\Services\ChildFeeCalculator;
use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Forms\Services\FormBridge;
class ChildController extends Controller
......@@ -153,6 +154,10 @@ class ChildController extends Controller
'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', [
'member_id' => (int) $memberId,
'child_id' => (int) $child->id,
......
......@@ -5,6 +5,7 @@ namespace App\Modules\Children\Models;
use App\Core\Model;
use App\Core\App;
use App\Modules\Rules\Services\RuleEngine;
class Child extends Model
{
......@@ -45,9 +46,10 @@ class Child extends Model
public static function countActiveUnder18ForMember(int $memberId): int
{
$db = App::getInstance()->db();
$maxAge = (int) (RuleEngine::getValue('CHILD_INCLUDED_MAX_AGE', 'value') ?? 18);
$row = $db->selectOne(
"SELECT COUNT(*) as cnt FROM children WHERE member_id = ? AND is_archived = 0 AND age_years < 18",
[$memberId]
"SELECT COUNT(*) as cnt FROM children WHERE member_id = ? AND is_archived = 0 AND age_years < ?",
[$memberId, $maxAge]
);
return (int) ($row['cnt'] ?? 0);
}
......
......@@ -40,8 +40,8 @@ final class ChildFeeCalculator
// Stepchildren (ابناء الزوجة/الزوج) always cost 10% — never counted as free
if ($relationship === 'stepchild') {
$stepData = RuleEngine::get('STEPCHILD_FEE');
$stepPct = $stepData['percentage'] ?? '10.00';
$stepData = RuleEngine::require('STEPCHILD_FEE');
$stepPct = $stepData['percentage'];
$stepFee = bcmul($membershipValue, bcdiv($stepPct, '100', 4), 2);
$feeResult = [
'fee' => $stepFee,
......
......@@ -8,10 +8,12 @@ use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Fines\Models\Fine;
use App\Modules\Fines\Models\Violation;
use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Workflow\Services\WorkflowEngine;
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]);
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', [
'fine_id' => (int) $fine->id,
'member_id' => (int) $violation['member_id'],
......@@ -125,6 +137,12 @@ class FineController extends Controller
'updated_at' => date('Y-m-d H:i:s'),
], '`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]);
return $this->redirect('/fines')->withSuccess('تم تسجيل دفع الغرامة — إيصال: ' . $result['receipt_number']);
......@@ -156,6 +174,12 @@ class FineController extends Controller
'updated_at' => date('Y-m-d H:i:s'),
], '`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']]);
return $this->redirect('/fines')->withSuccess('تم تقديم التظلم');
......@@ -193,6 +217,17 @@ class FineController extends Controller
$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' => 'إلغاء العقوبة' });
}
......@@ -211,6 +246,12 @@ class FineController extends Controller
'updated_at' => date('Y-m-d H:i:s'),
], '`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('تم الإعفاء من الغرامة');
}
}
\ No newline at end of file
......@@ -8,7 +8,9 @@ use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Fines\Models\Violation;
use App\Modules\Workflow\Services\WorkflowEngine;
class ViolationController extends Controller
{
......@@ -60,6 +62,14 @@ class ViolationController extends Controller
'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]);
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;
use App\Modules\HR\Models\HrDisciplinaryAction;
use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Services\DisciplinaryService;
use App\Modules\Workflow\Services\WorkflowEngine;
class DisciplinaryController extends Controller
{
......@@ -61,6 +62,14 @@ class DisciplinaryController extends Controller
$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']]);
return $this->redirect('/hr/disciplinary/' . $actionId)->withSuccess('تم إنشاء الإجراء التأديبي بنجاح');
......@@ -193,6 +202,12 @@ class DisciplinaryController extends Controller
$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", [
'action_id' => (int) $id,
'penalty_type' => $penaltyType,
......@@ -229,6 +244,12 @@ class DisciplinaryController extends Controller
'updated_by' => $employee ? (int) $employee->id : null,
], '`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('تم تقديم التظلم بنجاح');
}
......
......@@ -11,6 +11,7 @@ use App\Core\Logger;
use App\Modules\HR\Models\HrEndOfService;
use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Services\EndOfServiceService;
use App\Modules\Workflow\Services\WorkflowEngine;
class EndOfServiceController extends Controller
{
......@@ -86,6 +87,10 @@ class EndOfServiceController extends Controller
'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('تم إنشاء سجل نهاية الخدمة بنجاح');
}
......@@ -151,6 +156,12 @@ class EndOfServiceController extends Controller
'updated_at' => date('Y-m-d H:i:s'),
], '`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']]);
return $this->redirect('/hr/end-of-service/' . $id)->withSuccess(
......@@ -188,6 +199,12 @@ class EndOfServiceController extends Controller
'updated_at' => date('Y-m-d H:i:s'),
], '`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]);
return $this->redirect('/hr/end-of-service/' . $id)->withSuccess('تم اعتماد نهاية الخدمة');
......@@ -215,6 +232,12 @@ class EndOfServiceController extends Controller
'updated_by' => $employee ? (int) $employee->id : null,
], '`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]);
return $this->redirect('/hr/end-of-service/' . $id)->withSuccess('تم صرف مستحقات نهاية الخدمة');
......
......@@ -13,6 +13,8 @@ use App\Modules\HR\Models\HrLeaveBalance;
use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Models\HrDepartment;
use App\Modules\HR\Services\LeaveService;
use App\Modules\Workflow\Services\WorkflowEngine;
use App\Core\Logger;
class LeaveController extends Controller
{
......@@ -177,6 +179,12 @@ class LeaveController extends Controller
'updated_by' => $employee ? (int) $employee->id : null,
], '`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('تم إلغاء طلب الإجازة');
}
......
......@@ -7,6 +7,7 @@ use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\HR\Models\HrContract;
use App\Modules\Workflow\Services\WorkflowEngine;
/**
* Contract Management Service
......@@ -69,6 +70,10 @@ final class ContractService
$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]);
return ['success' => true, 'contract_id' => $contractId, 'contract_number' => $contractNumber];
......@@ -122,6 +127,12 @@ final class ContractService
$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', [
'old_contract_id' => $contractId,
'new_contract_id' => $newContractId,
......@@ -153,6 +164,12 @@ final class ContractService
'updated_by' => $employee ? (int) $employee->id : null,
], '`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', [
'contract_id' => $contractId,
'employee_profile_id'=> (int) $contract['employee_profile_id'],
......
......@@ -10,6 +10,7 @@ use App\Modules\HR\Models\HrLeaveBalance;
use App\Modules\HR\Models\HrLeaveType;
use App\Modules\HR\Models\HrLeaveRequest;
use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\Workflow\Services\WorkflowEngine;
/**
* Leave Entitlement & Management Service
......@@ -139,6 +140,10 @@ final class LeaveService
$db->commit();
if (WorkflowEngine::hasDefinition('hr_leave_approval')) {
WorkflowEngine::createInstance('hr_leave_approval', 'hr_leave_requests', $requestId);
}
EventBus::dispatch('hr.leave.submitted', [
'request_id' => $requestId,
'employee_id' => $profile->employee_id,
......@@ -187,6 +192,12 @@ final class LeaveService
$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']);
EventBus::dispatch('hr.leave.approved', [
'request_id' => $requestId,
......@@ -239,6 +250,12 @@ final class LeaveService
$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']);
EventBus::dispatch('hr.leave.rejected', [
'request_id' => $requestId,
......
......@@ -9,6 +9,7 @@ use App\Core\Logger;
use App\Modules\HR\Models\HrEmployeeLoan;
use App\Modules\HR\Models\HrLoanInstallment;
use App\Modules\HR\Services\HrNumberGenerator;
use App\Modules\Workflow\Services\WorkflowEngine;
/**
* Loan & Salary Advance Service
......@@ -86,6 +87,10 @@ final class LoanService
$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]);
return ['success' => true, 'loan_id' => $loanId];
......@@ -113,6 +118,12 @@ final class LoanService
'updated_at' => date('Y-m-d H:i:s'),
], '`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', [
'loan_id' => $loanId,
'employee_id' => (int) $loan['employee_profile_id'],
......@@ -137,6 +148,12 @@ final class LoanService
'updated_at' => date('Y-m-d H:i:s'),
], '`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];
}
......@@ -175,6 +192,14 @@ final class LoanService
], '`id` = ?', [$loanId]);
$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];
} catch (\Throwable $e) {
......
......@@ -8,6 +8,7 @@ use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Interviews\Models\Interview;
use App\Modules\Workflow\Services\WorkflowEngine;
......@@ -80,12 +81,11 @@ class InterviewController extends Controller
'status' => 'scheduled',
]);
// Try to transition workflow
if ($member['workflow_instance_id']) {
try {
WorkflowEngine::transition((int) $member['workflow_instance_id'], 'schedule_interview', 'تم تحديد موعد المقابلة');
} 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
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $interview['member_id']]);
// Transition workflow
if ($interview['workflow_instance_id']) {
try {
$transitionName = $decision === 'accepted' ? 'accept' : 'reject';
try {
WorkflowEngine::transition((int) $interview['workflow_instance_id'], $transitionName, $decisionNotes ?: 'قرار مجلس الأمناء');
} catch (\Throwable $e) {
// Try alternate transition names
try {
$altName = $decision === 'accepted' ? 'payment_completed' : 'reject_from_review';
WorkflowEngine::transition((int) $interview['workflow_instance_id'], $altName, $decisionNotes ?: 'قرار مجلس الأمناء');
} catch (\Throwable $e2) {
// Non-blocking
}
Logger::warning("Workflow transition failed for interview decide", [
'interview_id' => (int) $id,
'transition' => $transitionName,
'error' => $e->getMessage(),
]);
}
}
......
......@@ -17,6 +17,9 @@ use App\Modules\Payments\Services\PaymentService;
use App\Modules\Cashier\Services\PaymentRequestService;
use App\Modules\Rules\Services\RuleEngine;
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
{
......@@ -100,8 +103,9 @@ class MemberController extends Controller
if ($dob) { $age = age_from_dob($dob); $ageYears = $age['years']; $ageMonths = $age['months']; }
$idType = 'passport';
}
if ($ageYears !== null && $ageYears < 21) {
$errors[] = 'الحد الأدنى لسن العضوية العاملة 21 سنة (السن الحالي: ' . $ageYears . ')';
$workingMinAge = (int) (RuleEngine::getValue('WORKING_MEMBER_MIN_AGE', 'value') ?? 21);
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'); }
......@@ -110,6 +114,14 @@ class MemberController extends Controller
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']);
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]);
return $this->redirect('/members/' . $member->id)->withSuccess('تم تسجيل العضو — استمارة رقم: ' . $formNumber);
}
......@@ -303,6 +315,9 @@ class MemberController extends Controller
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]);
if (!$formFeePaid) return $this->redirect('/members/' . $id)->withError('⚠ يجب دفع رسوم الاستمارة أولاً');
$schemaHtml = FormBridge::render('NEW_MEMBERSHIP', $member->toArray());
return $this->view('Members.Views.fill-form', [
'member' => $member,
'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
'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"),
'specialDiscounts' => SpecialDiscount::allActive(),
'schemaHtml' => $schemaHtml,
]);
}
......@@ -345,6 +361,11 @@ class MemberController extends Controller
if ($member->status === 'potential') $update['status'] = 'under_review';
if (!empty($update)) $member->update($update);
if (FormBridge::exists('NEW_MEMBERSHIP')) {
FormBridge::submit('NEW_MEMBERSHIP', $data, (int) $id, 'ملء استمارة عضوية');
}
return $this->redirect('/members/' . $id)->withSuccess('تم ملء الاستمارة');
}
......@@ -482,6 +503,11 @@ class MemberController extends Controller
$newStatus = trim((string) $request->post('status', ''));
if (!in_array($newStatus, array_keys(Member::getStatusOptions()))) return $this->redirect('/members/' . $id)->withError('حالة غير صالحة');
$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]);
return $this->redirect('/members/' . $id)->withSuccess('تم تغيير الحالة');
}
......
......@@ -43,7 +43,10 @@ final class FormFeeService
}
$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
*/
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;
}
......@@ -74,8 +77,8 @@ final class FormFeeService
*/
public static function isChildFreeSlot(int $memberId, int $childOrder, int $childAge): bool
{
$maxFreeCount = (int) (RuleEngine::getValue('INITIAL_FREE_CHILDREN_COUNT', 'value') ?? 2);
$maxFreeAge = (int) (RuleEngine::getValue('CHILD_INCLUDED_MAX_AGE', 'value') ?? 18);
$maxFreeCount = (int) RuleEngine::requireValue('INITIAL_FREE_CHILDREN_COUNT', 'value');
$maxFreeAge = (int) RuleEngine::requireValue('CHILD_INCLUDED_MAX_AGE', 'value');
return $childOrder <= $maxFreeCount && $childAge < $maxFreeAge;
}
......
......@@ -20,6 +20,19 @@
<form method="POST" action="/members/<?= (int) $member->id ?>/fill-form">
<?= 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 -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
......
......@@ -339,15 +339,9 @@ if ($formFilled && !$bill['membership_paid'] && !in_array($member->status, ['act
foreach ($bill['items'] as $item) {
if (in_array($item['type'] ?? '', ['spouse_fee', 'child_fee', 'temp_fee'], true) && !$item['included'] && !$item['paid']) {
$inQueue = !empty($item['in_queue']) || !empty($pendingMembership);
$statusText = $inQueue ? ' — في انتظار الخزينة' : '';
$statusText = $inQueue ? ' — في انتظار الخزينة' : ' — سيتم تحصيلها ضمن الفاتورة المجمعة';
$statusColor = $inQueue ? '#D97706' : '#DC2626';
$stepEntry = ['icon' => $inQueue ? '&#x1f4b3;' : '&#x26a0;', 'text' => ($inQueue ? '' : 'لم يتم سداد: ') . $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;
$missingSteps[] = ['icon' => $inQueue ? '&#x1f4b3;' : '&#x26a0;', 'text' => $item['label'] . $statusText, 'color' => $statusColor, 'done' => false];
}
}
......@@ -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;">
<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>
<?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>
<?php endforeach; ?>
</div>
......
......@@ -5,6 +5,7 @@ namespace App\Modules\PlayerAffairs\Models;
use App\Core\Model;
use App\Core\App;
use App\Modules\Rules\Services\RuleEngine;
class Player extends Model
{
......@@ -176,7 +177,8 @@ class Player extends Model
public function isMinor(): bool
{
$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
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);
$maxIncludedAge = (int) (RuleEngine::getValue('CHILD_INCLUDED_MAX_AGE', 'value') ?? 18);
$maxIncluded = (int) RuleEngine::requireValue('INITIAL_FREE_CHILDREN_COUNT', 'value');
$maxIncludedAge = (int) RuleEngine::requireValue('CHILD_INCLUDED_MAX_AGE', 'value');
$maxChildAge = (int) (RuleEngine::getValue('CHILD_MAX_AGE', 'value') ?? 25);
if ($childAge < $maxIncludedAge && $childOrder <= $maxIncluded) {
return ['fee' => '0.00', 'rule_applied' => 'included', 'percentage' => '0.00', 'classification' => 'included'];
}
if ($childAge < $maxIncludedAge && $childOrder > $maxIncluded) {
$data = RuleEngine::get('CHILD_4TH_UNDER_18_FEE');
$pct = $data['percentage'] ?? '5.00';
$data = RuleEngine::require('CHILD_4TH_UNDER_18_FEE');
$pct = $data['percentage'];
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['fee' => $fee, 'rule_applied' => 'CHILD_4TH_UNDER_18_FEE', 'percentage' => $pct, 'classification' => 'dependent_with_fee'];
}
......@@ -55,20 +56,20 @@ final class PricingEngine
$ruleMap = [18 => 'CHILD_FEE_AGE_18', 19 => 'CHILD_FEE_AGE_19', 20 => 'CHILD_FEE_AGE_20'];
if (isset($ruleMap[$childAge])) {
$data = RuleEngine::get($ruleMap[$childAge]);
$pct = $data['percentage'] ?? '10.00';
$data = RuleEngine::require($ruleMap[$childAge]);
$pct = $data['percentage'];
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['fee' => $fee, 'rule_applied' => $ruleMap[$childAge], 'percentage' => $pct, 'classification' => $data['type'] ?? 'regular'];
}
if ($childAge >= 21 && $childAge < 25) {
$data = RuleEngine::get('CHILD_FEE_AGE_21');
$pct = $data['percentage'] ?? '15.00';
if ($childAge >= 21 && $childAge < $maxChildAge) {
$data = RuleEngine::require('CHILD_FEE_AGE_21');
$pct = $data['percentage'];
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['fee' => $fee, 'rule_applied' => 'CHILD_FEE_AGE_21', 'percentage' => $pct, 'classification' => 'temporary'];
}
return ['fee' => '0.00', 'rule_applied' => 'CHILD_AUTO_DELETE_AGE', 'percentage' => '0.00', 'classification' => 'not_accepted', 'error' => 'Child age 25+ not accepted'];
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
......@@ -78,29 +79,25 @@ final class PricingEngine
}
if (strtolower($nationality) !== 'مصري' && strtolower($nationality) !== 'egyptian' && strtolower($nationality) !== 'egy') {
$data = RuleEngine::get('SPOUSE_FOREIGN_FEE');
$pct = $data['percentage'] ?? '15.00';
$data = RuleEngine::require('SPOUSE_FOREIGN_FEE');
$pct = $data['percentage'];
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['percentage_fee' => $fee, 'annual_fee' => '0.00', 'total' => $fee, 'years_count' => 0, 'rule_applied' => 'SPOUSE_FOREIGN_FEE'];
}
if ($memberType === 'acquired') {
$data = RuleEngine::get('SPOUSE_ACQUIRED_MEMBER_FEE');
$pct = $data['percentage'] ?? '50.00';
$data = RuleEngine::require('SPOUSE_ACQUIRED_MEMBER_FEE');
$pct = $data['percentage'];
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
return ['percentage_fee' => $fee, 'annual_fee' => '0.00', 'total' => $fee, 'years_count' => 0, 'rule_applied' => 'SPOUSE_ACQUIRED_MEMBER_FEE'];
}
$ruleMap = [2 => 'SPOUSE_2ND_FEE', 3 => 'SPOUSE_3RD_FEE', 4 => 'SPOUSE_4TH_FEE'];
$ruleCode = $ruleMap[$spouseOrder] ?? 'SPOUSE_4TH_FEE';
$data = RuleEngine::get($ruleCode);
$data = RuleEngine::require($ruleCode);
if (!$data) {
return ['percentage_fee' => '0.00', 'annual_fee' => '0.00', 'total' => '0.00', 'years_count' => 0, 'rule_applied' => 'error', 'error' => 'Rule not found'];
}
$pct = $data['percentage'] ?? '10.00';
$annualFlat = $data['annual_flat'] ?? '150.00';
$pct = $data['percentage'];
$annualFlat = $data['annual_flat'];
$percentageFee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
......@@ -142,8 +139,8 @@ final class PricingEngine
];
$ruleCode = $ruleMap[$yearsSinceAcquisition] ?? 'SEPARATION_FEE_YEAR_6_PLUS';
$data = RuleEngine::get($ruleCode);
$pct = $data['percentage'] ?? '2.50';
$data = RuleEngine::require($ruleCode);
$pct = $data['percentage'];
$fee = bcmul($newMembershipValue, bcdiv($pct, '100', 4), 2);
......@@ -157,8 +154,8 @@ final class PricingEngine
public static function calculateInstallmentPlan(string $totalAmount, string $downPayment, int $months): array
{
$rateData = RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$annualRate = $rateData['percentage'] ?? '22.00';
$rateData = RuleEngine::require('INSTALLMENT_INTEREST_RATE');
$annualRate = $rateData['percentage'];
$monthlyRate = bcdiv($annualRate, '1200', 8);
$remaining = bcsub($totalAmount, $downPayment, 2);
......
......@@ -148,6 +148,22 @@ final class RuleEngine
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
{
self::$cache = [];
......
......@@ -72,25 +72,25 @@ final class SpouseFeeCalculator
$ruleApplied = 'الزوجة الأولى — مشمولة (رسوم الاستمارة فقط ' . money($formFee) . ')';
}
} elseif ($isForeign) {
$data = RuleEngine::get('SPOUSE_FOREIGN_FEE');
$percentage = $data['percentage'] ?? '15.00';
$data = RuleEngine::require('SPOUSE_FOREIGN_FEE');
$percentage = $data['percentage'];
$ruleApplied = 'زوج/ة أجنبي — ' . $percentage . '% من قيمة العضوية';
} elseif ($isAcquiredMember) {
$data = RuleEngine::get('SPOUSE_ACQUIRED_MEMBER_FEE');
$percentage = $data['percentage'] ?? '50.00';
$data = RuleEngine::require('SPOUSE_ACQUIRED_MEMBER_FEE');
$percentage = $data['percentage'];
$ruleApplied = 'إضافة زوج/ة لعضو مكتسب العضوية (فصل/طلاق/وفاة/تنازل) — ' . $percentage . '% من قيمة العضوية';
} else {
$data = RuleEngine::get('SPOUSE_BASE_MEMBER_FEE');
$percentage = $data['percentage'] ?? '15.00';
$data = RuleEngine::require('SPOUSE_BASE_MEMBER_FEE');
$percentage = $data['percentage'];
$ruleApplied = 'إضافة زوج/ة لعضو أساس العضوية (إضافة لاحقة) — ' . $percentage . '% من قيمة العضوية';
}
$percentageFee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2);
break;
case ($spouseOrder === 2):
$data = RuleEngine::get('SPOUSE_2ND_FEE');
$percentage = $data['percentage'] ?? '10.00';
$annualPerYear = $data['annual_flat'] ?? '150.00';
$data = RuleEngine::require('SPOUSE_2ND_FEE');
$percentage = $data['percentage'];
$annualPerYear = $data['annual_flat'];
$ruleApplied = 'الزوجة الثانية — ' . $percentage . '% من قيمة العضوية + ' . $annualPerYear . ' ج.م عن كل سنة';
$percentageFee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2);
$yearCount = self::calculateYears($marriageDate, $memberCreatedDate);
......@@ -98,9 +98,9 @@ final class SpouseFeeCalculator
break;
case ($spouseOrder === 3):
$data = RuleEngine::get('SPOUSE_3RD_FEE');
$percentage = $data['percentage'] ?? '20.00';
$annualPerYear = $data['annual_flat'] ?? '200.00';
$data = RuleEngine::require('SPOUSE_3RD_FEE');
$percentage = $data['percentage'];
$annualPerYear = $data['annual_flat'];
$ruleApplied = 'الزوجة الثالثة — ' . $percentage . '% من قيمة العضوية + ' . $annualPerYear . ' ج.م عن كل سنة';
$percentageFee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2);
$yearCount = self::calculateYears($marriageDate, $memberCreatedDate);
......@@ -108,9 +108,9 @@ final class SpouseFeeCalculator
break;
case ($spouseOrder >= 4):
$data = RuleEngine::get('SPOUSE_4TH_FEE');
$percentage = $data['percentage'] ?? '30.00';
$annualPerYear = $data['annual_flat'] ?? '300.00';
$data = RuleEngine::require('SPOUSE_4TH_FEE');
$percentage = $data['percentage'];
$annualPerYear = $data['annual_flat'];
$ruleApplied = 'الزوجة الرابعة — ' . $percentage . '% من قيمة العضوية + ' . $annualPerYear . ' ج.م عن كل سنة';
$percentageFee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2);
$yearCount = self::calculateYears($marriageDate, $memberCreatedDate);
......
......@@ -13,6 +13,8 @@ use App\Modules\Transfers\Services\SeparationFeeCalculator;
use App\Modules\Transfers\Services\TransferProcessor;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Cashier\Services\PaymentRequestService;
use App\Modules\Forms\Services\FormBridge;
use App\Modules\Rules\Services\RuleEngine;
class TransferController extends Controller
{
......@@ -70,16 +72,17 @@ class TransferController extends Controller
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) {
$child = $db->selectOne("SELECT * FROM children WHERE id = ? AND is_archived = 0", [$childId]);
if ($child && !empty($child['date_of_birth'])) {
$dob = new \DateTime($child['date_of_birth']);
$now = new \DateTime();
$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(
'لا يمكن فصل الملحق تحت سن 25 سنة. العمر الحالي: ' . $age . ' سنة. يتم الفصل الوجوبي عند بلوغ 25 سنة.'
'لا يمكن فصل الملحق تحت سن ' . $separationAge . ' سنة. العمر الحالي: ' . $age . ' سنة. يتم الفصل الوجوبي عند بلوغ ' . $separationAge . ' سنة.'
);
}
}
......@@ -117,6 +120,10 @@ class TransferController extends Controller
'notes' => $notes ?: null,
]);
if (FormBridge::exists('TRANSFER_SEPARATION')) {
FormBridge::submit('TRANSFER_SEPARATION', $request->all(), (int) $memberId, 'طلب فصل/تحويل');
}
EventBus::dispatch('transfer.requested', [
'transfer_id' => (int) $transferReq->id,
'member_id' => (int) $memberId,
......
......@@ -80,27 +80,20 @@ final class SeparationFeeCalculator
public static function getFeePercentageByYear(int $year): string
{
if ($year <= 1) {
$data = RuleEngine::get('SEPARATION_FEE_YEAR_1');
return $data['percentage'] ?? '30.00';
}
if ($year === 2) {
$data = RuleEngine::get('SEPARATION_FEE_YEAR_2');
return $data['percentage'] ?? '20.00';
}
if ($year === 3) {
$data = RuleEngine::get('SEPARATION_FEE_YEAR_3');
return $data['percentage'] ?? '15.00';
}
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';
$ruleMap = [
1 => 'SEPARATION_FEE_YEAR_1',
2 => 'SEPARATION_FEE_YEAR_2',
3 => 'SEPARATION_FEE_YEAR_3',
4 => 'SEPARATION_FEE_YEAR_4',
5 => 'SEPARATION_FEE_YEAR_5',
];
$ruleCode = $ruleMap[min($year, 5)] ?? 'SEPARATION_FEE_YEAR_6_PLUS';
if ($year > 5) {
$ruleCode = 'SEPARATION_FEE_YEAR_6_PLUS';
}
$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 @@
<?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;">
<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">
<?= csrf_field() ?>
<input type="hidden" name="amount" value="<?= e($waiver['waiver_fee_amount']) ?>">
......
......@@ -243,6 +243,56 @@ final class WorkflowEngine
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.
*/
......
<?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