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

xdgxdgng

parent 4bd5dfab
# SYSTEM MAP — ClubPHP ERP
> Complete reference for AI-assisted development. Covers architecture, modules, database, patterns, and cross-cutting concerns.
> Last updated: 2026-05-03
---
## 1. ARCHITECTURE OVERVIEW
```
public/index.php → App::boot() → Router::dispatch() → Middleware → Controller → Response
cli.php → App (no session) → MigrationRunner / SeederRunner / CronRunner
```
**Stack:** Custom PHP 8.1+ framework, MySQL 8, plain PDO, no ORM relations, bcmath for money, Arabic-first RTL.
**Boot sequence:** timezone(Africa/Cairo) → .env → config files → DB connect → session → system_config overrides → module bootstrap.php (sorted) → module Routes.php
**Key singletons:** `App::getInstance()``->db()`, `->session()`, `->router()`, `->currentEmployee()`, `->currentBranch()`
---
## 2. MODULE INVENTORY (50 modules)
### Membership Domain
| Module | Route Prefix | Controllers | Key Permissions | Events |
|--------|-------------|-------------|-----------------|--------|
| **Members** | `/members` | MemberController, MemberApiController | member.view/create/edit/archive/search/view_financial/change_status/pay_form_fee/pay_membership/fill_form | Listens: member.profile_data |
| **Spouses** | `/members/{id}/spouses` | SpouseController | spouse.add/edit/remove/view | — |
| **Children** | `/members/{id}/children` | ChildController | child.add/edit/remove/view/freeze/separate | — |
| **Temporary** | `/temporary` | TemporaryController | temp.add/edit/remove/view | Listens: member.profile_data |
| **Seasonal** | `/seasonal` | SeasonalController | (uses temp.*) | Listens: member.profile_data |
| **Sports** | `/sports` | SportsController | (uses temp.*) | — |
| **Honorary** | `/honorary` | HonoraryController | (uses member.*) | — |
| **Foreign** | `/foreign` | ForeignController | — | — |
| **Interviews** | `/interviews` | InterviewController | interview.view/schedule/decide/conduct | — |
| **Carnets** | `/carnets` | CarnetController | carnet.print/replace/view_log | — |
### Financial Domain
| Module | Route Prefix | Controllers | Key Permissions | Events |
|--------|-------------|-------------|-----------------|--------|
| **Payments** | `/payments` | PaymentController | payment.process_cash/check/visa/view/void_receipt/refund | Dispatches: sms.send, member.activated; Listens: payment.completed |
| **Cashier** | `/cashier` | CashierController | cashier.view_queue/process_payment/cancel_request | Dispatches: installment.plan_created, member.activated, divorce/death/waiver/transfer/spouse/child.fee_paid; Listens: payment_request.completed |
| **Receipts** | `/receipts` | ReceiptController | receipt.view/print/void | — |
| **Installments** | `/installments` | InstallmentController | installment.view/create_plan/record_payment/modify_plan | — |
| **Subscriptions** | `/subscriptions` | SubscriptionController | subscription.view/collect/exempt/generate_batch | — |
| **Fines** | `/violations`, `/fines` | ViolationController, FineController | fine.view/impose/collect/waive | — |
| **Sales** | `/sales` | SaleController, PackageController, SaleReportController | sales.view/create/void/refund, package.view/manage, report.sales | — |
| **Pricing** | `/pricing` | PricingController, SpecialDiscountController | (uses rules.*) | — |
| **Rules** | `/rules` | RuleController | rules.view/edit/create/deactivate + pricing.* + special_discounts.* | — |
| **ServiceCatalog** | `/catalog` | CatalogController | (uses pricing.*) | — |
### Accounting Domain
| Module | Route Prefix | Controllers | Key Permissions | Events |
|--------|-------------|-------------|-----------------|--------|
| **Accounting** | `/accounting` | 7 controllers (Reports, FiscalYear, COA, CostCenter, BankAccount, Journal, BankRecon, PeriodClosing) | 28 permissions | Listens: payment.completed/voided, sale.completed/voided, hr.payroll.paid, subscription.paid, fine.imposed/paid, installment.plan_created/paid, sale.refunded, procurement.* events, rental.deposit_collected/refunded |
### Life Events Domain
| Module | Route Prefix | Controllers | Key Permissions |
|--------|-------------|-------------|-----------------|
| **Transfers** | `/transfers` | TransferController | transfer.view/create/approve + separation.* + waiver.* |
| **Divorce** | `/divorce` | DivorceController | (uses transfer.*) |
| **Death** | `/death` | DeathController | (uses transfer.*) |
| **Waiver** | `/waivers` | WaiverController | (uses transfer.*) |
### HR Domain
| Module | Route Prefix | Controllers | Key Permissions | Events |
|--------|-------------|-------------|-----------------|--------|
| **HR** | `/hr` | 18 controllers (Department, JobTitle, EmployeeProfile, Contract, Salary, Attendance, Leave, Payroll, Insurance, Tax, Disciplinary, Loan, EndOfService, Performance, EmployeeDocument, Holiday, WorkSchedule, HrReport) | 35 permissions | Dispatches: hr.leave.submitted/approved/rejected, hr.contract.renewed; Listens: employee.created, hr.leave.approved/rejected, hr.payroll.paid, hr.loan.approved |
### Operations Domain
| Module | Route Prefix | Controllers | Key Permissions |
|--------|-------------|-------------|-----------------|
| **Facilities** | `/facilities` | FacilityController | facility.view/manage |
| **Academies** | `/academies` | AcademyController | academy.view/manage/enroll |
| **Disciplines** | `/disciplines` | SportsDashboardController, DisciplineController | discipline.view/manage |
| **Reservations** | `/reservations` | ReservationController | reservation.view/create/confirm/cancel |
| **Rentals** | `/rentals` | RentalController | rental.view/manage_entity/approve/manage_contract/manage_deposit |
| **ActivitySubscriptions** | `/activity-subscriptions` | ActivitySubscriptionController | activity_sub.view/collect/exempt/generate/manage_pricing |
| **PlayerAffairs** | `/players` | PlayerController, AttendanceController | player.view/search/register/edit/manage_card/view_medical/manage_medical/record_attendance |
### Inventory & Procurement Domain
| Module | Route Prefix | Controllers | Key Permissions |
|--------|-------------|-------------|-----------------|
| **Inventory** | `/inventory` | 10 controllers (Warehouse, Category, Item, StockMovement, StockTransfer, StockAudit, Supplier, PurchaseOrder, Asset, InventoryReport) | 18 permissions |
| **Procurement** | `/procurement` | 7 controllers (Dashboard, Requisition, GRN, VendorInvoice, VendorPayment, RTV, Report) | 16 permissions |
### System Domain
| Module | Route Prefix | Controllers | Key Permissions |
|--------|-------------|-------------|-----------------|
| **Auth** | `/` (login/logout) | AuthController | auth.force_logout |
| **Dashboard** | `/dashboard` | DashboardController | — |
| **Roles** | `/roles` | RoleController | role.view/create/edit |
| **Users** | `/users` | UserController, UserPermissionController | user.view/create/edit/deactivate/assign_role/manage_permissions |
| **Settings** | `/settings` | SettingsController, BrandingController | settings.view/edit/backup |
| **Branches** | `/branches` | BranchController | settings.* |
| **Notifications** | `/notifications` | NotificationController | sms.send_single/send_bulk/view_log/edit_templates |
| **Reports** | `/reports` | ReportController | report.view_membership/financial/operations/audit/sports/sports_financial/export |
| **Audit** | `/audit` | AuditController | — (Listens: auth.login/logout/password_changed) |
| **Archive** | `/archive` | ArchiveController | archive.view/take_snapshot/compare/number_chain |
| **Documents** | `/documents` | DocumentController | document.upload/view/delete (Listens: member.profile_data) |
| **Forms** | `/forms` | FormController, FormBuilderController | forms.view/edit_schema/create_schema |
| **Workflow** | `/workflows` | WorkflowController | workflow.view/transition/manage |
| **Support** | `/support` | TicketController | support.view/create/reply/assign/close/manage |
---
## 3. EVENT BUS MAP
### Events Dispatched
| Event | Source | Data |
|-------|--------|------|
| `member.created` | Model auto | `{id, form_number, ...}` |
| `member.updated` | Model auto | `{id, changed_fields}` |
| `member.activated` | Cashier/Payments | `{member_id}` |
| `member.profile_data` | MemberController | `{member_id}` — enrichment point |
| `payment.completed` | PaymentService | `{payment_id, member_id, amount, type}` |
| `payment.voided` | PaymentService | `{payment_id}` |
| `payment_request.completed` | PaymentRequestService | `{request_id}` |
| `sale.completed` | SaleService | `{sale_id, amount}` |
| `sale.voided` | SaleService | `{sale_id}` |
| `sale.refunded` | SaleService | `{sale_id, refund_id}` |
| `fine.imposed` | FineService | `{fine_id, amount}` |
| `fine.paid` | FineService | `{fine_id}` |
| `subscription.paid` | SubscriptionService | `{subscription_id}` |
| `installment.plan_created` | Cashier | `{plan_id, total}` |
| `installment.paid` | InstallmentService | `{installment_id}` |
| `hr.leave.submitted` | LeaveService | `{leave_id}` |
| `hr.leave.approved` | LeaveService | `{leave_id}` |
| `hr.leave.rejected` | LeaveService | `{leave_id}` |
| `hr.payroll.paid` | PayrollService | `{payroll_run_id, total_net}` |
| `hr.contract.renewed` | ContractService | `{contract_id}` |
| `hr.loan.approved` | LoanService | `{loan_id}` |
| `procurement.invoice_approved` | ProcurementService | `{invoice_id}` |
| `procurement.payment_completed` | ProcurementService | `{payment_id}` |
| `procurement.payment_voided` | ProcurementService | `{payment_id}` |
| `procurement.rtv_completed` | ProcurementService | `{rtv_id}` |
| `rental.deposit_collected` | RentalService | `{contract_id}` |
| `rental.deposit_refunded` | RentalService | `{contract_id}` |
| `player.card_activated` | PlayerCardService | `{player_id}` |
| `player.medical_updated` | MedicalRecordService | `{player_id}` |
| `sms.send` | Payments/various | `{phone, template, data}` |
| `auth.login` | AuthController | `{employee_id}` |
| `auth.logout` | AuthController | `{employee_id}` |
### Event Listeners (Hub: Accounting Module)
The Accounting module listens to 16+ events and auto-posts double-entry journal entries via `GLSyncService`. No other module needs to know about GL — it's fully event-driven.
---
## 4. DATABASE SCHEMA (151 tables)
### System Foundation (Phase 01-02)
| Table | Purpose | Key Columns |
|-------|---------|-------------|
| `migrations` | Track schema versions | batch, migration_name |
| `system_config` | Runtime config overrides | config_key, config_value, value_type |
| `branches` | Multi-branch support | code, name_ar, name_en, address, phone |
| `employees` | System users | username, password_hash, email, branch_id, is_active |
| `roles` | Permission groups | role_code, name_ar, name_en |
| `role_permissions` | Role→Permission mapping | role_id, permission_key |
| `employee_roles` | Employee→Role mapping | employee_id, role_id, is_active |
| `employee_permissions` | Direct employee permissions | employee_id, permission_key, is_denied |
| `login_attempts` | Security tracking | username, ip_address, success |
| `active_sessions` | Session management | employee_id, session_id, ip_address |
| `password_history` | Password reuse prevention | employee_id, password_hash |
| `audit_trail` | All system changes | entity_type, entity_id, action, before/after_data_json |
### Master Data (Phase 03-04)
| Table | Purpose | Key Columns |
|-------|---------|-------------|
| `governorates` | Egyptian governorates | code, name_ar |
| `countries` | Country list | code, name_ar, nationality_ar |
| `qualifications` | Education levels | code, name_ar (affects pricing) |
### Business Rules (Phase 05-06)
| Table | Purpose | Key Columns |
|-------|---------|-------------|
| `business_rules` | All configurable rules | rule_code, category, data_type, current_value_json, branch_id |
| `rule_versions` | Rule change history | rule_id, version_number, old/new_value_json, change_reason |
| `pricing_configs` | Branch×Qualification pricing | branch_id, qualification_id, membership_type, price |
| `service_catalog` | Service fees | service_code, base_amount, percentage |
| `discount_rules` | Discount tiers | min_amount, max_amount, discount_percentage |
| `rule_overrides` | Branch-specific overrides | rule_id, branch_id, override_value_json |
### Members (Phase 07-09)
| Table | Purpose | Key Columns |
|-------|---------|-------------|
| `members` | Core member data | form_number, membership_number, full_name_ar, national_id, membership_value, status, branch_id, qualification_id |
| `member_notes` | Internal notes | member_id, note_text, created_by |
| `spouses` | Member spouses | member_id, full_name_ar, national_id, nationality, marriage_date, status |
| `children` | Member children | member_id, full_name_ar, date_of_birth, relationship, classification, status |
| `temporary_members` | Temp membership | member_id, start_date, end_date, fee_amount |
| `seasonal_memberships` | Seasonal passes | member_id, season_year, start_date, end_date |
| `sports_members` | Sports membership | member_id, sport_type, status |
| `honorary_members` | Honorary membership | member_id, granted_by, grant_reason |
| `foreign_member_details` | Foreign member extras | member_id, passport_number, visa_type |
### Payments & Finance (Phase 10-12)
| Table | Purpose | Key Columns |
|-------|---------|-------------|
| `payment_methods` | Available methods | code, name_ar (cash/visa/check/bank_transfer) |
| `payments` | All payments | payment_number, member_id, amount, payment_method, status |
| `receipts` | Payment receipts | receipt_number, payment_id, member_id, amount |
| `installment_plans` | Payment plans | member_id, total_amount, down_payment, months, interest_rate |
| `installment_schedule` | Plan installments | plan_id, installment_number, due_date, amount, status |
| `subscriptions` | Annual subscriptions | member_id, year, amount, status, due_date |
| `violations` | Violation types | code, name_ar, fine_amount |
| `fines` | Imposed fines | member_id, violation_id, amount, status |
| `payment_requests` | Cashier queue | request_number, member_id, payment_type, amount, status, requested_by, processed_by |
### Life Events (Phase 13-14)
| Table | Purpose | Key Columns |
|-------|---------|-------------|
| `transfer_requests` | Membership transfers | source_member_id, target_member_id, transfer_type, status, fee_amount |
| `divorce_cases` | Divorce separations | member_id, spouse_id, divorce_date, spouse_new_member_id, status |
| `death_cases` | Death transfers | member_id, death_date, transferred_to_member_id, status |
| `waiver_requests` | Voluntary waivers | member_id, target_member_id, waiver_reason, status |
### Interviews & Documents (Phase 15-16)
| Table | Purpose | Key Columns |
|-------|---------|-------------|
| `interviews` | Membership interviews | member_id, scheduled_date, interviewer_id, result, notes |
| `carnets` | Member ID cards | member_id, carnet_number, issue_date, expiry_date, status |
| `documents` | Uploaded documents | member_id, document_type, file_path, original_filename |
### Notifications (Phase 17)
| Table | Purpose |
|-------|---------|
| `sms_templates` | SMS message templates |
| `sms_log` | Sent SMS history |
| `notification_queue` | Pending notifications |
| `report_definitions` | Report configurations |
| `scheduled_reports` | Auto-generated reports |
### Facilities & Sports (Phase 18-21)
| Table | Purpose |
|-------|---------|
| `sport_disciplines` | Available sports |
| `facilities` | Physical facilities |
| `facility_time_slots` | Booking slots |
| `facility_blackout_dates` | Closed dates |
| `academies` | Training academies |
| `academy_levels` | Skill levels |
| `academy_schedules` | Class schedules |
| `players` | Athlete records |
| `player_disciplines` | Player→Sport mapping |
| `academy_enrollments` | Academy registrations |
| `player_medical_records` | Medical data |
| `player_attendance` | Training attendance |
| `reservations` | Facility bookings |
| `rental_entities` | Rentable spaces |
| `rental_contracts` | Lease agreements |
| `rental_bookings` | Rental reservations |
| `activity_subscriptions` | Activity passes |
| `activity_pricing` | Activity fees |
### Inventory & Procurement (Phase 25-34)
| Table | Purpose |
|-------|---------|
| `warehouses` | Storage locations |
| `item_categories` | Item classification |
| `inventory_items` | Stock items |
| `item_warehouse_stock` | Stock levels per warehouse |
| `stock_movements` | In/out movements |
| `item_batches` | Batch tracking |
| `asset_register` | Fixed assets |
| `depreciation_entries` | Asset depreciation |
| `stock_transfers` | Inter-warehouse transfers |
| `stock_audits` | Physical counts |
| `suppliers` | Vendor master |
| `purchase_orders` | POs |
| `purchase_requisitions` | PRs |
| `goods_received_notes` | GRNs |
| `vendor_invoices` | Vendor bills |
| `vendor_payments` | Vendor payments |
| `return_to_vendor` | RTVs |
| `packages` | Sale packages |
| `sales` | POS sales |
| `sale_items` | Sale line items |
| `sale_refunds` | Refund records |
### HR (Phase 26-33)
| Table | Purpose |
|-------|---------|
| `hr_departments` | Org structure |
| `hr_job_titles` | Position definitions |
| `hr_salary_structures` | Pay structures |
| `hr_salary_components` | Earnings/deductions |
| `hr_employee_profiles` | Extended employee data |
| `hr_employee_salary_details` | Individual salary setup |
| `hr_contracts` | Employment contracts |
| `hr_employee_documents` | Employee documents |
| `hr_work_schedules` | Shift patterns |
| `hr_employee_schedules` | Assigned schedules |
| `hr_attendance` | Clock in/out |
| `hr_leave_types` | Leave categories |
| `hr_leave_balances` | Remaining leave |
| `hr_leave_requests` | Leave applications |
| `hr_sick_leave_records` | Sick leave |
| `hr_holidays` | Public holidays |
| `hr_disciplinary_actions` | Warnings/penalties |
| `hr_employee_loans` | Employee loans |
| `hr_loan_installments` | Loan repayments |
| `hr_payroll_periods` | Pay periods |
| `hr_payroll_runs` | Payroll execution |
| `hr_payroll_components_log` | Payslip breakdown |
| `hr_insurance_records` | Social insurance |
| `hr_tax_records` | Tax calculations |
| `hr_end_of_service` | Termination settlements |
| `hr_performance_cycles` | Review periods |
| `hr_performance_reviews` | Performance ratings |
| `hr_salary_adjustments` | Pay changes |
### Accounting (Phase 27-30)
| Table | Purpose |
|-------|---------|
| `fiscal_years` | Financial years |
| `chart_of_accounts` | GL accounts (hierarchical) |
| `cost_centers` | Cost allocation |
| `bank_accounts` | Bank accounts |
| `journal_entries` | GL journal headers |
| `journal_entry_lines` | Debit/credit lines |
| `account_balances` | Running balances |
| `bank_reconciliations` | Bank recon headers |
| `bank_reconciliation_items` | Recon matches |
| `accounts_payable` | AP ledger |
| `accounts_receivable` | AR ledger |
| `period_closings` | Period close records |
### Forms & Workflow (Phase 22-23)
| Table | Purpose |
|-------|---------|
| `form_field_types` | Dynamic form field types |
| `form_schemas` | Form definitions (JSON schema) |
| `form_submissions` | Submitted form data |
| `workflow_definitions` | Workflow state machines |
| `workflow_instances` | Active workflows |
| `workflow_transition_log` | State change history |
### Support & Discounts (Phase 35-39)
| Table | Purpose |
|-------|---------|
| `special_discounts` | Discount types (e.g., disabled, military) |
| `support_tickets` | Help desk tickets |
| `support_ticket_replies` | Ticket responses |
| `support_ticket_attachments` | Ticket files |
---
## 5. CORE FRAMEWORK API
### App (Singleton)
```php
App::getInstance()->boot() // Initialize everything
App::getInstance()->db() // Database instance
App::getInstance()->session() // Session instance
App::getInstance()->router() // Router instance
App::getInstance()->config('key') // Config with dot notation
App::getInstance()->currentEmployee()// Authenticated user
App::getInstance()->currentBranch() // Active branch
App::getInstance()->basePath() // /path/to/clubphp
```
### Database
```php
$db->select($sql, $params) // Returns array of rows
$db->selectOne($sql, $params) // Returns ?array (first row)
$db->insert($table, $data) // Returns int (last ID)
$db->update($table, $data, $where, $params) // Returns affected count
$db->delete($table, $where, $params) // Returns affected count
$db->query($sql, $params) // Returns PDOStatement
$db->raw($sql) // Execute without params
$db->beginTransaction() / commit() / rollBack()
$db->tableExists($table) // Cached check
// Audit hooks:
$db->onAfterInsert(fn($table, $data, $id))
$db->onBeforeUpdate(fn($table, $data, $entityId, $oldRecord))
$db->onAfterUpdate(fn($table, $data, $entityId, $oldRecord, $newRecord))
$db->onAfterDelete(fn($table, $oldRecord, $entityId))
```
### QueryBuilder (Immutable, fluent)
```php
Model::query()
->where('col', '=', $val) // AND WHERE
->orWhere('col', '=', $val) // OR WHERE
->whereIn('col', $arr) // WHERE IN
->whereNull('col') // IS NULL
->whereNotNull('col') // IS NOT NULL
->whereBetween('col', $min, $max)
->whereRaw('sql', $bindings) // Raw WHERE
->join($table, $on) // INNER JOIN
->leftJoin($table, $on) // LEFT JOIN
->orderBy('col', 'ASC')
->groupBy('col')
->having('raw')
->limit($n)->offset($n)
->when($bool, fn($q)) // Conditional
->withArchived() // Include soft-deleted
->get() // array of rows
->first() // ?array
->count() // int
->sum('col') // string
->paginate($perPage, $page) // ['data' => [], 'pagination' => []]
```
### Model (Active Record)
```php
// Static properties to override:
protected static string $table = 'table_name';
protected static array $fillable = ['col1', 'col2'];
protected static bool $timestamps = true; // created_at, updated_at
protected static bool $softDelete = true; // is_archived
protected static bool $dispatchEvents = true;// fires {singular}.created/updated/archived
// Methods:
Model::find($id) // ?static
Model::findOrFail($id) // static (throws RuntimeException)
Model::create($data) // static (filtered by $fillable)
Model::query() // QueryBuilder
$model->save() // bool
$model->update($data) // bool (filtered by $fillable)
$model->archive() // bool (soft delete)
$model->toArray() // array
```
### Controller
```php
$this->view('Module.Views.template', $data) // HTML response
$this->json($data) // JSON response
$this->redirect($url) // 302 redirect
$this->back() // Redirect to referer
$this->validate($data, $rules) // Validate or flash errors
$this->authorize('permission.key') // Check or throw 403
$this->currentEmployee() // ?object
```
### Response (Fluent builder)
```php
$this->redirect('/path')
->withSuccess('msg') // Flash success alert
->withError('msg') // Flash error alert
->withWarning('msg') // Flash warning alert
->withInput($data) // Flash old input
// Also: ->html($content), ->json($data), ->download($path, $name)
```
### Request
```php
$request->method() // GET/POST/PUT/DELETE
$request->path() // /members/123
$request->get('key', $default) // GET param
$request->post('key', $default) // POST param
$request->input('key', $default) // POST or GET
$request->all() // All input merged
$request->file('key') // ?array (uploaded file)
$request->isAjax() // bool
$request->ip() // Client IP
$request->routeParam('id') // Route parameter
```
### Template
```php
// In views:
$__template->layout('Layout.main')
$__template->section('content') / $__template->endSection()
$__template->yield('content', $default)
$__template->include('Module.Views.partial', $data)
$__template->includeIf('path', $data)
// Path resolution (dot notation):
'Layout.main' app/Shared/Layout/main.php
'Members.Views.show' app/Modules/Members/Views/show.php
'Shared.Components.alerts' app/Shared/Components/alerts.php
```
### EventBus
```php
EventBus::listen('event.name', fn($data), $priority) // Higher priority = first
EventBus::dispatch('event.name', $data) // Returns handler results
EventBus::dispatchAsync('event.name', $data) // Queue for async
EventBus::hasListeners('event.name') // bool
```
### Session
```php
$session->get('key', $default) / ->set('key', $val) / ->has('key') / ->remove('key')
$session->flash('key', $val) // One-time data
$session->getFlash('key') // Get and delete
$session->getAlerts() // Get flashed alerts array
$session->regenerate() / ->destroy()
```
### Validator
```php
$result = (new Validator())->validate($data, [
'name' => 'required|string|max:200',
'age' => 'required|integer|min:1|max:120',
'email' => 'nullable|email|unique:employees,email',
'nid' => 'required|national_id',
'phone' => 'required|phone_eg',
]);
$result->passes() / ->fails() / ->errors() / ->validated()
// Available rules: required, string, integer, numeric, email, date,
// min:N, max:N, between:M,N, in:a,b,c, not_in:a,b,c, digits:N,
// digits_between:M,N, regex:pattern, confirmed, unique:table,col[,except],
// exists:table,col, phone_eg, national_id, arabic_text, english_text,
// date_before_today, date_after_today, date_before:field, date_after:field,
// file, file_max:kb, file_types:ext1,ext2, array, json, decimal:N, nullable
```
### Global Helpers
```php
e($text) // HTML escape
money($amount) // Currency format (e.g., "1,500.00 ج.م")
csrf_field() // <input type="hidden" ...>
old('field', $default) // Flashed input
arabic_date($date) // Localized date
now() / today() // Current datetime/date strings
age_from_dob($dob) // ['years' => N, 'months' => M, 'days' => D]
generate_uuid() // UUID v4
url('/path') // Full URL with base
flash_success/error/warning($msg) // Shorthand flash
```
---
## 6. CODING PATTERNS & CONVENTIONS
### File Structure
```
app/Modules/{Name}/
├── bootstrap.php — PermissionRegistry, MenuRegistry, EventBus::listen
├── Routes.php — return [[METHOD, PATH, HANDLER, MIDDLEWARE, PERM], ...]
├── Controllers/XxxController.php
├── Models/Xxx.php
├── Services/XxxService.php
└── Views/
├── index.php
├── show.php
├── create.php
├── edit.php
└── _partials/
```
### Route Definition
```php
return [
['GET', '/prefix', 'Module\Controllers\Ctrl@index', ['auth'], 'perm.view'],
['GET', '/prefix/create','Module\Controllers\Ctrl@create', ['auth'], 'perm.create'],
['POST', '/prefix', 'Module\Controllers\Ctrl@store', ['auth', 'csrf'], 'perm.create'],
['GET', '/prefix/{id}', 'Module\Controllers\Ctrl@show', ['auth'], 'perm.view'],
['POST', '/prefix/{id}', 'Module\Controllers\Ctrl@update', ['auth', 'csrf'], 'perm.edit'],
];
```
### Controller Pattern
```php
public function store(Request $request): Response
{
$this->authorize('module.create');
$data = $request->all();
// Validate
$errors = [];
if (empty($data['name'])) $errors[] = 'الاسم مطلوب';
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type'=>'error','message'=>$e], $errors));
return $this->redirect('/prefix/create')->withInput($data);
}
// Create
$model = Model::create($data);
return $this->redirect('/prefix/' . $model->id)
->withSuccess('تم الإنشاء بنجاح');
}
```
### Service Pattern (Static, final)
```php
final class XxxService
{
public static function doSomething(int $id, array $data): array
{
$db = App::getInstance()->db();
// ... business logic with bcmath ...
return ['success' => true, 'id' => $newId];
// OR: return ['success' => false, 'error' => 'Arabic message'];
}
}
```
### Financial Calculations (ALWAYS bcmath)
```php
// Percentage of value
$fee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2);
// Addition
$total = bcadd($subtotal, $tax, 2);
// Comparison
if (bccomp($amount, '0.00', 2) > 0) { /* positive */ }
// NEVER use float for money. NEVER cast to (float).
```
### Bootstrap Pattern
```php
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
use App\Core\Registries\MenuRegistry;
use App\Core\EventBus;
PermissionRegistry::register('module_name', [
'perm.view' => ['ar' => 'عرض', 'en' => 'View'],
'perm.create' => ['ar' => 'إنشاء', 'en' => 'Create'],
]);
MenuRegistry::register('menu_key', [
'label_ar' => 'القائمة',
'icon' => 'lucide-icon-name',
'route' => '/prefix',
'permission' => 'perm.view',
'order' => 500,
'children' => [...],
]);
EventBus::listen('some.event', function(array $data) {
// React to event
}, priority: 100);
```
### Migration Pattern
```php
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `table_name` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`code` VARCHAR(50) NOT NULL,
`name_ar` VARCHAR(255) NOT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` TIMESTAMP NULL,
`archived_by` BIGINT UNSIGNED NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
INDEX `idx_active` (`is_active`, `is_archived`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `table_name`",
];
```
### Seed Pattern
```php
<?php
declare(strict_types=1);
return function (\App\Core\Database $db) {
$exists = $db->selectOne("SELECT id FROM table WHERE code = ?", ['CODE']);
if ($exists) return;
$db->insert('table', [
'code' => 'CODE',
'name_ar' => 'الاسم',
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
]);
};
```
### Standard Table Columns
Every entity table includes:
- `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
- `is_archived` TINYINT(1) DEFAULT 0 (soft delete)
- `archived_at` TIMESTAMP NULL
- `archived_by` BIGINT UNSIGNED NULL
- `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
- `created_by` BIGINT UNSIGNED NULL
- `updated_by` BIGINT UNSIGNED NULL
- `branch_id` BIGINT UNSIGNED NULL (for branch isolation; NULL = global)
### Naming Conventions
| Item | Convention | Example |
|------|-----------|---------|
| Table | snake_case plural | `payment_requests` |
| Column | snake_case | `membership_value` |
| Model | PascalCase singular | `PaymentRequest` |
| Controller | PascalCase + Controller | `PaymentController` |
| Service | PascalCase + Service | `PaymentRequestService` |
| Permission | module.action | `cashier.process_payment` |
| Route | kebab-case | `/activity-subscriptions` |
| Migration | Phase_NN_NNN_description | `Phase_38_001_create_payment_requests_table.php` |
| Seed | Phase_NN_NNN_seed_description | `Phase_40_001_seed_master_document_compliance.php` |
| Rule code | UPPER_SNAKE_CASE | `CHILD_INCLUDED_MAX_COUNT` |
| Event | dot.separated | `payment.completed` |
---
## 7. BUSINESS RULES ENGINE
All configurable values live in `business_rules` table, accessed via `RuleEngine`:
```php
// Get full rule data (returns decoded JSON)
$data = RuleEngine::get('RULE_CODE'); // e.g., ['percentage' => '10.00']
// Get single value from rule
$val = RuleEngine::getValue('RULE_CODE', 'value'); // e.g., 3
```
### Key Rule Codes
| Code | Category | Type | Value |
|------|----------|------|-------|
| `INITIAL_FREE_CHILDREN_COUNT` | children_fee | integer | 3 |
| `CHILD_INCLUDED_MAX_AGE` | age | integer | 18 |
| `CHILD_MAX_AGE` | age | integer | 25 |
| `CHILD_FEE_AGE_18/19/20` | children_fee | percentage | 5-10% |
| `CHILD_FEE_AGE_21` | children_fee | percentage | 15% |
| `CHILD_4TH_UNDER_18_FEE` | children_fee | percentage | 5% |
| `STEPCHILD_FEE` | children_fee | percentage | 10% |
| `SPOUSE_BASE_MEMBER_FEE` | spouse_fee | percentage | 15% |
| `SPOUSE_ACQUIRED_MEMBER_FEE` | spouse_fee | percentage | 50% |
| `SPOUSE_FOREIGN_FEE` | spouse_fee | percentage | 15% |
| `SPOUSE_2ND_FEE` | spouse_fee | percentage+annual_flat | 10% + 150/yr |
| `SPOUSE_3RD_FEE` | spouse_fee | percentage+annual_flat | 20% + 200/yr |
| `SPOUSE_4TH_FEE` | spouse_fee | percentage+annual_flat | 30% + 300/yr |
| `FORM_ADDITION_FEE` | financial | amount | varies |
| `MAX_SPOUSES_MALE_MEMBER` | spouse_fee | integer | 4 |
| `MAX_SPOUSES_FEMALE_MEMBER` | spouse_fee | integer | 1 |
| `SPOUSE_MIN_AGE` | spouse_fee | integer | 18 |
| `CHILD_NID_REQUIRED_AGE` | age | integer | 16 |
| `CHILD_MIN_AGE_GAP` | age | integer | 15 |
| `CHILD_MAX_AGE_GAP` | age | integer | 60 |
| `DEATH_TRANSFER_WINDOW_MONTHS` | death | integer | 12 |
| `FREEZE_MAX_YEARS` | workflow | integer | 3 |
| `FREEZE_ANNUAL_FEE_PERCENTAGE` | workflow | percentage | 50% |
| `CARNET_REPLACEMENT_FEE` | financial | amount | 200.00 |
| `BOARD_APPROVAL_THRESHOLD` | workflow | amount | 50,000.00 |
| `SEPARATION_FEE_YEAR_1..5` | transfer | percentage | varies |
| `INSTALLMENT_INTEREST_RATE` | financial | percentage | 22% |
---
## 8. PAYMENT & CASHIER WORKFLOW
```
Service Desk Cashier Queue System
───────────── ───────────── ──────
Creates payment_request ─────────► Appears in /cashier queue
(status: pending) │
Cashier opens request
Selects payment method
Clicks "Complete"
PaymentRequestService::processRequest()
├── PaymentService::processPayment()
│ ├── Creates payment record
│ ├── Creates receipt
│ └── Dispatches payment.completed
├── Updates request status → completed
├── Dispatches domain event (e.g., member.activated)
Accounting module (via EventBus)
├── GLSyncService posts journal entry
└── Updates account_balances
```
### Member Activation Flow
```
1. member created (status: draft)
2. form_fee payment_request → cashier pays → form unlocked
3. form filled (status: form_filled)
4. membership_fee payment_request → cashier pays
5. member.activated event fired → status: active
```
---
## 9. MULTI-BRANCH ISOLATION
- `branch_id` column on most data tables
- `NULL` branch_id = company-wide (rules, roles, master data)
- Employee bound to one branch via `employees.branch_id`
- `App::getInstance()->currentBranch()` set by AuthMiddleware
- Queries filter by branch: `WHERE branch_id = ?`
- Pricing is branch-specific: `pricing_configs.branch_id`
- Reports support cross-branch aggregation for admins
---
## 10. AUTHORIZATION MODEL
```
Employee ──► employee_roles ──► roles ──► role_permissions ──► permission_key
└── employee_permissions (direct grant/deny overrides)
```
- Route-level: 5th parameter in route array → checked in AuthMiddleware
- Controller-level: `$this->authorize('perm.key')` → throws 403
- View-level: Check employee permissions before rendering buttons/sections
- Super admin: role_code = 'super_admin' bypasses all checks
---
## 11. AUDIT SYSTEM
Automatic via Database hooks registered in Audit module bootstrap:
- Every INSERT/UPDATE/DELETE on non-system tables → `audit_trail` record
- Stores: who, when, what table, what entity, before/after JSON, changed fields
- Ignores: migrations, seeds, sessions, audit_trail itself
- Sensitive fields (password) masked in logs
---
## 12. KEY SERVICES REFERENCE
| Service | Module | Purpose |
|---------|--------|---------|
| `PricingEngine` | Pricing | Calculate child/spouse/separation/installment fees |
| `RuleEngine` | Rules | Read business rules from DB |
| `PaymentService` | Payments | Process payments, create receipts |
| `PaymentRequestService` | Cashier | Cashier queue management |
| `BillingService` | Members | Combined invoice calculation |
| `FormFeeService` | Members | Form fee logic, free slot detection |
| `ChildFeeCalculator` | Children | Child addition fee calculation |
| `SpouseFeeCalculator` | Spouses | Spouse addition fee calculation |
| `GLSyncService` | Accounting | Auto-post journal entries from events |
| `JournalService` | Accounting | Manual journal entry creation |
| `MemberSearchService` | Members | Full-text member search |
| `MemberNumberGenerator` | Members | Sequential membership numbers |
| `FormNumberGenerator` | Members | Sequential form numbers |
| `NationalIdParser` | Members | Extract DOB/gender/governorate from NID |
| `AuditService` | Audit | Manual audit logging + hook registration |
| `AttachmentService` | Support | File upload handling |
| `PayrollCalculationService` | HR | Salary computation |
| `LeaveService` | HR | Leave request workflow |
---
## 13. COMMON PITFALLS
1. **CLI context**: `App::getInstance()->db()` is null. Seeds receive `Database` as param.
2. **Table existence**: Never `SHOW TABLES LIKE ?` — use `information_schema.tables` query.
3. **Soft delete column**: Not all tables have `is_archived`. Check migration first.
4. **strict_types**: All files declare it. Type mismatches = fatal error.
5. **Empty string to DECIMAL**: Convert `''` to `null` before DB insert/update.
6. **bcmath strings**: Always pass strings to bcmath functions, never floats.
7. **Route params**: Come as strings. Cast: `(int) $id` before DB queries.
8. **CSRF**: Required on all POST/PUT/DELETE. Missing = 419 error.
9. **Flash messages**: Consumed on read. Don't read twice.
10. **Event priority**: Higher number = runs first. Default = 0.
---
## 14. ADDING A NEW MODULE (Checklist)
1. Create `app/Modules/{Name}/` directory structure
2. Create `bootstrap.php` — register permissions, menu, event listeners
3. Create `Routes.php` — define all routes
4. Create Controller(s) extending `App\Core\Controller`
5. Create Model(s) extending `App\Core\Model` with `$table`, `$fillable`
6. Create migration `database/migrations/Phase_NN_NNN_create_xxx_table.php`
7. Create seed if needed `database/seeds/Phase_NN_NNN_seed_xxx.php`
8. Create Views using `$__template->layout('Layout.main')`
9. Run `php cli.php migrate` then `php cli.php seed`
10. Verify via UI — check permissions, menu visibility, CRUD operations
......@@ -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