Commit ac32be3c authored by Mahmoud Aglan's avatar Mahmoud Aglan

add these

parent 0c5becba
# Full ERP Features Execution Plan
## Every Missing Feature — Detailed Implementation Blueprint
**Total Scope:** 70+ features across 8 phases
**Estimated Timeline:** 30-39 weeks
**Migration Phase Numbering:** Starting at Phase_56 (after existing Phase_55)
---
# ═══════════════════════════════════════════════════════════════════════
# PHASE 1: QUICK WINS (Week 1-3)
# ═══════════════════════════════════════════════════════════════════════
## 1.1 — Customer & Supplier Credit Limits
### Database Changes
```
Migration: Phase_56_001_add_credit_limits.php
ALTER TABLE members ADD COLUMN credit_limit DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER membership_status;
ALTER TABLE members ADD COLUMN credit_balance DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER credit_limit;
ALTER TABLE suppliers ADD COLUMN credit_limit DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER email;
ALTER TABLE suppliers ADD COLUMN credit_balance DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER credit_limit;
```
### Files to Create/Modify
```
MODIFY: app/Modules/Members/Controllers/MemberController.php
- Add credit_limit to store/update validation and saving
- Add credit_balance display on show view
MODIFY: app/Modules/Members/Views/show.php
- Add credit limit display section
- Show: حد الائتمان | التحصيلات النقدية | الحد المسموح به
MODIFY: app/Modules/Inventory/Controllers/SupplierController.php
- Add credit_limit field to store/update
MODIFY: app/Modules/Inventory/Views/suppliers/show.php
- Display credit information
CREATE: app/Modules/Members/Services/CreditLimitService.php
Methods:
- checkMemberCredit(int $memberId, float $amount): bool
- getMemberAvailableCredit(int $memberId): float
- updateMemberCreditBalance(int $memberId): void
- checkSupplierCredit(int $supplierId, float $amount): bool
- getSupplierAvailableCredit(int $supplierId): float
MODIFY: app/Modules/Sales/Controllers/SaleController.php
- In store(): call CreditLimitService::checkMemberCredit() before processing
- If exceeds: flash error "تم تجاوز حد الائتمان المسموح به" and redirect back
MODIFY: app/Modules/Procurement/Controllers/VendorInvoiceController.php
- In store(): call CreditLimitService::checkSupplierCredit()
```
### Event Integration
```
MODIFY: app/Modules/Accounting/bootstrap.php
- Listen to 'payment.completed' → recalculate member credit_balance
- Listen to 'vendor_payment.completed' → recalculate supplier credit_balance
```
---
## 1.2 — Purchase Order Delivery Tracking
### Database Changes
```
Migration: Phase_56_002_add_po_delivery_tracking.php
ALTER TABLE purchase_order_items ADD COLUMN expected_delivery_date DATE NULL AFTER quantity;
ALTER TABLE purchase_order_items ADD COLUMN quantity_received DECIMAL(15,3) NOT NULL DEFAULT 0 AFTER expected_delivery_date;
ALTER TABLE purchase_order_items ADD COLUMN delivery_percentage DECIMAL(5,2) GENERATED ALWAYS AS (
CASE WHEN quantity > 0 THEN ROUND((quantity_received / quantity) * 100, 2) ELSE 0 END
) STORED;
```
### Files to Create/Modify
```
MODIFY: app/Modules/Inventory/Controllers/PurchaseOrderController.php
- Add expected_delivery_date to store/receiveForm
- Update quantity_received on receive action
MODIFY: app/Modules/Inventory/Views/purchase-orders/create.php
- Add date picker per line item for expected delivery
MODIFY: app/Modules/Procurement/Controllers/ProcurementReportController.php
- Add method: overdueDeliveries()
CREATE: app/Modules/Procurement/Views/reports/overdue-deliveries.php
- Table: PO#, Supplier, Item, Expected Date, Days Overdue, Qty Ordered, Qty Received, %
MODIFY: app/Modules/Procurement/Routes.php
- Add: ['GET', '/procurement/reports/overdue-deliveries', 'Procurement\Controllers\ProcurementReportController@overdueDeliveries', ['auth'], 'procurement.report']
```
---
## 1.3 — PO Balance Report (Ordered vs Received vs Remaining)
### Files to Create/Modify
```
MODIFY: app/Modules/Procurement/Controllers/ProcurementReportController.php
- Add method: poBalance()
- Query: SELECT po.*, poi.*, s.name as supplier_name, ii.name_ar as item_name
FROM purchase_orders po
JOIN purchase_order_items poi ON po.id = poi.purchase_order_id
JOIN suppliers s ON po.supplier_id = s.id
JOIN inventory_items ii ON poi.item_id = ii.id
WHERE poi.quantity > poi.quantity_received
GROUP BY po.id
CREATE: app/Modules/Procurement/Views/reports/po-balance.php
- Filters: supplier, date range, item group
- Table: PO#, Date, Item, Qty Ordered, Qty Received, Remaining, Delivery %
- Summary row with totals
MODIFY: app/Modules/Procurement/Routes.php
- Add route for PO balance report
```
---
## 1.4 — Customer Account Statement
### Files to Create/Modify
```
CREATE: app/Modules/Members/Controllers/MemberStatementController.php
namespace App\Modules\Members\Controllers;
Methods:
- statement(Request $request, int $id)
→ Fetches all financial movements for member:
- Payments (from payments table)
- Sales (from sales table)
- Fines (from fines table)
- Installments (from installment_schedule)
- Subscriptions (from subscriptions)
→ Merges, sorts by date
→ Calculates running balance (debit/credit)
→ Returns detailed or summary view based on ?type=detailed|summary
CREATE: app/Modules/Members/Views/statement.php
- Header: Member name, code, date range filter
- Table (detailed): التاريخ | نوع المستند | رقم المستند | البيان | مدين | دائن | الرصيد
- Table (summary): نوع المستند | عدد | إجمالي مدين | إجمالي دائن
- Footer: الإجمالي
MODIFY: app/Modules/Members/Routes.php
- Add: ['GET', '/members/{id:\d+}/statement', 'Members\Controllers\MemberStatementController@statement', ['auth'], 'member.view']
MODIFY: app/Modules/Members/bootstrap.php
- Add permission: 'member.statement' => ['ar' => 'كشف حساب العضو', 'en' => 'Member Statement']
```
---
## 1.5 — Supplier Account Statement
### Files to Create/Modify
```
CREATE: app/Modules/Inventory/Controllers/SupplierStatementController.php
namespace App\Modules\Inventory\Controllers;
Methods:
- statement(Request $request, int $id)
→ Fetches all movements for supplier:
- Purchase Orders (from purchase_orders)
- GRNs (from goods_received_notes)
- Vendor Invoices (from vendor_invoices)
- Vendor Payments (from vendor_payments)
- Returns to Vendor (from returns_to_vendor)
→ Running balance calculation
→ Detailed and summary modes
CREATE: app/Modules/Inventory/Views/suppliers/statement.php
- Same format as member statement but for suppliers
- التاريخ | نوع المستند | رقم المستند | البيان | مدين | دائن | الرصيد
MODIFY: app/Modules/Inventory/Routes.php
- Add: ['GET', '/inventory/suppliers/{id:\d+}/statement', 'Inventory\Controllers\SupplierStatementController@statement', ['auth'], 'supplier.view']
```
---
## 1.6 — Sales Returns with Automatic Warehouse Update
### Files to Create/Modify
```
MODIFY: app/Modules/Sales/Controllers/SaleController.php
- In refund() method: after creating sale_refund record, call StockService to add items back
MODIFY: app/Modules/Sales/Controllers/SaleController.php::refund()
Add after refund record creation:
```php
// Return items to stock
foreach ($refundItems as $item) {
StockService::adjustStock(
itemId: $item['item_id'],
warehouseId: $sale['warehouse_id'],
quantity: $item['quantity'],
type: 'in',
reference: 'sale_refund',
referenceId: $refundId,
notes: "مرتجع مبيعات - فاتورة #{$sale['invoice_number']}"
);
}
EventBus::dispatch('sale.refunded', ['sale_id' => $saleId, 'refund_id' => $refundId, 'items' => $refundItems]);
```
MODIFY: app/Modules/Inventory/Services/StockService.php
- Ensure adjustStock method handles 'sale_refund' reference type
```
---
## 1.7 — Inventory Opening Balances
### Files to Create/Modify
```
CREATE: app/Modules/Inventory/Controllers/OpeningBalanceController.php
namespace App\Modules\Inventory\Controllers;
Methods:
- index(Request $request)
→ Show form: select warehouse, list all items with quantity input
- store(Request $request)
→ For each item with quantity > 0:
- Create stock movement (type: 'opening_balance')
- Update item_warehouse_stocks
→ Flash success
CREATE: app/Modules/Inventory/Views/opening-balance/index.php
- Warehouse selector dropdown
- Items table: كود الصنف | اسم الصنف | الوحدة | الرصيد الحالي | رصيد أول المدة (input)
- Submit button
MODIFY: app/Modules/Inventory/Routes.php
- ['GET', '/inventory/opening-balance', ...]
- ['POST', '/inventory/opening-balance', ...]
MODIFY: app/Modules/Inventory/bootstrap.php
- Permission: 'inventory.opening_balance' => ['ar' => 'أرصدة أول المدة', 'en' => 'Opening Balances']
```
---
## 1.8 — Asset Custody & Location Tracking
### Database Changes
```
Migration: Phase_56_003_add_asset_custody_tracking.php
ALTER TABLE asset_registers ADD COLUMN custodian_employee_id INT UNSIGNED NULL AFTER depreciation_start;
ALTER TABLE asset_registers ADD COLUMN site_location VARCHAR(255) NULL AFTER custodian_employee_id;
CREATE TABLE asset_custody_history (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
asset_id INT UNSIGNED NOT NULL,
from_employee_id INT UNSIGNED NULL,
to_employee_id INT UNSIGNED NULL,
from_location VARCHAR(255) NULL,
to_location VARCHAR(255) NULL,
transfer_date DATE NOT NULL,
notes TEXT NULL,
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_asset (asset_id),
KEY idx_employee (to_employee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### Files to Create/Modify
```
MODIFY: app/Modules/Inventory/Controllers/AssetController.php
- Add method: transferCustody(Request $request, int $id)
- Add method: custodyHistory(Request $request, int $id)
CREATE: app/Modules/Inventory/Views/assets/transfer-custody.php
- Form: from employee (auto-filled), to employee (dropdown), location, date, notes
CREATE: app/Modules/Inventory/Views/assets/custody-history.php
- Table: Date | From | To | Location | Notes | Done By
MODIFY: app/Modules/Inventory/Routes.php
- ['GET', '/inventory/assets/{id:\d+}/transfer-custody', ...]
- ['POST', '/inventory/assets/{id:\d+}/transfer-custody', ...]
- ['GET', '/inventory/assets/{id:\d+}/custody-history', ...]
```
---
# ═══════════════════════════════════════════════════════════════════════
# PHASE 2: FINANCIAL & BANKING ENHANCEMENTS (Week 4-7)
# ═══════════════════════════════════════════════════════════════════════
## 2.1 — Check Lifecycle Status Tracking
### Database Changes
```
Migration: Phase_57_001_check_lifecycle_tracking.php
CREATE TABLE instrument_status_history (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
instrument_id INT UNSIGNED NOT NULL,
from_status VARCHAR(50) NULL,
to_status VARCHAR(50) NOT NULL,
transition_date DATE NOT NULL,
notes TEXT NULL,
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_instrument (instrument_id),
KEY idx_date (transition_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE negotiable_instruments ADD COLUMN deposit_date DATE NULL AFTER maturity_date;
ALTER TABLE negotiable_instruments ADD COLUMN collection_date DATE NULL AFTER deposit_date;
ALTER TABLE negotiable_instruments ADD COLUMN bounce_date DATE NULL AFTER collection_date;
ALTER TABLE negotiable_instruments ADD COLUMN endorsed_to VARCHAR(255) NULL AFTER bounce_date;
ALTER TABLE negotiable_instruments ADD COLUMN endorsement_date DATE NULL AFTER endorsed_to;
```
### Status Workflow
```
ISSUED → DEPOSITED → COLLECTED (success path)
ISSUED → DEPOSITED → BOUNCED (failure path)
ISSUED → ENDORSED (transfer to another party)
DEPOSITED → RETURNED (bank returns before maturity)
```
### Files to Create/Modify
```
CREATE: app/Modules/Accounting/Services/InstrumentLifecycleService.php
namespace App\Modules\Accounting\Services;
Methods:
- deposit(int $instrumentId, string $depositDate, ?string $notes): void
→ Validate status = 'issued' or 'active'
→ Update status to 'deposited', set deposit_date
→ Insert status_history record
→ Dispatch 'instrument.deposited' event
- collect(int $instrumentId, string $collectionDate, ?string $notes): void
→ Validate status = 'deposited'
→ Update status to 'collected', set collection_date
→ Insert status_history
→ Auto-post journal entry (DR Bank, CR Instruments Receivable)
→ Dispatch 'instrument.collected'
- bounce(int $instrumentId, string $bounceDate, ?string $notes): void
→ Validate status = 'deposited'
→ Update status to 'bounced', set bounce_date
→ Insert status_history
→ Auto-post reversal journal entry
→ Dispatch 'instrument.bounced'
- endorse(int $instrumentId, string $endorsedTo, string $endorsementDate, ?string $notes): void
→ Validate status = 'issued' or 'active'
→ Update status to 'endorsed', set endorsed_to, endorsement_date
→ Insert status_history
→ Auto-post journal entry (DR AP/new party, CR Instruments)
→ Dispatch 'instrument.endorsed'
- getStatusHistory(int $instrumentId): array
- getValidTransitions(string $currentStatus): array
MODIFY: app/Modules/Accounting/Controllers/NegotiableInstrumentController.php
- Add method: deposit(Request $request, int $id)
- Add method: collect(Request $request, int $id)
- Add method: bounce(Request $request, int $id)
- Add method: endorse(Request $request, int $id)
- Add method: statusHistory(Request $request, int $id)
CREATE: app/Modules/Accounting/Views/instruments/status-actions.php
- Show current status with colored badge
- Show available actions as buttons based on current status
- Action modals with date/notes inputs
CREATE: app/Modules/Accounting/Views/instruments/status-history.php
- Timeline view of all status changes
MODIFY: app/Modules/Accounting/Routes.php
- ['POST', '/accounting/instruments/{id:\d+}/deposit', ...]
- ['POST', '/accounting/instruments/{id:\d+}/collect', ...]
- ['POST', '/accounting/instruments/{id:\d+}/bounce', ...]
- ['POST', '/accounting/instruments/{id:\d+}/endorse', ...]
- ['GET', '/accounting/instruments/{id:\d+}/history', ...]
```
---
## 2.2 — Check Endorsement (تظهير)
### Already covered in 2.1 endorse() method above
### Additional UI:
```
CREATE: app/Modules/Accounting/Views/instruments/endorse-form.php
- Endorsed to (text field — person/company name)
- Endorsement date
- Reason/notes
- Original check details displayed for reference
```
---
## 2.3 — Payment Note Portfolio Batch Processing
### Files to Create/Modify
```
MODIFY: app/Modules/Accounting/Controllers/InstrumentPortfolioController.php
- Add method: batchUpdateStatus(Request $request, int $id)
→ Accept array of instrument IDs + target status
→ Call InstrumentLifecycleService for each
→ Return summary of successes/failures
CREATE: app/Modules/Accounting/Views/portfolios/batch-update.php
- Portfolio header info
- Checkbox list of all instruments in portfolio
- "Select All" / "Deselect All" buttons
- Target status dropdown (filtered to valid transitions)
- Date field
- Notes field
- Submit button
MODIFY: app/Modules/Accounting/Routes.php
- ['POST', '/accounting/portfolios/{id:\d+}/batch-update', ...]
```
---
## 2.4 — Paper/Check Multi-Criteria Inquiry
### Files to Create/Modify
```
MODIFY: app/Modules/Accounting/Controllers/NegotiableInstrumentController.php
- Add method: advancedSearch(Request $request)
→ Accept filters: serial, check_number, bank_id, status, type (check/note),
date_from, date_to, date_type (issue/maturity), party_name,
amount_from, amount_to, direction (receivable/payable)
→ Build dynamic WHERE clause
→ Return paginated results
CREATE: app/Modules/Accounting/Views/instruments/advanced-search.php
- Multi-field filter form (collapsible)
- Results grid: مسلسل | رقم الشيك | البنك | الجهة | القيمة | تاريخ الإصدار | الاستحقاق | الحالة | النوع
- Export to Excel button
MODIFY: app/Modules/Accounting/Routes.php
- ['GET', '/accounting/instruments/search', ...]
```
---
## 2.5 — Daily Cash Movement Report (Safe/Treasury)
### Files to Create/Modify
```
MODIFY: app/Modules/Accounting/Controllers/ReportController.php
- Add method: dailyCashMovement(Request $request)
→ Accept: date, safe_id/bank_account_id
→ Calculate: opening balance (sum of all prior movements)
→ List all movements for the day with description
→ Calculate: closing = opening + inflows - outflows
CREATE: app/Modules/Accounting/Views/reports/daily-cash-movement.php
- Header: Safe/Bank name, Date
- Opening balance
- Inflows table: مسلسل | البيان | المبلغ | المرجع
- Outflows table: same structure
- Summary: Opening | + Inflows | - Outflows | = Closing
MODIFY: app/Modules/Accounting/Routes.php
- ['GET', '/accounting/reports/daily-cash', ...]
```
---
## 2.6 — Cross-Entity Settlements
### Database Changes
```
Migration: Phase_57_002_cross_entity_settlements.php
CREATE TABLE cross_entity_settlements (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
settlement_number VARCHAR(20) NOT NULL,
settlement_date DATE NOT NULL,
purpose ENUM('payment','advance','adjustment') NOT NULL,
-- Source party
source_type ENUM('supplier','customer','bank','safe','member') NOT NULL,
source_id INT UNSIGNED NOT NULL,
source_name VARCHAR(255) NOT NULL,
-- Destination party
dest_type ENUM('supplier','customer','bank','safe','member') NOT NULL,
dest_id INT UNSIGNED NOT NULL,
dest_name VARCHAR(255) NOT NULL,
amount DECIMAL(15,2) NOT NULL,
notes TEXT NULL,
journal_entry_id INT UNSIGNED NULL,
status ENUM('draft','posted','reversed') NOT NULL DEFAULT 'draft',
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_number (settlement_number),
KEY idx_source (source_type, source_id),
KEY idx_dest (dest_type, dest_id),
KEY idx_date (settlement_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### Files to Create/Modify
```
CREATE: app/Modules/Accounting/Controllers/SettlementController.php
Methods:
- index(Request $request) — list all settlements
- create(Request $request) — show form
- store(Request $request) — validate & save
- show(Request $request, int $id) — view details
- post(Request $request, int $id) — post to GL
- reverse(Request $request, int $id) — reverse
CREATE: app/Modules/Accounting/Services/SettlementService.php
Methods:
- createSettlement(array $data): int
- postSettlement(int $id): void
→ Create journal entry: DR source account, CR destination account
→ Update related entity balances
- reverseSettlement(int $id): void
- getNextNumber(): string
CREATE: app/Modules/Accounting/Models/CrossEntitySettlement.php
protected static string $table = 'cross_entity_settlements';
CREATE: app/Modules/Accounting/Views/settlements/index.php
CREATE: app/Modules/Accounting/Views/settlements/create.php
- Purpose dropdown: سداد | دفعة مقدمة | تسوية
- Source party: type dropdown + entity picker
- Destination party: type dropdown + entity picker
- Amount, Date, Notes
CREATE: app/Modules/Accounting/Views/settlements/show.php
MODIFY: app/Modules/Accounting/Routes.php
- Full CRUD + post + reverse routes for settlements
MODIFY: app/Modules/Accounting/bootstrap.php
- Permissions: 'accounting.settlement.view', 'accounting.settlement.create', 'accounting.settlement.post'
- Menu item under Accounting
```
---
## 2.7 — Bank Loan Management
### Database Changes
```
Migration: Phase_57_003_bank_loans.php
CREATE TABLE bank_loans (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
loan_number VARCHAR(30) NOT NULL,
bank_account_id INT UNSIGNED NOT NULL,
loan_type ENUM('short_term','long_term','revolving') NOT NULL,
principal_amount DECIMAL(15,2) NOT NULL,
interest_rate DECIMAL(5,4) NOT NULL,
term_months INT UNSIGNED NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
payment_frequency ENUM('monthly','quarterly','semi_annual','annual') NOT NULL DEFAULT 'monthly',
total_interest DECIMAL(15,2) NOT NULL DEFAULT 0,
total_paid_principal DECIMAL(15,2) NOT NULL DEFAULT 0,
total_paid_interest DECIMAL(15,2) NOT NULL DEFAULT 0,
outstanding_balance DECIMAL(15,2) NOT NULL DEFAULT 0,
gl_account_id INT UNSIGNED NULL COMMENT 'Loan liability account',
interest_expense_account_id INT UNSIGNED NULL,
status ENUM('active','completed','defaulted') NOT NULL DEFAULT 'active',
notes TEXT NULL,
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_number (loan_number),
KEY idx_bank (bank_account_id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE bank_loan_schedule (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
loan_id INT UNSIGNED NOT NULL,
installment_number INT UNSIGNED NOT NULL,
due_date DATE NOT NULL,
principal_amount DECIMAL(15,2) NOT NULL,
interest_amount DECIMAL(15,2) NOT NULL,
total_amount DECIMAL(15,2) NOT NULL,
paid_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
paid_date DATE NULL,
status ENUM('pending','paid','overdue','partial') NOT NULL DEFAULT 'pending',
journal_entry_id INT UNSIGNED NULL,
KEY idx_loan (loan_id),
KEY idx_due (due_date),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### Files to Create/Modify
```
CREATE: app/Modules/Accounting/Controllers/BankLoanController.php
Methods:
- index — list loans with status filter
- create — new loan form
- store — validate, save, generate amortization schedule
- show — loan details with schedule
- payInstallment — record payment against schedule
- scheduleReport — amortization schedule view
CREATE: app/Modules/Accounting/Services/BankLoanService.php
Methods:
- createLoan(array $data): int
- generateAmortizationSchedule(int $loanId): void
→ Calculate PMT formula: M = P * [r(1+r)^n] / [(1+r)^n - 1]
→ Split each payment into principal + interest
→ Insert schedule records
- recordPayment(int $scheduleItemId, float $amount, string $date): void
→ Post journal: DR Loan Payable + DR Interest Expense, CR Bank
→ Update outstanding_balance
- checkOverdueInstallments(): array
- getOutstandingBalance(int $loanId): float
CREATE: app/Modules/Accounting/Models/BankLoan.php
CREATE: app/Modules/Accounting/Models/BankLoanSchedule.php
CREATE: app/Modules/Accounting/Views/loans/index.php
CREATE: app/Modules/Accounting/Views/loans/create.php
- Bank account (dropdown)
- Loan type, amount, rate, term, start date, frequency
- GL accounts mapping
CREATE: app/Modules/Accounting/Views/loans/show.php
- Loan summary card
- Amortization schedule table: # | Due Date | Principal | Interest | Total | Paid | Status
- "Pay" button for pending installments
MODIFY: app/Modules/Accounting/Routes.php
- CRUD + payInstallment routes
MODIFY: app/Modules/Accounting/bootstrap.php
- Permissions: loan.view, loan.create, loan.pay
- Menu: "القروض البنكية" under Accounting
```
---
# ═══════════════════════════════════════════════════════════════════════
# PHASE 3: PROCUREMENT CYCLE ENHANCEMENT (Week 8-11)
# ═══════════════════════════════════════════════════════════════════════
## 3.1 — Supplier Price Quote Management
### Database Changes
```
Migration: Phase_58_001_supplier_price_quotes.php
CREATE TABLE supplier_price_quotes (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
quote_number VARCHAR(20) NOT NULL,
requisition_id INT UNSIGNED NULL COMMENT 'Links to purchase_requisitions',
supplier_id INT UNSIGNED NOT NULL,
quote_date DATE NOT NULL,
valid_until DATE NULL,
delivery_days INT UNSIGNED NULL,
payment_terms VARCHAR(255) NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'EGP',
total_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
notes TEXT NULL,
status ENUM('draft','submitted','evaluated','accepted','rejected','expired') NOT NULL DEFAULT 'draft',
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_number (quote_number),
KEY idx_requisition (requisition_id),
KEY idx_supplier (supplier_id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE supplier_quote_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
quote_id INT UNSIGNED NOT NULL,
item_id INT UNSIGNED NOT NULL,
quantity DECIMAL(15,3) NOT NULL,
unit_price DECIMAL(15,4) NOT NULL,
total_price DECIMAL(15,2) NOT NULL,
delivery_days INT UNSIGNED NULL,
notes VARCHAR(500) NULL,
KEY idx_quote (quote_id),
KEY idx_item (item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### Files to Create/Modify
```
CREATE: app/Modules/Procurement/Controllers/SupplierQuoteController.php
Methods:
- index(Request $request) — list with filters (supplier, status, date range)
- create(Request $request) — form, optionally pre-fill from requisition
- store(Request $request) — validate, save quote + items
- show(Request $request, int $id) — view details
- edit(Request $request, int $id)
- update(Request $request, int $id)
- submit(Request $request, int $id) — mark as submitted
- createFromRequisition(Request $request, int $requisitionId) — pre-fill from PR
CREATE: app/Modules/Procurement/Models/SupplierPriceQuote.php
CREATE: app/Modules/Procurement/Models/SupplierQuoteItem.php
CREATE: app/Modules/Procurement/Views/quotes/index.php
CREATE: app/Modules/Procurement/Views/quotes/create.php
- Supplier selector (with lookup helper)
- Link to requisition (optional)
- Items grid: Item | Qty | Unit Price | Total | Delivery Days | Notes
- Validity date, payment terms, total
CREATE: app/Modules/Procurement/Views/quotes/show.php
MODIFY: app/Modules/Procurement/Routes.php
- Full CRUD + submit + createFromRequisition
MODIFY: app/Modules/Procurement/bootstrap.php
- Permissions: procurement.quote.view, procurement.quote.create, procurement.quote.manage
- Menu: 'عروض أسعار الموردين' in Procurement submenu
```
---
## 3.2 — Quote Evaluation & Side-by-Side Comparison
### Database Changes
```
Migration: Phase_58_002_quote_evaluations.php
CREATE TABLE quote_evaluations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
evaluation_number VARCHAR(20) NOT NULL,
requisition_id INT UNSIGNED NOT NULL,
evaluation_date DATE NOT NULL,
winning_quote_id INT UNSIGNED NULL,
winning_supplier_id INT UNSIGNED NULL,
justification TEXT NULL,
purchase_order_id INT UNSIGNED NULL COMMENT 'PO created from this evaluation',
status ENUM('in_progress','completed','cancelled') NOT NULL DEFAULT 'in_progress',
evaluated_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_number (evaluation_number),
KEY idx_requisition (requisition_id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE quote_evaluation_criteria (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
evaluation_id INT UNSIGNED NOT NULL,
quote_id INT UNSIGNED NOT NULL,
price_score DECIMAL(5,2) NULL,
delivery_score DECIMAL(5,2) NULL,
quality_score DECIMAL(5,2) NULL,
terms_score DECIMAL(5,2) NULL,
total_score DECIMAL(5,2) NULL,
notes TEXT NULL,
KEY idx_evaluation (evaluation_id),
KEY idx_quote (quote_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### Files to Create/Modify
```
CREATE: app/Modules/Procurement/Controllers/QuoteEvaluationController.php
Methods:
- index(Request $request) — list evaluations
- create(Request $request) — start new evaluation (select PR)
- store(Request $request) — create evaluation record
- show(Request $request, int $id) — THE BIG ONE: side-by-side comparison view
- score(Request $request, int $id) — save scoring criteria
- selectWinner(Request $request, int $id) — mark winning quote
- generatePO(Request $request, int $id) — create PO from winner
CREATE: app/Modules/Procurement/Services/QuoteEvaluationService.php
Methods:
- getQuotesForRequisition(int $requisitionId): array
→ Returns all quotes linked to this PR with their items
- buildComparisonMatrix(int $requisitionId): array
→ Returns structure:
[
'items' => [
['item_id' => 1, 'name' => '...', 'qty' => 100, 'quotes' => [
['quote_id' => 1, 'supplier' => '...', 'unit_price' => 50, 'total' => 5000, 'delivery' => 7],
['quote_id' => 2, 'supplier' => '...', 'unit_price' => 48, 'total' => 4800, 'delivery' => 14],
]],
],
'totals' => ['quote_1' => 50000, 'quote_2' => 48000],
'best_price_per_item' => [1 => 'quote_2', 2 => 'quote_1', ...],
]
- createPOFromEvaluation(int $evaluationId): int
→ Get winning quote
→ Copy items to new purchase_order + purchase_order_items
→ Link evaluation to PO
→ Update statuses
CREATE: app/Modules/Procurement/Views/evaluations/index.php
CREATE: app/Modules/Procurement/Views/evaluations/show.php
THIS IS THE KEY VIEW — Side-by-side comparison:
┌─────────────────────────────────────────────────────────────────────┐
│ تقييم عروض الأسعار - طلب شراء رقم: PR-0042 │
├──────────┬──────────┬──────────┬──────────┬──────────┬──────────────┤
│ الصنف │ الكمية │ مورد 1 │ مورد 2 │ مورد 3 │ أقل سعر │
│ │ │ سعر|إجم │ سعر|إجم │ سعر|إجم │ │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────────┤
│ صنف أ │ 100 │ 50|5000 │ 48|4800✓ │ 52|5200 │ مورد 2 │
│ صنف ب │ 200 │ 30|6000✓ │ 35|7000 │ 32|6400 │ مورد 1 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────────┤
│ الإجمالي │ │ 11,000 │ 11,800 │ 11,600 │ │
│ التوصيل │ │ 7 أيام │ 14 يوم │ 10 أيام │ │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────────────┘
Buttons: [اختيار الفائز] [إصدار أمر شراء]
MODIFY: app/Modules/Procurement/Routes.php
- CRUD + score + selectWinner + generatePO routes
MODIFY: app/Modules/Procurement/bootstrap.php
- Permissions: procurement.evaluation.view, procurement.evaluation.create, procurement.evaluation.decide
- Menu: 'تقييم عروض الأسعار'
```
---
## 3.3 — Supplier Payment Scheduling
### Database Changes
```
Migration: Phase_58_003_supplier_payment_scheduling.php
CREATE TABLE supplier_payment_schedules (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
schedule_number VARCHAR(20) NOT NULL,
supplier_id INT UNSIGNED NOT NULL,
vendor_invoice_id INT UNSIGNED NULL,
total_amount DECIMAL(15,2) NOT NULL,
installments_count INT UNSIGNED NOT NULL,
start_date DATE NOT NULL,
frequency ENUM('weekly','biweekly','monthly') NOT NULL DEFAULT 'monthly',
notes TEXT NULL,
status ENUM('active','completed','cancelled') NOT NULL DEFAULT 'active',
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_number (schedule_number),
KEY idx_supplier (supplier_id),
KEY idx_invoice (vendor_invoice_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE supplier_payment_schedule_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
schedule_id INT UNSIGNED NOT NULL,
installment_number INT UNSIGNED NOT NULL,
due_date DATE NOT NULL,
amount DECIMAL(15,2) NOT NULL,
paid_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
payment_method ENUM('cash','check','transfer') NULL,
paid_date DATE NULL,
vendor_payment_id INT UNSIGNED NULL,
status ENUM('pending','paid','overdue','partial') NOT NULL DEFAULT 'pending',
KEY idx_schedule (schedule_id),
KEY idx_due (due_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### Files to Create/Modify
```
CREATE: app/Modules/Procurement/Controllers/SupplierPaymentScheduleController.php
Methods:
- index — list schedules
- create — form (select supplier, invoice, amounts)
- store — generate schedule items
- show — view schedule with items
- payItem — record payment for one installment
CREATE: app/Modules/Procurement/Services/SupplierPaymentScheduleService.php
- createSchedule(array $data): int
- generateInstallments(int $scheduleId, float $total, int $count, string $startDate, string $frequency): void
- recordPayment(int $itemId, float $amount, string $method, string $date): void
- getOverdueItems(): array
- getUpcomingItems(int $days = 7): array
CREATE: Views for schedule CRUD
MODIFY: Routes.php, bootstrap.php with permissions and menu
```
---
## 3.4 — Item Price Deviation Monitoring
### Database Changes
```
Migration: Phase_58_004_item_price_tracking.php
CREATE TABLE item_price_history (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id INT UNSIGNED NOT NULL,
supplier_id INT UNSIGNED NULL,
price_type ENUM('purchase','sale') NOT NULL,
unit_price DECIMAL(15,4) NOT NULL,
quantity DECIMAL(15,3) NOT NULL,
reference_type VARCHAR(50) NOT NULL COMMENT 'vendor_invoice, purchase_order, sale',
reference_id INT UNSIGNED NOT NULL,
recorded_date DATE NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_item (item_id),
KEY idx_supplier (supplier_id),
KEY idx_date (recorded_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE inventory_items ADD COLUMN standard_cost DECIMAL(15,4) NULL AFTER cost_price;
ALTER TABLE inventory_items ADD COLUMN price_deviation_threshold DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT 'Percentage' AFTER standard_cost;
```
### Files to Create/Modify
```
CREATE: app/Modules/Inventory/Services/PriceMonitoringService.php
Methods:
- recordPrice(int $itemId, ?int $supplierId, string $type, float $price, float $qty, string $refType, int $refId): void
- checkDeviation(int $itemId, float $newPrice): ?array
→ Compare vs standard_cost
→ If deviation > threshold: return ['item_id', 'standard', 'actual', 'deviation_pct', 'alert']
- getItemPriceHistory(int $itemId, ?int $supplierId, ?string $dateFrom, ?string $dateTo): array
- getPriceDeviationReport(float $thresholdPct): array
MODIFY: app/Modules/Procurement/Services/VendorInvoiceService.php
- After invoice approval: call PriceMonitoringService::recordPrice() for each line item
- If deviation detected: flash warning (don't block, just warn)
CREATE: app/Modules/Procurement/Views/reports/price-deviation.php
- Table: Item | Standard Price | Actual Price | Deviation % | Supplier | Invoice# | Date
- Color-code: green (<5%), yellow (5-10%), red (>10%)
```
---
## 3.5 — Purchase vs Budget Comparison
### Files to Create/Modify
```
MODIFY: app/Modules/Procurement/Controllers/ProcurementReportController.php
- Add method: budgetComparison(Request $request)
→ Query actual purchases grouped by cost_center / budget account
→ Join with account_budgets or cost_center_budgets
→ Calculate: budget | actual | variance | variance %
CREATE: app/Modules/Procurement/Views/reports/budget-comparison.php
- Filters: fiscal year, cost center, account
- Table: Account/Center | Budget | Actual YTD | Variance | % Used
- Progress bars for visual representation
- Red highlight for over-budget items
```
---
# ═══════════════════════════════════════════════════════════════════════
# PHASE 4: HR & PAYROLL ENHANCEMENTS (Week 12-16)
# ═══════════════════════════════════════════════════════════════════════
## 4.1 — Overtime System
### Database Changes
```
Migration: Phase_59_001_overtime_system.php
CREATE TABLE hr_overtime_types (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NULL,
rate_multiplier DECIMAL(4,2) NOT NULL DEFAULT 1.50 COMMENT '1.5x, 2x, etc',
max_hours_per_day DECIMAL(4,2) NULL,
max_hours_per_month DECIMAL(5,2) NULL,
requires_approval TINYINT(1) NOT NULL DEFAULT 1,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE hr_overtime_requests (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_id INT UNSIGNED NOT NULL,
overtime_type_id INT UNSIGNED NOT NULL,
request_date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
hours DECIMAL(4,2) NOT NULL,
reason TEXT NULL,
status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending',
approved_by INT UNSIGNED NULL,
approved_at DATETIME NULL,
rejection_reason VARCHAR(500) NULL,
payroll_run_id INT UNSIGNED NULL COMMENT 'Linked when processed in payroll',
calculated_amount DECIMAL(10,2) NULL,
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_employee (employee_id),
KEY idx_date (request_date),
KEY idx_status (status),
KEY idx_payroll (payroll_run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Seed default overtime types
INSERT INTO hr_overtime_types (name_ar, name_en, rate_multiplier) VALUES
('عمل إضافي عادي', 'Regular Overtime', 1.50),
('عمل إضافي ليلي', 'Night Overtime', 2.00),
('عمل إضافي أجازات', 'Holiday Overtime', 2.50);
```
### Files to Create/Modify
```
CREATE: app/Modules/HR/Controllers/OvertimeController.php
Methods:
- types() — manage overtime types
- storeType() / updateType()
- index() — list overtime requests (filterable)
- request() — employee submits request
- storeRequest()
- approve(int $id)
- reject(int $id)
- monthly(Request $request) — monthly overtime summary
CREATE: app/Modules/HR/Services/OvertimeService.php
Methods:
- calculateOvertimeAmount(int $employeeId, float $hours, int $typeId): float
→ Get employee hourly rate = monthly_salary / (working_days * hours_per_day)
→ Multiply by rate_multiplier from overtime_type
→ Return amount
- getMonthlyOvertimeForPayroll(int $employeeId, string $month): array
→ Sum all approved overtime for the month
→ Return ['total_hours' => x, 'total_amount' => y, 'breakdown' => [...]]
MODIFY: app/Modules/HR/Controllers/PayrollController.php
- In calculate(): include overtime from OvertimeService::getMonthlyOvertimeForPayroll()
- Add overtime as a payroll component
CREATE: Views for overtime CRUD + request + approval
MODIFY: Routes, bootstrap with permissions
```
---
## 4.2 — Attendance Violation Detection
### Database Changes
```
Migration: Phase_59_002_attendance_violations.php
CREATE TABLE hr_attendance_violations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_id INT UNSIGNED NOT NULL,
violation_date DATE NOT NULL,
violation_type ENUM('late_arrival','early_departure','absence','unauthorized_absence','incomplete_day') NOT NULL,
scheduled_time TIME NULL COMMENT 'When they should have been',
actual_time TIME NULL COMMENT 'When they actually were',
difference_minutes INT NULL COMMENT 'How many minutes off',
auto_detected TINYINT(1) NOT NULL DEFAULT 1,
penalty_applied TINYINT(1) NOT NULL DEFAULT 0,
penalty_amount DECIMAL(10,2) NULL,
notes VARCHAR(500) NULL,
acknowledged TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_employee (employee_id),
KEY idx_date (violation_date),
KEY idx_type (violation_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE hr_attendance_rules (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
rule_name_ar VARCHAR(100) NOT NULL,
rule_type ENUM('grace_period','late_penalty','absence_penalty','early_leave_penalty') NOT NULL,
threshold_minutes INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Minutes before this rule triggers',
penalty_type ENUM('none','warning','deduction_fixed','deduction_percentage','deduction_hours') NOT NULL DEFAULT 'warning',
penalty_value DECIMAL(10,2) NOT NULL DEFAULT 0,
max_occurrences_per_month INT UNSIGNED NULL COMMENT 'After this many, escalate',
escalation_penalty_type ENUM('none','deduction_day','deduction_percentage') NULL,
escalation_penalty_value DECIMAL(10,2) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Default rules
INSERT INTO hr_attendance_rules (rule_name_ar, rule_type, threshold_minutes, penalty_type, penalty_value) VALUES
('فترة سماح', 'grace_period', 15, 'none', 0),
('تأخير بسيط (15-30 دقيقة)', 'late_penalty', 30, 'warning', 0),
('تأخير متوسط (30-60 دقيقة)', 'late_penalty', 60, 'deduction_hours', 1),
('تأخير كبير (أكثر من ساعة)', 'late_penalty', 120, 'deduction_hours', 2),
('غياب بدون إذن', 'absence_penalty', 0, 'deduction_day', 1);
```
### Files to Create/Modify
```
CREATE: app/Modules/HR/Services/AttendanceViolationService.php
Methods:
- detectViolations(string $date): array
→ For each employee with attendance on $date:
- Compare check_in time vs scheduled start (from work_schedule or shift)
- If late beyond grace_period → create violation
- Compare check_out time vs scheduled end
- If early → create violation
→ For employees with NO attendance record and no leave:
- Create 'absence' or 'unauthorized_absence' violation
→ Return array of detected violations
- applyPenalties(string $month): array
→ For each employee, count violations in month
→ Apply rules based on threshold and occurrence count
→ Return penalties to include in payroll
- getViolationSummary(int $employeeId, string $month): array
- acknowledgeViolation(int $violationId): void
CREATE: app/Modules/HR/Controllers/AttendanceViolationController.php
Methods:
- index() — list violations with filters
- detect() — trigger detection for a date
- rules() — manage rules
- storeRule() / updateRule()
- summary() — monthly summary per employee
MODIFY: app/Modules/HR/Controllers/PayrollController.php
- In calculate(): call AttendanceViolationService::applyPenalties() to include deductions
CREATE: Views for violations list, rules config, summary
MODIFY: Routes, bootstrap
```
---
## 4.3 — Permission Requests (Hourly Leaves)
### Database Changes
```
Migration: Phase_59_003_permission_requests.php
CREATE TABLE hr_permission_requests (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_id INT UNSIGNED NOT NULL,
permission_date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
duration_hours DECIMAL(4,2) NOT NULL,
reason VARCHAR(500) NOT NULL,
status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending',
approved_by INT UNSIGNED NULL,
approved_at DATETIME NULL,
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_employee (employee_id),
KEY idx_date (permission_date),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### Files to Create/Modify
```
CREATE: app/Modules/HR/Controllers/PermissionRequestController.php
Methods: index, request, store, approve, reject, monthly
CREATE: app/Modules/HR/Views/permissions/index.php
CREATE: app/Modules/HR/Views/permissions/request.php
- Date, start time, end time (auto-calc duration), reason
MODIFY: Routes, bootstrap with 'hr.permission.view', 'hr.permission.request', 'hr.permission.approve'
```
---
## 4.4 — Social Insurance Auto-Calculation
### Database Changes
```
Migration: Phase_59_004_insurance_configuration.php
CREATE TABLE hr_insurance_config (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
effective_date DATE NOT NULL,
basic_salary_cap DECIMAL(12,2) NOT NULL COMMENT 'Max basic insurable salary',
variable_salary_cap DECIMAL(12,2) NOT NULL COMMENT 'Max variable insurable salary',
employer_basic_rate DECIMAL(5,4) NOT NULL COMMENT 'e.g. 0.1850 = 18.5%',
employer_variable_rate DECIMAL(5,4) NOT NULL,
employee_basic_rate DECIMAL(5,4) NOT NULL COMMENT 'e.g. 0.1100 = 11%',
employee_variable_rate DECIMAL(5,4) NOT NULL,
min_basic_salary DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT 'Floor for insurance calc',
is_active TINYINT(1) NOT NULL DEFAULT 1,
KEY idx_effective (effective_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Egyptian 2024/2025 rates (example)
INSERT INTO hr_insurance_config (effective_date, basic_salary_cap, variable_salary_cap,
employer_basic_rate, employer_variable_rate, employee_basic_rate, employee_variable_rate, min_basic_salary)
VALUES ('2024-01-01', 12600.00, 10900.00, 0.1850, 0.1150, 0.1100, 0.1100, 2000.00);
```
### Files to Create/Modify
```
CREATE: app/Modules/HR/Services/InsuranceCalculationService.php
Methods:
- calculate(int $employeeId, string $month): array
→ Get employee salary (basic + variable)
→ Get active insurance config
→ Apply caps: min(basic_salary, basic_salary_cap)
→ Calculate:
- employee_basic_share = capped_basic * employee_basic_rate
- employee_variable_share = capped_variable * employee_variable_rate
- employer_basic_share = capped_basic * employer_basic_rate
- employer_variable_share = capped_variable * employer_variable_rate
→ Return ['employee_total', 'employer_total', 'breakdown' => [...]]
- calculateBulk(string $month): array — for all active employees
- getConfig(?string $date = null): array — get applicable config
CREATE: app/Modules/HR/Controllers/InsuranceConfigController.php
- CRUD for insurance configuration
MODIFY: app/Modules/HR/Controllers/PayrollController.php
- In calculate(): auto-include insurance deduction from InsuranceCalculationService
CREATE: app/Modules/HR/Views/insurance/config.php
- Configuration form for rates and caps
```
---
## 4.5 — Progressive Tax Bracket Calculation
### Database Changes
```
Migration: Phase_59_005_tax_brackets.php
CREATE TABLE hr_tax_brackets (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
effective_year INT UNSIGNED NOT NULL,
bracket_from DECIMAL(12,2) NOT NULL,
bracket_to DECIMAL(12,2) NOT NULL,
rate DECIMAL(5,4) NOT NULL COMMENT '0.0000 to 1.0000',
is_exempt TINYINT(1) NOT NULL DEFAULT 0,
description_ar VARCHAR(255) NULL,
KEY idx_year (effective_year)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE hr_tax_exemptions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
effective_year INT UNSIGNED NOT NULL,
exemption_type VARCHAR(50) NOT NULL COMMENT 'personal, insurance, etc',
description_ar VARCHAR(255) NOT NULL,
amount DECIMAL(12,2) NOT NULL,
is_percentage TINYINT(1) NOT NULL DEFAULT 0,
KEY idx_year (effective_year)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Egyptian 2024 tax brackets (example)
INSERT INTO hr_tax_brackets (effective_year, bracket_from, bracket_to, rate, is_exempt, description_ar) VALUES
(2024, 0, 40000, 0.0000, 1, 'شريحة معفاة'),
(2024, 40000, 55000, 0.1000, 0, '10%'),
(2024, 55000, 70000, 0.1500, 0, '15%'),
(2024, 70000, 200000, 0.2000, 0, '20%'),
(2024, 200000, 400000, 0.2250, 0, '22.5%'),
(2024, 400000, 999999999, 0.2500, 0, '25%');
INSERT INTO hr_tax_exemptions (effective_year, exemption_type, description_ar, amount) VALUES
(2024, 'personal', 'إعفاء شخصي', 20000),
(2024, 'insurance', 'حصة التأمينات (خصم)', 0);
```
### Files to Create/Modify
```
CREATE: app/Modules/HR/Services/TaxCalculationService.php
Methods:
- calculateAnnualTax(float $annualTaxableIncome, int $year): float
→ Get brackets for year
→ For each bracket: tax += (min(income, bracket_to) - bracket_from) * rate
→ Apply exemptions
→ Return annual tax
- calculateMonthlyTax(int $employeeId, string $month): float
→ Get annual gross salary projection
→ Subtract exemptions (personal + insurance contributions)
→ Calculate annual tax
→ Divide by 12 for monthly
→ Adjust for YTD actual vs expected (reconciliation)
- yearEndReconciliation(int $employeeId, int $year): array
→ Compare actual annual income vs projected
→ Calculate actual tax owed vs tax already withheld
→ Return adjustment amount
CREATE: app/Modules/HR/Controllers/TaxConfigController.php
- CRUD for brackets and exemptions
MODIFY: app/Modules/HR/Controllers/PayrollController.php
- In calculate(): use TaxCalculationService for tax deduction
```
---
## 4.6 — Shift Management
### Database Changes
```
Migration: Phase_59_006_shift_management.php
CREATE TABLE hr_shifts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
break_start TIME NULL,
break_end TIME NULL,
break_duration_minutes INT UNSIGNED NOT NULL DEFAULT 0,
working_hours DECIMAL(4,2) NOT NULL,
is_night_shift TINYINT(1) NOT NULL DEFAULT 0,
grace_minutes INT UNSIGNED NOT NULL DEFAULT 15,
overtime_after_minutes INT UNSIGNED NULL COMMENT 'Auto-overtime after this many extra minutes',
color_code VARCHAR(7) NULL COMMENT 'For calendar display',
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE hr_shift_assignments (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_id INT UNSIGNED NOT NULL,
shift_id INT UNSIGNED NOT NULL,
start_date DATE NOT NULL,
end_date DATE NULL COMMENT 'NULL = ongoing',
rotation_pattern ENUM('fixed','weekly','biweekly','monthly') NOT NULL DEFAULT 'fixed',
notes VARCHAR(255) NULL,
KEY idx_employee (employee_id),
KEY idx_shift (shift_id),
KEY idx_dates (start_date, end_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### Files to Create/Modify
```
CREATE: app/Modules/HR/Controllers/ShiftController.php
Methods: index, create, store, edit, update, assign, calendar
CREATE: app/Modules/HR/Views/shifts/index.php — list shifts
CREATE: app/Modules/HR/Views/shifts/create.php — shift form
CREATE: app/Modules/HR/Views/shifts/assign.php — assign employees to shifts
CREATE: app/Modules/HR/Views/shifts/calendar.php — visual shift calendar
MODIFY: app/Modules/HR/Services/AttendanceViolationService.php
- Use shift assignment to determine expected times (not just work schedule)
```
---
# ═══════════════════════════════════════════════════════════════════════
# PHASE 5: ADVANCED INVENTORY & SALES (Week 17-22)
# ═══════════════════════════════════════════════════════════════════════
## 5.1 — Bill of Materials (BOM)
### Database Changes
```
Migration: Phase_60_001_bill_of_materials.php
CREATE TABLE bill_of_materials (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
parent_item_id INT UNSIGNED NOT NULL COMMENT 'The composite/finished item',
version INT UNSIGNED NOT NULL DEFAULT 1,
effective_date DATE NOT NULL,
end_date DATE NULL,
yield_quantity DECIMAL(15,3) NOT NULL DEFAULT 1 COMMENT 'How many parent items this BOM produces',
notes TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_parent (parent_item_id),
UNIQUE KEY uk_item_version (parent_item_id, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE bom_components (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
bom_id INT UNSIGNED NOT NULL,
component_item_id INT UNSIGNED NOT NULL,
quantity DECIMAL(15,6) NOT NULL,
unit_of_measure VARCHAR(50) NULL,
waste_percentage DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT 'Expected waste/scrap %',
is_optional TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
notes VARCHAR(255) NULL,
KEY idx_bom (bom_id),
KEY idx_component (component_item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE inventory_items ADD COLUMN has_bom TINYINT(1) NOT NULL DEFAULT 0 AFTER is_active;
ALTER TABLE inventory_items ADD COLUMN bom_cost DECIMAL(15,4) NULL COMMENT 'Rolled-up component cost' AFTER has_bom;
```
### Files to Create/Modify
```
CREATE: app/Modules/Inventory/Controllers/BOMController.php
Methods:
- index() — list all items with BOMs
- show(int $itemId) — show BOM for item
- create(int $itemId) — create/edit BOM
- store(Request $request) — save BOM + components
- rollupCost(int $bomId) — recalculate BOM cost from components
- explode(int $itemId, float $quantity) — show all components needed for N units
CREATE: app/Modules/Inventory/Services/BOMService.php
Methods:
- getActiveBOM(int $itemId): ?array
- explodeBOM(int $itemId, float $quantity): array
→ Recursively expand components (BOM within BOM)
→ Apply waste percentages
→ Return flat list: [['item_id' => x, 'quantity_needed' => y], ...]
- deductComponents(int $itemId, float $quantity, int $warehouseId, string $reference, int $referenceId): void
→ Get BOM → explode → for each component: deduct stock
- rollupCost(int $bomId): float
→ Sum(component.cost_price * component.quantity * (1 + waste%))
- checkComponentAvailability(int $itemId, float $quantity, int $warehouseId): array
→ Return which components have insufficient stock
MODIFY: app/Modules/Sales/Controllers/SaleController.php
- In store(): for items with has_bom=1, call BOMService::deductComponents() instead of simple stock deduction
CREATE: app/Modules/Inventory/Views/bom/show.php
- Parent item header
- Components table: # | Component Item | Qty per Unit | Waste % | Effective Qty | Unit Cost | Total Cost
- Total BOM cost
- "Check Availability" button
CREATE: app/Modules/Inventory/Views/bom/create.php
- Parent item (fixed)
- Dynamic rows for components: item picker + qty + waste% + optional flag
- Add/remove row buttons
MODIFY: app/Modules/Inventory/Routes.php
MODIFY: app/Modules/Inventory/bootstrap.php
- Permissions: 'inventory.bom.view', 'inventory.bom.manage'
- Menu: 'قوائم المواد (BOM)' under Inventory
```
---
## 5.2 — Multiple Units per Item with Conversion
### Database Changes
```
Migration: Phase_60_002_item_units.php
CREATE TABLE item_units (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id INT UNSIGNED NOT NULL,
unit_name_ar VARCHAR(50) NOT NULL,
unit_name_en VARCHAR(50) NULL,
conversion_factor DECIMAL(15,6) NOT NULL COMMENT 'How many base units in this unit',
is_base_unit TINYINT(1) NOT NULL DEFAULT 0,
barcode VARCHAR(50) NULL COMMENT 'Different barcode per unit pack',
purchase_default TINYINT(1) NOT NULL DEFAULT 0,
sale_default TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
KEY idx_item (item_id),
UNIQUE KEY uk_item_unit (item_id, unit_name_ar)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### Files to Create/Modify
```
CREATE: app/Modules/Inventory/Services/UnitConversionService.php
Methods:
- convertToBaseUnit(int $itemId, float $quantity, int $unitId): float
→ quantity * conversion_factor
- convertFromBaseUnit(int $itemId, float $baseQuantity, int $unitId): float
→ baseQuantity / conversion_factor
- getItemUnits(int $itemId): array
- getPurchaseDefaultUnit(int $itemId): ?array
- getSaleDefaultUnit(int $itemId): ?array
MODIFY: app/Modules/Inventory/Controllers/ItemController.php
- In create/edit: add unit management section
- Store/update: save item_units
MODIFY: app/Modules/Inventory/Views/items/create.php
- Add "Units" section: dynamic rows for unit_name, conversion_factor, is_base, purchase_default, sale_default
MODIFY: app/Modules/Sales/Controllers/SaleController.php
- When adding item to sale: show unit dropdown, convert quantity to base for stock deduction
MODIFY: app/Modules/Procurement/Controllers/VendorInvoiceController.php
- Allow selecting purchase unit, convert to base for stock-in
```
---
## 5.3 — Sales Representative & Commission
### Database Changes
```
Migration: Phase_60_003_sales_reps_commissions.php
CREATE TABLE sales_representatives (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_id INT UNSIGNED NULL COMMENT 'If linked to an employee',
code VARCHAR(20) NOT NULL,
name_ar VARCHAR(100) NOT NULL,
phone VARCHAR(20) NULL,
commission_type ENUM('flat','percentage','tiered') NOT NULL DEFAULT 'percentage',
commission_rate DECIMAL(5,4) NULL COMMENT 'For percentage type',
commission_flat DECIMAL(10,2) NULL COMMENT 'For flat type',
target_amount DECIMAL(15,2) NULL COMMENT 'Monthly sales target',
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_code (code),
KEY idx_employee (employee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE sales_commission_tiers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
representative_id INT UNSIGNED NOT NULL,
tier_from DECIMAL(15,2) NOT NULL,
tier_to DECIMAL(15,2) NOT NULL,
rate DECIMAL(5,4) NOT NULL,
KEY idx_rep (representative_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE sales_commissions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
representative_id INT UNSIGNED NOT NULL,
sale_id INT UNSIGNED NOT NULL,
sale_amount DECIMAL(15,2) NOT NULL,
commission_amount DECIMAL(10,2) NOT NULL,
period VARCHAR(7) NOT NULL COMMENT 'YYYY-MM',
is_paid TINYINT(1) NOT NULL DEFAULT 0,
paid_date DATE NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_rep (representative_id),
KEY idx_sale (sale_id),
KEY idx_period (period)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE sales ADD COLUMN representative_id INT UNSIGNED NULL AFTER member_id;
```
### Files to Create/Modify
```
CREATE: app/Modules/Sales/Controllers/RepresentativeController.php
Methods: index, create, store, show, edit, update, commissionReport, payCommissions
CREATE: app/Modules/Sales/Services/CommissionService.php
Methods:
- calculateCommission(int $repId, float $saleAmount): float
→ Based on commission_type: flat, percentage, or tiered calculation
- recordCommission(int $repId, int $saleId, float $saleAmount): void
- getMonthlyCommissions(int $repId, string $month): array
- getCommissionReport(string $month): array — all reps
MODIFY: app/Modules/Sales/Controllers/SaleController.php
- Add representative_id field to sale form
- After sale completion: call CommissionService::recordCommission()
CREATE: Views for rep CRUD + commission reports
MODIFY: Routes, bootstrap
```
---
## 5.4 — Per-Customer Pricing & Discounts
### Database Changes
```
Migration: Phase_60_004_customer_pricing.php
CREATE TABLE customer_price_lists (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NULL,
discount_percentage DECIMAL(5,2) NOT NULL DEFAULT 0,
is_default TINYINT(1) NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE customer_item_prices (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
member_id INT UNSIGNED NULL COMMENT 'Specific member price',
price_list_id INT UNSIGNED NULL COMMENT 'Or via price list',
item_id INT UNSIGNED NOT NULL,
special_price DECIMAL(15,4) NULL COMMENT 'Override price',
discount_percentage DECIMAL(5,2) NULL COMMENT 'Or just a discount',
effective_from DATE NULL,
effective_to DATE NULL,
KEY idx_member (member_id),
KEY idx_list (price_list_id),
KEY idx_item (item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE members ADD COLUMN price_list_id INT UNSIGNED NULL AFTER credit_balance;
```
### Files to Create/Modify
```
CREATE: app/Modules/Sales/Services/PricingService.php
Methods:
- getItemPriceForMember(int $itemId, ?int $memberId): float
→ Priority: member-specific price > price list price > default price
→ Check effective dates
- getMemberDiscount(int $memberId, int $itemId): float
MODIFY: app/Modules/Sales/Controllers/SaleController.php
- In searchItems(): use PricingService to return member-specific prices
CREATE: app/Modules/Sales/Controllers/PriceListController.php
- CRUD for price lists and item prices
```
---
# ═══════════════════════════════════════════════════════════════════════
# PHASE 6: CONFIGURABLE MOVEMENT ENGINE (Week 23-30)
# ═══════════════════════════════════════════════════════════════════════
## 6.1 — Movement Specifications Engine
### Database Changes
```
Migration: Phase_61_001_movement_specifications_engine.php
CREATE TABLE movement_types (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
module ENUM('sales','inventory','procurement') NOT NULL,
code VARCHAR(50) NOT NULL COMMENT 'machine-readable identifier',
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NULL,
description_ar VARCHAR(255) NULL,
direction ENUM('in','out','neutral','both') NOT NULL,
is_system TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Cannot be deleted',
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_code (code),
KEY idx_module (module)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE movement_specifications (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
movement_type_id INT UNSIGNED NOT NULL,
spec_key VARCHAR(50) NOT NULL,
spec_value TEXT NULL,
UNIQUE KEY uk_type_key (movement_type_id, spec_key),
KEY idx_type (movement_type_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Specification keys include:
-- auto_number_enabled (bool)
-- auto_number_prefix (string)
-- auto_number_start (int)
-- auto_number_digits (int)
-- affects_stock (bool)
-- stock_direction (in/out)
-- posts_to_gl (bool)
-- gl_debit_account_id (int)
-- gl_credit_account_id (int)
-- requires_approval (bool)
-- approval_levels (int)
-- creates_ar (bool)
-- creates_ap (bool)
-- allows_tax (bool)
-- default_tax_rate (decimal)
-- allows_discount (bool)
-- max_discount_percentage (decimal)
-- party_type (customer/supplier/warehouse/any)
-- requires_reference_document (bool)
-- reference_document_type (string)
-- print_template (string)
-- allows_partial (bool)
-- currency_editable (bool)
-- default_warehouse_id (int)
-- cost_center_required (bool)
-- notes_required (bool)
-- Seed default movement types
INSERT INTO movement_types (module, code, name_ar, name_en, direction, is_system) VALUES
('sales', 'sales_invoice', 'فاتورة بيع', 'Sales Invoice', 'out', 1),
('sales', 'sales_return', 'مرتجع بيع', 'Sales Return', 'in', 1),
('sales', 'sales_quotation', 'عرض أسعار', 'Sales Quotation', 'neutral', 0),
('inventory', 'stock_in', 'إذن إضافة', 'Stock In', 'in', 1),
('inventory', 'stock_out', 'إذن صرف', 'Stock Out', 'out', 1),
('inventory', 'stock_transfer', 'تحويل مخزني', 'Stock Transfer', 'both', 1),
('inventory', 'stock_adjustment', 'تسوية مخزنية', 'Stock Adjustment', 'both', 1),
('procurement', 'purchase_order', 'أمر شراء', 'Purchase Order', 'neutral', 1),
('procurement', 'goods_receipt', 'إذن استلام', 'Goods Receipt', 'in', 1),
('procurement', 'purchase_return', 'مرتجع مشتريات', 'Purchase Return', 'out', 1),
('procurement', 'purchase_invoice', 'فاتورة مورد', 'Supplier Invoice', 'in', 1);
```
### Files to Create/Modify
```
CREATE: app/Core/Services/MovementSpecificationEngine.php
namespace App\Core\Services;
THE HEART OF THE SYSTEM — This class controls all movement behavior:
Methods:
- getMovementType(string $code): array
- getSpecifications(string $movementTypeCode): array
- getSpec(string $movementTypeCode, string $specKey, $default = null): mixed
- getNextNumber(string $movementTypeCode): string
→ Read auto_number_prefix + auto_number_digits
→ Query MAX existing number for this type
→ Return next formatted number
- shouldAffectStock(string $code): bool
- shouldPostToGL(string $code): bool
- getGLAccounts(string $code): array ['debit' => int, 'credit' => int]
- requiresApproval(string $code): bool
- getValidTransitions(string $code, string $currentStatus): array
- validateMovement(string $code, array $data): array (errors)
- getActiveMovementTypes(string $module): array
- getAllSpecs(int $movementTypeId): array
CREATE: app/Modules/Settings/Controllers/MovementSpecController.php
Methods:
- index() — list all movement types grouped by module
- edit(int $typeId) — show all specs for a type
- update(Request $request, int $typeId) — save specs
- createType() — create new movement type
- storeType() — save new type
- toggleActive(int $typeId) — enable/disable
CREATE: app/Modules/Settings/Views/movement-specs/index.php
- Tabs: المبيعات | المخازن | المشتريات
- Per tab: list of movement types with edit buttons
CREATE: app/Modules/Settings/Views/movement-specs/edit.php
- Movement type name/description
- Grouped specification fields:
- الترقيم التلقائي (auto numbering section)
- التأثير على المخزون (stock impact section)
- الترحيل المحاسبي (GL posting section)
- الاعتمادات (approval section)
- الأطراف (party configuration section)
- الضرائب والخصومات (tax/discount section)
- الطباعة (printing section)
MODIFY: app/Modules/Sales/Controllers/SaleController.php
- Use MovementSpecificationEngine::getNextNumber('sales_invoice') for invoice numbering
- Check shouldAffectStock() before stock deduction
- Check shouldPostToGL() before GL posting
MODIFY: app/Modules/Inventory/Controllers/StockMovementController.php
- Use engine for movement type selection and behavior
MODIFY: app/Modules/Procurement/* controllers
- Use engine for all procurement movement types
MODIFY: Routes, bootstrap with permissions for movement spec management
```
---
## 6.2 — Multi-Currency Support
### Database Changes
```
Migration: Phase_61_002_multi_currency.php
CREATE TABLE currencies (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(3) NOT NULL COMMENT 'ISO 4217',
name_ar VARCHAR(50) NOT NULL,
name_en VARCHAR(50) NOT NULL,
symbol VARCHAR(5) NOT NULL,
decimal_places INT UNSIGNED NOT NULL DEFAULT 2,
is_base TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'System base currency',
is_active TINYINT(1) NOT NULL DEFAULT 1,
UNIQUE KEY uk_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE exchange_rates (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
currency_code VARCHAR(3) NOT NULL,
rate_date DATE NOT NULL,
buy_rate DECIMAL(15,6) NOT NULL COMMENT 'How many base units to buy 1 foreign',
sell_rate DECIMAL(15,6) NOT NULL,
mid_rate DECIMAL(15,6) NOT NULL,
source VARCHAR(50) NULL COMMENT 'CBE, manual, etc',
UNIQUE KEY uk_currency_date (currency_code, rate_date),
KEY idx_date (rate_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Add currency columns to transaction tables
ALTER TABLE journal_entries ADD COLUMN currency_code VARCHAR(3) NOT NULL DEFAULT 'EGP' AFTER description;
ALTER TABLE journal_entries ADD COLUMN exchange_rate DECIMAL(15,6) NOT NULL DEFAULT 1.000000 AFTER currency_code;
ALTER TABLE vendor_invoices ADD COLUMN currency_code VARCHAR(3) NOT NULL DEFAULT 'EGP' AFTER total_amount;
ALTER TABLE vendor_invoices ADD COLUMN exchange_rate DECIMAL(15,6) NOT NULL DEFAULT 1.000000 AFTER currency_code;
ALTER TABLE vendor_invoices ADD COLUMN base_currency_amount DECIMAL(15,2) NULL AFTER exchange_rate;
ALTER TABLE sales ADD COLUMN currency_code VARCHAR(3) NOT NULL DEFAULT 'EGP' AFTER total;
ALTER TABLE sales ADD COLUMN exchange_rate DECIMAL(15,6) NOT NULL DEFAULT 1.000000 AFTER currency_code;
-- Seed base currency
INSERT INTO currencies (code, name_ar, name_en, symbol, is_base) VALUES
('EGP', 'جنيه مصري', 'Egyptian Pound', 'ج.م', 1),
('USD', 'دولار أمريكي', 'US Dollar', '$', 0),
('EUR', 'يورو', 'Euro', '€', 0),
('SAR', 'ريال سعودي', 'Saudi Riyal', 'ر.س', 0);
```
### Files to Create/Modify
```
CREATE: app/Core/Services/CurrencyService.php
Methods:
- getBaseCurrency(): string
- getRate(string $currencyCode, ?string $date = null): array
- convertToBase(float $amount, string $fromCurrency, ?string $date = null): float
- convertFromBase(float $baseAmount, string $toCurrency, ?string $date = null): float
- getExchangeGainLoss(float $originalAmount, string $currency, string $originalDate, string $currentDate): float
CREATE: app/Modules/Settings/Controllers/CurrencyController.php
Methods: index, store, rates, storeRate
CREATE: app/Modules/Settings/Views/currencies/index.php
CREATE: app/Modules/Settings/Views/currencies/rates.php
```
---
# ═══════════════════════════════════════════════════════════════════════
# PHASE 7: ADVANCED FINANCIAL (Week 31-35)
# ═══════════════════════════════════════════════════════════════════════
## 7.1 — Documentary Credits (LC)
### Database Changes
```
Migration: Phase_62_001_documentary_credits.php
CREATE TABLE documentary_credits (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
lc_number VARCHAR(30) NOT NULL,
bank_account_id INT UNSIGNED NOT NULL,
supplier_id INT UNSIGNED NULL,
lc_type ENUM('import','export','local') NOT NULL DEFAULT 'import',
currency_code VARCHAR(3) NOT NULL DEFAULT 'USD',
amount DECIMAL(15,2) NOT NULL,
margin_percentage DECIMAL(5,2) NOT NULL DEFAULT 0,
margin_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
issue_date DATE NOT NULL,
expiry_date DATE NOT NULL,
shipment_deadline DATE NULL,
beneficiary_name VARCHAR(255) NOT NULL,
beneficiary_bank VARCHAR(255) NULL,
terms TEXT NULL,
goods_description TEXT NULL,
-- Expenses
bank_charges DECIMAL(10,2) NOT NULL DEFAULT 0,
insurance_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
freight_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
customs_duty DECIMAL(10,2) NOT NULL DEFAULT 0,
other_expenses DECIMAL(10,2) NOT NULL DEFAULT 0,
total_landed_cost DECIMAL(15,2) NOT NULL DEFAULT 0,
-- GL
margin_account_id INT UNSIGNED NULL,
expense_account_id INT UNSIGNED NULL,
status ENUM('draft','opened','shipped','documents_presented','paid','closed','cancelled') NOT NULL DEFAULT 'draft',
purchase_order_id INT UNSIGNED NULL,
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_number (lc_number),
KEY idx_bank (bank_account_id),
KEY idx_supplier (supplier_id),
KEY idx_status (status),
KEY idx_expiry (expiry_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE lc_documents (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
lc_id INT UNSIGNED NOT NULL,
document_type VARCHAR(50) NOT NULL COMMENT 'bill_of_lading, commercial_invoice, packing_list, certificate_origin, insurance_cert',
document_number VARCHAR(100) NULL,
received_date DATE NULL,
notes VARCHAR(255) NULL,
file_path VARCHAR(255) NULL,
KEY idx_lc (lc_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### Files to Create/Modify
```
CREATE: app/Modules/Accounting/Controllers/DocumentaryCreditController.php
Methods: index, create, store, show, edit, update, open, ship, presentDocs, pay, close, cancel
CREATE: app/Modules/Accounting/Services/DocumentaryCreditService.php
Methods:
- createLC(array $data): int
- openLC(int $id): void — posts margin to GL
- recordShipment(int $id, array $docs): void
- presentDocuments(int $id): void
- settleLC(int $id): void — final payment, release margin, post expenses
- calculateLandedCost(int $id): float
- getLCsByStatus(string $status): array
- getExpiringLCs(int $daysAhead): array
CREATE: Views for full LC lifecycle management
MODIFY: Routes, bootstrap
```
---
## 7.2 — Letter of Guarantee
### Database Changes
```
Migration: Phase_62_002_letters_of_guarantee.php
CREATE TABLE letters_of_guarantee (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
guarantee_number VARCHAR(30) NOT NULL,
bank_account_id INT UNSIGNED NOT NULL,
guarantee_type ENUM('tender','performance','advance_payment','maintenance','customs') NOT NULL,
beneficiary_name VARCHAR(255) NOT NULL,
beneficiary_type ENUM('government','private','other') NOT NULL DEFAULT 'private',
currency_code VARCHAR(3) NOT NULL DEFAULT 'EGP',
amount DECIMAL(15,2) NOT NULL,
margin_percentage DECIMAL(5,2) NOT NULL DEFAULT 0,
margin_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
issue_date DATE NOT NULL,
expiry_date DATE NOT NULL,
commission_rate DECIMAL(5,4) NULL,
commission_amount DECIMAL(10,2) NULL,
commission_frequency ENUM('quarterly','semi_annual','annual','one_time') NOT NULL DEFAULT 'quarterly',
project_name VARCHAR(255) NULL,
tender_number VARCHAR(100) NULL,
margin_account_id INT UNSIGNED NULL,
commission_account_id INT UNSIGNED NULL,
status ENUM('requested','issued','active','renewed','released','called','expired') NOT NULL DEFAULT 'requested',
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_number (guarantee_number),
KEY idx_bank (bank_account_id),
KEY idx_status (status),
KEY idx_expiry (expiry_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### Files to Create/Modify
```
CREATE: app/Modules/Accounting/Controllers/GuaranteeController.php
Methods: index, create, store, show, issue, renew, release, call, commissionReport
CREATE: app/Modules/Accounting/Services/GuaranteeService.php
- createGuarantee, issueGuarantee, renewGuarantee, releaseGuarantee
- calculateCommission, recordCommissionPayment
- getExpiringGuarantees, getActiveGuarantees
CREATE: Views for guarantee lifecycle
MODIFY: Routes, bootstrap
```
---
## 7.3 — Fixed Asset Enhancements
### Database Changes
```
Migration: Phase_62_003_enhanced_fixed_assets.php
CREATE TABLE asset_categories (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NULL,
depreciation_method ENUM('straight_line','declining_balance','sum_of_years','units_of_production') NOT NULL DEFAULT 'straight_line',
useful_life_years INT UNSIGNED NOT NULL DEFAULT 5,
salvage_value_percentage DECIMAL(5,2) NOT NULL DEFAULT 0,
asset_account_id INT UNSIGNED NULL,
depreciation_account_id INT UNSIGNED NULL,
accumulated_depreciation_account_id INT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE asset_revaluations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
asset_id INT UNSIGNED NOT NULL,
revaluation_date DATE NOT NULL,
old_value DECIMAL(15,2) NOT NULL,
new_value DECIMAL(15,2) NOT NULL,
difference DECIMAL(15,2) NOT NULL,
reason TEXT NULL,
journal_entry_id INT UNSIGNED NULL,
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_asset (asset_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE asset_improvements (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
asset_id INT UNSIGNED NOT NULL,
improvement_date DATE NOT NULL,
description_ar VARCHAR(255) NOT NULL,
amount DECIMAL(15,2) NOT NULL,
extends_life_months INT UNSIGNED NOT NULL DEFAULT 0,
journal_entry_id INT UNSIGNED NULL,
created_by INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_asset (asset_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE asset_registers ADD COLUMN category_id INT UNSIGNED NULL AFTER id;
ALTER TABLE asset_registers ADD COLUMN insurance_policy_number VARCHAR(50) NULL;
ALTER TABLE asset_registers ADD COLUMN insurance_company VARCHAR(100) NULL;
ALTER TABLE asset_registers ADD COLUMN insurance_expiry DATE NULL;
ALTER TABLE asset_registers ADD COLUMN insurance_value DECIMAL(15,2) NULL;
```
### Files to Create/Modify
```
CREATE: app/Modules/Inventory/Controllers/AssetCategoryController.php
CREATE: app/Modules/Inventory/Services/DepreciationCalculatorService.php
Methods:
- straightLine(float $cost, float $salvage, int $lifeMonths, int $elapsedMonths): float
- decliningBalance(float $bookValue, float $rate): float
- sumOfYears(float $depreciableAmount, int $lifeYears, int $currentYear): float
- unitsOfProduction(float $depreciableAmount, int $totalUnits, int $periodUnits): float
- calculateForAsset(int $assetId): float — uses category method
MODIFY: app/Modules/Inventory/Controllers/AssetController.php
- Add: revalue(), improve(), addInsurance()
CREATE: Views for categories, revaluation form, improvement form
```
---
# ═══════════════════════════════════════════════════════════════════════
# PHASE 8: REPORTING & ANALYTICS (Week 36-39)
# ═══════════════════════════════════════════════════════════════════════
## 8.1 — Universal Report Export Engine
### Files to Create/Modify
```
CREATE: app/Core/Services/ReportExportService.php
namespace App\Core\Services;
Methods:
- exportToExcel(array $headers, array $data, string $filename): Response
→ Uses PhpSpreadsheet-compatible plain PHP (no composer)
→ Generate CSV with BOM for Arabic Excel compatibility
- exportToPDF(string $html, string $filename): Response
→ Use DomPDF or wkhtmltopdf (server binary)
- exportToCSV(array $headers, array $data, string $filename): Response
- exportToHTML(string $title, array $headers, array $data): string
CREATE: app/Core/Traits/ExportableTrait.php
- Mixin for report controllers
- Adds: handleExport(Request $request, array $headers, array $data, string $filename)
→ Check $request->query('export') value
→ Call appropriate export method
→ Return download response
```
### Apply to ALL existing report controllers:
```
MODIFY: app/Modules/Accounting/Controllers/ReportController.php — add ExportableTrait
MODIFY: app/Modules/HR/Controllers/HrReportController.php — add ExportableTrait
MODIFY: app/Modules/Inventory/Controllers/InventoryReportController.php — add ExportableTrait
MODIFY: app/Modules/Procurement/Controllers/ProcurementReportController.php — add ExportableTrait
MODIFY: app/Modules/Sales/Controllers/SaleReportController.php — add ExportableTrait
All report views get an export dropdown button:
[تصدير ▼] → Excel | PDF | CSV
```
---
## 8.2 — Executive Dashboard Enhancements
### Files to Create/Modify
```
MODIFY: app/Modules/Dashboard/Controllers/DashboardController.php
Add method: executive(Request $request)
Widgets:
- Revenue this month vs last month (% change)
- Expenses this month vs last month
- Net cash position (total across all safes + banks)
- AR aging summary (0-30, 31-60, 61-90, 90+ days)
- AP aging summary
- Top 5 overdue supplier payments
- Procurement: pending approvals count
- HR: attendance % today, leaves pending count
- Inventory: low stock items count, expiring items count
- Budget utilization gauge (% of annual budget consumed)
CREATE: app/Modules/Dashboard/Views/executive.php
- 4-column grid of metric cards
- Mini charts (can use Chart.js or plain SVG)
```
---
## 8.3 — Enhanced Purchasing Reports Suite
### Files to Create/Modify
```
MODIFY: app/Modules/Procurement/Controllers/ProcurementReportController.php
Add methods:
- supplierDebtAging() — aging buckets per supplier
- dailyPurchaseAnalysis() — purchases per day with trends
- itemSupplierCrossRef() — which items from which suppliers
- purchaseRequestFollowup() — status of all PRs
- costCenterExpenseAnalysis() — expenses by cost center from procurement
CREATE views for each report
MODIFY routes to add each endpoint
```
---
## 8.4 — Enhanced Sales Reports
### Files to Create/Modify
```
MODIFY: app/Modules/Sales/Controllers/SaleReportController.php
Add methods:
- customerPosition() — all customers/members with balances snapshot
- customerMovements() — movements for specific customer in period
- commissionSummary() — sales by rep with commission totals
- itemMargins() — cost vs sell price, margin analysis
- topCustomers() — top N by revenue
CREATE views for each
MODIFY routes
```
---
# ═══════════════════════════════════════════════════════════════════════
# CROSS-CUTTING CONCERNS
# ═══════════════════════════════════════════════════════════════════════
## Event Bus Registrations (new events to add across phases)
```
// Phase 1
'credit_limit.exceeded' — log attempts
'sale.refunded' — enhanced with stock return
// Phase 2
'instrument.deposited'
'instrument.collected'
'instrument.bounced'
'instrument.endorsed'
'settlement.posted'
'loan.installment_paid'
// Phase 3
'supplier_quote.submitted'
'quote_evaluation.completed'
'purchase_order.created_from_evaluation'
'price_deviation.detected'
// Phase 4
'overtime.approved'
'attendance_violation.detected'
'insurance.calculated'
'tax.calculated'
// Phase 5
'bom.components_deducted'
'commission.recorded'
// Phase 6
'movement.processed' — generic event from engine
'exchange_rate.updated'
// Phase 7
'lc.opened'
'lc.settled'
'guarantee.issued'
'guarantee.released'
'asset.revalued'
```
---
## Permission Registration Summary (new permissions per phase)
### Phase 1 (add to existing modules' bootstrap.php):
- member.statement
- supplier.statement
- inventory.opening_balance
- asset.custody
### Phase 2 (Accounting module):
- accounting.settlement.view/create/post
- accounting.loan.view/create/pay
- accounting.instrument.deposit/collect/bounce/endorse
### Phase 3 (Procurement module):
- procurement.quote.view/create/manage
- procurement.evaluation.view/create/decide
- procurement.payment_schedule.view/create/pay
### Phase 4 (HR module):
- hr.overtime.view/request/approve/manage_types
- hr.violation.view/detect/rules
- hr.permission.view/request/approve
- hr.shift.view/manage/assign
- hr.insurance_config
- hr.tax_config
### Phase 5 (Inventory + Sales):
- inventory.bom.view/manage
- inventory.units.manage
- sales.representative.view/manage
- sales.commission.view/pay
- sales.pricing.view/manage
### Phase 6 (Settings):
- settings.movement_specs.view/manage
- settings.currency.view/manage
### Phase 7 (Accounting):
- accounting.lc.view/create/manage
- accounting.guarantee.view/create/manage
- accounting.asset_category.manage
- accounting.asset.revalue/improve
---
## Menu Structure (sidebar additions)
```
المحاسبة (existing)
└── + التسويات (settlements)
└── + القروض البنكية (bank loans)
└── + الاعتمادات المستندية (documentary credits)
└── + خطابات الضمان (letters of guarantee)
└── + البحث المتقدم عن الأوراق (advanced paper search)
المشتريات (existing)
└── + عروض أسعار الموردين (supplier quotes)
└── + تقييم عروض الأسعار (quote evaluation)
└── + جدولة مدفوعات الموردين (supplier payment schedules)
الموارد البشرية (existing)
└── + العمل الإضافي (overtime)
└── + مخالفات الحضور (attendance violations)
└── + طلبات الإذن (permission requests)
└── + الورديات (shifts)
المخازن (existing)
└── + قوائم المواد BOM (bill of materials)
└── + أرصدة أول المدة (opening balances)
└── + عهدة الأصول (asset custody)
المبيعات (existing)
└── + مندوبي المبيعات (sales representatives)
└── + قوائم الأسعار (price lists)
الإعدادات (existing or new)
└── + مواصفات الحركات (movement specifications)
└── + العملات وأسعار الصرف (currencies & exchange rates)
```
---
# ═══════════════════════════════════════════════════════════════════════
# EXECUTION CHECKLIST PER FEATURE
# ═══════════════════════════════════════════════════════════════════════
For EVERY feature above, the implementation steps are:
```
□ 1. Write migration file (Phase_XX_YYY_description.php)
□ 2. Run migration: php cli.php migrate
□ 3. Create Model class(es) with $table, $fillable, $timestamps
□ 4. Create Service class with business logic
□ 5. Create Controller with action methods
□ 6. Create Views (Arabic UI, RTL)
□ 7. Add Routes to module's Routes.php
□ 8. Register Permissions in bootstrap.php
□ 9. Register Menu Items in bootstrap.php
□ 10. Register Event Listeners if needed
□ 11. Test the happy path manually
□ 12. Test edge cases (empty data, invalid input, permission denied)
□ 13. Verify GL posting if applicable
□ 14. Check Arabic translation completeness in views
```
---
# STATUS TRACKING
| Phase | Feature | Status |
|-------|---------|--------|
| 1.1 | Credit Limits | ⬜ TODO |
| 1.2 | PO Delivery Tracking | ⬜ TODO |
| 1.3 | PO Balance Report | ⬜ TODO |
| 1.4 | Customer Account Statement | ⬜ TODO |
| 1.5 | Supplier Account Statement | ⬜ TODO |
| 1.6 | Sales Returns + Stock Update | ⬜ TODO |
| 1.7 | Inventory Opening Balances | ⬜ TODO |
| 1.8 | Asset Custody & Location | ⬜ TODO |
| 2.1 | Check Lifecycle Status | ⬜ TODO |
| 2.2 | Check Endorsement | ⬜ TODO |
| 2.3 | Portfolio Batch Processing | ⬜ TODO |
| 2.4 | Paper Multi-Criteria Inquiry | ⬜ TODO |
| 2.5 | Daily Cash Movement Report | ⬜ TODO |
| 2.6 | Cross-Entity Settlements | ⬜ TODO |
| 2.7 | Bank Loan Management | ⬜ TODO |
| 3.1 | Supplier Price Quotes | ⬜ TODO |
| 3.2 | Quote Evaluation & Comparison | ⬜ TODO |
| 3.3 | Supplier Payment Scheduling | ⬜ TODO |
| 3.4 | Item Price Deviation | ⬜ TODO |
| 3.5 | Purchase vs Budget | ⬜ TODO |
| 4.1 | Overtime System | ⬜ TODO |
| 4.2 | Attendance Violations | ⬜ TODO |
| 4.3 | Permission Requests | ⬜ TODO |
| 4.4 | Insurance Auto-Calc | ⬜ TODO |
| 4.5 | Tax Bracket Calc | ⬜ TODO |
| 4.6 | Shift Management | ⬜ TODO |
| 5.1 | Bill of Materials | ⬜ TODO |
| 5.2 | Multiple Units/Item | ⬜ TODO |
| 5.3 | Sales Reps & Commission | ⬜ TODO |
| 5.4 | Per-Customer Pricing | ⬜ TODO |
| 6.1 | Movement Spec Engine | ⬜ TODO |
| 6.2 | Multi-Currency | ⬜ TODO |
| 7.1 | Documentary Credits | ⬜ TODO |
| 7.2 | Letters of Guarantee | ⬜ TODO |
| 7.3 | Fixed Asset Enhancements | ⬜ TODO |
| 8.1 | Report Export Engine | ⬜ TODO |
| 8.2 | Executive Dashboard | ⬜ TODO |
| 8.3 | Purchasing Reports Suite | ⬜ TODO |
| 8.4 | Sales Reports Suite | ⬜ TODO |
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Accounting\Services\AccountStatementService;
class AccountStatementController extends Controller
{
public function customerStatement(Request $request): Response
{
$this->authorize('accounting.statements.view');
$db = App::getInstance()->db();
$memberId = (int) $request->get('member_id', 0);
$dateFrom = $request->get('date_from', '');
$dateTo = $request->get('date_to', '');
$statement = null;
$member = null;
if ($memberId > 0) {
$member = $db->selectOne("SELECT id, full_name_ar, membership_number, credit_limit, credit_balance FROM members WHERE id = ?", [$memberId]);
$statement = AccountStatementService::getCustomerStatement(
$memberId,
$dateFrom ?: null,
$dateTo ?: null
);
}
$members = $db->select("SELECT id, full_name_ar, membership_number FROM members WHERE is_archived = 0 ORDER BY full_name_ar LIMIT 1000");
return $this->view('Accounting.Views.statements.customer', [
'members' => $members,
'member' => $member,
'statement' => $statement,
'filters' => ['member_id' => $memberId, 'date_from' => $dateFrom, 'date_to' => $dateTo],
]);
}
public function supplierStatement(Request $request): Response
{
$this->authorize('accounting.statements.view');
$db = App::getInstance()->db();
$supplierId = (int) $request->get('supplier_id', 0);
$dateFrom = $request->get('date_from', '');
$dateTo = $request->get('date_to', '');
$statement = null;
$supplier = null;
if ($supplierId > 0) {
$supplier = $db->selectOne("SELECT id, name_ar, code, credit_limit, credit_balance FROM suppliers WHERE id = ?", [$supplierId]);
$statement = AccountStatementService::getSupplierStatement(
$supplierId,
$dateFrom ?: null,
$dateTo ?: null
);
}
$suppliers = $db->select("SELECT id, name_ar, code FROM suppliers WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Accounting.Views.statements.supplier', [
'suppliers' => $suppliers,
'supplier' => $supplier,
'statement' => $statement,
'filters' => ['supplier_id' => $supplierId, 'date_from' => $dateFrom, 'date_to' => $dateTo],
]);
}
public function dailyCash(Request $request): Response
{
$this->authorize('accounting.cash.view');
$db = App::getInstance()->db();
$date = $request->get('date', date('Y-m-d'));
$movements = $db->select(
"SELECT * FROM daily_cash_movements WHERE movement_date = ? ORDER BY bank_account_id, safe_id",
[$date]
);
$bankAccounts = $db->select("SELECT id, account_name_ar FROM bank_accounts WHERE is_active = 1");
return $this->view('Accounting.Views.statements.daily_cash', [
'movements' => $movements,
'bankAccounts' => $bankAccounts,
'date' => $date,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Accounting\Services\BankLoanService;
class BankLoanController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.loans.view');
$db = App::getInstance()->db();
$status = $request->get('status', '');
$where = '1=1';
$params = [];
if ($status) {
$where .= ' AND l.status = ?';
$params[] = $status;
}
$loans = $db->select(
"SELECT l.*, ba.account_name_ar as bank_name
FROM bank_loans l
LEFT JOIN bank_accounts ba ON ba.id = l.bank_account_id
WHERE {$where}
ORDER BY l.created_at DESC",
$params
);
return $this->view('Accounting.Views.loans.index', [
'loans' => $loans,
'status' => $status,
]);
}
public function create(Request $request): Response
{
$this->authorize('accounting.loans.manage');
$db = App::getInstance()->db();
$bankAccounts = $db->select("SELECT id, account_name_ar FROM bank_accounts WHERE is_active = 1 ORDER BY account_name_ar");
return $this->view('Accounting.Views.loans.form', [
'loan' => null,
'bankAccounts' => $bankAccounts,
]);
}
public function store(Request $request): Response
{
$this->authorize('accounting.loans.manage');
$data = [
'bank_account_id' => (int) $request->post('bank_account_id'),
'loan_type' => $request->post('loan_type', 'term'),
'principal_amount' => $request->post('principal_amount'),
'interest_rate' => $request->post('interest_rate'),
'term_months' => (int) $request->post('term_months'),
'start_date' => $request->post('start_date'),
'collateral_description' => $request->post('collateral_description'),
'notes' => $request->post('notes'),
];
$result = BankLoanService::createLoan($data);
if (!$result['success']) {
return $this->redirect('/accounting/loans/create')->withError($result['error']);
}
return $this->redirect('/accounting/loans/' . $result['loan_id'])->withSuccess('تم إنشاء القرض بنجاح');
}
public function show(Request $request, string $id): Response
{
$this->authorize('accounting.loans.view');
$db = App::getInstance()->db();
$loan = $db->selectOne(
"SELECT l.*, ba.account_name_ar as bank_name
FROM bank_loans l
LEFT JOIN bank_accounts ba ON ba.id = l.bank_account_id
WHERE l.id = ?",
[(int) $id]
);
if (!$loan) {
return $this->redirect('/accounting/loans')->withError('القرض غير موجود');
}
$schedule = BankLoanService::getSchedule((int) $id);
return $this->view('Accounting.Views.loans.show', [
'loan' => $loan,
'schedule' => $schedule,
]);
}
public function recordPayment(Request $request, string $id): Response
{
$this->authorize('accounting.loans.manage');
$installmentNumber = (int) $request->post('installment_number');
$paidAmount = $request->post('paid_amount');
$paidDate = $request->post('paid_date');
$result = BankLoanService::recordPayment((int) $id, $installmentNumber, $paidAmount, $paidDate);
if (!$result['success']) {
return $this->redirect('/accounting/loans/' . $id)->withError($result['error']);
}
return $this->redirect('/accounting/loans/' . $id)->withSuccess('تم تسجيل السداد بنجاح');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class DocumentaryCreditController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.lc.view');
$db = App::getInstance()->db();
$status = $request->get('status', '');
$where = '1=1';
$params = [];
if ($status) {
$where .= ' AND lc.status = ?';
$params[] = $status;
}
$credits = $db->select(
"SELECT lc.*, ba.account_name_ar as bank_name, s.name_ar as supplier_name
FROM documentary_credits lc
LEFT JOIN bank_accounts ba ON ba.id = lc.issuing_bank_id
LEFT JOIN suppliers s ON s.id = lc.beneficiary_supplier_id
WHERE {$where}
ORDER BY lc.created_at DESC",
$params
);
return $this->view('Accounting.Views.documentary_credits.index', [
'credits' => $credits,
'status' => $status,
]);
}
public function create(Request $request): Response
{
$this->authorize('accounting.lc.manage');
$db = App::getInstance()->db();
$bankAccounts = $db->select("SELECT id, account_name_ar FROM bank_accounts WHERE is_active = 1 ORDER BY account_name_ar");
$suppliers = $db->select("SELECT id, name_ar FROM suppliers WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Accounting.Views.documentary_credits.form', [
'credit' => null,
'bankAccounts' => $bankAccounts,
'suppliers' => $suppliers,
]);
}
public function store(Request $request): Response
{
$this->authorize('accounting.lc.manage');
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$lcNumber = 'LC-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$amount = (string) $request->post('amount');
$marginPct = (string) $request->post('margin_percentage', '0');
$marginAmount = bcmul($amount, bcdiv($marginPct, '100', 4), 2);
$lcId = $db->insert('documentary_credits', [
'lc_number' => $lcNumber,
'issuing_bank_id' => (int) $request->post('issuing_bank_id'),
'beneficiary_supplier_id' => $request->post('beneficiary_supplier_id') ?: null,
'beneficiary_name' => $request->post('beneficiary_name'),
'amount' => $amount,
'currency_code' => $request->post('currency_code', 'EGP'),
'margin_percentage' => $marginPct,
'margin_amount' => $marginAmount,
'opening_date' => $request->post('opening_date'),
'expiry_date' => $request->post('expiry_date'),
'shipment_date' => $request->post('shipment_date') ?: null,
'status' => 'opened',
'terms' => $request->post('terms'),
'purchase_order_id' => $request->post('purchase_order_id') ?: null,
'notes' => $request->post('notes'),
'created_by' => $employee ? (int) $employee->id : null,
]);
return $this->redirect('/accounting/documentary-credits/' . $lcId)->withSuccess('تم فتح الاعتماد المستندي بنجاح — رقم: ' . $lcNumber);
}
public function show(Request $request, string $id): Response
{
$this->authorize('accounting.lc.view');
$db = App::getInstance()->db();
$credit = $db->selectOne(
"SELECT lc.*, ba.account_name_ar as bank_name, s.name_ar as supplier_name
FROM documentary_credits lc
LEFT JOIN bank_accounts ba ON ba.id = lc.issuing_bank_id
LEFT JOIN suppliers s ON s.id = lc.beneficiary_supplier_id
WHERE lc.id = ?",
[(int) $id]
);
if (!$credit) {
return $this->redirect('/accounting/documentary-credits')->withError('الاعتماد غير موجود');
}
$documents = $db->select("SELECT * FROM lc_documents WHERE lc_id = ? ORDER BY created_at DESC", [(int) $id]);
return $this->view('Accounting.Views.documentary_credits.show', [
'credit' => $credit,
'documents' => $documents,
]);
}
public function updateStatus(Request $request, string $id): Response
{
$this->authorize('accounting.lc.manage');
$db = App::getInstance()->db();
$newStatus = $request->post('status');
$db->update('documentary_credits', ['status' => $newStatus], 'id = ?', [(int) $id]);
return $this->redirect('/accounting/documentary-credits/' . $id)->withSuccess('تم تحديث حالة الاعتماد');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class LetterOfGuaranteeController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.guarantee.view');
$db = App::getInstance()->db();
$status = $request->get('status', '');
$type = $request->get('type', '');
$where = '1=1';
$params = [];
if ($status) {
$where .= ' AND lg.status = ?';
$params[] = $status;
}
if ($type) {
$where .= ' AND lg.guarantee_type = ?';
$params[] = $type;
}
$guarantees = $db->select(
"SELECT lg.*, ba.account_name_ar as bank_name
FROM letters_of_guarantee lg
LEFT JOIN bank_accounts ba ON ba.id = lg.bank_account_id
WHERE {$where}
ORDER BY lg.expiry_date ASC",
$params
);
return $this->view('Accounting.Views.guarantees.index', [
'guarantees' => $guarantees,
'status' => $status,
'type' => $type,
]);
}
public function create(Request $request): Response
{
$this->authorize('accounting.guarantee.manage');
$db = App::getInstance()->db();
$bankAccounts = $db->select("SELECT id, account_name_ar FROM bank_accounts WHERE is_active = 1 ORDER BY account_name_ar");
return $this->view('Accounting.Views.guarantees.form', [
'guarantee' => null,
'bankAccounts' => $bankAccounts,
]);
}
public function store(Request $request): Response
{
$this->authorize('accounting.guarantee.manage');
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$guaranteeNumber = 'LG-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$amount = (string) $request->post('amount');
$marginPct = (string) $request->post('margin_percentage', '0');
$marginAmount = bcmul($amount, bcdiv($marginPct, '100', 4), 2);
$commissionRate = (string) $request->post('commission_rate', '0');
$commissionAmount = bcmul($amount, bcdiv($commissionRate, '100', 6), 2);
$lgId = $db->insert('letters_of_guarantee', [
'guarantee_number' => $guaranteeNumber,
'bank_account_id' => (int) $request->post('bank_account_id'),
'beneficiary_name' => $request->post('beneficiary_name'),
'guarantee_type' => $request->post('guarantee_type'),
'amount' => $amount,
'currency_code' => $request->post('currency_code', 'EGP'),
'margin_percentage' => $marginPct,
'margin_amount' => $marginAmount,
'commission_rate' => $commissionRate,
'commission_amount' => $commissionAmount,
'issue_date' => $request->post('issue_date'),
'expiry_date' => $request->post('expiry_date'),
'status' => 'issued',
'related_contract' => $request->post('related_contract'),
'notes' => $request->post('notes'),
'created_by' => $employee ? (int) $employee->id : null,
]);
return $this->redirect('/accounting/guarantees/' . $lgId)->withSuccess('تم إصدار خطاب الضمان بنجاح — رقم: ' . $guaranteeNumber);
}
public function show(Request $request, string $id): Response
{
$this->authorize('accounting.guarantee.view');
$db = App::getInstance()->db();
$guarantee = $db->selectOne(
"SELECT lg.*, ba.account_name_ar as bank_name
FROM letters_of_guarantee lg
LEFT JOIN bank_accounts ba ON ba.id = lg.bank_account_id
WHERE lg.id = ?",
[(int) $id]
);
if (!$guarantee) {
return $this->redirect('/accounting/guarantees')->withError('خطاب الضمان غير موجود');
}
return $this->view('Accounting.Views.guarantees.show', ['guarantee' => $guarantee]);
}
public function updateStatus(Request $request, string $id): Response
{
$this->authorize('accounting.guarantee.manage');
$db = App::getInstance()->db();
$newStatus = $request->post('status');
$updateData = ['status' => $newStatus];
if ($newStatus === 'renewed') {
$updateData['renewal_date'] = date('Y-m-d');
$updateData['expiry_date'] = $request->post('new_expiry_date');
}
$db->update('letters_of_guarantee', $updateData, 'id = ?', [(int) $id]);
return $this->redirect('/accounting/guarantees/' . $id)->withSuccess('تم تحديث حالة خطاب الضمان');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Accounting\Services\CrossEntitySettlementService;
class SettlementController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.settlements.view');
$db = App::getInstance()->db();
$settlements = $db->select(
"SELECT * FROM cross_entity_settlements ORDER BY created_at DESC LIMIT 100"
);
return $this->view('Accounting.Views.settlements.index', [
'settlements' => $settlements,
]);
}
public function create(Request $request): Response
{
$this->authorize('accounting.settlements.manage');
$db = App::getInstance()->db();
$suppliers = $db->select("SELECT id, name_ar FROM suppliers WHERE is_archived = 0 ORDER BY name_ar");
$members = $db->select("SELECT id, full_name_ar, membership_number FROM members WHERE is_archived = 0 ORDER BY full_name_ar LIMIT 500");
$bankAccounts = $db->select("SELECT id, account_name_ar FROM bank_accounts WHERE is_active = 1 ORDER BY account_name_ar");
return $this->view('Accounting.Views.settlements.form', [
'suppliers' => $suppliers,
'members' => $members,
'bankAccounts' => $bankAccounts,
]);
}
public function store(Request $request): Response
{
$this->authorize('accounting.settlements.manage');
$data = [
'settlement_date' => $request->post('settlement_date'),
'from_entity_type' => $request->post('from_entity_type'),
'from_entity_id' => $request->post('from_entity_id'),
'from_entity_name' => $request->post('from_entity_name'),
'to_entity_type' => $request->post('to_entity_type'),
'to_entity_id' => $request->post('to_entity_id'),
'to_entity_name' => $request->post('to_entity_name'),
'amount' => $request->post('amount'),
'purpose' => $request->post('purpose', 'payment'),
'description' => $request->post('description'),
];
$result = CrossEntitySettlementService::create($data);
if (!$result['success']) {
return $this->redirect('/accounting/settlements/create')->withError($result['error']);
}
return $this->redirect('/accounting/settlements')->withSuccess('تم إنشاء التسوية بنجاح — رقم: ' . $result['settlement_number']);
}
public function approve(Request $request, string $id): Response
{
$this->authorize('accounting.settlements.approve');
$result = CrossEntitySettlementService::approve((int) $id);
if (!$result['success']) {
return $this->redirect('/accounting/settlements')->withError($result['error']);
}
return $this->redirect('/accounting/settlements')->withSuccess('تم اعتماد وترحيل التسوية بنجاح');
}
}
......@@ -118,4 +118,36 @@ return [
['GET', '/accounting/reports/member-statement', 'Accounting\Controllers\ReportController@memberStatement', ['auth'], 'accounting.reports.member_statement'],
['GET', '/accounting/reports/treasury', 'Accounting\Controllers\ReportController@treasury', ['auth'], 'accounting.reports.treasury'],
['GET', '/accounting/reports/revenue-analysis', 'Accounting\Controllers\ReportController@revenueAnalysis', ['auth'], 'accounting.reports.revenue_analysis'],
// ── Account Statements ──────────────────────────────────
['GET', '/accounting/statements/customer', 'Accounting\Controllers\AccountStatementController@customerStatement', ['auth'], 'accounting.statements.view'],
['GET', '/accounting/statements/supplier', 'Accounting\Controllers\AccountStatementController@supplierStatement', ['auth'], 'accounting.statements.view'],
['GET', '/accounting/statements/daily-cash', 'Accounting\Controllers\AccountStatementController@dailyCash', ['auth'], 'accounting.cash.view'],
// ── Bank Loans ──────────────────────────────────────────
['GET', '/accounting/loans', 'Accounting\Controllers\BankLoanController@index', ['auth'], 'accounting.loans.view'],
['GET', '/accounting/loans/create', 'Accounting\Controllers\BankLoanController@create', ['auth'], 'accounting.loans.manage'],
['POST', '/accounting/loans', 'Accounting\Controllers\BankLoanController@store', ['auth', 'csrf'], 'accounting.loans.manage'],
['GET', '/accounting/loans/{id:\d+}', 'Accounting\Controllers\BankLoanController@show', ['auth'], 'accounting.loans.view'],
['POST', '/accounting/loans/{id:\d+}/payment', 'Accounting\Controllers\BankLoanController@recordPayment', ['auth', 'csrf'], 'accounting.loans.manage'],
// ── Cross-Entity Settlements ────────────────────────────
['GET', '/accounting/settlements', 'Accounting\Controllers\SettlementController@index', ['auth'], 'accounting.settlements.view'],
['GET', '/accounting/settlements/create', 'Accounting\Controllers\SettlementController@create', ['auth'], 'accounting.settlements.manage'],
['POST', '/accounting/settlements', 'Accounting\Controllers\SettlementController@store', ['auth', 'csrf'], 'accounting.settlements.manage'],
['POST', '/accounting/settlements/{id:\d+}/approve', 'Accounting\Controllers\SettlementController@approve', ['auth', 'csrf'], 'accounting.settlements.approve'],
// ── Documentary Credits ─────────────────────────────────
['GET', '/accounting/documentary-credits', 'Accounting\Controllers\DocumentaryCreditController@index', ['auth'], 'accounting.lc.view'],
['GET', '/accounting/documentary-credits/create', 'Accounting\Controllers\DocumentaryCreditController@create', ['auth'], 'accounting.lc.manage'],
['POST', '/accounting/documentary-credits', 'Accounting\Controllers\DocumentaryCreditController@store', ['auth', 'csrf'], 'accounting.lc.manage'],
['GET', '/accounting/documentary-credits/{id:\d+}', 'Accounting\Controllers\DocumentaryCreditController@show', ['auth'], 'accounting.lc.view'],
['POST', '/accounting/documentary-credits/{id:\d+}/status', 'Accounting\Controllers\DocumentaryCreditController@updateStatus', ['auth', 'csrf'], 'accounting.lc.manage'],
// ── Letters of Guarantee ────────────────────────────────
['GET', '/accounting/guarantees', 'Accounting\Controllers\LetterOfGuaranteeController@index', ['auth'], 'accounting.guarantee.view'],
['GET', '/accounting/guarantees/create', 'Accounting\Controllers\LetterOfGuaranteeController@create', ['auth'], 'accounting.guarantee.manage'],
['POST', '/accounting/guarantees', 'Accounting\Controllers\LetterOfGuaranteeController@store', ['auth', 'csrf'], 'accounting.guarantee.manage'],
['GET', '/accounting/guarantees/{id:\d+}', 'Accounting\Controllers\LetterOfGuaranteeController@show', ['auth'], 'accounting.guarantee.view'],
['POST', '/accounting/guarantees/{id:\d+}/status', 'Accounting\Controllers\LetterOfGuaranteeController@updateStatus', ['auth', 'csrf'], 'accounting.guarantee.manage'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
final class AccountStatementService
{
public static function getCustomerStatement(int $memberId, ?string $dateFrom = null, ?string $dateTo = null): array
{
$db = App::getInstance()->db();
$where = 'member_id = ?';
$params = [$memberId];
if ($dateFrom) {
$where .= ' AND transaction_date >= ?';
$params[] = $dateFrom;
}
if ($dateTo) {
$where .= ' AND transaction_date <= ?';
$params[] = $dateTo;
}
$transactions = $db->select(
"SELECT * FROM customer_transactions WHERE {$where} ORDER BY transaction_date ASC, id ASC",
$params
);
$runningBalance = '0.00';
if ($dateFrom) {
$priorRow = $db->selectOne(
"SELECT COALESCE(SUM(debit) - SUM(credit), 0) as balance
FROM customer_transactions WHERE member_id = ? AND transaction_date < ?",
[$memberId, $dateFrom]
);
$runningBalance = (string) ($priorRow['balance'] ?? '0.00');
}
$result = [];
foreach ($transactions as $tx) {
$runningBalance = bcadd(bcsub($runningBalance, (string) $tx['credit'], 2), (string) $tx['debit'], 2);
$tx['running_balance'] = $runningBalance;
$result[] = $tx;
}
$totalDebit = '0.00';
$totalCredit = '0.00';
foreach ($transactions as $tx) {
$totalDebit = bcadd($totalDebit, (string) $tx['debit'], 2);
$totalCredit = bcadd($totalCredit, (string) $tx['credit'], 2);
}
return [
'transactions' => $result,
'opening_balance' => $dateFrom ? (string) ($priorRow['balance'] ?? '0.00') : '0.00',
'total_debit' => $totalDebit,
'total_credit' => $totalCredit,
'closing_balance' => $runningBalance,
];
}
public static function getSupplierStatement(int $supplierId, ?string $dateFrom = null, ?string $dateTo = null): array
{
$db = App::getInstance()->db();
$where = 'supplier_id = ?';
$params = [$supplierId];
if ($dateFrom) {
$where .= ' AND transaction_date >= ?';
$params[] = $dateFrom;
}
if ($dateTo) {
$where .= ' AND transaction_date <= ?';
$params[] = $dateTo;
}
$transactions = $db->select(
"SELECT * FROM supplier_transactions WHERE {$where} ORDER BY transaction_date ASC, id ASC",
$params
);
$runningBalance = '0.00';
if ($dateFrom) {
$priorRow = $db->selectOne(
"SELECT COALESCE(SUM(credit) - SUM(debit), 0) as balance
FROM supplier_transactions WHERE supplier_id = ? AND transaction_date < ?",
[$supplierId, $dateFrom]
);
$runningBalance = (string) ($priorRow['balance'] ?? '0.00');
}
$result = [];
foreach ($transactions as $tx) {
$runningBalance = bcadd(bcsub($runningBalance, (string) $tx['debit'], 2), (string) $tx['credit'], 2);
$tx['running_balance'] = $runningBalance;
$result[] = $tx;
}
$totalDebit = '0.00';
$totalCredit = '0.00';
foreach ($transactions as $tx) {
$totalDebit = bcadd($totalDebit, (string) $tx['debit'], 2);
$totalCredit = bcadd($totalCredit, (string) $tx['credit'], 2);
}
return [
'transactions' => $result,
'opening_balance' => $dateFrom ? (string) ($priorRow['balance'] ?? '0.00') : '0.00',
'total_debit' => $totalDebit,
'total_credit' => $totalCredit,
'closing_balance' => $runningBalance,
];
}
public static function recordCustomerTransaction(array $data): int
{
$db = App::getInstance()->db();
return $db->insert('customer_transactions', [
'member_id' => (int) $data['member_id'],
'transaction_date' => $data['transaction_date'] ?? date('Y-m-d'),
'document_type' => $data['document_type'],
'document_id' => $data['document_id'] ?? null,
'document_number' => $data['document_number'] ?? null,
'description' => $data['description'],
'debit' => $data['debit'] ?? '0.00',
'credit' => $data['credit'] ?? '0.00',
'branch_id' => $data['branch_id'] ?? null,
]);
}
public static function recordSupplierTransaction(array $data): int
{
$db = App::getInstance()->db();
return $db->insert('supplier_transactions', [
'supplier_id' => (int) $data['supplier_id'],
'transaction_date' => $data['transaction_date'] ?? date('Y-m-d'),
'document_type' => $data['document_type'],
'document_id' => $data['document_id'] ?? null,
'document_number' => $data['document_number'] ?? null,
'description' => $data['description'],
'debit' => $data['debit'] ?? '0.00',
'credit' => $data['credit'] ?? '0.00',
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\EventBus;
final class BankLoanService
{
public static function createLoan(array $data): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$principal = (string) $data['principal_amount'];
$rate = (string) $data['interest_rate'];
$termMonths = (int) $data['term_months'];
$startDate = $data['start_date'];
$monthlyRate = bcdiv($rate, '1200', 8);
$monthlyPayment = self::calculateMonthlyPayment($principal, $monthlyRate, $termMonths);
$totalInterest = bcsub(bcmul($monthlyPayment, (string) $termMonths, 2), $principal, 2);
$endDate = date('Y-m-d', strtotime($startDate . " +{$termMonths} months"));
$loanNumber = 'LOAN-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$db->beginTransaction();
try {
$loanId = $db->insert('bank_loans', [
'loan_number' => $loanNumber,
'bank_account_id' => (int) $data['bank_account_id'],
'loan_type' => $data['loan_type'] ?? 'term',
'principal_amount' => $principal,
'interest_rate' => $rate,
'term_months' => $termMonths,
'start_date' => $startDate,
'end_date' => $endDate,
'monthly_payment' => $monthlyPayment,
'outstanding_balance' => $principal,
'total_interest' => $totalInterest,
'collateral_description' => $data['collateral_description'] ?? null,
'notes' => $data['notes'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
]);
self::generateAmortizationSchedule($loanId, $principal, $monthlyRate, $monthlyPayment, $termMonths, $startDate);
$db->commit();
EventBus::dispatch('bank_loan.created', ['loan_id' => $loanId]);
return ['success' => true, 'loan_id' => $loanId, 'loan_number' => $loanNumber];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
private static function calculateMonthlyPayment(string $principal, string $monthlyRate, int $termMonths): string
{
if (bccomp($monthlyRate, '0', 8) === 0) {
return bcdiv($principal, (string) $termMonths, 2);
}
$onePlusR = bcadd('1', $monthlyRate, 8);
$power = bcpow($onePlusR, (string) $termMonths, 8);
$numerator = bcmul($principal, bcmul($monthlyRate, $power, 8), 8);
$denominator = bcsub($power, '1', 8);
return bcdiv($numerator, $denominator, 2);
}
private static function generateAmortizationSchedule(int $loanId, string $principal, string $monthlyRate, string $monthlyPayment, int $termMonths, string $startDate): void
{
$db = App::getInstance()->db();
$balance = $principal;
for ($i = 1; $i <= $termMonths; $i++) {
$interest = bcmul($balance, $monthlyRate, 2);
$principalPart = bcsub($monthlyPayment, $interest, 2);
if ($i === $termMonths) {
$principalPart = $balance;
$monthlyPayment = bcadd($principalPart, $interest, 2);
}
$balance = bcsub($balance, $principalPart, 2);
$dueDate = date('Y-m-d', strtotime($startDate . " +{$i} months"));
$db->insert('bank_loan_schedule', [
'loan_id' => $loanId,
'installment_number' => $i,
'due_date' => $dueDate,
'principal_amount' => $principalPart,
'interest_amount' => $interest,
'total_amount' => bcadd($principalPart, $interest, 2),
'status' => 'upcoming',
]);
}
}
public static function recordPayment(int $loanId, int $installmentNumber, string $paidAmount, ?string $paidDate = null): array
{
$db = App::getInstance()->db();
$schedule = $db->selectOne(
"SELECT * FROM bank_loan_schedule WHERE loan_id = ? AND installment_number = ?",
[$loanId, $installmentNumber]
);
if (!$schedule) {
return ['success' => false, 'error' => 'القسط غير موجود'];
}
$db->beginTransaction();
try {
$db->update('bank_loan_schedule', [
'paid_amount' => $paidAmount,
'paid_date' => $paidDate ?? date('Y-m-d'),
'status' => 'paid',
], 'id = ?', [$schedule['id']]);
$db->query(
"UPDATE bank_loans SET outstanding_balance = outstanding_balance - ?, total_paid = total_paid + ? WHERE id = ?",
[$schedule['principal_amount'], $paidAmount, $loanId]
);
$allPaid = $db->selectOne(
"SELECT COUNT(*) as cnt FROM bank_loan_schedule WHERE loan_id = ? AND status != 'paid'",
[$loanId]
);
if ((int) ($allPaid['cnt'] ?? 1) === 0) {
$db->update('bank_loans', ['status' => 'paid_off'], 'id = ?', [$loanId]);
}
$db->commit();
EventBus::dispatch('bank_loan.payment', [
'loan_id' => $loanId,
'installment_number' => $installmentNumber,
'amount' => $paidAmount,
]);
return ['success' => true];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function getOverdueInstallments(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT s.*, l.loan_number, l.bank_account_id, ba.account_name_ar as bank_name
FROM bank_loan_schedule s
JOIN bank_loans l ON l.id = s.loan_id
LEFT JOIN bank_accounts ba ON ba.id = l.bank_account_id
WHERE s.status IN ('upcoming','due') AND s.due_date < CURDATE()
ORDER BY s.due_date ASC"
);
}
public static function getSchedule(int $loanId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM bank_loan_schedule WHERE loan_id = ? ORDER BY installment_number ASC",
[$loanId]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\EventBus;
final class CheckLifecycleService
{
private static array $validTransitions = [
'in_hand' => ['under_collection', 'endorsed', 'cancelled', 'returned'],
'under_collection' => ['collected', 'bounced', 'returned'],
'collected' => [],
'bounced' => ['in_hand', 'under_collection', 'cancelled'],
'endorsed' => ['returned'],
'cancelled' => [],
'paid' => [],
'returned' => ['in_hand'],
];
public static function canTransition(string $fromStatus, string $toStatus): bool
{
$allowed = self::$validTransitions[$fromStatus] ?? [];
return in_array($toStatus, $allowed, true);
}
public static function transition(int $instrumentId, string $toStatus, array $options = []): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$instrument = $db->selectOne(
"SELECT * FROM negotiable_instruments WHERE id = ?",
[$instrumentId]
);
if (!$instrument) {
return ['success' => false, 'error' => 'الورقة غير موجودة'];
}
$fromStatus = (string) $instrument['status'];
if (!self::canTransition($fromStatus, $toStatus)) {
return ['success' => false, 'error' => 'لا يمكن تغيير الحالة من "' . $fromStatus . '" إلى "' . $toStatus . '"'];
}
$db->beginTransaction();
try {
$updateData = ['status' => $toStatus];
switch ($toStatus) {
case 'under_collection':
$updateData['deposited_date'] = $options['date'] ?? date('Y-m-d');
if (!empty($options['bank_account_id'])) {
$updateData['bank_account_id'] = (int) $options['bank_account_id'];
}
break;
case 'collected':
$updateData['collected_date'] = $options['date'] ?? date('Y-m-d');
break;
case 'bounced':
$updateData['bounced_date'] = $options['date'] ?? date('Y-m-d');
$updateData['bounce_reason'] = $options['bounce_reason'] ?? null;
break;
case 'endorsed':
$updateData['endorsed_to'] = $options['endorsed_to'] ?? null;
$updateData['endorsed_date'] = $options['date'] ?? date('Y-m-d');
break;
}
$db->update('negotiable_instruments', $updateData, 'id = ?', [$instrumentId]);
$db->insert('instrument_status_history', [
'instrument_id' => $instrumentId,
'from_status' => $fromStatus,
'to_status' => $toStatus,
'transition_date' => $options['date'] ?? date('Y-m-d'),
'bank_account_id' => $options['bank_account_id'] ?? null,
'endorsed_to' => $options['endorsed_to'] ?? null,
'bounce_reason' => $options['bounce_reason'] ?? null,
'notes' => $options['notes'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
]);
EventBus::dispatch('instrument.status_changed', [
'instrument_id' => $instrumentId,
'from_status' => $fromStatus,
'to_status' => $toStatus,
'instrument' => $instrument,
]);
$db->commit();
return ['success' => true, 'instrument_id' => $instrumentId];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function batchTransition(array $instrumentIds, string $toStatus, array $options = []): array
{
$results = ['success' => 0, 'failed' => 0, 'errors' => []];
foreach ($instrumentIds as $id) {
$result = self::transition((int) $id, $toStatus, $options);
if ($result['success']) {
$results['success']++;
} else {
$results['failed']++;
$results['errors'][] = "#{$id}: " . $result['error'];
}
}
return $results;
}
public static function getStatusHistory(int $instrumentId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT h.*, e.full_name_ar as created_by_name
FROM instrument_status_history h
LEFT JOIN employees e ON e.id = h.created_by
WHERE h.instrument_id = ?
ORDER BY h.created_at DESC",
[$instrumentId]
);
}
public static function endorse(int $instrumentId, string $endorsedTo, ?string $date = null, ?string $notes = null): array
{
return self::transition($instrumentId, 'endorsed', [
'endorsed_to' => $endorsedTo,
'date' => $date ?? date('Y-m-d'),
'notes' => $notes,
]);
}
public static function deposit(int $instrumentId, int $bankAccountId, ?string $date = null): array
{
return self::transition($instrumentId, 'under_collection', [
'bank_account_id' => $bankAccountId,
'date' => $date ?? date('Y-m-d'),
]);
}
public static function collect(int $instrumentId, ?string $date = null): array
{
return self::transition($instrumentId, 'collected', [
'date' => $date ?? date('Y-m-d'),
]);
}
public static function bounce(int $instrumentId, string $reason, ?string $date = null): array
{
return self::transition($instrumentId, 'bounced', [
'bounce_reason' => $reason,
'date' => $date ?? date('Y-m-d'),
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\EventBus;
final class CrossEntitySettlementService
{
public static function create(array $data): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$settlementNumber = 'SET-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$db->beginTransaction();
try {
$settlementId = $db->insert('cross_entity_settlements', [
'settlement_number' => $settlementNumber,
'settlement_date' => $data['settlement_date'] ?? date('Y-m-d'),
'from_entity_type' => $data['from_entity_type'],
'from_entity_id' => (int) $data['from_entity_id'],
'from_entity_name' => $data['from_entity_name'],
'to_entity_type' => $data['to_entity_type'],
'to_entity_id' => (int) $data['to_entity_id'],
'to_entity_name' => $data['to_entity_name'],
'amount' => $data['amount'],
'purpose' => $data['purpose'] ?? 'payment',
'description' => $data['description'] ?? null,
'status' => 'draft',
'created_by' => $employee ? (int) $employee->id : null,
]);
$db->commit();
return ['success' => true, 'settlement_id' => $settlementId, 'settlement_number' => $settlementNumber];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function approve(int $settlementId): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$settlement = $db->selectOne("SELECT * FROM cross_entity_settlements WHERE id = ?", [$settlementId]);
if (!$settlement) {
return ['success' => false, 'error' => 'التسوية غير موجودة'];
}
if ($settlement['status'] !== 'draft') {
return ['success' => false, 'error' => 'التسوية ليست في حالة مسودة'];
}
$db->beginTransaction();
try {
$db->update('cross_entity_settlements', [
'status' => 'approved',
'approved_by' => $employee ? (int) $employee->id : null,
'approved_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$settlementId]);
self::postSettlementToAccounts($settlement);
$db->update('cross_entity_settlements', ['status' => 'posted'], 'id = ?', [$settlementId]);
$db->commit();
EventBus::dispatch('settlement.posted', [
'settlement_id' => $settlementId,
'amount' => $settlement['amount'],
]);
return ['success' => true];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
private static function postSettlementToAccounts(array $settlement): void
{
$amount = (string) $settlement['amount'];
$date = $settlement['settlement_date'];
if ($settlement['from_entity_type'] === 'customer') {
AccountStatementService::recordCustomerTransaction([
'member_id' => (int) $settlement['from_entity_id'],
'transaction_date' => $date,
'document_type' => 'adjustment',
'document_number' => $settlement['settlement_number'],
'description' => 'تسوية لصالح: ' . $settlement['to_entity_name'],
'credit' => $amount,
]);
} elseif ($settlement['from_entity_type'] === 'supplier') {
AccountStatementService::recordSupplierTransaction([
'supplier_id' => (int) $settlement['from_entity_id'],
'transaction_date' => $date,
'document_type' => 'adjustment',
'document_number' => $settlement['settlement_number'],
'description' => 'تسوية لصالح: ' . $settlement['to_entity_name'],
'debit' => $amount,
]);
}
if ($settlement['to_entity_type'] === 'customer') {
AccountStatementService::recordCustomerTransaction([
'member_id' => (int) $settlement['to_entity_id'],
'transaction_date' => $date,
'document_type' => 'adjustment',
'document_number' => $settlement['settlement_number'],
'description' => 'تسوية من: ' . $settlement['from_entity_name'],
'debit' => $amount,
]);
} elseif ($settlement['to_entity_type'] === 'supplier') {
AccountStatementService::recordSupplierTransaction([
'supplier_id' => (int) $settlement['to_entity_id'],
'transaction_date' => $date,
'document_type' => 'adjustment',
'document_number' => $settlement['settlement_number'],
'description' => 'تسوية من: ' . $settlement['from_entity_name'],
'credit' => $amount,
]);
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\EventBus;
final class DailyCashMovementService
{
public static function registerListeners(): void
{
EventBus::listen('sale.completed', [self::class, 'onSaleCompleted'], 30);
EventBus::listen('payment.completed', [self::class, 'onPaymentReceived'], 30);
EventBus::listen('procurement.payment_completed', [self::class, 'onPaymentMade'], 30);
}
public static function record(array $data): int
{
$db = App::getInstance()->db();
return $db->insert('daily_cash_movements', [
'movement_date' => $data['movement_date'] ?? date('Y-m-d'),
'movement_type' => $data['movement_type'],
'direction' => $data['direction'],
'amount' => $data['amount'],
'description' => $data['description'] ?? null,
'reference_type' => $data['reference_type'] ?? null,
'reference_id' => $data['reference_id'] ?? null,
'bank_account_id' => $data['bank_account_id'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
]);
}
public static function onSaleCompleted(array $data): void
{
$amount = (string) ($data['total_amount'] ?? '0');
if (bccomp($amount, '0', 2) <= 0) return;
self::record([
'movement_type' => 'sale',
'direction' => 'in',
'amount' => $amount,
'description' => 'مبيعات — ' . ($data['invoice_number'] ?? ''),
'reference_type' => 'sales',
'reference_id' => $data['sale_id'] ?? null,
]);
}
public static function onPaymentReceived(array $data): void
{
$amount = (string) ($data['amount'] ?? '0');
if (bccomp($amount, '0', 2) <= 0) return;
self::record([
'movement_type' => 'collection',
'direction' => 'in',
'amount' => $amount,
'description' => 'تحصيل — ' . ($data['receipt_number'] ?? ''),
'reference_type' => 'payments',
'reference_id' => $data['payment_id'] ?? null,
]);
}
public static function onPaymentMade(array $data): void
{
$amount = (string) ($data['amount'] ?? '0');
if (bccomp($amount, '0', 2) <= 0) return;
self::record([
'movement_type' => 'disbursement',
'direction' => 'out',
'amount' => $amount,
'description' => 'سداد مورد — ' . ($data['payment_number'] ?? ''),
'reference_type' => 'vendor_payments',
'reference_id' => $data['payment_id'] ?? null,
]);
}
public static function getDailySummary(string $date): array
{
$db = App::getInstance()->db();
$inflows = $db->selectOne(
"SELECT COALESCE(SUM(amount), 0) as total FROM daily_cash_movements WHERE movement_date = ? AND direction = 'in'",
[$date]
);
$outflows = $db->selectOne(
"SELECT COALESCE(SUM(amount), 0) as total FROM daily_cash_movements WHERE movement_date = ? AND direction = 'out'",
[$date]
);
$totalIn = (string) ($inflows['total'] ?? '0.00');
$totalOut = (string) ($outflows['total'] ?? '0.00');
return [
'date' => $date,
'total_in' => $totalIn,
'total_out' => $totalOut,
'net' => bcsub($totalIn, $totalOut, 2),
];
}
public static function getMovements(string $date): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM daily_cash_movements WHERE movement_date = ? ORDER BY created_at",
[$date]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\EventBus;
final class DocumentaryCreditService
{
private const VALID_TRANSITIONS = [
'draft' => ['submitted'],
'submitted' => ['opened', 'cancelled'],
'opened' => ['partially_utilized', 'fully_utilized', 'expired', 'cancelled'],
'partially_utilized' => ['fully_utilized', 'expired'],
'fully_utilized' => ['closed'],
'expired' => ['closed'],
];
public static function create(array $data): int
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
return $db->insert('documentary_credits', [
'credit_number' => $data['credit_number'],
'type' => $data['type'] ?? 'import',
'beneficiary_name' => $data['beneficiary_name'],
'issuing_bank' => $data['issuing_bank'],
'advising_bank' => $data['advising_bank'] ?? null,
'amount' => $data['amount'],
'currency_code' => $data['currency_code'] ?? 'EGP',
'opening_date' => $data['opening_date'] ?? date('Y-m-d'),
'expiry_date' => $data['expiry_date'],
'shipment_date' => $data['shipment_date'] ?? null,
'purchase_order_id' => $data['purchase_order_id'] ?? null,
'bank_account_id' => $data['bank_account_id'] ?? null,
'margin_amount' => $data['margin_amount'] ?? '0.00',
'commission_rate' => $data['commission_rate'] ?? '0.00',
'status' => 'draft',
'notes' => $data['notes'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
public static function transition(int $creditId, string $newStatus): array
{
$db = App::getInstance()->db();
$credit = $db->selectOne("SELECT * FROM documentary_credits WHERE id = ?", [$creditId]);
if (!$credit) {
return ['success' => false, 'error' => 'الاعتماد المستندي غير موجود'];
}
$current = $credit['status'];
$allowed = self::VALID_TRANSITIONS[$current] ?? [];
if (!in_array($newStatus, $allowed, true)) {
return ['success' => false, 'error' => "لا يمكن الانتقال من {$current} إلى {$newStatus}"];
}
$db->update('documentary_credits', [
'status' => $newStatus,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$creditId]);
EventBus::dispatch('accounting.lc.status_changed', [
'credit_id' => $creditId,
'from_status' => $current,
'to_status' => $newStatus,
'amount' => (string) $credit['amount'],
]);
return ['success' => true];
}
public static function addDocument(int $creditId, array $data): int
{
$db = App::getInstance()->db();
return $db->insert('lc_documents', [
'credit_id' => $creditId,
'document_type' => $data['document_type'],
'document_number' => $data['document_number'] ?? null,
'description' => $data['description'] ?? null,
'received_date' => $data['received_date'] ?? date('Y-m-d'),
'status' => $data['status'] ?? 'received',
'created_at' => date('Y-m-d H:i:s'),
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\EventBus;
final class LetterOfGuaranteeService
{
private const VALID_TRANSITIONS = [
'draft' => ['active'],
'active' => ['extended', 'released', 'called'],
'extended' => ['released', 'called'],
'called' => ['settled'],
];
public static function create(array $data): int
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
return $db->insert('letters_of_guarantee', [
'guarantee_number' => $data['guarantee_number'],
'type' => $data['type'] ?? 'bid',
'beneficiary_name' => $data['beneficiary_name'],
'issuing_bank' => $data['issuing_bank'],
'amount' => $data['amount'],
'currency_code' => $data['currency_code'] ?? 'EGP',
'issue_date' => $data['issue_date'] ?? date('Y-m-d'),
'expiry_date' => $data['expiry_date'],
'purpose' => $data['purpose'] ?? null,
'bank_account_id' => $data['bank_account_id'] ?? null,
'margin_amount' => $data['margin_amount'] ?? '0.00',
'commission_rate' => $data['commission_rate'] ?? '0.00',
'status' => 'draft',
'notes' => $data['notes'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
public static function transition(int $guaranteeId, string $newStatus): array
{
$db = App::getInstance()->db();
$guarantee = $db->selectOne("SELECT * FROM letters_of_guarantee WHERE id = ?", [$guaranteeId]);
if (!$guarantee) {
return ['success' => false, 'error' => 'خطاب الضمان غير موجود'];
}
$current = $guarantee['status'];
$allowed = self::VALID_TRANSITIONS[$current] ?? [];
if (!in_array($newStatus, $allowed, true)) {
return ['success' => false, 'error' => "لا يمكن الانتقال من {$current} إلى {$newStatus}"];
}
$updateData = [
'status' => $newStatus,
'updated_at' => date('Y-m-d H:i:s'),
];
if ($newStatus === 'released') {
$updateData['released_date'] = date('Y-m-d');
}
$db->update('letters_of_guarantee', $updateData, 'id = ?', [$guaranteeId]);
EventBus::dispatch('accounting.guarantee.status_changed', [
'guarantee_id' => $guaranteeId,
'from_status' => $current,
'to_status' => $newStatus,
'amount' => (string) $guarantee['amount'],
]);
return ['success' => true];
}
public static function getExpiringWithinDays(int $days = 30): array
{
$db = App::getInstance()->db();
$futureDate = date('Y-m-d', strtotime("+{$days} days"));
return $db->select(
"SELECT * FROM letters_of_guarantee
WHERE status IN ('active', 'extended')
AND expiry_date <= ?
ORDER BY expiry_date ASC",
[$futureDate]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
final class MultiCurrencyService
{
public static function getBaseCurrency(): array
{
$db = App::getInstance()->db();
$currency = $db->selectOne("SELECT * FROM currencies WHERE is_base_currency = 1 LIMIT 1");
return $currency ?: ['code' => 'EGP', 'name_ar' => 'جنيه مصري', 'symbol' => 'ج.م', 'decimal_places' => 2];
}
public static function getExchangeRate(string $currencyCode, ?string $date = null): array
{
if ($currencyCode === 'EGP') {
return ['buy_rate' => '1.000000', 'sell_rate' => '1.000000', 'mid_rate' => '1.000000'];
}
$db = App::getInstance()->db();
$date = $date ?? date('Y-m-d');
$currency = $db->selectOne("SELECT id FROM currencies WHERE code = ?", [$currencyCode]);
if (!$currency) {
return ['buy_rate' => '1.000000', 'sell_rate' => '1.000000', 'mid_rate' => '1.000000'];
}
$rate = $db->selectOne(
"SELECT * FROM exchange_rates WHERE currency_id = ? AND rate_date <= ? ORDER BY rate_date DESC LIMIT 1",
[(int) $currency['id'], $date]
);
if (!$rate) {
return ['buy_rate' => '1.000000', 'sell_rate' => '1.000000', 'mid_rate' => '1.000000'];
}
return [
'buy_rate' => (string) $rate['buy_rate'],
'sell_rate' => (string) $rate['sell_rate'],
'mid_rate' => (string) $rate['mid_rate'],
];
}
public static function convert(string $amount, string $fromCurrency, string $toCurrency, ?string $date = null): array
{
if ($fromCurrency === $toCurrency) {
return ['converted_amount' => $amount, 'rate' => '1.000000'];
}
$baseCurrency = self::getBaseCurrency()['code'];
if ($fromCurrency === $baseCurrency) {
$rate = self::getExchangeRate($toCurrency, $date);
$converted = bcdiv($amount, $rate['mid_rate'], 2);
return ['converted_amount' => $converted, 'rate' => $rate['mid_rate']];
}
if ($toCurrency === $baseCurrency) {
$rate = self::getExchangeRate($fromCurrency, $date);
$converted = bcmul($amount, $rate['mid_rate'], 2);
return ['converted_amount' => $converted, 'rate' => $rate['mid_rate']];
}
$fromRate = self::getExchangeRate($fromCurrency, $date);
$toRate = self::getExchangeRate($toCurrency, $date);
$baseAmount = bcmul($amount, $fromRate['mid_rate'], 6);
$converted = bcdiv($baseAmount, $toRate['mid_rate'], 2);
$crossRate = bcdiv($fromRate['mid_rate'], $toRate['mid_rate'], 6);
return ['converted_amount' => $converted, 'rate' => $crossRate];
}
public static function calculateGainLoss(string $originalAmount, string $originalRate, string $currentRate, string $currency): array
{
$originalBase = bcmul($originalAmount, $originalRate, 2);
$currentBase = bcmul($originalAmount, $currentRate, 2);
$difference = bcsub($currentBase, $originalBase, 2);
return [
'original_base_amount' => $originalBase,
'current_base_amount' => $currentBase,
'gain_loss' => $difference,
'is_gain' => bccomp($difference, '0', 2) > 0,
];
}
public static function getActiveCurrencies(): array
{
$db = App::getInstance()->db();
return $db->select("SELECT * FROM currencies WHERE is_active = 1 ORDER BY is_base_currency DESC, name_ar ASC");
}
public static function setExchangeRate(string $currencyCode, string $buyRate, string $sellRate, ?string $date = null): bool
{
$db = App::getInstance()->db();
$date = $date ?? date('Y-m-d');
$currency = $db->selectOne("SELECT id FROM currencies WHERE code = ?", [$currencyCode]);
if (!$currency) return false;
$midRate = bcdiv(bcadd($buyRate, $sellRate, 6), '2', 6);
$existing = $db->selectOne(
"SELECT id FROM exchange_rates WHERE currency_id = ? AND rate_date = ?",
[(int) $currency['id'], $date]
);
if ($existing) {
$db->update('exchange_rates', [
'buy_rate' => $buyRate,
'sell_rate' => $sellRate,
'mid_rate' => $midRate,
], 'id = ?', [(int) $existing['id']]);
} else {
$db->insert('exchange_rates', [
'currency_id' => (int) $currency['id'],
'rate_date' => $date,
'buy_rate' => $buyRate,
'sell_rate' => $sellRate,
'mid_rate' => $midRate,
]);
}
return true;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Inventory\Services\CreditLimitService;
final class StatementIntegrationService
{
public static function registerListeners(): void
{
EventBus::listen('sale.completed', [self::class, 'onSaleCompleted'], 40);
EventBus::listen('sale.refunded', [self::class, 'onSaleRefunded'], 40);
EventBus::listen('payment.completed', [self::class, 'onPaymentCompleted'], 40);
EventBus::listen('procurement.invoice_approved', [self::class, 'onVendorInvoiceApproved'], 40);
EventBus::listen('procurement.payment_completed', [self::class, 'onVendorPaymentCompleted'], 40);
EventBus::listen('procurement.rtv_completed', [self::class, 'onReturnToVendor'], 40);
EventBus::listen('fine.imposed', [self::class, 'onFineImposed'], 40);
EventBus::listen('subscription.paid', [self::class, 'onSubscriptionPaid'], 40);
}
public static function onSaleCompleted(array $data): void
{
try {
$memberId = (int) ($data['member_id'] ?? 0);
if ($memberId <= 0) return;
$amount = (string) ($data['total_amount'] ?? '0');
$saleNumber = $data['sale_number'] ?? '';
AccountStatementService::recordCustomerTransaction([
'member_id' => $memberId,
'transaction_date' => date('Y-m-d'),
'document_type' => 'sale',
'document_id' => $data['sale_id'] ?? null,
'document_number' => $saleNumber,
'description' => 'فاتورة مبيعات: ' . $saleNumber,
'debit' => $amount,
]);
CreditLimitService::updateCustomerBalance($memberId, $amount, 'increase');
} catch (\Throwable $e) {
Logger::error('StatementIntegration sale.completed failed: ' . $e->getMessage());
}
}
public static function onSaleRefunded(array $data): void
{
try {
$memberId = (int) ($data['member_id'] ?? 0);
if ($memberId <= 0) return;
$amount = (string) ($data['refund_amount'] ?? '0');
$refundNumber = $data['refund_number'] ?? '';
AccountStatementService::recordCustomerTransaction([
'member_id' => $memberId,
'transaction_date' => date('Y-m-d'),
'document_type' => 'refund',
'document_id' => $data['refund_id'] ?? null,
'document_number' => $refundNumber,
'description' => 'مرتجع مبيعات: ' . $refundNumber,
'credit' => $amount,
]);
CreditLimitService::updateCustomerBalance($memberId, $amount, 'decrease');
} catch (\Throwable $e) {
Logger::error('StatementIntegration sale.refunded failed: ' . $e->getMessage());
}
}
public static function onPaymentCompleted(array $data): void
{
try {
$memberId = (int) ($data['member_id'] ?? 0);
if ($memberId <= 0) return;
$amount = (string) ($data['amount'] ?? '0');
$receiptNumber = $data['receipt_number'] ?? '';
AccountStatementService::recordCustomerTransaction([
'member_id' => $memberId,
'transaction_date' => date('Y-m-d'),
'document_type' => 'payment',
'document_id' => $data['payment_id'] ?? null,
'document_number' => $receiptNumber,
'description' => 'سداد: ' . $receiptNumber,
'credit' => $amount,
]);
CreditLimitService::updateCustomerBalance($memberId, $amount, 'decrease');
} catch (\Throwable $e) {
Logger::error('StatementIntegration payment.completed failed: ' . $e->getMessage());
}
}
public static function onVendorInvoiceApproved(array $data): void
{
try {
$supplierId = (int) ($data['supplier_id'] ?? 0);
if ($supplierId <= 0) return;
$amount = (string) ($data['total_amount'] ?? '0');
$invoiceNumber = $data['invoice_number'] ?? '';
AccountStatementService::recordSupplierTransaction([
'supplier_id' => $supplierId,
'transaction_date' => date('Y-m-d'),
'document_type' => 'invoice',
'document_id' => $data['invoice_id'] ?? null,
'document_number' => $invoiceNumber,
'description' => 'فاتورة مورد: ' . $invoiceNumber,
'credit' => $amount,
]);
CreditLimitService::updateSupplierBalance($supplierId, $amount, 'increase');
} catch (\Throwable $e) {
Logger::error('StatementIntegration vendor_invoice_approved failed: ' . $e->getMessage());
}
}
public static function onVendorPaymentCompleted(array $data): void
{
try {
$supplierId = (int) ($data['supplier_id'] ?? 0);
if ($supplierId <= 0) return;
$amount = (string) ($data['amount'] ?? '0');
$paymentNumber = $data['payment_number'] ?? '';
AccountStatementService::recordSupplierTransaction([
'supplier_id' => $supplierId,
'transaction_date' => date('Y-m-d'),
'document_type' => 'payment',
'document_id' => $data['payment_id'] ?? null,
'document_number' => $paymentNumber,
'description' => 'سداد مورد: ' . $paymentNumber,
'debit' => $amount,
]);
CreditLimitService::updateSupplierBalance($supplierId, $amount, 'decrease');
} catch (\Throwable $e) {
Logger::error('StatementIntegration vendor_payment_completed failed: ' . $e->getMessage());
}
}
public static function onReturnToVendor(array $data): void
{
try {
$supplierId = (int) ($data['supplier_id'] ?? 0);
if ($supplierId <= 0) return;
$amount = (string) ($data['total_amount'] ?? '0');
$rtvNumber = $data['rtv_number'] ?? '';
AccountStatementService::recordSupplierTransaction([
'supplier_id' => $supplierId,
'transaction_date' => date('Y-m-d'),
'document_type' => 'return',
'document_id' => $data['rtv_id'] ?? null,
'document_number' => $rtvNumber,
'description' => 'مرتجع مورد: ' . $rtvNumber,
'debit' => $amount,
]);
CreditLimitService::updateSupplierBalance($supplierId, $amount, 'decrease');
} catch (\Throwable $e) {
Logger::error('StatementIntegration rtv_completed failed: ' . $e->getMessage());
}
}
public static function onFineImposed(array $data): void
{
try {
$memberId = (int) ($data['member_id'] ?? 0);
if ($memberId <= 0) return;
$amount = (string) ($data['amount'] ?? '0');
AccountStatementService::recordCustomerTransaction([
'member_id' => $memberId,
'transaction_date' => date('Y-m-d'),
'document_type' => 'fine',
'document_id' => $data['fine_id'] ?? null,
'description' => 'غرامة: ' . ($data['reason'] ?? ''),
'debit' => $amount,
]);
CreditLimitService::updateCustomerBalance($memberId, $amount, 'increase');
} catch (\Throwable $e) {
Logger::error('StatementIntegration fine.imposed failed: ' . $e->getMessage());
}
}
public static function onSubscriptionPaid(array $data): void
{
try {
$memberId = (int) ($data['member_id'] ?? 0);
if ($memberId <= 0) return;
$amount = (string) ($data['amount'] ?? '0');
AccountStatementService::recordCustomerTransaction([
'member_id' => $memberId,
'transaction_date' => date('Y-m-d'),
'document_type' => 'subscription',
'document_id' => $data['subscription_id'] ?? null,
'description' => 'اشتراك: ' . ($data['description'] ?? ''),
'debit' => $amount,
]);
} catch (\Throwable $e) {
Logger::error('StatementIntegration subscription.paid failed: ' . $e->getMessage());
}
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الاعتمادات المستندية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/accounting/documentary-credits/create" class="btn btn-primary">+ اعتماد جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/documentary-credits" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select" onchange="this.form.submit()">
<option value="">الكل</option>
<option value="draft" <?= ($status ?? '') === 'draft' ? 'selected' : '' ?>>مسودة</option>
<option value="opened" <?= ($status ?? '') === 'opened' ? 'selected' : '' ?>>مفتوح</option>
<option value="partially_utilized" <?= ($status ?? '') === 'partially_utilized' ? 'selected' : '' ?>>مستخدم جزئياً</option>
<option value="fully_utilized" <?= ($status ?? '') === 'fully_utilized' ? 'selected' : '' ?>>مستخدم بالكامل</option>
<option value="expired" <?= ($status ?? '') === 'expired' ? 'selected' : '' ?>>منتهي</option>
<option value="cancelled" <?= ($status ?? '') === 'cancelled' ? 'selected' : '' ?>>ملغي</option>
</select>
</div>
</form>
</div>
</div>
<!-- Credits Table -->
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>رقم الاعتماد</th>
<th>المستفيد</th>
<th>البنك المصدر</th>
<th>المبلغ</th>
<th>تاريخ الفتح</th>
<th>تاريخ الانتهاء</th>
<th>الحالة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($credits as $lc): ?>
<?php
$statusColor = match($lc['status'] ?? '') {
'draft' => '#F59E0B',
'opened' => '#059669',
'partially_utilized' => '#0284C7',
'fully_utilized' => '#6B7280',
'expired' => '#DC2626',
'cancelled' => '#9CA3AF',
default => '#374151',
};
$statusLabel = match($lc['status'] ?? '') {
'draft' => 'مسودة',
'opened' => 'مفتوح',
'partially_utilized' => 'مستخدم جزئياً',
'fully_utilized' => 'مستخدم بالكامل',
'expired' => 'منتهي',
'cancelled' => 'ملغي',
default => $lc['status'] ?? '—',
};
?>
<tr>
<td style="font-weight:600;direction:ltr;text-align:right;"><?= e($lc['credit_number'] ?? '') ?></td>
<td><?= e($lc['beneficiary_name'] ?? '—') ?></td>
<td><?= e($lc['issuing_bank'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= money($lc['amount'] ?? 0) ?></td>
<td style="direction:ltr;text-align:right;"><?= e($lc['opening_date'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;"><?= e($lc['expiry_date'] ?? '—') ?></td>
<td><span style="color:<?= $statusColor ?>;font-weight:600;"><?= $statusLabel ?></span></td>
<td><a href="/accounting/documentary-credits/<?= (int)$lc['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($credits)): ?>
<tr><td colspan="8" style="text-align:center;color:#6B7280;padding:30px;">لا توجد اعتمادات مستندية</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>خطابات الضمان<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/accounting/guarantees/create" class="btn btn-primary">+ خطاب ضمان جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/guarantees" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select" onchange="this.form.submit()">
<option value="">الكل</option>
<option value="active" <?= ($status ?? '') === 'active' ? 'selected' : '' ?>>نشط</option>
<option value="expired" <?= ($status ?? '') === 'expired' ? 'selected' : '' ?>>منتهي</option>
<option value="claimed" <?= ($status ?? '') === 'claimed' ? 'selected' : '' ?>>مُطالب به</option>
<option value="released" <?= ($status ?? '') === 'released' ? 'selected' : '' ?>>محرر</option>
<option value="cancelled" <?= ($status ?? '') === 'cancelled' ? 'selected' : '' ?>>ملغي</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">النوع</label>
<select name="type" class="form-select" onchange="this.form.submit()">
<option value="">الكل</option>
<option value="bid_bond" <?= ($type ?? '') === 'bid_bond' ? 'selected' : '' ?>>ضمان ابتدائي</option>
<option value="performance_bond" <?= ($type ?? '') === 'performance_bond' ? 'selected' : '' ?>>ضمان حسن تنفيذ</option>
<option value="advance_payment" <?= ($type ?? '') === 'advance_payment' ? 'selected' : '' ?>>ضمان دفعة مقدمة</option>
<option value="retention" <?= ($type ?? '') === 'retention' ? 'selected' : '' ?>>ضمان محتجزات</option>
<option value="customs" <?= ($type ?? '') === 'customs' ? 'selected' : '' ?>>ضمان جمركي</option>
</select>
</div>
</form>
</div>
</div>
<!-- Guarantees Table -->
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>رقم الخطاب</th>
<th>النوع</th>
<th>المستفيد</th>
<th>البنك المصدر</th>
<th>المبلغ</th>
<th>تاريخ الانتهاء</th>
<th>الحالة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($guarantees as $g): ?>
<?php
$statusColor = match($g['status'] ?? '') {
'active' => '#059669',
'expired' => '#DC2626',
'claimed' => '#D97706',
'released' => '#6B7280',
'cancelled' => '#9CA3AF',
default => '#374151',
};
$statusLabel = match($g['status'] ?? '') {
'active' => 'نشط',
'expired' => 'منتهي',
'claimed' => 'مُطالب به',
'released' => 'محرر',
'cancelled' => 'ملغي',
default => $g['status'] ?? '—',
};
$typeLabel = match($g['guarantee_type'] ?? $g['type'] ?? '') {
'bid_bond' => 'ضمان ابتدائي',
'performance_bond' => 'ضمان حسن تنفيذ',
'advance_payment' => 'ضمان دفعة مقدمة',
'retention' => 'ضمان محتجزات',
'customs' => 'ضمان جمركي',
default => $g['guarantee_type'] ?? $g['type'] ?? '—',
};
?>
<tr>
<td style="font-weight:600;direction:ltr;text-align:right;"><?= e($g['guarantee_number'] ?? '') ?></td>
<td><?= $typeLabel ?></td>
<td><?= e($g['beneficiary_name'] ?? '—') ?></td>
<td><?= e($g['issuing_bank'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= money($g['amount'] ?? 0) ?></td>
<td style="direction:ltr;text-align:right;"><?= e($g['expiry_date'] ?? '—') ?></td>
<td><span style="color:<?= $statusColor ?>;font-weight:600;"><?= $statusLabel ?></span></td>
<td><a href="/accounting/guarantees/<?= (int)$g['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($guarantees)): ?>
<tr><td colspan="8" style="text-align:center;color:#6B7280;padding:30px;">لا توجد خطابات ضمان</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= !empty($loan['id']) ? 'تعديل قرض' : 'قرض جديد' ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;"><?= !empty($loan['id']) ? 'تعديل قرض' : 'إضافة قرض بنكي جديد' ?></h3>
</div>
<div style="padding:20px;">
<form method="POST" action="<?= !empty($loan['id']) ? '/accounting/loans/' . (int)$loan['id'] : '/accounting/loans' ?>">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">الحساب البنكي <span style="color:#DC2626;">*</span></label>
<select name="bank_account_id" class="form-select" required>
<option value="">— اختر حساب بنكي —</option>
<?php foreach ($bankAccounts as $ba): ?>
<option value="<?= (int)$ba['id'] ?>" <?= (int)($loan['bank_account_id'] ?? 0) === (int)$ba['id'] ? 'selected' : '' ?>><?= e($ba['account_name_ar'] ?? $ba['bank_name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">نوع القرض <span style="color:#DC2626;">*</span></label>
<select name="loan_type" class="form-select" required>
<option value="">— اختر —</option>
<option value="term_loan" <?= ($loan['loan_type'] ?? '') === 'term_loan' ? 'selected' : '' ?>>قرض لأجل</option>
<option value="revolving" <?= ($loan['loan_type'] ?? '') === 'revolving' ? 'selected' : '' ?>>تسهيل ائتماني متجدد</option>
<option value="overdraft" <?= ($loan['loan_type'] ?? '') === 'overdraft' ? 'selected' : '' ?>>سحب على المكشوف</option>
<option value="mortgage" <?= ($loan['loan_type'] ?? '') === 'mortgage' ? 'selected' : '' ?>>قرض عقاري</option>
<option value="equipment" <?= ($loan['loan_type'] ?? '') === 'equipment' ? 'selected' : '' ?>>تمويل معدات</option>
</select>
</div>
<div>
<label class="form-label">المبلغ الأصلي <span style="color:#DC2626;">*</span></label>
<input type="number" name="principal_amount" class="form-input" step="0.01" min="0.01" required dir="ltr" value="<?= e($loan['principal_amount'] ?? '') ?>">
</div>
<div>
<label class="form-label">سعر الفائدة (%) <span style="color:#DC2626;">*</span></label>
<input type="number" name="interest_rate" class="form-input" step="0.01" min="0" required dir="ltr" value="<?= e($loan['interest_rate'] ?? '') ?>">
</div>
<div>
<label class="form-label">مدة القرض (أشهر) <span style="color:#DC2626;">*</span></label>
<input type="number" name="term_months" class="form-input" min="1" required dir="ltr" value="<?= e($loan['term_months'] ?? '') ?>">
</div>
<div>
<label class="form-label">تاريخ البداية <span style="color:#DC2626;">*</span></label>
<input type="date" name="start_date" class="form-input" required dir="ltr" value="<?= e($loan['start_date'] ?? date('Y-m-d')) ?>">
</div>
<div>
<label class="form-label">رقم القرض</label>
<input type="text" name="loan_number" class="form-input" dir="ltr" value="<?= e($loan['loan_number'] ?? '') ?>">
</div>
<div>
<label class="form-label">اسم البنك</label>
<input type="text" name="bank_name" class="form-input" value="<?= e($loan['bank_name'] ?? '') ?>">
</div>
<div style="grid-column:1/-1;">
<label class="form-label">الضمان</label>
<textarea name="collateral" class="form-textarea" rows="2"><?= e($loan['collateral'] ?? '') ?></textarea>
</div>
<div style="grid-column:1/-1;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-textarea" rows="2"><?= e($loan['notes'] ?? '') ?></textarea>
</div>
</div>
<div style="margin-top:20px;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">حفظ</button>
<a href="/accounting/loans" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>القروض البنكية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/accounting/loans/create" class="btn btn-primary">+ قرض جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/loans" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select" onchange="this.form.submit()">
<option value="">الكل</option>
<option value="active" <?= ($status ?? '') === 'active' ? 'selected' : '' ?>>نشط</option>
<option value="paid_off" <?= ($status ?? '') === 'paid_off' ? 'selected' : '' ?>>مسدد</option>
<option value="defaulted" <?= ($status ?? '') === 'defaulted' ? 'selected' : '' ?>>متعثر</option>
<option value="restructured" <?= ($status ?? '') === 'restructured' ? 'selected' : '' ?>>مُعاد هيكلته</option>
</select>
</div>
</form>
</div>
</div>
<!-- Loans Table -->
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>رقم القرض</th>
<th>البنك</th>
<th>المبلغ الأصلي</th>
<th>سعر الفائدة</th>
<th>الرصيد القائم</th>
<th>الحالة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($loans as $loan): ?>
<?php
$statusColor = match($loan['status'] ?? '') {
'active' => '#059669',
'paid_off' => '#6B7280',
'defaulted' => '#DC2626',
'restructured' => '#D97706',
default => '#374151',
};
$statusLabel = match($loan['status'] ?? '') {
'active' => 'نشط',
'paid_off' => 'مسدد',
'defaulted' => 'متعثر',
'restructured' => 'مُعاد هيكلته',
default => $loan['status'] ?? '—',
};
?>
<tr>
<td style="font-weight:600;direction:ltr;text-align:right;"><?= e($loan['loan_number'] ?? '') ?></td>
<td><?= e($loan['bank_name'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;"><?= money($loan['principal_amount'] ?? 0) ?></td>
<td style="direction:ltr;text-align:right;"><?= e($loan['interest_rate'] ?? 0) ?>%</td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= money($loan['outstanding_balance'] ?? 0) ?></td>
<td><span style="color:<?= $statusColor ?>;font-weight:600;"><?= $statusLabel ?></span></td>
<td><a href="/accounting/loans/<?= (int)$loan['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($loans)): ?>
<tr><td colspan="7" style="text-align:center;color:#6B7280;padding:30px;">لا توجد قروض</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تفاصيل القرض<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$statusColor = match($loan['status'] ?? '') {
'active' => '#059669',
'paid_off' => '#6B7280',
'defaulted' => '#DC2626',
'restructured' => '#D97706',
default => '#374151',
};
$statusLabel = match($loan['status'] ?? '') {
'active' => 'نشط',
'paid_off' => 'مسدد',
'defaulted' => 'متعثر',
'restructured' => 'مُعاد هيكلته',
default => $loan['status'] ?? '—',
};
?>
<!-- Loan Details -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;">قرض رقم: <?= e($loan['loan_number'] ?? '') ?></h3>
<span style="color:<?= $statusColor ?>;font-weight:700;font-size:14px;"><?= $statusLabel ?></span>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:15px;">
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">البنك</div>
<div style="font-size:16px;font-weight:700;"><?= e($loan['bank_name'] ?? '—') ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">المبلغ الأصلي</div>
<div style="font-size:18px;font-weight:700;direction:ltr;"><?= money($loan['principal_amount'] ?? 0) ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">سعر الفائدة</div>
<div style="font-size:18px;font-weight:700;direction:ltr;"><?= e($loan['interest_rate'] ?? 0) ?>%</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">الرصيد القائم</div>
<div style="font-size:18px;font-weight:700;direction:ltr;color:#DC2626;"><?= money($loan['outstanding_balance'] ?? 0) ?></div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-top:15px;">
<div>
<span style="font-size:12px;color:#6B7280;">نوع القرض:</span>
<span style="font-weight:600;"><?= e($loan['loan_type'] ?? '—') ?></span>
</div>
<div>
<span style="font-size:12px;color:#6B7280;">مدة القرض:</span>
<span style="font-weight:600;"><?= (int)($loan['term_months'] ?? 0) ?> شهر</span>
</div>
<div>
<span style="font-size:12px;color:#6B7280;">تاريخ البداية:</span>
<span style="font-weight:600;direction:ltr;display:inline-block;"><?= e($loan['start_date'] ?? '—') ?></span>
</div>
<div>
<span style="font-size:12px;color:#6B7280;">الضمان:</span>
<span style="font-weight:600;"><?= e($loan['collateral'] ?? '—') ?></span>
</div>
</div>
</div>
</div>
<!-- Amortization Schedule -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">جدول السداد</h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>رقم القسط</th>
<th>تاريخ الاستحقاق</th>
<th>أصل الدين</th>
<th>الفائدة</th>
<th>الإجمالي</th>
<th>الحالة</th>
<th>تاريخ السداد</th>
</tr>
</thead>
<tbody>
<?php foreach ($schedule as $inst): ?>
<?php
$instStatusColor = match($inst['status'] ?? '') {
'paid' => '#059669',
'due' => '#D97706',
'overdue' => '#DC2626',
'upcoming' => '#6B7280',
default => '#374151',
};
$instStatusLabel = match($inst['status'] ?? '') {
'paid' => 'مسدد',
'due' => 'مستحق',
'overdue' => 'متأخر',
'upcoming' => 'قادم',
default => $inst['status'] ?? '—',
};
?>
<tr>
<td style="text-align:center;"><?= (int)($inst['installment_number'] ?? 0) ?></td>
<td style="direction:ltr;text-align:right;"><?= e($inst['due_date'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;"><?= money($inst['principal'] ?? 0) ?></td>
<td style="direction:ltr;text-align:right;"><?= money($inst['interest'] ?? 0) ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= money($inst['total'] ?? 0) ?></td>
<td><span style="color:<?= $instStatusColor ?>;font-weight:600;"><?= $instStatusLabel ?></span></td>
<td style="direction:ltr;text-align:right;"><?= e($inst['paid_date'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($schedule)): ?>
<tr><td colspan="7" style="text-align:center;color:#6B7280;padding:30px;">لا يوجد جدول سداد</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<div style="margin-top:15px;">
<a href="/accounting/loans" class="btn btn-outline">رجوع للقائمة</a>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>التسويات<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/accounting/settlements/create" class="btn btn-primary">+ تسوية جديدة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>رقم التسوية</th>
<th>التاريخ</th>
<th>النوع</th>
<th>البيان</th>
<th>المبلغ</th>
<th>الحالة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($settlements as $s): ?>
<?php
$statusColor = match($s['status'] ?? '') {
'draft' => '#F59E0B',
'approved' => '#059669',
'posted' => '#0284C7',
'rejected' => '#DC2626',
default => '#6B7280',
};
$statusLabel = match($s['status'] ?? '') {
'draft' => 'مسودة',
'approved' => 'معتمدة',
'posted' => 'مرحّلة',
'rejected' => 'مرفوضة',
default => $s['status'] ?? '—',
};
$typeLabel = match($s['type'] ?? '') {
'debt_settlement' => 'تسوية ديون',
'advance_settlement' => 'تسوية سلف',
'account_settlement' => 'تسوية حسابات',
default => $s['type'] ?? '—',
};
?>
<tr>
<td style="font-weight:600;direction:ltr;text-align:right;"><?= e($s['settlement_number'] ?? '') ?></td>
<td style="direction:ltr;text-align:right;"><?= e($s['settlement_date'] ?? $s['created_at'] ?? '—') ?></td>
<td><?= $typeLabel ?></td>
<td><?= e($s['description'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= money($s['amount'] ?? 0) ?></td>
<td><span style="color:<?= $statusColor ?>;font-weight:600;"><?= $statusLabel ?></span></td>
<td><a href="/accounting/settlements/<?= (int)$s['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($settlements)): ?>
<tr><td colspan="7" style="text-align:center;color:#6B7280;padding:30px;">لا توجد تسويات</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>كشف حساب عميل<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/statements/customer" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">العضو</label>
<select name="member_id" class="form-select">
<option value="">— اختر عضو —</option>
<?php foreach ($members as $m): ?>
<option value="<?= (int)$m['id'] ?>" <?= (int)($filters['member_id'] ?? 0) === (int)$m['id'] ? 'selected' : '' ?>><?= e($m['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">من تاريخ</label>
<input type="date" name="date_from" class="form-input" value="<?= e($filters['date_from'] ?? '') ?>" dir="ltr">
</div>
<div>
<label class="form-label" style="font-size:12px;">إلى تاريخ</label>
<input type="date" name="date_to" class="form-input" value="<?= e($filters['date_to'] ?? '') ?>" dir="ltr">
</div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
</div>
<?php if (!empty($member) && !empty($statement)): ?>
<!-- Statement -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;">كشف حساب: <?= e($member['name']) ?></h3>
<button type="button" class="btn btn-outline" onclick="window.print()">طباعة</button>
</div>
<!-- Opening Balance -->
<div style="padding:10px 20px;background:#F9FAFB;border-bottom:1px solid #E5E7EB;">
<strong>الرصيد الافتتاحي:</strong>
<span style="font-weight:700;direction:ltr;display:inline-block;"><?= money($statement['opening_balance'] ?? 0) ?></span>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>التاريخ</th>
<th>نوع المستند</th>
<th>البيان</th>
<th>مدين</th>
<th>دائن</th>
<th>الرصيد</th>
</tr>
</thead>
<tbody>
<?php foreach ($statement['entries'] ?? [] as $entry): ?>
<tr>
<td style="direction:ltr;text-align:right;"><?= e($entry['date']) ?></td>
<td><?= e($entry['doc_type'] ?? '—') ?></td>
<td><?= e($entry['description'] ?? '') ?></td>
<td style="direction:ltr;text-align:right;color:#DC2626;"><?= (float)($entry['debit'] ?? 0) > 0 ? money($entry['debit']) : '' ?></td>
<td style="direction:ltr;text-align:right;color:#059669;"><?= (float)($entry['credit'] ?? 0) > 0 ? money($entry['credit']) : '' ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= money($entry['balance'] ?? 0) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($statement['entries'])): ?>
<tr><td colspan="6" style="text-align:center;color:#6B7280;padding:30px;">لا توجد حركات</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr style="font-weight:700;background:#F9FAFB;">
<td colspan="3">الإجمالي</td>
<td style="direction:ltr;text-align:right;color:#DC2626;"><?= money($statement['total_debit'] ?? 0) ?></td>
<td style="direction:ltr;text-align:right;color:#059669;"><?= money($statement['total_credit'] ?? 0) ?></td>
<td style="direction:ltr;text-align:right;"><?= money($statement['closing_balance'] ?? 0) ?></td>
</tr>
</tfoot>
</table>
</div>
<!-- Closing Balance -->
<div style="padding:10px 20px;background:#F0FDF4;border-top:1px solid #E5E7EB;">
<strong>الرصيد الختامي:</strong>
<span style="font-weight:700;direction:ltr;display:inline-block;"><?= money($statement['closing_balance'] ?? 0) ?></span>
</div>
</div>
<?php elseif (!empty($filters['member_id'])): ?>
<div class="card">
<div style="padding:30px;text-align:center;color:#6B7280;">لا توجد بيانات للعرض</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>كشف حساب مورد<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/statements/supplier" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">المورد</label>
<select name="supplier_id" class="form-select">
<option value="">— اختر مورد —</option>
<?php foreach ($suppliers as $s): ?>
<option value="<?= (int)$s['id'] ?>" <?= (int)($filters['supplier_id'] ?? 0) === (int)$s['id'] ? 'selected' : '' ?>><?= e($s['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">من تاريخ</label>
<input type="date" name="date_from" class="form-input" value="<?= e($filters['date_from'] ?? '') ?>" dir="ltr">
</div>
<div>
<label class="form-label" style="font-size:12px;">إلى تاريخ</label>
<input type="date" name="date_to" class="form-input" value="<?= e($filters['date_to'] ?? '') ?>" dir="ltr">
</div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
</div>
<?php if (!empty($supplier) && !empty($statement)): ?>
<!-- Statement -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;">كشف حساب: <?= e($supplier['name']) ?></h3>
<button type="button" class="btn btn-outline" onclick="window.print()">طباعة</button>
</div>
<!-- Opening Balance -->
<div style="padding:10px 20px;background:#F9FAFB;border-bottom:1px solid #E5E7EB;">
<strong>الرصيد الافتتاحي:</strong>
<span style="font-weight:700;direction:ltr;display:inline-block;"><?= money($statement['opening_balance'] ?? 0) ?></span>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>التاريخ</th>
<th>نوع المستند</th>
<th>البيان</th>
<th>مدين</th>
<th>دائن</th>
<th>الرصيد</th>
</tr>
</thead>
<tbody>
<?php foreach ($statement['entries'] ?? [] as $entry): ?>
<tr>
<td style="direction:ltr;text-align:right;"><?= e($entry['date']) ?></td>
<td><?= e($entry['doc_type'] ?? '—') ?></td>
<td><?= e($entry['description'] ?? '') ?></td>
<td style="direction:ltr;text-align:right;color:#DC2626;"><?= (float)($entry['debit'] ?? 0) > 0 ? money($entry['debit']) : '' ?></td>
<td style="direction:ltr;text-align:right;color:#059669;"><?= (float)($entry['credit'] ?? 0) > 0 ? money($entry['credit']) : '' ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= money($entry['balance'] ?? 0) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($statement['entries'])): ?>
<tr><td colspan="6" style="text-align:center;color:#6B7280;padding:30px;">لا توجد حركات</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr style="font-weight:700;background:#F9FAFB;">
<td colspan="3">الإجمالي</td>
<td style="direction:ltr;text-align:right;color:#DC2626;"><?= money($statement['total_debit'] ?? 0) ?></td>
<td style="direction:ltr;text-align:right;color:#059669;"><?= money($statement['total_credit'] ?? 0) ?></td>
<td style="direction:ltr;text-align:right;"><?= money($statement['closing_balance'] ?? 0) ?></td>
</tr>
</tfoot>
</table>
</div>
<!-- Closing Balance -->
<div style="padding:10px 20px;background:#F0FDF4;border-top:1px solid #E5E7EB;">
<strong>الرصيد الختامي:</strong>
<span style="font-weight:700;direction:ltr;display:inline-block;"><?= money($statement['closing_balance'] ?? 0) ?></span>
</div>
</div>
<?php elseif (!empty($filters['supplier_id'])): ?>
<div class="card">
<div style="padding:30px;text-align:center;color:#6B7280;">لا توجد بيانات للعرض</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
......@@ -5,6 +5,8 @@ use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
use App\Core\EventBus;
use App\Modules\Accounting\Services\AccountingIntegrationService;
use App\Modules\Accounting\Services\StatementIntegrationService;
use App\Modules\Accounting\Services\DailyCashMovementService;
// ────────────────────────────────────────────────────────────
// Accounting Module — Permissions (28 total)
......@@ -79,6 +81,27 @@ PermissionRegistry::register('accounting', [
'accounting.period.view' => ['ar' => 'عرض إقفال الفترات', 'en' => 'View Period Closings'],
'accounting.period.close' => ['ar' => 'إقفال فترة', 'en' => 'Close Period'],
'accounting.period.reopen' => ['ar' => 'إعادة فتح فترة مغلقة', 'en' => 'Reopen Closed Period'],
// Account Statements
'accounting.statements.view' => ['ar' => 'كشوف حسابات العملاء والموردين', 'en' => 'Account Statements'],
'accounting.cash.view' => ['ar' => 'حركة النقدية اليومية', 'en' => 'Daily Cash Movements'],
// Bank Loans
'accounting.loans.view' => ['ar' => 'عرض القروض البنكية', 'en' => 'View Bank Loans'],
'accounting.loans.manage' => ['ar' => 'إدارة القروض البنكية', 'en' => 'Manage Bank Loans'],
// Cross-Entity Settlements
'accounting.settlements.view' => ['ar' => 'عرض التسويات', 'en' => 'View Settlements'],
'accounting.settlements.manage' => ['ar' => 'إنشاء تسويات', 'en' => 'Create Settlements'],
'accounting.settlements.approve' => ['ar' => 'اعتماد التسويات', 'en' => 'Approve Settlements'],
// Documentary Credits
'accounting.lc.view' => ['ar' => 'عرض الاعتمادات المستندية', 'en' => 'View Documentary Credits'],
'accounting.lc.manage' => ['ar' => 'إدارة الاعتمادات المستندية', 'en' => 'Manage Documentary Credits'],
// Letters of Guarantee
'accounting.guarantee.view' => ['ar' => 'عرض خطابات الضمان', 'en' => 'View Letters of Guarantee'],
'accounting.guarantee.manage' => ['ar' => 'إدارة خطابات الضمان', 'en' => 'Manage Letters of Guarantee'],
]);
// ────────────────────────────────────────────────────────────
......@@ -118,6 +141,13 @@ MenuRegistry::register('accounting', [
['label_ar' => 'كشف حساب عضو', 'label_en' => 'Member Statement', 'route' => '/accounting/reports/member-statement', 'permission' => 'accounting.reports.member_statement','order' => 16],
['label_ar' => 'الخزينة والمدفوعات', 'label_en' => 'Treasury & Payments', 'route' => '/accounting/reports/treasury', 'permission' => 'accounting.reports.treasury', 'order' => 17],
['label_ar' => 'تحليل الإيرادات', 'label_en' => 'Revenue Analysis', 'route' => '/accounting/reports/revenue-analysis', 'permission' => 'accounting.reports.revenue_analysis','order' => 18],
['label_ar' => 'كشف حساب عميل', 'label_en' => 'Customer Statement', 'route' => '/accounting/statements/customer', 'permission' => 'accounting.statements.view', 'order' => 19],
['label_ar' => 'كشف حساب مورد', 'label_en' => 'Supplier Statement', 'route' => '/accounting/statements/supplier', 'permission' => 'accounting.statements.view', 'order' => 20],
['label_ar' => 'حركة النقدية اليومية', 'label_en' => 'Daily Cash', 'route' => '/accounting/statements/daily-cash', 'permission' => 'accounting.cash.view', 'order' => 21],
['label_ar' => 'القروض البنكية', 'label_en' => 'Bank Loans', 'route' => '/accounting/loans', 'permission' => 'accounting.loans.view', 'order' => 22],
['label_ar' => 'التسويات', 'label_en' => 'Settlements', 'route' => '/accounting/settlements', 'permission' => 'accounting.settlements.view', 'order' => 23],
['label_ar' => 'الاعتمادات المستندية', 'label_en' => 'Documentary Credits', 'route' => '/accounting/documentary-credits', 'permission' => 'accounting.lc.view', 'order' => 24],
['label_ar' => 'خطابات الضمان', 'label_en' => 'Guarantees', 'route' => '/accounting/guarantees', 'permission' => 'accounting.guarantee.view', 'order' => 25],
],
]);
......@@ -319,3 +349,11 @@ EventBus::listen('rental.deposit_refunded', function (array $data): void {
\App\Core\Logger::error('Accounting auto-post failed (rental.deposit_refunded): ' . $e->getMessage());
}
}, 50);
// ── Statement Integration ───────────────────────────────────
// Auto-records customer/supplier transactions for account statements and credit limits
StatementIntegrationService::registerListeners();
// ── Daily Cash Movement Tracking ────────────────────────────
// Auto-records daily cash inflows/outflows for treasury dashboard
DailyCashMovementService::registerListeners();
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\HR\Services\OvertimeService;
class OvertimeController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('hr.overtime.view');
$db = App::getInstance()->db();
$status = $request->get('status', '');
$month = $request->get('month', date('Y-m'));
$where = "DATE_FORMAT(r.request_date, '%Y-%m') = ?";
$params = [$month];
if ($status) {
$where .= ' AND r.status = ?';
$params[] = $status;
}
$requests = $db->select(
"SELECT r.*, e.full_name_ar as employee_name, t.name_ar as type_name, t.rate_multiplier,
ap.full_name_ar as approved_by_name
FROM hr_overtime_requests r
JOIN employees e ON e.id = r.employee_id
JOIN hr_overtime_types t ON t.id = r.overtime_type_id
LEFT JOIN employees ap ON ap.id = r.approved_by
WHERE {$where}
ORDER BY r.request_date DESC, r.created_at DESC",
$params
);
$types = $db->select("SELECT * FROM hr_overtime_types WHERE is_active = 1");
return $this->view('HR.Views.overtime.index', [
'requests' => $requests,
'types' => $types,
'status' => $status,
'month' => $month,
]);
}
public function create(Request $request): Response
{
$this->authorize('hr.overtime.create');
$db = App::getInstance()->db();
$employees = $db->select("SELECT id, full_name_ar, employee_number FROM employees WHERE is_archived = 0 AND employment_status = 'active' ORDER BY full_name_ar");
$types = $db->select("SELECT * FROM hr_overtime_types WHERE is_active = 1");
return $this->view('HR.Views.overtime.form', [
'employees' => $employees,
'types' => $types,
]);
}
public function store(Request $request): Response
{
$this->authorize('hr.overtime.create');
$data = [
'employee_id' => (int) $request->post('employee_id'),
'overtime_type_id' => (int) $request->post('overtime_type_id'),
'request_date' => $request->post('request_date'),
'start_time' => $request->post('start_time'),
'end_time' => $request->post('end_time'),
'reason' => $request->post('reason'),
];
$result = OvertimeService::submitRequest($data);
if (!$result['success']) {
return $this->redirect('/hr/overtime/create')->withError($result['error']);
}
return $this->redirect('/hr/overtime')->withSuccess('تم تقديم طلب العمل الإضافي — ' . $result['hours'] . ' ساعة');
}
public function approve(Request $request, string $id): Response
{
$this->authorize('hr.overtime.approve');
$result = OvertimeService::approve((int) $id);
if (!$result['success']) {
return $this->redirect('/hr/overtime')->withError($result['error']);
}
return $this->redirect('/hr/overtime')->withSuccess('تم اعتماد طلب العمل الإضافي');
}
public function reject(Request $request, string $id): Response
{
$this->authorize('hr.overtime.approve');
$reason = (string) $request->post('rejection_reason', '');
OvertimeService::reject((int) $id, $reason);
return $this->redirect('/hr/overtime')->withSuccess('تم رفض طلب العمل الإضافي');
}
public function types(Request $request): Response
{
$this->authorize('hr.overtime.manage');
$db = App::getInstance()->db();
$types = $db->select("SELECT * FROM hr_overtime_types ORDER BY created_at DESC");
return $this->view('HR.Views.overtime.types', ['types' => $types]);
}
public function storeType(Request $request): Response
{
$this->authorize('hr.overtime.manage');
$db = App::getInstance()->db();
$db->insert('hr_overtime_types', [
'name_ar' => $request->post('name_ar'),
'name_en' => $request->post('name_en'),
'rate_multiplier' => $request->post('rate_multiplier', '1.50'),
'max_hours_per_day' => $request->post('max_hours_per_day') ?: null,
'max_hours_per_month' => $request->post('max_hours_per_month') ?: null,
'requires_approval' => (int) $request->post('requires_approval', 1),
]);
return $this->redirect('/hr/overtime/types')->withSuccess('تم إضافة نوع العمل الإضافي');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class PermissionRequestController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('hr.permissions.view');
$db = App::getInstance()->db();
$status = $request->get('status', '');
$month = $request->get('month', date('Y-m'));
$where = "DATE_FORMAT(p.permission_date, '%Y-%m') = ?";
$params = [$month];
if ($status) {
$where .= ' AND p.status = ?';
$params[] = $status;
}
$permissions = $db->select(
"SELECT p.*, e.full_name_ar as employee_name, e.employee_number,
ap.full_name_ar as approved_by_name
FROM hr_permission_requests p
JOIN employees e ON e.id = p.employee_id
LEFT JOIN employees ap ON ap.id = p.approved_by
WHERE {$where}
ORDER BY p.permission_date DESC",
$params
);
return $this->view('HR.Views.permissions.index', [
'permissions' => $permissions,
'status' => $status,
'month' => $month,
]);
}
public function create(Request $request): Response
{
$this->authorize('hr.permissions.create');
$db = App::getInstance()->db();
$employees = $db->select("SELECT id, full_name_ar, employee_number FROM employees WHERE is_archived = 0 AND employment_status = 'active' ORDER BY full_name_ar");
return $this->view('HR.Views.permissions.form', ['employees' => $employees]);
}
public function store(Request $request): Response
{
$this->authorize('hr.permissions.create');
$db = App::getInstance()->db();
$employeeId = (int) $request->post('employee_id');
$startTime = $request->post('start_time');
$endTime = $request->post('end_time');
$startMin = self::timeToMinutes($startTime);
$endMin = self::timeToMinutes($endTime);
$hours = ($endMin - $startMin) / 60;
$monthTotal = $db->selectOne(
"SELECT COALESCE(SUM(hours), 0) as total FROM hr_permission_requests
WHERE employee_id = ? AND status IN ('pending','approved')
AND DATE_FORMAT(permission_date, '%Y-%m') = ?",
[$employeeId, date('Y-m', strtotime($request->post('permission_date')))]
);
$db->insert('hr_permission_requests', [
'employee_id' => $employeeId,
'permission_date' => $request->post('permission_date'),
'start_time' => $startTime,
'end_time' => $endTime,
'hours' => number_format($hours, 2),
'reason' => $request->post('reason'),
'status' => 'pending',
'monthly_total_hours' => bcadd((string) ($monthTotal['total'] ?? '0'), number_format($hours, 2), 2),
]);
return $this->redirect('/hr/permissions')->withSuccess('تم تقديم طلب الإذن');
}
public function approve(Request $request, string $id): Response
{
$this->authorize('hr.permissions.approve');
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$db->update('hr_permission_requests', [
'status' => 'approved',
'approved_by' => $employee ? (int) $employee->id : null,
'approved_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $id]);
return $this->redirect('/hr/permissions')->withSuccess('تم اعتماد الإذن');
}
public function reject(Request $request, string $id): Response
{
$this->authorize('hr.permissions.approve');
$db = App::getInstance()->db();
$db->update('hr_permission_requests', ['status' => 'rejected'], 'id = ?', [(int) $id]);
return $this->redirect('/hr/permissions')->withSuccess('تم رفض الإذن');
}
private static function timeToMinutes(string $time): int
{
$parts = explode(':', $time);
return ((int) $parts[0]) * 60 + ((int) ($parts[1] ?? 0));
}
}
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class ShiftController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('hr.shifts.view');
$db = App::getInstance()->db();
$shifts = $db->select("SELECT * FROM hr_shifts ORDER BY start_time ASC");
return $this->view('HR.Views.shifts.index', ['shifts' => $shifts]);
}
public function create(Request $request): Response
{
$this->authorize('hr.shifts.manage');
return $this->view('HR.Views.shifts.form', ['shift' => null]);
}
public function store(Request $request): Response
{
$this->authorize('hr.shifts.manage');
$db = App::getInstance()->db();
$startTime = $request->post('start_time');
$endTime = $request->post('end_time');
$breakDuration = (int) $request->post('break_duration_minutes', 0);
$startMin = self::timeToMinutes($startTime);
$endMin = self::timeToMinutes($endTime);
if ($endMin <= $startMin) $endMin += 1440;
$workingHours = ($endMin - $startMin - $breakDuration) / 60;
$db->insert('hr_shifts', [
'name_ar' => $request->post('name_ar'),
'name_en' => $request->post('name_en'),
'start_time' => $startTime,
'end_time' => $endTime,
'break_start' => $request->post('break_start') ?: null,
'break_end' => $request->post('break_end') ?: null,
'break_duration_minutes' => $breakDuration,
'working_hours' => number_format($workingHours, 2),
'grace_period_minutes' => (int) $request->post('grace_period_minutes', 15),
'early_leave_threshold_minutes' => (int) $request->post('early_leave_threshold_minutes', 15),
'is_night_shift' => (int) $request->post('is_night_shift', 0),
]);
return $this->redirect('/hr/shifts')->withSuccess('تم إضافة الوردية بنجاح');
}
public function assignments(Request $request): Response
{
$this->authorize('hr.shifts.manage');
$db = App::getInstance()->db();
$assignments = $db->select(
"SELECT sa.*, e.full_name_ar as employee_name, e.employee_number, s.name_ar as shift_name
FROM hr_shift_assignments sa
JOIN employees e ON e.id = sa.employee_id
JOIN hr_shifts s ON s.id = sa.shift_id
WHERE sa.is_active = 1
ORDER BY e.full_name_ar"
);
$employees = $db->select("SELECT id, full_name_ar, employee_number FROM employees WHERE is_archived = 0 AND employment_status = 'active' ORDER BY full_name_ar");
$shifts = $db->select("SELECT id, name_ar FROM hr_shifts WHERE is_active = 1");
return $this->view('HR.Views.shifts.assignments', [
'assignments' => $assignments,
'employees' => $employees,
'shifts' => $shifts,
]);
}
public function assignShift(Request $request): Response
{
$this->authorize('hr.shifts.manage');
$db = App::getInstance()->db();
$employeeId = (int) $request->post('employee_id');
$shiftId = (int) $request->post('shift_id');
$db->query(
"UPDATE hr_shift_assignments SET is_active = 0 WHERE employee_id = ? AND is_active = 1",
[$employeeId]
);
$db->insert('hr_shift_assignments', [
'employee_id' => $employeeId,
'shift_id' => $shiftId,
'start_date' => $request->post('start_date', date('Y-m-d')),
'end_date' => $request->post('end_date') ?: null,
'rotation_pattern' => $request->post('rotation_pattern', 'fixed'),
'is_active' => 1,
]);
return $this->redirect('/hr/shifts/assignments')->withSuccess('تم تعيين الوردية');
}
public function violations(Request $request): Response
{
$this->authorize('hr.attendance.view');
$db = App::getInstance()->db();
$month = $request->get('month', date('Y-m'));
$type = $request->get('type', '');
$where = "DATE_FORMAT(v.violation_date, '%Y-%m') = ?";
$params = [$month];
if ($type) {
$where .= ' AND v.violation_type = ?';
$params[] = $type;
}
$violations = $db->select(
"SELECT v.*, e.full_name_ar as employee_name, e.employee_number
FROM hr_attendance_violations v
JOIN employees e ON e.id = v.employee_id
WHERE {$where}
ORDER BY v.violation_date DESC, v.created_at DESC",
$params
);
return $this->view('HR.Views.shifts.violations', [
'violations' => $violations,
'month' => $month,
'type' => $type,
]);
}
private static function timeToMinutes(string $time): int
{
$parts = explode(':', $time);
return ((int) $parts[0]) * 60 + ((int) ($parts[1] ?? 0));
}
}
......@@ -155,6 +155,30 @@ return [
['GET', '/hr/schedules/{id:\d+}/edit', 'HR\Controllers\WorkScheduleController@edit', ['auth'], 'hr.schedule.manage'],
['POST', '/hr/schedules/{id:\d+}', 'HR\Controllers\WorkScheduleController@update', ['auth', 'csrf'], 'hr.schedule.manage'],
// ── Overtime ──
['GET', '/hr/overtime', 'HR\Controllers\OvertimeController@index', ['auth'], 'hr.overtime.view'],
['GET', '/hr/overtime/create', 'HR\Controllers\OvertimeController@create', ['auth'], 'hr.overtime.create'],
['POST', '/hr/overtime', 'HR\Controllers\OvertimeController@store', ['auth', 'csrf'], 'hr.overtime.create'],
['POST', '/hr/overtime/{id:\d+}/approve', 'HR\Controllers\OvertimeController@approve', ['auth', 'csrf'], 'hr.overtime.approve'],
['POST', '/hr/overtime/{id:\d+}/reject', 'HR\Controllers\OvertimeController@reject', ['auth', 'csrf'], 'hr.overtime.approve'],
['GET', '/hr/overtime/types', 'HR\Controllers\OvertimeController@types', ['auth'], 'hr.overtime.manage'],
['POST', '/hr/overtime/types', 'HR\Controllers\OvertimeController@storeType', ['auth', 'csrf'], 'hr.overtime.manage'],
// ── Shifts ──
['GET', '/hr/shifts', 'HR\Controllers\ShiftController@index', ['auth'], 'hr.shifts.view'],
['GET', '/hr/shifts/create', 'HR\Controllers\ShiftController@create', ['auth'], 'hr.shifts.manage'],
['POST', '/hr/shifts', 'HR\Controllers\ShiftController@store', ['auth', 'csrf'], 'hr.shifts.manage'],
['GET', '/hr/shifts/assignments', 'HR\Controllers\ShiftController@assignments', ['auth'], 'hr.shifts.manage'],
['POST', '/hr/shifts/assignments', 'HR\Controllers\ShiftController@assignShift', ['auth', 'csrf'], 'hr.shifts.manage'],
['GET', '/hr/shifts/violations', 'HR\Controllers\ShiftController@violations', ['auth'], 'hr.attendance.view'],
// ── Permission Requests (Hourly Leaves) ──
['GET', '/hr/permissions', 'HR\Controllers\PermissionRequestController@index', ['auth'], 'hr.permissions.view'],
['GET', '/hr/permissions/create', 'HR\Controllers\PermissionRequestController@create', ['auth'], 'hr.permissions.create'],
['POST', '/hr/permissions', 'HR\Controllers\PermissionRequestController@store', ['auth', 'csrf'], 'hr.permissions.create'],
['POST', '/hr/permissions/{id:\d+}/approve', 'HR\Controllers\PermissionRequestController@approve', ['auth', 'csrf'], 'hr.permissions.approve'],
['POST', '/hr/permissions/{id:\d+}/reject', 'HR\Controllers\PermissionRequestController@reject', ['auth', 'csrf'], 'hr.permissions.approve'],
// ── HR Reports ──
['GET', '/hr/reports', 'HR\Controllers\HrReportController@index', ['auth'], 'hr.report.view'],
['GET', '/hr/reports/headcount', 'HR\Controllers\HrReportController@headcount', ['auth'], 'hr.report.view'],
......
<?php
declare(strict_types=1);
namespace App\Modules\HR\Services;
use App\Core\App;
use App\Core\EventBus;
final class AttendanceViolationService
{
public static function detectViolations(string $date): array
{
$db = App::getInstance()->db();
$violations = [];
$attendanceRecords = $db->select(
"SELECT a.*, e.id as emp_id, e.full_name_ar
FROM hr_attendance a
JOIN employees e ON e.id = a.employee_id
WHERE a.attendance_date = ?",
[$date]
);
foreach ($attendanceRecords as $record) {
$shift = self::getEmployeeShift((int) $record['employee_id'], $date);
if (!$shift) continue;
if (!empty($record['check_in'])) {
$lateMinutes = self::calculateLateness($record['check_in'], $shift['start_time'], (int) $shift['grace_period_minutes']);
if ($lateMinutes > 0) {
$violations[] = self::createViolation([
'employee_id' => (int) $record['employee_id'],
'attendance_id' => (int) $record['id'],
'violation_date' => $date,
'violation_type' => 'late_arrival',
'scheduled_time' => $shift['start_time'],
'actual_time' => $record['check_in'],
'difference_minutes' => $lateMinutes,
]);
}
}
if (!empty($record['check_out'])) {
$earlyMinutes = self::calculateEarlyLeave($record['check_out'], $shift['end_time'], (int) $shift['early_leave_threshold_minutes']);
if ($earlyMinutes > 0) {
$violations[] = self::createViolation([
'employee_id' => (int) $record['employee_id'],
'attendance_id' => (int) $record['id'],
'violation_date' => $date,
'violation_type' => 'early_departure',
'scheduled_time' => $shift['end_time'],
'actual_time' => $record['check_out'],
'difference_minutes' => $earlyMinutes,
]);
}
}
}
$absentees = self::detectAbsences($date, $attendanceRecords);
foreach ($absentees as $absentee) {
$violations[] = self::createViolation([
'employee_id' => (int) $absentee['employee_id'],
'violation_date' => $date,
'violation_type' => 'absence',
'difference_minutes' => 480,
]);
}
EventBus::dispatch('attendance.violations_detected', [
'date' => $date,
'count' => count($violations),
]);
return $violations;
}
private static function getEmployeeShift(int $employeeId, string $date): ?array
{
$db = App::getInstance()->db();
$assignment = $db->selectOne(
"SELECT s.* FROM hr_shift_assignments sa
JOIN hr_shifts s ON s.id = sa.shift_id
WHERE sa.employee_id = ? AND sa.is_active = 1 AND sa.start_date <= ?
AND (sa.end_date IS NULL OR sa.end_date >= ?)
ORDER BY sa.start_date DESC LIMIT 1",
[$employeeId, $date, $date]
);
if ($assignment) return $assignment;
return $db->selectOne(
"SELECT ws.start_time, ws.end_time, 15 as grace_period_minutes, 15 as early_leave_threshold_minutes
FROM hr_work_schedules ws
JOIN employees e ON e.id = ?
WHERE ws.is_active = 1
LIMIT 1",
[$employeeId]
);
}
private static function calculateLateness(string $checkIn, string $shiftStart, int $gracePeriod): int
{
$checkInMin = self::timeToMinutes($checkIn);
$shiftStartMin = self::timeToMinutes($shiftStart) + $gracePeriod;
$diff = $checkInMin - $shiftStartMin;
return max(0, $diff);
}
private static function calculateEarlyLeave(string $checkOut, string $shiftEnd, int $threshold): int
{
$checkOutMin = self::timeToMinutes($checkOut);
$shiftEndMin = self::timeToMinutes($shiftEnd) - $threshold;
$diff = $shiftEndMin - $checkOutMin;
return max(0, $diff);
}
private static function detectAbsences(string $date, array $presentRecords): array
{
$db = App::getInstance()->db();
$presentIds = array_column($presentRecords, 'employee_id');
$dayOfWeek = (int) date('w', strtotime($date));
if ($dayOfWeek === 5) return [];
$isHoliday = $db->selectOne(
"SELECT id FROM hr_holidays WHERE holiday_date = ?",
[$date]
);
if ($isHoliday) return [];
$onLeave = $db->select(
"SELECT employee_id FROM hr_leave_requests WHERE status = 'approved' AND ? BETWEEN start_date AND end_date",
[$date]
);
$onLeaveIds = array_column($onLeave, 'employee_id');
$allActive = $db->select(
"SELECT id as employee_id FROM employees WHERE is_archived = 0 AND employment_status = 'active'"
);
$absentees = [];
foreach ($allActive as $emp) {
$empId = (string) $emp['employee_id'];
if (in_array($empId, $presentIds) || in_array($empId, $onLeaveIds)) continue;
$absentees[] = $emp;
}
return $absentees;
}
private static function createViolation(array $data): int
{
$db = App::getInstance()->db();
return $db->insert('hr_attendance_violations', [
'employee_id' => $data['employee_id'],
'attendance_id' => $data['attendance_id'] ?? null,
'violation_date' => $data['violation_date'],
'violation_type' => $data['violation_type'],
'scheduled_time' => $data['scheduled_time'] ?? null,
'actual_time' => $data['actual_time'] ?? null,
'difference_minutes' => $data['difference_minutes'],
]);
}
public static function getMonthlyViolationSummary(int $employeeId, string $month): array
{
$db = App::getInstance()->db();
$rows = $db->select(
"SELECT violation_type, COUNT(*) as count, SUM(difference_minutes) as total_minutes
FROM hr_attendance_violations
WHERE employee_id = ? AND DATE_FORMAT(violation_date, '%Y-%m') = ?
GROUP BY violation_type",
[$employeeId, $month]
);
$summary = ['late_arrival' => 0, 'early_departure' => 0, 'absence' => 0, 'total_violations' => 0];
foreach ($rows as $row) {
$summary[$row['violation_type']] = (int) $row['count'];
$summary['total_violations'] += (int) $row['count'];
}
return $summary;
}
private static function timeToMinutes(string $time): int
{
$parts = explode(':', $time);
return ((int) $parts[0]) * 60 + ((int) ($parts[1] ?? 0));
}
}
......@@ -169,6 +169,34 @@ final class IncomeTaxService
{
$db = App::getInstance()->db();
// Try dedicated hr_tax_brackets table first
$dbBrackets = $db->select(
"SELECT from_amount, to_amount, rate, annual_exemption
FROM hr_tax_brackets
WHERE is_active = 1
ORDER BY bracket_order ASC"
);
if (!empty($dbBrackets)) {
$personalExemption = '0.00';
$brackets = [];
foreach ($dbBrackets as $b) {
if (bccomp((string) $b['annual_exemption'], '0', 2) > 0) {
$personalExemption = (string) $b['annual_exemption'];
}
$brackets[] = [
'from' => (string) $b['from_amount'],
'to' => (string) $b['to_amount'],
'rate' => bcmul((string) $b['rate'], '100', 2),
];
}
if (bccomp($personalExemption, '0', 2) <= 0) {
$personalExemption = '20000.00';
}
return ['personal_exemption' => $personalExemption, 'brackets' => $brackets];
}
// Fallback to system_config
$exemptionRow = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'hr.tax.personal_exemption'"
);
......
<?php
declare(strict_types=1);
namespace App\Modules\HR\Services;
use App\Core\App;
final class InsuranceCalculationService
{
public static function calculate(int $employeeId, ?string $date = null): array
{
$db = App::getInstance()->db();
$date = $date ?? date('Y-m-d');
$config = $db->selectOne(
"SELECT * FROM hr_insurance_config WHERE effective_date <= ? AND is_active = 1 ORDER BY effective_date DESC LIMIT 1",
[$date]
);
if (!$config) {
return ['employer_share' => '0.00', 'employee_share' => '0.00', 'total' => '0.00', 'details' => []];
}
$salary = $db->selectOne(
"SELECT basic_salary, variable_salary FROM hr_employee_salary_details
WHERE employee_id = ? AND effective_date <= ?
ORDER BY effective_date DESC LIMIT 1",
[$employeeId, $date]
);
if (!$salary) {
return ['employer_share' => '0.00', 'employee_share' => '0.00', 'total' => '0.00', 'details' => []];
}
$basicSalary = (string) $salary['basic_salary'];
$variableSalary = (string) ($salary['variable_salary'] ?? '0');
$insuredBasic = bccomp($basicSalary, (string) $config['basic_salary_cap'], 2) > 0
? (string) $config['basic_salary_cap']
: $basicSalary;
$insuredVariable = bccomp($variableSalary, (string) $config['variable_salary_cap'], 2) > 0
? (string) $config['variable_salary_cap']
: $variableSalary;
$employerBasic = bcmul($insuredBasic, (string) $config['employer_basic_rate'], 2);
$employerVariable = bcmul($insuredVariable, (string) $config['employer_variable_rate'], 2);
$employerShare = bcadd($employerBasic, $employerVariable, 2);
$employeeBasic = bcmul($insuredBasic, (string) $config['employee_basic_rate'], 2);
$employeeVariable = bcmul($insuredVariable, (string) $config['employee_variable_rate'], 2);
$employeeShare = bcadd($employeeBasic, $employeeVariable, 2);
$total = bcadd($employerShare, $employeeShare, 2);
return [
'employer_share' => $employerShare,
'employee_share' => $employeeShare,
'total' => $total,
'details' => [
'basic_salary' => $basicSalary,
'variable_salary' => $variableSalary,
'insured_basic' => $insuredBasic,
'insured_variable' => $insuredVariable,
'basic_cap' => (string) $config['basic_salary_cap'],
'variable_cap' => (string) $config['variable_salary_cap'],
'employer_basic_amount' => $employerBasic,
'employer_variable_amount' => $employerVariable,
'employee_basic_amount' => $employeeBasic,
'employee_variable_amount' => $employeeVariable,
],
];
}
public static function calculateBulk(array $employeeIds, ?string $date = null): array
{
$results = [];
foreach ($employeeIds as $id) {
$results[$id] = self::calculate((int) $id, $date);
}
return $results;
}
}
......@@ -75,9 +75,130 @@ final class OvertimeService
];
}
public static function submitRequest(array $data): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$typeId = (int) ($data['overtime_type_id'] ?? 0);
$type = $db->selectOne("SELECT * FROM hr_overtime_types WHERE id = ?", [$typeId]);
if (!$type) {
return ['success' => false, 'error' => 'نوع العمل الإضافي غير موجود'];
}
$hours = (string) ($data['hours'] ?? '0');
if (bccomp($hours, '0', 2) <= 0) {
return ['success' => false, 'error' => 'يجب تحديد عدد ساعات صحيح'];
}
if (bccomp($hours, (string) $type['max_hours_per_day'], 2) > 0) {
return ['success' => false, 'error' => 'تجاوز الحد الأقصى اليومي: ' . $type['max_hours_per_day'] . ' ساعات'];
}
$requestId = $db->insert('hr_overtime_requests', [
'employee_profile_id' => (int) $data['employee_profile_id'],
'overtime_type_id' => $typeId,
'request_date' => $data['request_date'] ?? date('Y-m-d'),
'start_time' => $data['start_time'] ?? null,
'end_time' => $data['end_time'] ?? null,
'hours' => $hours,
'reason' => $data['reason'] ?? null,
'status' => 'pending',
'requested_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
]);
return ['success' => true, 'request_id' => $requestId];
}
public static function approve(int $requestId): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$request = $db->selectOne("SELECT * FROM hr_overtime_requests WHERE id = ?", [$requestId]);
if (!$request || $request['status'] !== 'pending') {
return ['success' => false, 'error' => 'الطلب غير موجود أو تم معالجته'];
}
$db->update('hr_overtime_requests', [
'status' => 'approved',
'approved_by' => $employee ? (int) $employee->id : null,
'approved_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$requestId]);
return ['success' => true];
}
public static function reject(int $requestId, string $reason = ''): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$request = $db->selectOne("SELECT * FROM hr_overtime_requests WHERE id = ?", [$requestId]);
if (!$request || $request['status'] !== 'pending') {
return ['success' => false, 'error' => 'الطلب غير موجود أو تم معالجته'];
}
$db->update('hr_overtime_requests', [
'status' => 'rejected',
'rejection_reason' => $reason,
'approved_by' => $employee ? (int) $employee->id : null,
'approved_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$requestId]);
return ['success' => true];
}
public static function calculateOvertimeForPayroll(int $profileId, int $year, int $month): array
{
$db = App::getInstance()->db();
$startDate = sprintf('%04d-%02d-01', $year, $month);
$endDate = date('Y-m-t', strtotime($startDate));
$requests = $db->select(
"SELECT r.*, t.rate_multiplier, t.name_en
FROM hr_overtime_requests r
JOIN hr_overtime_types t ON t.id = r.overtime_type_id
WHERE r.employee_profile_id = ? AND r.status = 'approved'
AND r.request_date BETWEEN ? AND ?",
[$profileId, $startDate, $endDate]
);
$totalHours = '0.00';
$details = [];
foreach ($requests as $req) {
$totalHours = bcadd($totalHours, (string) $req['hours'], 2);
$details[] = [
'date' => $req['request_date'],
'hours' => $req['hours'],
'multiplier' => $req['rate_multiplier'],
'type' => $req['name_en'],
];
}
return ['total_hours' => $totalHours, 'details' => $details];
}
private static function loadConfig(): array
{
$db = App::getInstance()->db();
// Try loading from hr_overtime_types table
$normalType = $db->selectOne("SELECT rate_multiplier FROM hr_overtime_types WHERE name_en = 'Normal Overtime' LIMIT 1");
$nightType = $db->selectOne("SELECT rate_multiplier FROM hr_overtime_types WHERE name_en = 'Night Overtime' LIMIT 1");
if ($normalType || $nightType) {
return [
'day_multiplier' => (string) ($normalType['rate_multiplier'] ?? '1.50'),
'night_multiplier' => (string) ($nightType['rate_multiplier'] ?? '1.75'),
'night_start_hour' => 19,
'night_end_hour' => 7,
];
}
// Fallback to system_config
$rows = $db->select(
"SELECT config_key, config_value FROM system_config WHERE config_key LIKE 'hr.overtime.%'"
);
......
......@@ -84,6 +84,26 @@ final class SocialInsuranceService
private static function loadConfig(): array
{
$db = App::getInstance()->db();
// Try the dedicated hr_insurance_config table first (2024+ rates with effective dates)
$record = $db->selectOne(
"SELECT * FROM hr_insurance_config WHERE is_active = 1 ORDER BY effective_date DESC LIMIT 1"
);
if ($record) {
return [
'employee_basic_pct' => bcmul((string) $record['employee_basic_rate'], '100', 2),
'employee_variable_pct' => bcmul((string) $record['employee_variable_rate'], '100', 2),
'employer_basic_pct' => bcmul((string) $record['employer_basic_rate'], '100', 2),
'employer_variable_pct' => bcmul((string) $record['employer_variable_rate'], '100', 2),
'basic_ceiling' => (string) $record['basic_salary_cap'],
'variable_ceiling' => (string) $record['variable_salary_cap'],
'basic_floor' => (string) ($record['min_subscription_salary'] ?? '2000.00'),
'variable_floor' => '0.00',
];
}
// Fallback to system_config
$rows = $db->select(
"SELECT config_key, config_value FROM system_config WHERE config_key LIKE 'hr.insurance.%'"
);
......@@ -95,13 +115,13 @@ final class SocialInsuranceService
}
return [
'employee_basic_pct' => $cfg['employee_basic_pct'] ?? '9.00',
'employee_variable_pct' => $cfg['employee_variable_pct'] ?? '2.00',
'employer_basic_pct' => $cfg['employer_basic_pct'] ?? '12.00',
'employer_variable_pct' => $cfg['employer_variable_pct'] ?? '6.75',
'employee_basic_pct' => $cfg['employee_basic_pct'] ?? '11.00',
'employee_variable_pct' => $cfg['employee_variable_pct'] ?? '11.00',
'employer_basic_pct' => $cfg['employer_basic_pct'] ?? '18.75',
'employer_variable_pct' => $cfg['employer_variable_pct'] ?? '15.75',
'basic_ceiling' => $cfg['basic_salary_ceiling'] ?? '12600.00',
'variable_ceiling' => $cfg['variable_salary_ceiling'] ?? '10900.00',
'basic_floor' => $cfg['basic_salary_floor'] ?? '2300.00',
'variable_ceiling' => $cfg['variable_salary_ceiling'] ?? '10080.00',
'basic_floor' => $cfg['basic_salary_floor'] ?? '2000.00',
'variable_floor' => $cfg['variable_salary_floor'] ?? '0.00',
];
}
......
<?php
declare(strict_types=1);
namespace App\Modules\HR\Services;
use App\Core\App;
final class TaxBracketService
{
public static function calculateAnnualTax(string $annualTaxableIncome, ?string $date = null): array
{
$db = App::getInstance()->db();
$date = $date ?? date('Y-m-d');
$brackets = $db->select(
"SELECT * FROM hr_tax_brackets WHERE effective_date <= ? AND is_active = 1 ORDER BY bracket_order ASC",
[$date]
);
if (empty($brackets)) {
return ['annual_tax' => '0.00', 'monthly_tax' => '0.00', 'effective_rate' => '0.00', 'breakdown' => []];
}
$totalExemption = '0.00';
foreach ($brackets as $bracket) {
if (bccomp((string) $bracket['annual_exemption'], '0', 2) > 0) {
$totalExemption = (string) $bracket['annual_exemption'];
break;
}
}
$taxableAfterExemption = bcsub($annualTaxableIncome, $totalExemption, 2);
if (bccomp($taxableAfterExemption, '0', 2) <= 0) {
return ['annual_tax' => '0.00', 'monthly_tax' => '0.00', 'effective_rate' => '0.00', 'breakdown' => [], 'exemption' => $totalExemption];
}
$remainingIncome = $taxableAfterExemption;
$totalTax = '0.00';
$breakdown = [];
foreach ($brackets as $bracket) {
if (bccomp($remainingIncome, '0', 2) <= 0) break;
$bracketSize = bcsub((string) $bracket['to_amount'], (string) $bracket['from_amount'], 2);
$taxableInBracket = bccomp($remainingIncome, $bracketSize, 2) > 0 ? $bracketSize : $remainingIncome;
$taxInBracket = bcmul($taxableInBracket, (string) $bracket['rate'], 2);
$totalTax = bcadd($totalTax, $taxInBracket, 2);
$remainingIncome = bcsub($remainingIncome, $taxableInBracket, 2);
$breakdown[] = [
'from' => $bracket['from_amount'],
'to' => $bracket['to_amount'],
'rate' => $bracket['rate'],
'taxable_amount' => $taxableInBracket,
'tax_amount' => $taxInBracket,
];
}
$monthlyTax = bcdiv($totalTax, '12', 2);
$effectiveRate = bccomp($annualTaxableIncome, '0', 2) > 0
? bcmul(bcdiv($totalTax, $annualTaxableIncome, 6), '100', 2)
: '0.00';
return [
'annual_tax' => $totalTax,
'monthly_tax' => $monthlyTax,
'effective_rate' => $effectiveRate,
'exemption' => $totalExemption,
'taxable_after_exemption' => $taxableAfterExemption,
'breakdown' => $breakdown,
];
}
public static function calculateMonthlyTax(int $employeeId, string $monthlyGross, ?string $date = null): array
{
$annualGross = bcmul($monthlyGross, '12', 2);
$db = App::getInstance()->db();
$insurance = InsuranceCalculationService::calculate($employeeId, $date);
$annualInsurance = bcmul($insurance['employee_share'], '12', 2);
$annualTaxable = bcsub($annualGross, $annualInsurance, 2);
$result = self::calculateAnnualTax($annualTaxable, $date);
$result['gross_salary'] = $monthlyGross;
$result['insurance_deduction'] = $insurance['employee_share'];
return $result;
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>طلب عمل إضافي جديد<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/hr/overtime" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="max-width:700px;padding:25px;">
<form method="POST" action="/hr/overtime/store">
<?= csrf_field() ?>
<div style="margin-bottom:15px;">
<label class="form-label">الموظف <span style="color:#DC2626;">*</span></label>
<select name="employee_id" class="form-select" required>
<option value="">-- اختر الموظف --</option>
<?php foreach ($employees as $emp): ?>
<option value="<?= (int) $emp['id'] ?>" <?= old('employee_id') == $emp['id'] ? 'selected' : '' ?>><?= e($emp['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="margin-bottom:15px;">
<label class="form-label">نوع العمل الإضافي <span style="color:#DC2626;">*</span></label>
<select name="overtime_type_id" class="form-select" required>
<option value="">-- اختر النوع --</option>
<?php foreach ($types as $type): ?>
<option value="<?= (int) $type['id'] ?>" <?= old('overtime_type_id') == $type['id'] ? 'selected' : '' ?>><?= e($type['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="margin-bottom:15px;">
<label class="form-label">التاريخ <span style="color:#DC2626;">*</span></label>
<input type="date" name="date" value="<?= e(old('date') ?? '') ?>" class="form-input" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">وقت البداية <span style="color:#DC2626;">*</span></label>
<input type="time" name="start_time" value="<?= e(old('start_time') ?? '') ?>" class="form-input" required>
</div>
<div>
<label class="form-label">وقت النهاية <span style="color:#DC2626;">*</span></label>
<input type="time" name="end_time" value="<?= e(old('end_time') ?? '') ?>" class="form-input" required>
</div>
</div>
<div style="margin-bottom:20px;">
<label class="form-label">السبب <span style="color:#DC2626;">*</span></label>
<textarea name="reason" class="form-control" rows="4" placeholder="اذكر سبب العمل الإضافي..." required><?= e(old('reason') ?? '') ?></textarea>
</div>
<div style="display:flex;gap:10px;">
<button type="submit" class="btn btn-primary"><i data-lucide="save" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> حفظ الطلب</button>
<a href="/hr/overtime" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>طلبات العمل الإضافي<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/hr/overtime/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> طلب عمل إضافي</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$statusLabels = ['pending' => 'معلق', 'approved' => 'موافق', 'rejected' => 'مرفوض'];
$statusColors = ['pending' => '#D97706', 'approved' => '#059669', 'rejected' => '#DC2626'];
$statusBgs = ['pending' => '#FFF7ED', 'approved' => '#ECFDF5', 'rejected' => '#FEE2E2'];
?>
<!-- Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/hr/overtime" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">الشهر</label>
<input type="month" name="month" value="<?= e($month ?? '') ?>" class="form-input">
</div>
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select">
<option value="">الكل</option>
<?php foreach ($statusLabels as $val => $label): ?>
<option value="<?= e($val) ?>" <?= ($status ?? '') === $val ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/hr/overtime" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Overtime Requests Table -->
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الموظف</th>
<th>نوع العمل الإضافي</th>
<th>التاريخ</th>
<th>الساعات</th>
<th>معامل الأجر</th>
<th>الحالة</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php if (empty($requests)): ?>
<tr><td colspan="7" style="text-align:center;padding:40px;color:#9CA3AF;">لا توجد طلبات عمل إضافي</td></tr>
<?php else: ?>
<?php foreach ($requests as $r): ?>
<?php
$st = $r['status'] ?? 'pending';
$stColor = $statusColors[$st] ?? '#6B7280';
$stBg = $statusBgs[$st] ?? '#F3F4F6';
$stLabel = $statusLabels[$st] ?? $st;
?>
<tr>
<td style="font-weight:600;"><?= e($r['employee_name'] ?? '—') ?></td>
<td><?= e($r['type_name'] ?? '—') ?></td>
<td style="font-size:13px;"><?= e($r['date'] ?? '—') ?></td>
<td style="text-align:center;font-weight:600;"><?= e($r['hours'] ?? '0') ?></td>
<td style="text-align:center;direction:ltr;">&times;<?= e($r['rate_multiplier'] ?? '1') ?></td>
<td>
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $stBg ?>;color:<?= $stColor ?>;">
<?= e($stLabel) ?>
</span>
</td>
<td>
<?php if ($st === 'pending'): ?>
<div style="display:flex;gap:4px;">
<form method="POST" action="/hr/overtime/<?= (int) $r['id'] ?>/approve" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-primary" style="font-size:11px;padding:4px 8px;background:#059669;border-color:#059669;">
<i data-lucide="check" style="width:12px;height:12px;vertical-align:middle;"></i> موافقة
</button>
</form>
<form method="POST" action="/hr/overtime/<?= (int) $r['id'] ?>/reject" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-outline" style="font-size:11px;padding:4px 8px;color:#DC2626;border-color:#DC2626;">
<i data-lucide="x" style="width:12px;height:12px;vertical-align:middle;"></i> رفض
</button>
</form>
</div>
<?php else: ?>
<a href="/hr/overtime/<?= (int) $r['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="eye" style="width:13px;height:13px;vertical-align:middle;"></i> عرض
</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الورديات<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/hr/shifts/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> وردية جديدة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>اسم الوردية</th>
<th>وقت البداية</th>
<th>وقت النهاية</th>
<th>ساعات العمل</th>
<th>فترة السماح (دقائق)</th>
<th>وردية ليلية</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php if (empty($shifts)): ?>
<tr><td colspan="7" style="text-align:center;padding:40px;color:#9CA3AF;">لا توجد ورديات</td></tr>
<?php else: ?>
<?php foreach ($shifts as $shift): ?>
<tr>
<td style="font-weight:600;"><?= e($shift['name_ar'] ?? '—') ?></td>
<td style="direction:ltr;text-align:center;"><?= e($shift['start_time'] ?? '—') ?></td>
<td style="direction:ltr;text-align:center;"><?= e($shift['end_time'] ?? '—') ?></td>
<td style="text-align:center;font-weight:600;"><?= e($shift['working_hours'] ?? '—') ?></td>
<td style="text-align:center;"><?= (int) ($shift['grace_period'] ?? 0) ?> دقيقة</td>
<td style="text-align:center;">
<?php if (!empty($shift['is_night_shift'])): ?>
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#EFF6FF;color:#2563EB;">
<i data-lucide="moon" style="width:12px;height:12px;vertical-align:middle;margin-left:3px;"></i> نعم
</span>
<?php else: ?>
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#F3F4F6;color:#6B7280;">
<i data-lucide="sun" style="width:12px;height:12px;vertical-align:middle;margin-left:3px;"></i> لا
</span>
<?php endif; ?>
</td>
<td>
<div style="display:flex;gap:4px;">
<a href="/hr/shifts/<?= (int) $shift['id'] ?>/edit" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="edit" style="width:13px;height:13px;vertical-align:middle;"></i> تعديل
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مخالفات الحضور<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/hr/shifts" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$violationLabels = [
'late_arrival' => 'تأخر',
'early_departure' => 'انصراف مبكر',
'absence' => 'غياب',
];
$violationColors = [
'late_arrival' => '#D97706',
'early_departure' => '#2563EB',
'absence' => '#DC2626',
];
$violationBgs = [
'late_arrival' => '#FFF7ED',
'early_departure' => '#EFF6FF',
'absence' => '#FEE2E2',
];
?>
<!-- Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/hr/shifts/violations" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">الشهر</label>
<input type="month" name="month" value="<?= e($month ?? '') ?>" class="form-input">
</div>
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">نوع المخالفة</label>
<select name="type" class="form-select">
<option value="">الكل</option>
<?php foreach ($violationLabels as $val => $label): ?>
<option value="<?= e($val) ?>" <?= ($type ?? '') === $val ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/hr/shifts/violations" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Violations Table -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="alert-circle" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;font-size:15px;">سجل المخالفات</h3>
<?php if (!empty($violations)): ?>
<span style="background:#FFF7ED;color:#D97706;font-size:12px;padding:2px 8px;border-radius:10px;font-weight:600;margin-right:8px;"><?= count($violations) ?> مخالفة</span>
<?php endif; ?>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الموظف</th>
<th>التاريخ</th>
<th>نوع المخالفة</th>
<th>الوقت المحدد</th>
<th>الوقت الفعلي</th>
<th>الفرق (دقائق)</th>
</tr>
</thead>
<tbody>
<?php if (empty($violations)): ?>
<tr><td colspan="6" style="text-align:center;padding:40px;color:#9CA3AF;">لا توجد مخالفات</td></tr>
<?php else: ?>
<?php foreach ($violations as $v): ?>
<?php
$vType = $v['violation_type'] ?? '';
$vLabel = $violationLabels[$vType] ?? $vType;
$vColor = $violationColors[$vType] ?? '#6B7280';
$vBg = $violationBgs[$vType] ?? '#F3F4F6';
?>
<tr>
<td style="font-weight:600;"><?= e($v['employee_name'] ?? '—') ?></td>
<td style="font-size:13px;"><?= e($v['date'] ?? '—') ?></td>
<td>
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $vBg ?>;color:<?= $vColor ?>;">
<?= e($vLabel) ?>
</span>
</td>
<td style="direction:ltr;text-align:center;"><?= e($v['scheduled_time'] ?? '—') ?></td>
<td style="direction:ltr;text-align:center;"><?= e($v['actual_time'] ?? '—') ?></td>
<td style="text-align:center;">
<span style="font-weight:700;color:<?= $vColor ?>;"><?= (int) ($v['difference_minutes'] ?? 0) ?> دقيقة</span>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
......@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
use App\Core\EventBus;
use App\Modules\HR\Services\AttendanceViolationService;
// ────────────────────────────────────────────────────────────
// HR Module — Permissions (35 total)
......@@ -82,6 +83,21 @@ PermissionRegistry::register('hr', [
// Schedule
'hr.schedule.manage' => ['ar' => 'إدارة جداول العمل', 'en' => 'Manage Work Schedules'],
// Overtime
'hr.overtime.view' => ['ar' => 'عرض العمل الإضافي', 'en' => 'View Overtime'],
'hr.overtime.create' => ['ar' => 'تقديم طلب عمل إضافي', 'en' => 'Submit Overtime Request'],
'hr.overtime.approve' => ['ar' => 'اعتماد العمل الإضافي', 'en' => 'Approve Overtime'],
'hr.overtime.manage' => ['ar' => 'إدارة أنواع العمل الإضافي', 'en' => 'Manage Overtime Types'],
// Shifts
'hr.shifts.view' => ['ar' => 'عرض الورديات', 'en' => 'View Shifts'],
'hr.shifts.manage' => ['ar' => 'إدارة الورديات والتعيينات', 'en' => 'Manage Shifts & Assignments'],
// Permission Requests (Hourly)
'hr.permissions.view' => ['ar' => 'عرض طلبات الأذونات', 'en' => 'View Permission Requests'],
'hr.permissions.create' => ['ar' => 'تقديم طلب إذن', 'en' => 'Submit Permission Request'],
'hr.permissions.approve' => ['ar' => 'اعتماد طلبات الأذونات', 'en' => 'Approve Permission Requests'],
]);
// ────────────────────────────────────────────────────────────
......@@ -113,7 +129,11 @@ MenuRegistry::register('hr', [
['label_ar' => 'مستندات الموظفين', 'label_en' => 'Documents', 'route' => '/hr/documents', 'permission' => 'hr.document.view', 'order' => 14],
['label_ar' => 'العطلات الرسمية', 'label_en' => 'Holidays', 'route' => '/hr/holidays', 'permission' => 'hr.holiday.manage', 'order' => 15],
['label_ar' => 'جداول العمل', 'label_en' => 'Work Schedules', 'route' => '/hr/schedules', 'permission' => 'hr.schedule.manage', 'order' => 16],
['label_ar' => 'تقارير الموارد البشرية','label_en' => 'HR Reports', 'route' => '/hr/reports', 'permission' => 'hr.report.view', 'order' => 17],
['label_ar' => 'العمل الإضافي', 'label_en' => 'Overtime', 'route' => '/hr/overtime', 'permission' => 'hr.overtime.view', 'order' => 17],
['label_ar' => 'الورديات', 'label_en' => 'Shifts', 'route' => '/hr/shifts', 'permission' => 'hr.shifts.view', 'order' => 18],
['label_ar' => 'طلبات الأذونات', 'label_en' => 'Permissions', 'route' => '/hr/permissions', 'permission' => 'hr.permissions.view', 'order' => 19],
['label_ar' => 'المخالفات', 'label_en' => 'Violations', 'route' => '/hr/shifts/violations', 'permission' => 'hr.attendance.view', 'order' => 20],
['label_ar' => 'تقارير الموارد البشرية','label_en' => 'HR Reports', 'route' => '/hr/reports', 'permission' => 'hr.report.view', 'order' => 21],
],
]);
......@@ -165,10 +185,21 @@ EventBus::listen('hr.payroll.paid', function (array $data): void {
// When a loan is approved, dispatch SMS notification
EventBus::listen('hr.loan.approved', function (array $data): void {
if (!empty($data['employee_id'])) {
\App\Core\EventBus::dispatch('sms.send', [
EventBus::dispatch('sms.send', [
'template_code' => 'hr_loan_approved',
'employee_id' => $data['employee_id'],
'params' => $data,
]);
}
});
// ── Attendance Violation Auto-Detection ─────────────────────
// When attendance is recorded, auto-detect violations (late/early/absence)
EventBus::listen('hr.attendance.recorded', function (array $data): void {
try {
$date = $data['date'] ?? date('Y-m-d');
AttendanceViolationService::detectViolations($date);
} catch (\Throwable $e) {
\App\Core\Logger::error('Attendance violation detection failed: ' . $e->getMessage());
}
});
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class AssetCustodyController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('asset.view');
$db = App::getInstance()->db();
$employeeId = (int) $request->get('employee_id', 0);
$location = $request->get('location', '');
$where = 'a.status = ?';
$params = ['active'];
if ($employeeId) {
$where .= ' AND a.custodian_employee_id = ?';
$params[] = $employeeId;
}
if ($location) {
$where .= ' AND a.site_location LIKE ?';
$params[] = '%' . $location . '%';
}
$assets = $db->select(
"SELECT a.*, i.name_ar as item_name, e.full_name_ar as custodian_name, w.name_ar as warehouse_name
FROM asset_register a
JOIN inventory_items i ON i.id = a.item_id
LEFT JOIN employees e ON e.id = a.custodian_employee_id
LEFT JOIN warehouses w ON w.id = a.warehouse_id
WHERE {$where}
ORDER BY a.site_location, i.name_ar",
$params
);
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_archived = 0 ORDER BY full_name_ar");
return $this->view('Inventory.Views.assets.custody_index', [
'assets' => $assets,
'employees' => $employees,
'employee_id' => $employeeId,
'location' => $location,
]);
}
public function transfer(Request $request, string $id): Response
{
$this->authorize('asset.manage');
$db = App::getInstance()->db();
$asset = $db->selectOne(
"SELECT a.*, i.name_ar as item_name, e.full_name_ar as current_custodian_name
FROM asset_register a
JOIN inventory_items i ON i.id = a.item_id
LEFT JOIN employees e ON e.id = a.custodian_employee_id
WHERE a.id = ?",
[(int) $id]
);
if (!$asset) {
return $this->redirect('/inventory/assets/custody')->withError('الأصل غير موجود');
}
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_archived = 0 ORDER BY full_name_ar");
return $this->view('Inventory.Views.assets.custody_transfer', [
'asset' => $asset,
'employees' => $employees,
]);
}
public function storeTransfer(Request $request, string $id): Response
{
$this->authorize('asset.manage');
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$asset = $db->selectOne("SELECT * FROM asset_register WHERE id = ?", [(int) $id]);
if (!$asset) {
return $this->redirect('/inventory/assets/custody')->withError('الأصل غير موجود');
}
$toEmployeeId = $request->post('to_employee_id') ? (int) $request->post('to_employee_id') : null;
$toLocation = $request->post('to_location', '');
$db->beginTransaction();
try {
$db->insert('asset_custody_history', [
'asset_id' => (int) $id,
'from_employee_id' => $asset['custodian_employee_id'] ? (int) $asset['custodian_employee_id'] : null,
'to_employee_id' => $toEmployeeId,
'from_location' => $asset['site_location'],
'to_location' => $toLocation,
'transfer_date' => $request->post('transfer_date', date('Y-m-d')),
'reason' => $request->post('reason'),
'notes' => $request->post('notes'),
'created_by' => $employee ? (int) $employee->id : null,
]);
$db->update('asset_register', [
'custodian_employee_id' => $toEmployeeId,
'site_location' => $toLocation ?: $asset['site_location'],
'custody_date' => date('Y-m-d'),
], 'id = ?', [(int) $id]);
$db->commit();
return $this->redirect('/inventory/assets/custody')->withSuccess('تم نقل عهدة الأصل بنجاح');
} catch (\Throwable $e) {
$db->rollBack();
return $this->redirect('/inventory/assets/custody/' . $id . '/transfer')->withError('خطأ: ' . $e->getMessage());
}
}
public function history(Request $request, string $id): Response
{
$this->authorize('asset.view');
$db = App::getInstance()->db();
$asset = $db->selectOne(
"SELECT a.*, i.name_ar as item_name FROM asset_register a JOIN inventory_items i ON i.id = a.item_id WHERE a.id = ?",
[(int) $id]
);
$history = $db->select(
"SELECT h.*, fe.full_name_ar as from_employee_name, te.full_name_ar as to_employee_name, ce.full_name_ar as created_by_name
FROM asset_custody_history h
LEFT JOIN employees fe ON fe.id = h.from_employee_id
LEFT JOIN employees te ON te.id = h.to_employee_id
LEFT JOIN employees ce ON ce.id = h.created_by
WHERE h.asset_id = ?
ORDER BY h.transfer_date DESC, h.created_at DESC",
[(int) $id]
);
return $this->view('Inventory.Views.assets.custody_history', [
'asset' => $asset,
'history' => $history,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Inventory\Services\BomService;
class BomController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('inventory.bom.view');
$db = App::getInstance()->db();
$boms = $db->select(
"SELECT b.*, i.name_ar as item_name, i.sku,
(SELECT COUNT(*) FROM bom_components WHERE bom_id = b.id) as component_count
FROM bill_of_materials b
JOIN inventory_items i ON i.id = b.item_id
WHERE b.is_active = 1
ORDER BY b.created_at DESC"
);
return $this->view('Inventory.Views.bom.index', ['boms' => $boms]);
}
public function create(Request $request): Response
{
$this->authorize('inventory.bom.manage');
$db = App::getInstance()->db();
$items = $db->select("SELECT id, name_ar, sku FROM inventory_items WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Inventory.Views.bom.form', [
'bom' => null,
'items' => $items,
'components' => [],
]);
}
public function store(Request $request): Response
{
$this->authorize('inventory.bom.manage');
$data = [
'item_id' => (int) $request->post('item_id'),
'name_ar' => $request->post('name_ar'),
'name_en' => $request->post('name_en'),
'output_quantity' => $request->post('output_quantity', '1'),
'notes' => $request->post('notes'),
];
$componentItemIds = $request->post('component_item_ids', []);
$componentQtys = $request->post('component_quantities', []);
$componentUnits = $request->post('component_units', []);
$componentWaste = $request->post('component_waste', []);
$components = [];
for ($i = 0; $i < count($componentItemIds); $i++) {
if (empty($componentItemIds[$i])) continue;
$components[] = [
'item_id' => (int) $componentItemIds[$i],
'quantity' => $componentQtys[$i] ?? '1',
'unit' => $componentUnits[$i] ?? null,
'waste_percentage' => $componentWaste[$i] ?? '0',
];
}
if (empty($components)) {
return $this->redirect('/inventory/bom/create')->withError('يجب إضافة مكون واحد على الأقل');
}
$result = BomService::create($data, $components);
if (!$result['success']) {
return $this->redirect('/inventory/bom/create')->withError($result['error']);
}
return $this->redirect('/inventory/bom/' . $result['bom_id'])->withSuccess('تم إنشاء قائمة المواد بنجاح');
}
public function show(Request $request, string $id): Response
{
$this->authorize('inventory.bom.view');
$db = App::getInstance()->db();
$bom = $db->selectOne(
"SELECT b.*, i.name_ar as item_name, i.sku
FROM bill_of_materials b
JOIN inventory_items i ON i.id = b.item_id
WHERE b.id = ?",
[(int) $id]
);
if (!$bom) {
return $this->redirect('/inventory/bom')->withError('قائمة المواد غير موجودة');
}
$components = $db->select(
"SELECT bc.*, i.name_ar as component_name, i.sku as component_sku
FROM bom_components bc
JOIN inventory_items i ON i.id = bc.component_item_id
WHERE bc.bom_id = ?
ORDER BY bc.sort_order",
[(int) $id]
);
$costData = BomService::calculateCost((int) $id);
return $this->view('Inventory.Views.bom.show', [
'bom' => $bom,
'components' => $components,
'costData' => $costData,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Inventory\Services\StockService;
class OpeningBalanceController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('inventory.opening.manage');
$db = App::getInstance()->db();
$warehouseId = (int) $request->get('warehouse_id', 0);
$where = '1=1';
$params = [];
if ($warehouseId) {
$where .= ' AND ob.warehouse_id = ?';
$params[] = $warehouseId;
}
$balances = $db->select(
"SELECT ob.*, i.name_ar as item_name, i.sku, w.name_ar as warehouse_name
FROM inventory_opening_balances ob
JOIN inventory_items i ON i.id = ob.item_id
JOIN warehouses w ON w.id = ob.warehouse_id
WHERE {$where}
ORDER BY ob.balance_date DESC, ob.created_at DESC
LIMIT 200",
$params
);
$warehouses = $db->select("SELECT id, name_ar FROM warehouses WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Inventory.Views.opening_balance.index', [
'balances' => $balances,
'warehouses' => $warehouses,
'warehouse_id' => $warehouseId,
]);
}
public function create(Request $request): Response
{
$this->authorize('inventory.opening.manage');
$db = App::getInstance()->db();
$warehouses = $db->select("SELECT id, name_ar FROM warehouses WHERE is_archived = 0 ORDER BY name_ar");
$items = $db->select("SELECT id, name_ar, sku FROM inventory_items WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Inventory.Views.opening_balance.form', [
'warehouses' => $warehouses,
'items' => $items,
]);
}
public function store(Request $request): Response
{
$this->authorize('inventory.opening.manage');
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$warehouseId = (int) $request->post('warehouse_id');
$balanceDate = $request->post('balance_date', date('Y-m-d'));
$batchReference = 'OB-' . date('Ymd-His');
$itemIds = $request->post('item_ids', []);
$quantities = $request->post('quantities', []);
$unitCosts = $request->post('unit_costs', []);
$count = 0;
$db->beginTransaction();
try {
for ($i = 0; $i < count($itemIds); $i++) {
$itemId = (int) ($itemIds[$i] ?? 0);
$qty = (string) ($quantities[$i] ?? '0');
$unitCost = (string) ($unitCosts[$i] ?? '0');
if ($itemId <= 0 || bccomp($qty, '0', 3) <= 0) continue;
$totalCost = bcmul($qty, $unitCost, 2);
$movementId = StockService::moveStock([
'item_id' => $itemId,
'warehouse_id' => $warehouseId,
'movement_type' => 'opening_balance',
'direction' => 'in',
'quantity' => $qty,
'unit_cost' => $unitCost,
'reference_type' => 'inventory_opening_balances',
'notes' => 'رصيد افتتاحي — ' . $batchReference,
'movement_date' => $balanceDate,
]);
$db->insert('inventory_opening_balances', [
'warehouse_id' => $warehouseId,
'item_id' => $itemId,
'quantity' => $qty,
'unit_cost' => $unitCost,
'total_cost' => $totalCost,
'balance_date' => $balanceDate,
'batch_reference' => $batchReference,
'stock_movement_id' => $movementId,
'created_by' => $employee ? (int) $employee->id : null,
]);
$count++;
}
$db->commit();
return $this->redirect('/inventory/opening-balances')->withSuccess("تم تسجيل الأرصدة الافتتاحية بنجاح — {$count} صنف");
} catch (\Throwable $e) {
$db->rollBack();
return $this->redirect('/inventory/opening-balances/create')->withError('خطأ: ' . $e->getMessage());
}
}
}
......@@ -73,6 +73,23 @@ return [
['POST', '/inventory/assets/{id:\d+}/dispose', 'Inventory\Controllers\AssetController@dispose', ['auth', 'csrf'], 'asset.manage'],
['POST', '/inventory/assets/run-depreciation', 'Inventory\Controllers\AssetController@runDepreciation',['auth', 'csrf'], 'asset.manage'],
// Bill of Materials (BOM)
['GET', '/inventory/bom', 'Inventory\Controllers\BomController@index', ['auth'], 'inventory.bom.view'],
['GET', '/inventory/bom/create', 'Inventory\Controllers\BomController@create', ['auth'], 'inventory.bom.manage'],
['POST', '/inventory/bom', 'Inventory\Controllers\BomController@store', ['auth', 'csrf'], 'inventory.bom.manage'],
['GET', '/inventory/bom/{id:\d+}', 'Inventory\Controllers\BomController@show', ['auth'], 'inventory.bom.view'],
// Opening Balances
['GET', '/inventory/opening-balances', 'Inventory\Controllers\OpeningBalanceController@index', ['auth'], 'inventory.opening.manage'],
['GET', '/inventory/opening-balances/create', 'Inventory\Controllers\OpeningBalanceController@create', ['auth'], 'inventory.opening.manage'],
['POST', '/inventory/opening-balances', 'Inventory\Controllers\OpeningBalanceController@store', ['auth', 'csrf'], 'inventory.opening.manage'],
// Asset Custody
['GET', '/inventory/assets/custody', 'Inventory\Controllers\AssetCustodyController@index', ['auth'], 'asset.view'],
['GET', '/inventory/assets/custody/{id:\d+}/transfer', 'Inventory\Controllers\AssetCustodyController@transfer', ['auth'], 'asset.manage'],
['POST', '/inventory/assets/custody/{id:\d+}/transfer', 'Inventory\Controllers\AssetCustodyController@storeTransfer', ['auth', 'csrf'], 'asset.manage'],
['GET', '/inventory/assets/custody/{id:\d+}/history', 'Inventory\Controllers\AssetCustodyController@history', ['auth'], 'asset.view'],
// Reports
['GET', '/inventory/reports/stock-balance', 'Inventory\Controllers\InventoryReportController@stockBalance', ['auth'], 'report.inventory'],
['GET', '/inventory/reports/movements', 'Inventory\Controllers\InventoryReportController@movements', ['auth'], 'report.inventory'],
......
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Services;
use App\Core\App;
use App\Core\EventBus;
final class AssetCustodyService
{
public static function transfer(int $assetId, array $data): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$asset = $db->selectOne("SELECT * FROM asset_register WHERE id = ?", [$assetId]);
if (!$asset) {
return ['success' => false, 'error' => 'الأصل غير موجود'];
}
$newEmployeeId = !empty($data['to_employee_id']) ? (int) $data['to_employee_id'] : null;
$newLocation = $data['new_location'] ?? null;
$db->beginTransaction();
try {
// Record history
$db->insert('asset_custody_history', [
'asset_id' => $assetId,
'from_employee_id' => $asset['custodian_employee_id'] ?? null,
'to_employee_id' => $newEmployeeId,
'transfer_date' => $data['transfer_date'] ?? date('Y-m-d'),
'reason' => $data['reason'] ?? null,
'from_location' => $asset['site_location'] ?? null,
'to_location' => $newLocation,
'transferred_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
]);
// Update asset
$updateData = ['updated_at' => date('Y-m-d H:i:s')];
if ($newEmployeeId !== null || isset($data['to_employee_id'])) {
$updateData['custodian_employee_id'] = $newEmployeeId;
}
if ($newLocation !== null) {
$updateData['site_location'] = $newLocation;
}
$db->update('asset_register', $updateData, 'id = ?', [$assetId]);
$db->commit();
EventBus::dispatch('asset.custody_transferred', [
'asset_id' => $assetId,
'from_employee_id' => $asset['custodian_employee_id'] ?? null,
'to_employee_id' => $newEmployeeId,
]);
return ['success' => true];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function getHistory(int $assetId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT ach.*,
ef.name_ar as from_name, et.name_ar as to_name, eb.name_ar as transferred_by_name
FROM asset_custody_history ach
LEFT JOIN employees ef ON ef.id = ach.from_employee_id
LEFT JOIN employees et ON et.id = ach.to_employee_id
LEFT JOIN employees eb ON eb.id = ach.transferred_by
WHERE ach.asset_id = ?
ORDER BY ach.transfer_date DESC, ach.id DESC",
[$assetId]
);
}
public static function getEmployeeAssets(int $employeeId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT ar.*, ac.name_ar as category_name
FROM asset_register ar
LEFT JOIN asset_categories ac ON ac.id = ar.category_id
WHERE ar.custodian_employee_id = ?
ORDER BY ar.asset_name_ar",
[$employeeId]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Services;
use App\Core\App;
use App\Core\EventBus;
final class BomService
{
public static function create(array $data, array $components): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$bomCode = $data['bom_code'] ?? ('BOM-' . str_pad((string) random_int(1, 99999), 5, '0', STR_PAD_LEFT));
$db->beginTransaction();
try {
$bomId = $db->insert('bill_of_materials', [
'item_id' => (int) $data['item_id'],
'bom_code' => $bomCode,
'version' => (int) ($data['version'] ?? 1),
'name_ar' => $data['name_ar'],
'name_en' => $data['name_en'] ?? null,
'output_quantity' => $data['output_quantity'] ?? '1.000',
'notes' => $data['notes'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
]);
foreach ($components as $i => $comp) {
$db->insert('bom_components', [
'bom_id' => $bomId,
'component_item_id' => (int) $comp['item_id'],
'quantity' => $comp['quantity'],
'unit' => $comp['unit'] ?? null,
'waste_percentage' => $comp['waste_percentage'] ?? '0.00',
'is_optional' => (int) ($comp['is_optional'] ?? 0),
'sort_order' => $i + 1,
'notes' => $comp['notes'] ?? null,
]);
}
$db->commit();
EventBus::dispatch('bom.created', ['bom_id' => $bomId, 'item_id' => (int) $data['item_id']]);
return ['success' => true, 'bom_id' => $bomId, 'bom_code' => $bomCode];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function deductComponents(int $bomId, string $quantity, int $warehouseId): array
{
$db = App::getInstance()->db();
$bom = $db->selectOne("SELECT * FROM bill_of_materials WHERE id = ? AND is_active = 1", [$bomId]);
if (!$bom) {
return ['success' => false, 'error' => 'قائمة المواد غير موجودة'];
}
$components = $db->select("SELECT * FROM bom_components WHERE bom_id = ?", [$bomId]);
$multiplier = bcdiv($quantity, (string) $bom['output_quantity'], 6);
$insufficientItems = [];
foreach ($components as $comp) {
if ($comp['is_optional']) continue;
$requiredQty = bcmul((string) $comp['quantity'], $multiplier, 3);
$wasteQty = bcmul($requiredQty, bcdiv((string) $comp['waste_percentage'], '100', 6), 3);
$totalRequired = bcadd($requiredQty, $wasteQty, 3);
$stock = $db->selectOne(
"SELECT quantity FROM item_warehouse_stock WHERE item_id = ? AND warehouse_id = ?",
[(int) $comp['component_item_id'], $warehouseId]
);
$available = (string) ($stock['quantity'] ?? '0');
if (bccomp($available, $totalRequired, 3) < 0) {
$item = $db->selectOne("SELECT name_ar FROM inventory_items WHERE id = ?", [(int) $comp['component_item_id']]);
$insufficientItems[] = ($item['name_ar'] ?? '#' . $comp['component_item_id']) . " (مطلوب: {$totalRequired}, متاح: {$available})";
}
}
if (!empty($insufficientItems)) {
return ['success' => false, 'error' => 'مخزون غير كافٍ: ' . implode(', ', $insufficientItems)];
}
$db->beginTransaction();
try {
foreach ($components as $comp) {
if ($comp['is_optional']) continue;
$requiredQty = bcmul((string) $comp['quantity'], $multiplier, 3);
$wasteQty = bcmul($requiredQty, bcdiv((string) $comp['waste_percentage'], '100', 6), 3);
$totalRequired = bcadd($requiredQty, $wasteQty, 3);
StockService::moveStock([
'item_id' => (int) $comp['component_item_id'],
'warehouse_id' => $warehouseId,
'movement_type' => 'consumed_out',
'direction' => 'out',
'quantity' => $totalRequired,
'reference_type' => 'bill_of_materials',
'reference_id' => $bomId,
'notes' => 'خصم مكونات BOM: ' . $bom['bom_code'] . ' × ' . $quantity,
]);
}
$db->commit();
EventBus::dispatch('bom.components_deducted', [
'bom_id' => $bomId,
'quantity' => $quantity,
'warehouse_id' => $warehouseId,
]);
return ['success' => true];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function calculateCost(int $bomId): array
{
$db = App::getInstance()->db();
$bom = $db->selectOne("SELECT * FROM bill_of_materials WHERE id = ?", [$bomId]);
if (!$bom) return ['total_cost' => '0.00', 'components' => []];
$components = $db->select(
"SELECT bc.*, i.name_ar, i.selling_price,
(SELECT AVG(unit_cost) FROM stock_movements WHERE item_id = bc.component_item_id AND direction = 'in' ORDER BY created_at DESC LIMIT 10) as avg_cost
FROM bom_components bc
JOIN inventory_items i ON i.id = bc.component_item_id
WHERE bc.bom_id = ?
ORDER BY bc.sort_order",
[$bomId]
);
$totalCost = '0.00';
$details = [];
foreach ($components as $comp) {
$unitCost = (string) ($comp['avg_cost'] ?? $comp['selling_price'] ?? '0');
$componentCost = bcmul($unitCost, (string) $comp['quantity'], 2);
$totalCost = bcadd($totalCost, $componentCost, 2);
$details[] = [
'item_id' => (int) $comp['component_item_id'],
'name' => $comp['name_ar'],
'quantity' => $comp['quantity'],
'unit_cost' => $unitCost,
'total_cost' => $componentCost,
];
}
$costPerUnit = bccomp((string) $bom['output_quantity'], '0', 3) > 0
? bcdiv($totalCost, (string) $bom['output_quantity'], 2)
: $totalCost;
return ['total_cost' => $totalCost, 'cost_per_unit' => $costPerUnit, 'components' => $details];
}
public static function getBomForItem(int $itemId): ?array
{
$db = App::getInstance()->db();
return $db->selectOne("SELECT * FROM bill_of_materials WHERE item_id = ? AND is_active = 1 ORDER BY version DESC LIMIT 1", [$itemId]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Services;
use App\Core\App;
final class CreditLimitService
{
public static function getCustomerCreditStatus(int $memberId): array
{
$db = App::getInstance()->db();
$member = $db->selectOne(
"SELECT credit_limit, credit_balance FROM members WHERE id = ?",
[$memberId]
);
if (!$member) {
return ['limit' => '0.00', 'used' => '0.00', 'available' => '0.00', 'exceeded' => false];
}
$limit = (string) ($member['credit_limit'] ?? '0.00');
$used = (string) ($member['credit_balance'] ?? '0.00');
$available = bcsub($limit, $used, 2);
$exceeded = bccomp($used, $limit, 2) > 0;
return [
'limit' => $limit,
'used' => $used,
'available' => $available,
'exceeded' => $exceeded,
];
}
public static function getSupplierCreditStatus(int $supplierId): array
{
$db = App::getInstance()->db();
$supplier = $db->selectOne(
"SELECT credit_limit, credit_balance FROM suppliers WHERE id = ?",
[$supplierId]
);
if (!$supplier) {
return ['limit' => '0.00', 'used' => '0.00', 'available' => '0.00', 'exceeded' => false];
}
$limit = (string) ($supplier['credit_limit'] ?? '0.00');
$used = (string) ($supplier['credit_balance'] ?? '0.00');
$available = bcsub($limit, $used, 2);
$exceeded = bccomp($used, $limit, 2) > 0;
return [
'limit' => $limit,
'used' => $used,
'available' => $available,
'exceeded' => $exceeded,
];
}
public static function checkCustomerCanTransact(int $memberId, string $amount): array
{
$status = self::getCustomerCreditStatus($memberId);
if (bccomp($status['limit'], '0', 2) === 0) {
return ['allowed' => true, 'message' => ''];
}
$newBalance = bcadd($status['used'], $amount, 2);
if (bccomp($newBalance, $status['limit'], 2) > 0) {
return [
'allowed' => false,
'message' => 'تجاوز الحد الائتماني — الحد: ' . $status['limit'] . ' | المستخدم: ' . $status['used'] . ' | المتاح: ' . $status['available'],
];
}
return ['allowed' => true, 'message' => ''];
}
public static function checkSupplierCanTransact(int $supplierId, string $amount): array
{
$status = self::getSupplierCreditStatus($supplierId);
if (bccomp($status['limit'], '0', 2) === 0) {
return ['allowed' => true, 'message' => ''];
}
$newBalance = bcadd($status['used'], $amount, 2);
if (bccomp($newBalance, $status['limit'], 2) > 0) {
return [
'allowed' => false,
'message' => 'تجاوز الحد الائتماني للمورد — الحد: ' . $status['limit'] . ' | المستخدم: ' . $status['used'] . ' | المتاح: ' . $status['available'],
];
}
return ['allowed' => true, 'message' => ''];
}
public static function updateCustomerBalance(int $memberId, string $amount, string $direction): void
{
$db = App::getInstance()->db();
if ($direction === 'increase') {
$db->query(
"UPDATE members SET credit_balance = credit_balance + ? WHERE id = ?",
[$amount, $memberId]
);
} else {
$db->query(
"UPDATE members SET credit_balance = GREATEST(0, credit_balance - ?) WHERE id = ?",
[$amount, $memberId]
);
}
}
public static function updateSupplierBalance(int $supplierId, string $amount, string $direction): void
{
$db = App::getInstance()->db();
if ($direction === 'increase') {
$db->query(
"UPDATE suppliers SET credit_balance = credit_balance + ? WHERE id = ?",
[$amount, $supplierId]
);
} else {
$db->query(
"UPDATE suppliers SET credit_balance = GREATEST(0, credit_balance - ?) WHERE id = ?",
[$amount, $supplierId]
);
}
}
public static function recalculateCustomerBalance(int $memberId): string
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COALESCE(SUM(debit) - SUM(credit), 0) as balance
FROM customer_transactions WHERE member_id = ?",
[$memberId]
);
$balance = (string) ($row['balance'] ?? '0.00');
$db->update('members', ['credit_balance' => $balance], 'id = ?', [$memberId]);
return $balance;
}
public static function recalculateSupplierBalance(int $supplierId): string
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COALESCE(SUM(credit) - SUM(debit), 0) as balance
FROM supplier_transactions WHERE supplier_id = ?",
[$supplierId]
);
$balance = (string) ($row['balance'] ?? '0.00');
$db->update('suppliers', ['credit_balance' => $balance], 'id = ?', [$supplierId]);
return $balance;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Services;
use App\Core\App;
use App\Core\EventBus;
final class MovementEngineService
{
private static array $specCache = [];
public static function getMovementType(string $code): ?array
{
$db = App::getInstance()->db();
return $db->selectOne("SELECT * FROM movement_types WHERE code = ? AND is_active = 1", [$code]);
}
public static function getSpecifications(int $movementTypeId): array
{
if (isset(self::$specCache[$movementTypeId])) {
return self::$specCache[$movementTypeId];
}
$db = App::getInstance()->db();
$rows = $db->select(
"SELECT spec_key, spec_value, spec_type FROM movement_specifications WHERE movement_type_id = ?",
[$movementTypeId]
);
$specs = [];
foreach ($rows as $row) {
$specs[$row['spec_key']] = self::castValue($row['spec_value'], $row['spec_type']);
}
self::$specCache[$movementTypeId] = $specs;
return $specs;
}
public static function executeMovement(string $typeCode, array $data): array
{
$type = self::getMovementType($typeCode);
if (!$type) {
return ['success' => false, 'error' => 'نوع الحركة غير موجود: ' . $typeCode];
}
$specs = self::getSpecifications((int) $type['id']);
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
if ($type['requires_approval'] && empty($data['skip_approval'])) {
return ['success' => false, 'needs_approval' => true, 'message' => 'هذه الحركة تحتاج اعتماد'];
}
$db->beginTransaction();
try {
$documentNumber = self::generateNumber($type);
if ($type['affects_stock'] && !empty($data['items'])) {
foreach ($data['items'] as $item) {
StockService::moveStock([
'item_id' => (int) $item['item_id'],
'warehouse_id' => (int) ($data['warehouse_id'] ?? $item['warehouse_id']),
'movement_type' => $typeCode,
'direction' => $type['direction'] === 'neutral' ? ($item['direction'] ?? 'in') : $type['direction'],
'quantity' => (string) $item['quantity'],
'unit_cost' => $item['unit_cost'] ?? null,
'reference_type' => 'movement_types',
'reference_id' => (int) $type['id'],
'notes' => $data['notes'] ?? $type['name_ar'],
]);
}
}
if ($type['posts_to_gl'] && !empty($specs['debit_account']) && !empty($specs['credit_account'])) {
EventBus::dispatch('movement.post_gl', [
'movement_type' => $typeCode,
'debit_account' => $specs['debit_account'],
'credit_account' => $specs['credit_account'],
'amount' => $data['total_amount'] ?? '0.00',
'description' => $type['name_ar'] . ' - ' . $documentNumber,
]);
}
if ($type['creates_ar'] && !empty($data['member_id'])) {
\App\Modules\Accounting\Services\AccountStatementService::recordCustomerTransaction([
'member_id' => (int) $data['member_id'],
'transaction_date' => $data['date'] ?? date('Y-m-d'),
'document_type' => 'sale',
'document_number' => $documentNumber,
'description' => $type['name_ar'],
'debit' => $data['total_amount'] ?? '0.00',
]);
}
if ($type['creates_ap'] && !empty($data['supplier_id'])) {
\App\Modules\Accounting\Services\AccountStatementService::recordSupplierTransaction([
'supplier_id' => (int) $data['supplier_id'],
'transaction_date' => $data['date'] ?? date('Y-m-d'),
'document_type' => 'invoice',
'document_number' => $documentNumber,
'description' => $type['name_ar'],
'credit' => $data['total_amount'] ?? '0.00',
]);
}
$db->query(
"UPDATE movement_types SET auto_number_next = auto_number_next + 1 WHERE id = ?",
[(int) $type['id']]
);
$db->commit();
EventBus::dispatch('movement.executed', [
'type_code' => $typeCode,
'document_number' => $documentNumber,
'data' => $data,
]);
return ['success' => true, 'document_number' => $documentNumber];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function getAllTypes(?string $module = null): array
{
$db = App::getInstance()->db();
if ($module) {
return $db->select(
"SELECT * FROM movement_types WHERE module = ? AND is_active = 1 ORDER BY sort_order",
[$module]
);
}
return $db->select("SELECT * FROM movement_types WHERE is_active = 1 ORDER BY module, sort_order");
}
private static function generateNumber(array $type): string
{
$prefix = $type['auto_number_prefix'] ?? strtoupper(substr($type['code'], 0, 3));
$next = (int) $type['auto_number_next'];
return $prefix . '-' . date('Ymd') . '-' . str_pad((string) $next, 5, '0', STR_PAD_LEFT);
}
private static function castValue(?string $value, string $type): mixed
{
if ($value === null) return null;
return match ($type) {
'boolean' => $value === '1' || $value === 'true',
'integer' => (int) $value,
'decimal' => $value,
'json' => json_decode($value, true),
default => $value,
};
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
final class OpeningBalanceService
{
public static function saveBalances(int $warehouseId, string $balanceDate, array $items): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
if (empty($items)) {
return ['success' => false, 'error' => 'يجب إضافة صنف واحد على الأقل'];
}
$db->beginTransaction();
try {
$savedCount = 0;
foreach ($items as $item) {
$itemId = (int) ($item['item_id'] ?? 0);
if ($itemId <= 0) continue;
$quantity = (string) ($item['quantity'] ?? '0');
$unitCost = (string) ($item['unit_cost'] ?? '0');
$totalCost = bcmul($quantity, $unitCost, 2);
$existing = $db->selectOne(
"SELECT id FROM inventory_opening_balances WHERE item_id = ? AND warehouse_id = ? AND balance_date = ?",
[$itemId, $warehouseId, $balanceDate]
);
if ($existing) {
$db->update('inventory_opening_balances', [
'quantity' => $quantity,
'unit_cost' => $unitCost,
'total_cost' => $totalCost,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $existing['id']]);
} else {
$db->insert('inventory_opening_balances', [
'item_id' => $itemId,
'warehouse_id' => $warehouseId,
'balance_date' => $balanceDate,
'quantity' => $quantity,
'unit_cost' => $unitCost,
'total_cost' => $totalCost,
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
// Also update the actual stock with an opening stock movement
StockService::moveStock([
'item_id' => $itemId,
'warehouse_id' => $warehouseId,
'movement_type' => 'opening_balance',
'direction' => 'in',
'quantity' => $quantity,
'unit_cost' => $unitCost,
'reference_type' => 'opening_balance',
'notes' => 'رصيد افتتاحي — ' . $balanceDate,
]);
$savedCount++;
}
$db->commit();
EventBus::dispatch('inventory.opening_balance_saved', [
'warehouse_id' => $warehouseId,
'balance_date' => $balanceDate,
'items_count' => $savedCount,
]);
Logger::info("Opening balances saved: {$savedCount} items in warehouse #{$warehouseId}");
return ['success' => true, 'saved_count' => $savedCount];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function getBalances(int $warehouseId, ?string $balanceDate = null): array
{
$db = App::getInstance()->db();
$sql = "SELECT ob.*, i.name_ar, i.sku
FROM inventory_opening_balances ob
JOIN inventory_items i ON i.id = ob.item_id
WHERE ob.warehouse_id = ?";
$params = [$warehouseId];
if ($balanceDate) {
$sql .= " AND ob.balance_date = ?";
$params[] = $balanceDate;
}
$sql .= " ORDER BY i.name_ar";
return $db->select($sql, $params);
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>عهد الأصول<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/inventory/assets/custody" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:200px;">
<label class="form-label" style="font-size:12px;">الموظف</label>
<select name="employee_id" class="form-select">
<option value="">جميع الموظفين</option>
<?php foreach ($employees as $emp): ?>
<option value="<?= (int) $emp['id'] ?>" <?= ($employee_id ?? '') == $emp['id'] ? 'selected' : '' ?>><?= e($emp['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="flex:1;min-width:200px;">
<label class="form-label" style="font-size:12px;">الموقع</label>
<input type="text" name="location" value="<?= e($location ?? '') ?>" placeholder="ابحث بالموقع..." class="form-input">
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
</form>
</div>
<!-- Assets Table -->
<?php if (!empty($assets)): ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>رقم الأصل</th>
<th>اسم الصنف</th>
<th>المستلم</th>
<th>الموقع</th>
<th>تاريخ التسليم</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($assets as $asset): ?>
<tr>
<td><code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($asset['asset_tag']) ?></code></td>
<td style="font-weight:600;"><?= e($asset['item_name']) ?></td>
<td><?= e($asset['custodian_name'] ?? '—') ?></td>
<td><?= e($asset['site_location'] ?? '—') ?></td>
<td><?= e($asset['custody_date'] ?? '—') ?></td>
<td>
<a href="/inventory/assets/<?= (int) $asset['id'] ?>/transfer" class="btn btn-sm btn-primary" style="font-size:12px;">
<i data-lucide="arrow-left-right" style="width:13px;height:13px;vertical-align:middle;margin-left:3px;"></i> نقل العهدة
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="box" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد عهد أصول</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0;">
<?php if (!empty($employee_id) || !empty($location)): ?>
لا توجد نتائج مطابقة لبحثك. جرب تغيير معايير البحث.
<?php else: ?>
لم يتم تسجيل أي عهد أصول بعد.
<?php endif; ?>
</p>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>نقل عهدة أصل<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/inventory/assets/custody" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة لقائمة العهد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Current Custody Info -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="box" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">بيانات الأصل الحالية</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:20px;">
<div>
<label style="font-size:12px;color:#6B7280;display:block;margin-bottom:4px;">رقم الأصل</label>
<code style="font-size:13px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($asset['asset_tag']) ?></code>
</div>
<div>
<label style="font-size:12px;color:#6B7280;display:block;margin-bottom:4px;">اسم الصنف</label>
<span style="font-weight:600;"><?= e($asset['item_name']) ?></span>
</div>
<div>
<label style="font-size:12px;color:#6B7280;display:block;margin-bottom:4px;">المستلم الحالي</label>
<span style="font-weight:600;color:#D97706;"><?= e($asset['custodian_name'] ?? 'غير مسند') ?></span>
</div>
<div>
<label style="font-size:12px;color:#6B7280;display:block;margin-bottom:4px;">الموقع الحالي</label>
<span style="font-weight:600;"><?= e($asset['site_location'] ?? '—') ?></span>
</div>
</div>
</div>
</div>
<!-- Transfer Form -->
<form method="POST" action="/inventory/assets/<?= (int) $asset['id'] ?>/transfer" id="transferForm">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="arrow-left-right" style="width:18px;height:18px;color:#059669;"></i>
<h3 style="margin:0;color:#059669;font-size:15px;">بيانات النقل</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">الموظف الجديد <span style="color:#DC2626;">*</span></label>
<select name="new_employee_id" class="form-select" required>
<option value="">-- اختر الموظف --</option>
<?php foreach ($employees as $emp): ?>
<option value="<?= (int) $emp['id'] ?>"><?= e($emp['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">الموقع الجديد <span style="color:#DC2626;">*</span></label>
<input type="text" name="new_location" class="form-input" required placeholder="الموقع الجديد للأصل">
</div>
<div>
<label class="form-label">تاريخ النقل <span style="color:#DC2626;">*</span></label>
<input type="date" name="transfer_date" class="form-input" value="<?= e(date('Y-m-d')) ?>" required>
</div>
<div>
<label class="form-label">سبب النقل</label>
<input type="text" name="reason" class="form-input" placeholder="سبب نقل العهدة">
</div>
</div>
</div>
</div>
<div style="text-align:left;">
<button type="submit" class="btn btn-primary" style="padding:10px 30px;">
<i data-lucide="check" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> تنفيذ النقل
</button>
</div>
</form>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= isset($bom) ? 'تعديل قائمة المواد' : 'قائمة مواد جديدة' ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/inventory/bom" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة للقائمة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$isEdit = isset($bom);
$action = $isEdit ? '/inventory/bom/' . (int) $bom['id'] . '/update' : '/inventory/bom';
?>
<form method="POST" action="<?= $action ?>" id="bomForm">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="layers" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">بيانات القائمة</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;">
<div>
<label class="form-label">الصنف المنتج <span style="color:#DC2626;">*</span></label>
<select name="item_id" class="form-select" required>
<option value="">-- اختر الصنف --</option>
<?php foreach ($items as $item): ?>
<option value="<?= (int) $item['id'] ?>" <?= ($bom['item_id'] ?? '') == $item['id'] ? 'selected' : '' ?>><?= e($item['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">اسم القائمة <span style="color:#DC2626;">*</span></label>
<input type="text" name="name" class="form-input" value="<?= e($bom['name'] ?? '') ?>" required placeholder="اسم قائمة المواد">
</div>
<div>
<label class="form-label">كمية الإنتاج <span style="color:#DC2626;">*</span></label>
<input type="number" name="output_quantity" class="form-input" step="0.001" min="0.001" value="<?= e($bom['output_quantity'] ?? '1') ?>" required>
</div>
</div>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:8px;">
<i data-lucide="package" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">المكونات</h3>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick="addComponentRow()">
<i data-lucide="plus" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> إضافة مكون
</button>
</div>
<div class="table-responsive">
<table class="data-table" id="componentsTable">
<thead>
<tr>
<th>الصنف</th>
<th style="width:100px;">الكمية</th>
<th style="width:100px;">الوحدة</th>
<th style="width:100px;">نسبة الهالك %</th>
<th style="width:50px;"></th>
</tr>
</thead>
<tbody id="componentsBody"></tbody>
</table>
</div>
</div>
<div style="text-align:left;">
<button type="submit" class="btn btn-primary" style="padding:10px 30px;">
<i data-lucide="save" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> حفظ القائمة
</button>
</div>
</form>
<script>
var compRowIdx = 0;
var itemsData = <?= json_encode($items) ?>;
function addComponentRow(data) {
data = data || {};
var idx = compRowIdx++;
var tr = document.createElement('tr');
var itemOptions = '<option value="">-- اختر المكون --</option>';
itemsData.forEach(function(item) {
var selected = (data.item_id && data.item_id == item.id) ? 'selected' : '';
itemOptions += '<option value="' + item.id + '" ' + selected + '>' + item.name_ar + '</option>';
});
tr.innerHTML = ''
+ '<td><select name="components['+idx+'][item_id]" class="form-select" required>' + itemOptions + '</select></td>'
+ '<td><input type="number" name="components['+idx+'][quantity]" class="form-input" step="0.001" min="0.001" value="'+(data.quantity||'')+'" required></td>'
+ '<td><input type="text" name="components['+idx+'][unit]" class="form-input" value="'+(data.unit||'')+'" placeholder="وحدة"></td>'
+ '<td><input type="number" name="components['+idx+'][waste_percent]" class="form-input" step="0.01" min="0" max="100" value="'+(data.waste_percent||'0')+'"></td>'
+ '<td><button type="button" onclick="this.closest(\'tr\').remove();" style="background:none;border:none;cursor:pointer;color:#DC2626;"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>';
document.getElementById('componentsBody').appendChild(tr);
if (typeof lucide !== 'undefined') lucide.createIcons();
}
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
<?php if ($isEdit && !empty($components)): ?>
<?= json_encode($components) ?>.forEach(function(comp) { addComponentRow(comp); });
<?php else: ?>
addComponentRow();
<?php endif; ?>
});
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>قوائم المواد (BOM)<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/inventory/bom/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> قائمة مواد جديدة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php if (!empty($boms)): ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>كود القائمة</th>
<th>اسم الصنف</th>
<th>الإصدار</th>
<th>عدد المكونات</th>
<th>كمية الإنتاج</th>
<th>الحالة</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($boms as $bom): ?>
<tr>
<td><code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($bom['bom_code']) ?></code></td>
<td style="font-weight:600;"><?= e($bom['item_name']) ?></td>
<td><?= e($bom['version']) ?></td>
<td><?= (int) $bom['component_count'] ?></td>
<td><?= e($bom['output_quantity']) ?></td>
<td>
<?php if (($bom['status'] ?? '') === 'active'): ?>
<span style="display:inline-block;padding:2px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#D1FAE5;color:#059669;">نشط</span>
<?php elseif (($bom['status'] ?? '') === 'draft'): ?>
<span style="display:inline-block;padding:2px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#FEF3C7;color:#D97706;">مسودة</span>
<?php else: ?>
<span style="display:inline-block;padding:2px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#F3F4F6;color:#6B7280;"><?= e($bom['status'] ?? '—') ?></span>
<?php endif; ?>
</td>
<td>
<div style="display:flex;gap:5px;">
<a href="/inventory/bom/<?= (int) $bom['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;">
<i data-lucide="eye" style="width:13px;height:13px;vertical-align:middle;margin-left:3px;"></i> عرض
</a>
<a href="/inventory/bom/<?= (int) $bom['id'] ?>/edit" class="btn btn-sm btn-outline" style="font-size:12px;">
<i data-lucide="edit-3" style="width:13px;height:13px;vertical-align:middle;margin-left:3px;"></i> تعديل
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="layers" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد قوائم مواد</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0 0 20px;">ابدأ بإضافة قائمة مواد جديدة لتتبع مكونات التصنيع.</p>
<a href="/inventory/bom/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> قائمة مواد جديدة</a>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تفاصيل قائمة المواد<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/inventory/bom" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة للقائمة</a>
<a href="/inventory/bom/<?= (int) $bom['id'] ?>/edit" class="btn btn-primary"><i data-lucide="edit-3" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> تعديل</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- BOM Info Card -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="layers" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">بيانات قائمة المواد</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:20px;">
<div>
<label style="font-size:12px;color:#6B7280;display:block;margin-bottom:4px;">اسم الصنف</label>
<span style="font-weight:600;font-size:15px;"><?= e($bom['item_name']) ?></span>
</div>
<div>
<label style="font-size:12px;color:#6B7280;display:block;margin-bottom:4px;">كود القائمة</label>
<code style="font-size:13px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($bom['bom_code']) ?></code>
</div>
<div>
<label style="font-size:12px;color:#6B7280;display:block;margin-bottom:4px;">الإصدار</label>
<span style="font-weight:600;"><?= e($bom['version']) ?></span>
</div>
<div>
<label style="font-size:12px;color:#6B7280;display:block;margin-bottom:4px;">كمية الإنتاج</label>
<span style="font-weight:600;"><?= e($bom['output_quantity']) ?></span>
</div>
</div>
</div>
</div>
<!-- Components Table -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="package" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">المكونات</h3>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>اسم المكون</th>
<th>الكمية</th>
<th>الوحدة</th>
<th>نسبة الهالك %</th>
<th>التكلفة</th>
</tr>
</thead>
<tbody>
<?php foreach ($components as $i => $comp): ?>
<tr>
<td><?= $i + 1 ?></td>
<td style="font-weight:600;"><?= e($comp['component_name']) ?></td>
<td><?= e($comp['quantity']) ?></td>
<td><?= e($comp['unit']) ?></td>
<td><?= e($comp['waste_percent'] ?? '0') ?>%</td>
<td style="direction:ltr;text-align:left;"><?= money($comp['cost'] ?? 0) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Total Cost Card -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="calculator" style="width:18px;height:18px;color:#059669;"></i>
<h3 style="margin:0;color:#059669;font-size:15px;">ملخص التكاليف</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;">
<div style="text-align:center;padding:15px;background:#F0FDF4;border-radius:8px;">
<label style="font-size:12px;color:#6B7280;display:block;margin-bottom:8px;">إجمالي تكلفة المكونات</label>
<span style="font-size:20px;font-weight:700;color:#059669;"><?= money($costData['total_components_cost'] ?? 0) ?></span>
</div>
<div style="text-align:center;padding:15px;background:#F0FDF4;border-radius:8px;">
<label style="font-size:12px;color:#6B7280;display:block;margin-bottom:8px;">كمية الإنتاج</label>
<span style="font-size:20px;font-weight:700;color:#059669;"><?= e($bom['output_quantity']) ?></span>
</div>
<div style="text-align:center;padding:15px;background:#ECFDF5;border-radius:8px;border:2px solid #059669;">
<label style="font-size:12px;color:#6B7280;display:block;margin-bottom:8px;">تكلفة الوحدة</label>
<span style="font-size:20px;font-weight:700;color:#059669;"><?= money($costData['cost_per_unit'] ?? 0) ?></span>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إدخال أرصدة افتتاحية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/inventory/opening-balances" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة للقائمة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/inventory/opening-balances" id="openingBalanceForm">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="archive" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">بيانات الرصيد الافتتاحي</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">المخزن <span style="color:#DC2626;">*</span></label>
<select name="warehouse_id" class="form-select" required>
<option value="">-- اختر المخزن --</option>
<?php foreach ($warehouses as $wh): ?>
<option value="<?= (int) $wh['id'] ?>"><?= e($wh['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">تاريخ الرصيد <span style="color:#DC2626;">*</span></label>
<input type="date" name="balance_date" class="form-input" value="<?= e(date('Y-m-d')) ?>" required>
</div>
</div>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:8px;">
<i data-lucide="package" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">الأصناف</h3>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick="addBalanceRow()">
<i data-lucide="plus" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> إضافة صنف
</button>
</div>
<div class="table-responsive">
<table class="data-table" id="balanceTable">
<thead>
<tr>
<th>الصنف</th>
<th style="width:120px;">الكمية</th>
<th style="width:120px;">تكلفة الوحدة</th>
<th style="width:120px;">الإجمالي</th>
<th style="width:50px;"></th>
</tr>
</thead>
<tbody id="balanceBody"></tbody>
<tfoot>
<tr>
<td colspan="3" style="text-align:left;font-weight:700;">الإجمالي الكلي</td>
<td style="font-weight:800;direction:ltr;text-align:left;" id="balanceGrandTotal">0.00</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div style="text-align:left;">
<button type="submit" class="btn btn-primary" style="padding:10px 30px;">
<i data-lucide="save" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> حفظ الأرصدة
</button>
</div>
</form>
<script>
var balRowIdx = 0;
var itemsData = <?= json_encode($items) ?>;
function addBalanceRow(data) {
data = data || {};
var idx = balRowIdx++;
var tr = document.createElement('tr');
var itemOptions = '<option value="">-- اختر الصنف --</option>';
itemsData.forEach(function(item) {
var selected = (data.item_id && data.item_id == item.id) ? 'selected' : '';
itemOptions += '<option value="' + item.id + '" ' + selected + '>' + item.name_ar + '</option>';
});
tr.innerHTML = ''
+ '<td><select name="items['+idx+'][item_id]" class="form-select" required>' + itemOptions + '</select></td>'
+ '<td><input type="number" name="items['+idx+'][quantity]" class="form-input" step="0.001" min="0.001" value="'+(data.quantity||'')+'" onchange="calcBalanceRow('+idx+')" required></td>'
+ '<td><input type="number" name="items['+idx+'][unit_cost]" class="form-input" step="0.01" min="0" value="'+(data.unit_cost||'')+'" onchange="calcBalanceRow('+idx+')" required></td>'
+ '<td style="font-weight:700;direction:ltr;text-align:left;" id="balLine_'+idx+'">0.00</td>'
+ '<td><button type="button" onclick="this.closest(\'tr\').remove();calcBalanceGrand();" style="background:none;border:none;cursor:pointer;color:#DC2626;"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>';
document.getElementById('balanceBody').appendChild(tr);
if (typeof lucide !== 'undefined') lucide.createIcons();
calcBalanceRow(idx);
}
function calcBalanceRow(idx) {
var q = parseFloat(document.querySelector('[name="items['+idx+'][quantity]"]').value) || 0;
var c = parseFloat(document.querySelector('[name="items['+idx+'][unit_cost]"]').value) || 0;
var el = document.getElementById('balLine_' + idx);
if (el) el.textContent = (q * c).toFixed(2);
calcBalanceGrand();
}
function calcBalanceGrand() {
var total = 0;
document.querySelectorAll('[id^="balLine_"]').forEach(function(el) { total += parseFloat(el.textContent) || 0; });
document.getElementById('balanceGrandTotal').textContent = total.toFixed(2);
}
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
addBalanceRow();
});
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>أرصدة افتتاحية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/inventory/opening-balances/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> رصيد افتتاحي جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Warehouse Filter -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/inventory/opening-balances" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:200px;">
<label class="form-label" style="font-size:12px;">المخزن</label>
<select name="warehouse_id" class="form-select">
<option value="">جميع المخازن</option>
<?php foreach ($warehouses as $wh): ?>
<option value="<?= (int) $wh['id'] ?>" <?= ($warehouse_id ?? '') == $wh['id'] ? 'selected' : '' ?>><?= e($wh['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> عرض</button>
</form>
</div>
<!-- Balances Table -->
<?php if (!empty($balances)): ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>اسم الصنف</th>
<th>SKU</th>
<th>المخزن</th>
<th>الكمية</th>
<th>تكلفة الوحدة</th>
<th>إجمالي التكلفة</th>
<th>تاريخ الرصيد</th>
</tr>
</thead>
<tbody>
<?php foreach ($balances as $bal): ?>
<tr>
<td style="font-weight:600;"><?= e($bal['item_name']) ?></td>
<td><code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($bal['sku']) ?></code></td>
<td><?= e($bal['warehouse_name']) ?></td>
<td><?= e($bal['quantity']) ?></td>
<td style="direction:ltr;text-align:left;"><?= money($bal['unit_cost'] ?? 0) ?></td>
<td style="direction:ltr;text-align:left;font-weight:600;"><?= money($bal['total_cost'] ?? 0) ?></td>
<td><?= e($bal['balance_date'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="archive" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد أرصدة افتتاحية</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0 0 20px;">ابدأ بإدخال الأرصدة الافتتاحية للأصناف في المخازن.</p>
<a href="/inventory/opening-balances/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> رصيد افتتاحي جديد</a>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
......@@ -26,6 +26,9 @@ PermissionRegistry::register('inventory', [
'asset.view' => ['ar' => 'عرض الأصول', 'en' => 'View Assets'],
'asset.manage' => ['ar' => 'إدارة الأصول والإهلاك', 'en' => 'Manage Assets & Depreciation'],
'report.inventory' => ['ar' => 'تقارير المخزون', 'en' => 'Inventory Reports'],
'inventory.bom.view' => ['ar' => 'عرض قوائم المواد', 'en' => 'View Bill of Materials'],
'inventory.bom.manage' => ['ar' => 'إدارة قوائم المواد', 'en' => 'Manage Bill of Materials'],
'inventory.opening.manage' => ['ar' => 'أرصدة افتتاحية', 'en' => 'Opening Balances'],
]);
// ────────────────────────────────────────────────────────────
......@@ -50,6 +53,9 @@ MenuRegistry::register('inventory', [
['label_ar' => 'الموردين', 'label_en' => 'Suppliers', 'route' => '/inventory/suppliers', 'permission' => 'supplier.view', 'order' => 7],
['label_ar' => 'أوامر الشراء', 'label_en' => 'Purchase Orders', 'route' => '/inventory/purchase-orders', 'permission' => 'purchase.view', 'order' => 8],
['label_ar' => 'الأصول والإهلاك', 'label_en' => 'Assets', 'route' => '/inventory/assets', 'permission' => 'asset.view', 'order' => 9],
['label_ar' => 'تقارير المخزون', 'label_en' => 'Inventory Reports', 'route' => '/inventory/reports/stock-balance','permission' => 'report.inventory', 'order' => 10],
['label_ar' => 'عهدة الأصول', 'label_en' => 'Asset Custody', 'route' => '/inventory/assets/custody', 'permission' => 'asset.view', 'order' => 10],
['label_ar' => 'قوائم المواد (BOM)', 'label_en' => 'Bill of Materials', 'route' => '/inventory/bom', 'permission' => 'inventory.bom.view','order' => 11],
['label_ar' => 'أرصدة افتتاحية', 'label_en' => 'Opening Balances', 'route' => '/inventory/opening-balances', 'permission' => 'inventory.opening.manage', 'order' => 12],
['label_ar' => 'تقارير المخزون', 'label_en' => 'Inventory Reports', 'route' => '/inventory/reports/stock-balance','permission' => 'report.inventory', 'order' => 13],
],
]);
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class DeliveryTrackingController extends Controller
{
public function overdueDeliveries(Request $request): Response
{
$this->authorize('procurement.report');
$db = App::getInstance()->db();
$supplierId = (int) $request->get('supplier_id', 0);
$where = "poi.expected_delivery_date < CURDATE() AND poi.delivery_status IN ('pending','partial')";
$params = [];
if ($supplierId) {
$where .= ' AND po.supplier_id = ?';
$params[] = $supplierId;
}
$overdue = $db->select(
"SELECT poi.*, po.po_number, po.supplier_id, s.name_ar as supplier_name,
i.name_ar as item_name, i.sku,
DATEDIFF(CURDATE(), poi.expected_delivery_date) as days_overdue
FROM purchase_order_items poi
JOIN purchase_orders po ON po.id = poi.purchase_order_id
JOIN suppliers s ON s.id = po.supplier_id
JOIN inventory_items i ON i.id = poi.item_id
WHERE {$where}
ORDER BY days_overdue DESC",
$params
);
$suppliers = $db->select("SELECT id, name_ar FROM suppliers WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Procurement.Views.reports.overdue_deliveries', [
'overdue' => $overdue,
'suppliers' => $suppliers,
'supplier_id' => $supplierId,
]);
}
public function poBalance(Request $request): Response
{
$this->authorize('procurement.report');
$db = App::getInstance()->db();
$supplierId = (int) $request->get('supplier_id', 0);
$where = "po.status IN ('approved','partial')";
$params = [];
if ($supplierId) {
$where .= ' AND po.supplier_id = ?';
$params[] = $supplierId;
}
$orders = $db->select(
"SELECT po.id, po.po_number, po.created_at as order_date, po.total_amount, po.status,
s.name_ar as supplier_name,
SUM(poi.quantity) as total_qty_ordered,
SUM(poi.qty_received) as total_qty_received,
SUM(poi.quantity - poi.qty_received) as total_qty_remaining,
CASE WHEN SUM(poi.quantity) > 0
THEN ROUND((SUM(poi.qty_received) / SUM(poi.quantity)) * 100, 1)
ELSE 0 END as delivery_percentage
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
JOIN purchase_order_items poi ON poi.purchase_order_id = po.id
WHERE {$where}
GROUP BY po.id
ORDER BY delivery_percentage ASC",
$params
);
$suppliers = $db->select("SELECT id, name_ar FROM suppliers WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Procurement.Views.reports.po_balance', [
'orders' => $orders,
'suppliers' => $suppliers,
'supplier_id' => $supplierId,
]);
}
public function priceDeviation(Request $request): Response
{
$this->authorize('procurement.report');
$db = App::getInstance()->db();
$threshold = (float) $request->get('threshold', 10);
$deviations = $db->select(
"SELECT iph.item_id, i.name_ar as item_name, i.sku, s.name_ar as supplier_name,
iph.price as actual_price, iph.effective_date,
(SELECT AVG(price) FROM item_price_history WHERE item_id = iph.item_id AND price_type = 'purchase') as avg_price,
ROUND(((iph.price - (SELECT AVG(price) FROM item_price_history WHERE item_id = iph.item_id AND price_type = 'purchase'))
/ NULLIF((SELECT AVG(price) FROM item_price_history WHERE item_id = iph.item_id AND price_type = 'purchase'), 0)) * 100, 1) as deviation_pct
FROM item_price_history iph
JOIN inventory_items i ON i.id = iph.item_id
LEFT JOIN suppliers s ON s.id = iph.supplier_id
WHERE iph.price_type = 'purchase'
HAVING ABS(deviation_pct) > ?
ORDER BY ABS(deviation_pct) DESC
LIMIT 100",
[$threshold]
);
return $this->view('Procurement.Views.reports.price_deviation', [
'deviations' => $deviations,
'threshold' => $threshold,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Procurement\Services\QuoteService;
class QuoteController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('procurement.quote.view');
$db = App::getInstance()->db();
$status = $request->get('status', '');
$where = '1=1';
$params = [];
if ($status) {
$where .= ' AND q.status = ?';
$params[] = $status;
}
$quotes = $db->select(
"SELECT q.*, s.name_ar as supplier_name, s.code as supplier_code
FROM supplier_price_quotes q
JOIN suppliers s ON s.id = q.supplier_id
WHERE {$where}
ORDER BY q.created_at DESC",
$params
);
return $this->view('Procurement.Views.quotes.index', [
'quotes' => $quotes,
'status' => $status,
]);
}
public function createRequest(Request $request): Response
{
$this->authorize('procurement.quote.create');
$db = App::getInstance()->db();
$requisitionId = (int) $request->get('requisition_id', 0);
$requisition = null;
$requisitionItems = [];
if ($requisitionId) {
$requisition = $db->selectOne("SELECT * FROM purchase_requisitions WHERE id = ?", [$requisitionId]);
$requisitionItems = $db->select(
"SELECT pri.*, i.name_ar as item_name, i.sku
FROM purchase_requisition_items pri
JOIN inventory_items i ON i.id = pri.item_id
WHERE pri.requisition_id = ?",
[$requisitionId]
);
}
$suppliers = $db->select("SELECT id, name_ar, code FROM suppliers WHERE is_archived = 0 AND is_active = 1 ORDER BY name_ar");
$requisitions = $db->select("SELECT id, requisition_number, status FROM purchase_requisitions WHERE status = 'approved' ORDER BY created_at DESC LIMIT 50");
return $this->view('Procurement.Views.quotes.request_form', [
'requisition' => $requisition,
'requisitionItems' => $requisitionItems,
'suppliers' => $suppliers,
'requisitions' => $requisitions,
]);
}
public function storeRequest(Request $request): Response
{
$this->authorize('procurement.quote.create');
$requisitionId = (int) $request->post('requisition_id');
$supplierIds = $request->post('supplier_ids', []);
if (empty($supplierIds)) {
return $this->redirect('/procurement/quotes/request?requisition_id=' . $requisitionId)->withError('يجب اختيار مورد واحد على الأقل');
}
$result = QuoteService::createQuoteRequest($requisitionId, $supplierIds);
if (!$result['success']) {
return $this->redirect('/procurement/quotes/request?requisition_id=' . $requisitionId)->withError($result['error']);
}
return $this->redirect('/procurement/quotes')->withSuccess('تم إرسال طلبات عروض الأسعار بنجاح — عدد: ' . count($result['quote_ids']));
}
public function recordResponse(Request $request, string $id): Response
{
$this->authorize('procurement.quote.create');
$db = App::getInstance()->db();
$quote = $db->selectOne(
"SELECT q.*, s.name_ar as supplier_name
FROM supplier_price_quotes q
JOIN suppliers s ON s.id = q.supplier_id
WHERE q.id = ?",
[(int) $id]
);
if (!$quote) {
return $this->redirect('/procurement/quotes')->withError('عرض السعر غير موجود');
}
$items = $db->select(
"SELECT qi.*, i.name_ar as item_name, i.sku
FROM supplier_quote_items qi
JOIN inventory_items i ON i.id = qi.item_id
WHERE qi.quote_id = ?",
[(int) $id]
);
return $this->view('Procurement.Views.quotes.response_form', [
'quote' => $quote,
'items' => $items,
]);
}
public function storeResponse(Request $request, string $id): Response
{
$this->authorize('procurement.quote.create');
$itemIds = $request->post('quote_item_ids', []);
$unitPrices = $request->post('unit_prices', []);
$quantities = $request->post('quantities', []);
$deliveryDays = $request->post('delivery_days', []);
$items = [];
for ($i = 0; $i < count($itemIds); $i++) {
$items[] = [
'quote_item_id' => (int) $itemIds[$i],
'unit_price' => $unitPrices[$i] ?? '0',
'quantity' => $quantities[$i] ?? '0',
'delivery_days' => $deliveryDays[$i] ?? null,
];
}
$terms = [
'delivery_terms' => $request->post('delivery_terms'),
'payment_terms' => $request->post('payment_terms'),
'expiry_date' => $request->post('expiry_date'),
];
$result = QuoteService::recordQuoteResponse((int) $id, $items, $terms);
if (!$result['success']) {
return $this->redirect('/procurement/quotes/' . $id . '/response')->withError($result['error']);
}
return $this->redirect('/procurement/quotes')->withSuccess('تم تسجيل رد عرض السعر بنجاح');
}
public function compare(Request $request): Response
{
$this->authorize('procurement.quote.evaluate');
$requisitionId = (int) $request->get('requisition_id');
$comparison = QuoteService::getComparisonData($requisitionId);
$db = App::getInstance()->db();
$requisition = $db->selectOne("SELECT * FROM purchase_requisitions WHERE id = ?", [$requisitionId]);
return $this->view('Procurement.Views.quotes.compare', [
'requisition' => $requisition,
'comparison' => $comparison,
]);
}
public function evaluate(Request $request): Response
{
$this->authorize('procurement.quote.evaluate');
$requisitionId = (int) $request->post('requisition_id');
$winningQuoteId = (int) $request->post('winning_quote_id');
$justification = (string) $request->post('justification');
$result = QuoteService::evaluateQuotes($requisitionId, $winningQuoteId, $justification);
if (!$result['success']) {
return $this->redirect('/procurement/quotes/compare?requisition_id=' . $requisitionId)->withError($result['error']);
}
return $this->redirect('/procurement/quotes')->withSuccess('تم تقييم واختيار عرض السعر الفائز');
}
public function convertToPo(Request $request, string $evaluationId): Response
{
$this->authorize('procurement.pr.convert');
$result = QuoteService::convertToPurchaseOrder((int) $evaluationId);
if (!$result['success']) {
return $this->redirect('/procurement/quotes')->withError($result['error']);
}
return $this->redirect('/inventory/purchase-orders/' . $result['po_id'])->withSuccess('تم إنشاء أمر الشراء بنجاح — رقم: ' . $result['po_number']);
}
}
......@@ -69,6 +69,21 @@ return [
['POST', '/procurement/rtv/{id}/complete', ReturnToVendorController::class . '@complete', ['auth', 'csrf'], 'procurement.rtv.manage'],
['POST', '/procurement/rtv/{id}/cancel', ReturnToVendorController::class . '@cancel', ['auth', 'csrf'], 'procurement.rtv.create'],
// ── Supplier Price Quotes ──
['GET', '/procurement/quotes', 'Procurement\Controllers\QuoteController@index', ['auth'], 'procurement.quote.view'],
['GET', '/procurement/quotes/request', 'Procurement\Controllers\QuoteController@createRequest', ['auth'], 'procurement.quote.create'],
['POST', '/procurement/quotes/request', 'Procurement\Controllers\QuoteController@storeRequest', ['auth', 'csrf'], 'procurement.quote.create'],
['GET', '/procurement/quotes/{id:\d+}/response', 'Procurement\Controllers\QuoteController@recordResponse', ['auth'], 'procurement.quote.create'],
['POST', '/procurement/quotes/{id:\d+}/response', 'Procurement\Controllers\QuoteController@storeResponse', ['auth', 'csrf'], 'procurement.quote.create'],
['GET', '/procurement/quotes/compare', 'Procurement\Controllers\QuoteController@compare', ['auth'], 'procurement.quote.evaluate'],
['POST', '/procurement/quotes/evaluate', 'Procurement\Controllers\QuoteController@evaluate', ['auth', 'csrf'], 'procurement.quote.evaluate'],
['POST', '/procurement/quotes/evaluations/{id:\d+}/convert', 'Procurement\Controllers\QuoteController@convertToPo', ['auth', 'csrf'], 'procurement.pr.convert'],
// ── Delivery Tracking & Reports ──
['GET', '/procurement/reports/overdue-deliveries', 'Procurement\Controllers\DeliveryTrackingController@overdueDeliveries', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/po-balance', 'Procurement\Controllers\DeliveryTrackingController@poBalance', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/price-deviation', 'Procurement\Controllers\DeliveryTrackingController@priceDeviation', ['auth'], 'procurement.report'],
// ── Reports ──
['GET', '/procurement/reports/purchase-volume', ProcurementReportController::class . '@purchaseVolume', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/supplier-performance', ProcurementReportController::class . '@supplierPerformance', ['auth'], 'procurement.report'],
......
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Services;
use App\Core\App;
use App\Core\EventBus;
final class ItemPriceTrackingService
{
public static function registerListeners(): void
{
EventBus::listen('procurement.invoice_approved', [self::class, 'onInvoiceApproved'], 60);
}
public static function onInvoiceApproved(array $data): void
{
$db = App::getInstance()->db();
$invoiceId = (int) ($data['invoice_id'] ?? 0);
if ($invoiceId <= 0) return;
$items = $db->select(
"SELECT item_id, unit_cost FROM vendor_invoice_items WHERE invoice_id = ? AND item_id IS NOT NULL",
[$invoiceId]
);
$supplierId = (int) ($data['supplier_id'] ?? 0);
$invoiceDate = date('Y-m-d');
foreach ($items as $item) {
$db->insert('item_price_history', [
'item_id' => (int) $item['item_id'],
'supplier_id' => $supplierId,
'price' => (string) $item['unit_cost'],
'price_date' => $invoiceDate,
'source' => 'invoice',
'reference_id' => $invoiceId,
'created_at' => date('Y-m-d H:i:s'),
]);
}
}
public static function getLatestPrice(int $itemId, ?int $supplierId = null): ?array
{
$db = App::getInstance()->db();
$sql = "SELECT * FROM item_price_history WHERE item_id = ?";
$params = [$itemId];
if ($supplierId) {
$sql .= " AND supplier_id = ?";
$params[] = $supplierId;
}
$sql .= " ORDER BY price_date DESC, id DESC LIMIT 1";
return $db->selectOne($sql, $params);
}
public static function getPriceDeviation(int $itemId, string $currentPrice): array
{
$db = App::getInstance()->db();
$avgRow = $db->selectOne(
"SELECT AVG(price) as avg_price, MIN(price) as min_price, MAX(price) as max_price
FROM item_price_history WHERE item_id = ? AND price_date >= DATE_SUB(NOW(), INTERVAL 6 MONTH)",
[$itemId]
);
if (!$avgRow || !$avgRow['avg_price']) {
return ['has_history' => false];
}
$avgPrice = (string) $avgRow['avg_price'];
$deviation = bccomp($avgPrice, '0', 2) > 0
? bcmul(bcdiv(bcsub($currentPrice, $avgPrice, 4), $avgPrice, 4), '100', 2)
: '0.00';
return [
'has_history' => true,
'avg_price' => $avgPrice,
'min_price' => (string) $avgRow['min_price'],
'max_price' => (string) $avgRow['max_price'],
'current_price' => $currentPrice,
'deviation_pct' => $deviation,
];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Services;
use App\Core\App;
use App\Core\EventBus;
final class QuoteService
{
public static function createQuoteRequest(int $requisitionId, array $supplierIds): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$requisition = $db->selectOne("SELECT * FROM purchase_requisitions WHERE id = ?", [$requisitionId]);
if (!$requisition) {
return ['success' => false, 'error' => 'طلب الشراء غير موجود'];
}
$items = $db->select(
"SELECT * FROM purchase_requisition_items WHERE requisition_id = ?",
[$requisitionId]
);
$db->beginTransaction();
try {
$quoteIds = [];
foreach ($supplierIds as $supplierId) {
$quoteNumber = 'QR-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$quoteId = $db->insert('supplier_price_quotes', [
'quote_number' => $quoteNumber,
'requisition_id' => $requisitionId,
'supplier_id' => (int) $supplierId,
'request_date' => date('Y-m-d'),
'status' => 'requested',
'created_by' => $employee ? (int) $employee->id : null,
]);
foreach ($items as $item) {
$db->insert('supplier_quote_items', [
'quote_id' => $quoteId,
'item_id' => (int) $item['item_id'],
'item_description' => $item['description'] ?? null,
'quantity' => $item['quantity'],
'unit_price' => '0.00',
'total_price' => '0.00',
]);
}
$quoteIds[] = $quoteId;
}
$db->commit();
EventBus::dispatch('quote.requested', [
'requisition_id' => $requisitionId,
'quote_ids' => $quoteIds,
]);
return ['success' => true, 'quote_ids' => $quoteIds];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function recordQuoteResponse(int $quoteId, array $items, array $terms = []): array
{
$db = App::getInstance()->db();
$quote = $db->selectOne("SELECT * FROM supplier_price_quotes WHERE id = ?", [$quoteId]);
if (!$quote) {
return ['success' => false, 'error' => 'عرض السعر غير موجود'];
}
$db->beginTransaction();
try {
$totalAmount = '0.00';
foreach ($items as $item) {
$total = bcmul((string) $item['unit_price'], (string) $item['quantity'], 2);
$totalAmount = bcadd($totalAmount, $total, 2);
$db->update('supplier_quote_items', [
'unit_price' => $item['unit_price'],
'total_price' => $total,
'delivery_days' => $item['delivery_days'] ?? null,
'notes' => $item['notes'] ?? null,
], 'id = ?', [(int) $item['quote_item_id']]);
}
$db->update('supplier_price_quotes', [
'status' => 'received',
'response_date' => date('Y-m-d'),
'total_amount' => $totalAmount,
'delivery_terms' => $terms['delivery_terms'] ?? null,
'payment_terms' => $terms['payment_terms'] ?? null,
'expiry_date' => $terms['expiry_date'] ?? null,
], 'id = ?', [$quoteId]);
$db->commit();
return ['success' => true, 'total_amount' => $totalAmount];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function evaluateQuotes(int $requisitionId, int $winningQuoteId, string $justification): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$evalNumber = 'EVAL-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$db->beginTransaction();
try {
$evalId = $db->insert('quote_evaluations', [
'evaluation_number' => $evalNumber,
'requisition_id' => $requisitionId,
'evaluation_date' => date('Y-m-d'),
'status' => 'completed',
'winning_quote_id' => $winningQuoteId,
'justification' => $justification,
'evaluated_by' => $employee ? (int) $employee->id : null,
]);
$db->update('supplier_price_quotes', ['status' => 'accepted', 'evaluation_id' => $evalId], 'id = ?', [$winningQuoteId]);
$db->query(
"UPDATE supplier_price_quotes SET status = 'rejected' WHERE requisition_id = ? AND id != ? AND status = 'received'",
[$requisitionId, $winningQuoteId]
);
$db->commit();
EventBus::dispatch('quote.evaluated', [
'evaluation_id' => $evalId,
'winning_quote_id' => $winningQuoteId,
'requisition_id' => $requisitionId,
]);
return ['success' => true, 'evaluation_id' => $evalId];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function convertToPurchaseOrder(int $evaluationId): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$eval = $db->selectOne("SELECT * FROM quote_evaluations WHERE id = ?", [$evaluationId]);
if (!$eval || !$eval['winning_quote_id']) {
return ['success' => false, 'error' => 'التقييم غير موجود أو لم يتم اختيار فائز'];
}
$quote = $db->selectOne("SELECT * FROM supplier_price_quotes WHERE id = ?", [(int) $eval['winning_quote_id']]);
$quoteItems = $db->select("SELECT * FROM supplier_quote_items WHERE quote_id = ?", [(int) $eval['winning_quote_id']]);
$db->beginTransaction();
try {
$poNumber = 'PO-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$poId = $db->insert('purchase_orders', [
'po_number' => $poNumber,
'supplier_id' => (int) $quote['supplier_id'],
'requisition_id' => $quote['requisition_id'],
'status' => 'draft',
'total_amount' => $quote['total_amount'],
'payment_terms' => $quote['payment_terms'],
'delivery_terms' => $quote['delivery_terms'],
'notes' => 'تم إنشاؤه من تقييم عروض الأسعار: ' . $eval['evaluation_number'],
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
]);
foreach ($quoteItems as $qi) {
$db->insert('purchase_order_items', [
'purchase_order_id' => $poId,
'item_id' => (int) $qi['item_id'],
'quantity' => $qi['quantity'],
'unit_price' => $qi['unit_price'],
'total_price' => $qi['total_price'],
'expected_delivery_date' => $qi['delivery_days']
? date('Y-m-d', strtotime("+{$qi['delivery_days']} days"))
: null,
]);
}
$db->update('quote_evaluations', ['purchase_order_id' => $poId, 'status' => 'approved'], 'id = ?', [$evaluationId]);
$db->commit();
EventBus::dispatch('purchase_order.created', ['po_id' => $poId, 'from_evaluation' => $evaluationId]);
return ['success' => true, 'po_id' => $poId, 'po_number' => $poNumber];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function getComparisonData(int $requisitionId): array
{
$db = App::getInstance()->db();
$quotes = $db->select(
"SELECT q.*, s.name_ar as supplier_name, s.code as supplier_code
FROM supplier_price_quotes q
JOIN suppliers s ON s.id = q.supplier_id
WHERE q.requisition_id = ? AND q.status IN ('received','accepted','rejected')
ORDER BY q.total_amount ASC",
[$requisitionId]
);
$items = $db->select(
"SELECT pri.item_id, i.name_ar as item_name, i.sku, pri.quantity
FROM purchase_requisition_items pri
JOIN inventory_items i ON i.id = pri.item_id
WHERE pri.requisition_id = ?",
[$requisitionId]
);
$comparison = [];
foreach ($items as $item) {
$itemQuotes = [];
foreach ($quotes as $quote) {
$qi = $db->selectOne(
"SELECT * FROM supplier_quote_items WHERE quote_id = ? AND item_id = ?",
[(int) $quote['id'], (int) $item['item_id']]
);
$itemQuotes[] = [
'quote_id' => (int) $quote['id'],
'supplier_name' => $quote['supplier_name'],
'unit_price' => $qi['unit_price'] ?? '0.00',
'total_price' => $qi['total_price'] ?? '0.00',
'delivery_days' => $qi['delivery_days'] ?? null,
];
}
$comparison[] = [
'item_id' => (int) $item['item_id'],
'item_name' => $item['item_name'],
'sku' => $item['sku'],
'quantity' => $item['quantity'],
'quotes' => $itemQuotes,
];
}
return ['quotes' => $quotes, 'items' => $items, 'comparison' => $comparison];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Services;
use App\Core\App;
use App\Core\EventBus;
final class SupplierPaymentScheduleService
{
public static function createSchedule(array $data): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$totalAmount = (string) $data['total_amount'];
$installmentsCount = (int) $data['installments_count'];
$startDate = $data['start_date'];
$intervalDays = (int) ($data['interval_days'] ?? 30);
$scheduleNumber = 'SCHED-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$installmentAmount = bcdiv($totalAmount, (string) $installmentsCount, 2);
$remainder = bcsub($totalAmount, bcmul($installmentAmount, (string) $installmentsCount, 2), 2);
$db->beginTransaction();
try {
$scheduleId = $db->insert('supplier_payment_schedules', [
'supplier_id' => (int) $data['supplier_id'],
'invoice_id' => $data['invoice_id'] ?? null,
'schedule_number' => $scheduleNumber,
'total_amount' => $totalAmount,
'installments_count' => $installmentsCount,
'status' => 'active',
'notes' => $data['notes'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
]);
for ($i = 1; $i <= $installmentsCount; $i++) {
$amount = $installmentAmount;
if ($i === $installmentsCount) {
$amount = bcadd($amount, $remainder, 2);
}
$dueDate = date('Y-m-d', strtotime($startDate . ' +' . ($i * $intervalDays) . ' days'));
$db->insert('supplier_payment_schedule_items', [
'schedule_id' => $scheduleId,
'installment_number' => $i,
'due_date' => $dueDate,
'amount' => $amount,
'payment_method' => $data['payment_method'] ?? 'cash',
'status' => 'upcoming',
]);
}
$db->commit();
EventBus::dispatch('supplier_schedule.created', [
'schedule_id' => $scheduleId,
'supplier_id' => (int) $data['supplier_id'],
]);
return ['success' => true, 'schedule_id' => $scheduleId, 'schedule_number' => $scheduleNumber];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function recordPayment(int $scheduleItemId, string $paidAmount, ?string $paidDate = null): array
{
$db = App::getInstance()->db();
$item = $db->selectOne("SELECT * FROM supplier_payment_schedule_items WHERE id = ?", [$scheduleItemId]);
if (!$item) {
return ['success' => false, 'error' => 'القسط غير موجود'];
}
$db->beginTransaction();
try {
$db->update('supplier_payment_schedule_items', [
'paid_amount' => $paidAmount,
'paid_date' => $paidDate ?? date('Y-m-d'),
'status' => 'paid',
], 'id = ?', [$scheduleItemId]);
$remaining = $db->selectOne(
"SELECT COUNT(*) as cnt FROM supplier_payment_schedule_items WHERE schedule_id = ? AND status != 'paid'",
[(int) $item['schedule_id']]
);
if ((int) ($remaining['cnt'] ?? 1) === 0) {
$db->update('supplier_payment_schedules', ['status' => 'completed'], 'id = ?', [(int) $item['schedule_id']]);
}
$db->commit();
return ['success' => true];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
}
}
public static function getOverdueItems(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT si.*, s.schedule_number, sp.name_ar as supplier_name
FROM supplier_payment_schedule_items si
JOIN supplier_payment_schedules s ON s.id = si.schedule_id
JOIN suppliers sp ON sp.id = s.supplier_id
WHERE si.status IN ('upcoming','due') AND si.due_date < CURDATE()
ORDER BY si.due_date ASC"
);
}
}
......@@ -6,6 +6,7 @@ namespace App\Modules\Procurement\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Inventory\Services\CreditLimitService;
final class VendorInvoiceService
{
......@@ -142,6 +143,14 @@ final class VendorInvoiceService
throw new \RuntimeException('الفاتورة ليست في حالة تم التحقق');
}
$creditCheck = CreditLimitService::checkSupplierCanTransact(
(int) $invoice['supplier_id'],
(string) $invoice['total_amount']
);
if (!$creditCheck['allowed']) {
throw new \RuntimeException($creditCheck['message']);
}
$db->update('vendor_invoices', [
'status' => 'approved',
'updated_at' => date('Y-m-d H:i:s'),
......@@ -149,6 +158,7 @@ final class VendorInvoiceService
EventBus::dispatch('procurement.invoice_approved', [
'invoice_id' => $invoiceId,
'invoice_number' => $invoice['internal_number'] ?? '',
'supplier_id' => (int) $invoice['supplier_id'],
'internal_number' => $invoice['internal_number'] ?? '',
'subtotal' => (string) $invoice['subtotal'],
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مقارنة عروض الأسعار<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/procurement/quotes" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$items = $comparison['items'] ?? [];
$suppliers = $comparison['suppliers'] ?? [];
?>
<!-- Requisition Info -->
<div class="card" style="margin-bottom:20px;padding:20px;">
<div style="display:flex;gap:20px;align-items:center;">
<div>
<span style="font-size:12px;color:#6B7280;">رقم طلب الشراء</span>
<div style="font-weight:700;font-size:16px;"><?= e($requisition['pr_number'] ?? '') ?></div>
</div>
<div>
<span style="font-size:12px;color:#6B7280;">التاريخ</span>
<div style="font-weight:600;"><?= e($requisition['created_at'] ?? '') ?></div>
</div>
</div>
</div>
<!-- Comparison Table -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:15px;"><i data-lucide="bar-chart-3" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;color:#0D7377;"></i> مقارنة الأسعار</h3>
</div>
<?php if (!empty($items) && !empty($suppliers)): ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الصنف</th>
<th>الكمية</th>
<?php foreach ($suppliers as $supplier): ?>
<th style="text-align:center;">
<div><?= e($supplier['name'] ?? '') ?></div>
<div style="font-size:11px;color:#6B7280;font-weight:normal;">مدة التوريد: <?= (int) ($supplier['delivery_days'] ?? 0) ?> يوم</div>
</th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($items as $item): ?>
<?php
// Find lowest price for this item
$prices = [];
foreach ($suppliers as $supplier) {
$key = 'supplier_' . ($supplier['id'] ?? 0);
if (isset($item[$key]) && $item[$key] > 0) {
$prices[$supplier['id']] = (float) $item[$key];
}
}
$lowestPrice = !empty($prices) ? min($prices) : 0;
?>
<tr>
<td style="font-weight:600;"><?= e($item['item_name'] ?? '') ?></td>
<td><?= (int) ($item['quantity'] ?? 0) ?></td>
<?php foreach ($suppliers as $supplier): ?>
<?php
$key = 'supplier_' . ($supplier['id'] ?? 0);
$price = isset($item[$key]) ? (float) $item[$key] : 0;
$isLowest = $price > 0 && $price === $lowestPrice;
?>
<td style="text-align:center;direction:ltr;<?= $isLowest ? 'background:#D1FAE5;font-weight:800;color:#047857;' : '' ?>">
<?= $price > 0 ? money($price) : '—' ?>
<?php if ($isLowest): ?>
<i data-lucide="check-circle" style="width:14px;height:14px;color:#047857;vertical-align:middle;margin-right:4px;"></i>
<?php endif; ?>
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div style="padding:40px;text-align:center;color:#9CA3AF;">لا توجد بيانات للمقارنة</div>
<?php endif; ?>
</div>
<!-- Winner Selection Form -->
<div class="card" style="padding:20px;">
<h3 style="margin:0 0 15px;font-size:15px;"><i data-lucide="award" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;color:#D97706;"></i> اختيار العرض الفائز</h3>
<form method="POST" action="/procurement/quotes/<?= (int) ($requisition['id'] ?? 0) ?>/select-winner">
<?= csrf_field() ?>
<div style="margin-bottom:15px;">
<label class="form-label">المورد الفائز</label>
<select name="winning_supplier_id" class="form-select" required>
<option value="">-- اختر المورد --</option>
<?php foreach ($suppliers as $supplier): ?>
<option value="<?= (int) ($supplier['id'] ?? 0) ?>"><?= e($supplier['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="margin-bottom:15px;">
<label class="form-label">مبررات الاختيار</label>
<textarea name="justification" class="form-control" rows="4" placeholder="اذكر أسباب اختيار هذا المورد..." required></textarea>
</div>
<button type="submit" class="btn btn-primary"><i data-lucide="check" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> تأكيد الاختيار</button>
</form>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>عروض الأسعار<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$statusColors = [
'requested' => '#2563EB', 'received' => '#059669', 'accepted' => '#047857',
'rejected' => '#DC2626', 'expired' => '#6B7280',
];
$statusBgs = [
'requested' => '#EFF6FF', 'received' => '#ECFDF5', 'accepted' => '#D1FAE5',
'rejected' => '#FEE2E2', 'expired' => '#F3F4F6',
];
$statusLabels = [
'requested' => 'مطلوب', 'received' => 'مستلم', 'accepted' => 'مقبول',
'rejected' => 'مرفوض', 'expired' => 'منتهي',
];
?>
<!-- Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/procurement/quotes" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select">
<option value="">الكل</option>
<?php foreach ($statusLabels as $val => $label): ?>
<option value="<?= e($val) ?>" <?= ($status ?? '') === $val ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/procurement/quotes" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Quotes Table -->
<?php if (!empty($quotes)): ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>رقم العرض</th>
<th>المورد</th>
<th>تاريخ الطلب</th>
<th>تاريخ الرد</th>
<th>المبلغ الإجمالي</th>
<th>الحالة</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($quotes as $q): ?>
<?php
$st = $q['status'] ?? 'requested';
$stColor = $statusColors[$st] ?? '#6B7280';
$stBg = $statusBgs[$st] ?? '#F3F4F6';
$stLabel = $statusLabels[$st] ?? $st;
?>
<tr>
<td style="font-weight:600;">
<code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($q['quote_number'] ?? '') ?></code>
</td>
<td style="font-weight:600;"><?= e($q['supplier_name'] ?? '—') ?></td>
<td style="font-size:13px;"><?= e($q['request_date'] ?? '—') ?></td>
<td style="font-size:13px;"><?= e($q['response_date'] ?? '—') ?></td>
<td style="font-weight:700;direction:ltr;text-align:left;"><?= money($q['total_amount'] ?? 0) ?></td>
<td>
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $stBg ?>;color:<?= $stColor ?>;">
<?= e($stLabel) ?>
</span>
</td>
<td>
<a href="/procurement/quotes/<?= (int) $q['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="eye" style="width:13px;height:13px;vertical-align:middle;"></i> عرض
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="file-text" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد عروض أسعار</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0;">لم يتم العثور على عروض أسعار مطابقة.</p>
</div>
<?php endif; ?>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تقرير التسليمات المتأخرة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/procurement" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/procurement/reports/overdue-deliveries" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">المورد</label>
<select name="supplier_id" class="form-select">
<option value="">جميع الموردين</option>
<?php foreach ($suppliers as $s): ?>
<option value="<?= (int) $s['id'] ?>" <?= ($supplier_id ?? '') == $s['id'] ? 'selected' : '' ?>><?= e($s['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/procurement/reports/overdue-deliveries" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Overdue Deliveries Table -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="alert-triangle" style="width:18px;height:18px;color:#DC2626;"></i>
<h3 style="margin:0;color:#DC2626;font-size:15px;">التسليمات المتأخرة</h3>
<?php if (!empty($overdue)): ?>
<span style="background:#FEE2E2;color:#DC2626;font-size:12px;padding:2px 8px;border-radius:10px;font-weight:600;margin-right:8px;"><?= count($overdue) ?> عنصر</span>
<?php endif; ?>
</div>
<?php if (!empty($overdue)): ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>رقم أمر الشراء</th>
<th>المورد</th>
<th>الصنف</th>
<th>الكمية المطلوبة</th>
<th>الكمية المستلمة</th>
<th>تاريخ التسليم المتوقع</th>
<th>أيام التأخير</th>
</tr>
</thead>
<tbody>
<?php foreach ($overdue as $item): ?>
<?php
$days = (int) ($item['days_overdue'] ?? 0);
$urgencyColor = $days > 30 ? '#991B1B' : ($days > 14 ? '#DC2626' : ($days > 7 ? '#EA580C' : '#D97706'));
?>
<tr>
<td style="font-weight:600;">
<code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($item['po_number'] ?? '') ?></code>
</td>
<td style="font-weight:600;"><?= e($item['supplier_name'] ?? '—') ?></td>
<td><?= e($item['item_name'] ?? '—') ?></td>
<td style="text-align:center;"><?= (int) ($item['qty_ordered'] ?? 0) ?></td>
<td style="text-align:center;"><?= (int) ($item['qty_received'] ?? 0) ?></td>
<td style="font-size:13px;"><?= e($item['expected_date'] ?? '—') ?></td>
<td>
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:700;background:<?= $urgencyColor ?>15;color:<?= $urgencyColor ?>;">
<?= $days ?> يوم
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;"><i data-lucide="check-circle" style="width:48px;height:48px;color:#059669;"></i></div>
<h3 style="color:#059669;margin:0 0 8px;">لا توجد تسليمات متأخرة</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0;">جميع التسليمات تتم في مواعيدها.</p>
</div>
<?php endif; ?>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تتبع رصيد أوامر الشراء<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/procurement" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/procurement/reports/po-balance" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">المورد</label>
<select name="supplier_id" class="form-select">
<option value="">جميع الموردين</option>
<?php foreach ($suppliers as $s): ?>
<option value="<?= (int) $s['id'] ?>" <?= ($supplier_id ?? '') == $s['id'] ? 'selected' : '' ?>><?= e($s['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/procurement/reports/po-balance" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- PO Balance Table -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="package" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;font-size:15px;">رصيد أوامر الشراء</h3>
</div>
<?php if (!empty($orders)): ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>رقم أمر الشراء</th>
<th>المورد</th>
<th>الكمية المطلوبة</th>
<th>الكمية المستلمة</th>
<th>الكمية المتبقية</th>
<th>نسبة التسليم</th>
</tr>
</thead>
<tbody>
<?php foreach ($orders as $order): ?>
<?php
$totalOrdered = (int) ($order['total_qty_ordered'] ?? 0);
$totalReceived = (int) ($order['total_qty_received'] ?? 0);
$totalRemaining = (int) ($order['total_qty_remaining'] ?? ($totalOrdered - $totalReceived));
$percentage = $totalOrdered > 0 ? round(($totalReceived / $totalOrdered) * 100) : 0;
$barColor = $percentage >= 100 ? '#059669' : ($percentage >= 50 ? '#D97706' : '#DC2626');
?>
<tr>
<td style="font-weight:600;">
<code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($order['po_number'] ?? '') ?></code>
</td>
<td style="font-weight:600;"><?= e($order['supplier_name'] ?? '—') ?></td>
<td style="text-align:center;"><?= $totalOrdered ?></td>
<td style="text-align:center;color:#059669;font-weight:600;"><?= $totalReceived ?></td>
<td style="text-align:center;color:#DC2626;font-weight:600;"><?= $totalRemaining ?></td>
<td style="min-width:160px;">
<div style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;background:#E5E7EB;border-radius:4px;height:8px;overflow:hidden;">
<div style="width:<?= $percentage ?>%;height:100%;background:<?= $barColor ?>;border-radius:4px;transition:width 0.3s;"></div>
</div>
<span style="font-size:12px;font-weight:700;color:<?= $barColor ?>;min-width:40px;text-align:left;direction:ltr;"><?= $percentage ?>%</span>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;"><i data-lucide="package" style="width:48px;height:48px;color:#D1D5DB;"></i></div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد أوامر شراء</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0;">لم يتم العثور على أوامر شراء مطابقة.</p>
</div>
<?php endif; ?>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
......@@ -3,6 +3,13 @@ declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
use App\Modules\Procurement\Services\ItemPriceTrackingService;
// ────────────────────────────────────────────────────────────
// Procurement — Event Listeners
// ────────────────────────────────────────────────────────────
ItemPriceTrackingService::registerListeners();
// ────────────────────────────────────────────────────────────
// Procurement — Permissions
......@@ -28,6 +35,9 @@ PermissionRegistry::register('procurement', [
'procurement.rtv.approve' => ['ar' => 'اعتماد مرتجعات الموردين', 'en' => 'Approve Returns to Vendor'],
'procurement.rtv.manage' => ['ar' => 'إدارة شحن وإتمام المرتجعات', 'en' => 'Manage RTV Ship & Complete'],
'procurement.report' => ['ar' => 'تقارير المشتريات', 'en' => 'Procurement Reports'],
'procurement.quote.view' => ['ar' => 'عرض عروض الأسعار', 'en' => 'View Supplier Quotes'],
'procurement.quote.create' => ['ar' => 'إنشاء طلبات عروض أسعار', 'en' => 'Create Quote Requests'],
'procurement.quote.evaluate' => ['ar' => 'تقييم ومقارنة العروض', 'en' => 'Evaluate & Compare Quotes'],
]);
// ────────────────────────────────────────────────────────────
......@@ -49,6 +59,9 @@ MenuRegistry::register('procurement', [
['label_ar' => 'فواتير الموردين', 'label_en' => 'Vendor Invoices', 'route' => '/procurement/invoices', 'permission' => 'procurement.invoice.view', 'order' => 4],
['label_ar' => 'مدفوعات الموردين', 'label_en' => 'Vendor Payments', 'route' => '/procurement/payments', 'permission' => 'procurement.payment.view', 'order' => 5],
['label_ar' => 'مرتجعات الموردين', 'label_en' => 'Returns to Vendor', 'route' => '/procurement/rtv', 'permission' => 'procurement.rtv.view', 'order' => 6],
['label_ar' => 'تقارير المشتريات', 'label_en' => 'Procurement Reports', 'route' => '/procurement/reports/purchase-volume', 'permission' => 'procurement.report', 'order' => 7],
['label_ar' => 'عروض الأسعار', 'label_en' => 'Supplier Quotes', 'route' => '/procurement/quotes', 'permission' => 'procurement.quote.view', 'order' => 7],
['label_ar' => 'تتبع التسليم', 'label_en' => 'Delivery Tracking', 'route' => '/procurement/reports/overdue-deliveries', 'permission' => 'procurement.report', 'order' => 8],
['label_ar' => 'رصيد أوامر الشراء', 'label_en' => 'PO Balance', 'route' => '/procurement/reports/po-balance', 'permission' => 'procurement.report', 'order' => 9],
['label_ar' => 'تقارير المشتريات', 'label_en' => 'Procurement Reports', 'route' => '/procurement/reports/purchase-volume', 'permission' => 'procurement.report', 'order' => 10],
],
]);
<?php
declare(strict_types=1);
namespace App\Modules\Sales\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class CommissionController extends Controller
{
public function representatives(Request $request): Response
{
$this->authorize('sales.commission.view');
$db = App::getInstance()->db();
$reps = $db->select(
"SELECT sr.*, e.full_name_ar as employee_name,
(SELECT COUNT(*) FROM sales_commissions WHERE representative_id = sr.id) as total_sales,
(SELECT COALESCE(SUM(commission_amount), 0) FROM sales_commissions WHERE representative_id = sr.id) as total_commission
FROM sales_representatives sr
LEFT JOIN employees e ON e.id = sr.employee_id
ORDER BY sr.name_ar"
);
return $this->view('Sales.Views.commissions.representatives', ['reps' => $reps]);
}
public function createRep(Request $request): Response
{
$this->authorize('sales.commission.manage');
$db = App::getInstance()->db();
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_archived = 0 ORDER BY full_name_ar");
return $this->view('Sales.Views.commissions.rep_form', ['rep' => null, 'employees' => $employees]);
}
public function storeRep(Request $request): Response
{
$this->authorize('sales.commission.manage');
$db = App::getInstance()->db();
$db->insert('sales_representatives', [
'employee_id' => $request->post('employee_id') ?: null,
'code' => $request->post('code'),
'name_ar' => $request->post('name_ar'),
'name_en' => $request->post('name_en'),
'phone' => $request->post('phone'),
'email' => $request->post('email'),
'commission_type' => $request->post('commission_type', 'percentage'),
'commission_rate' => $request->post('commission_rate', '0'),
'target_amount' => $request->post('target_amount') ?: null,
]);
return $this->redirect('/sales/commissions/representatives')->withSuccess('تم إضافة مندوب المبيعات');
}
public function report(Request $request): Response
{
$this->authorize('sales.commission.view');
$db = App::getInstance()->db();
$month = $request->get('month', date('Y-m'));
$repId = (int) $request->get('representative_id', 0);
$where = "DATE_FORMAT(sc.period_month, '%Y-%m') = ?";
$params = [$month];
if ($repId) {
$where .= ' AND sc.representative_id = ?';
$params[] = $repId;
}
$commissions = $db->select(
"SELECT sc.*, sr.name_ar as rep_name, sr.code as rep_code
FROM sales_commissions sc
JOIN sales_representatives sr ON sr.id = sc.representative_id
WHERE {$where}
ORDER BY sr.name_ar, sc.created_at DESC",
$params
);
$summary = $db->select(
"SELECT sr.id, sr.name_ar, sr.code, sr.commission_rate, sr.target_amount,
COUNT(sc.id) as sale_count,
COALESCE(SUM(sc.sale_amount), 0) as total_sales,
COALESCE(SUM(sc.commission_amount), 0) as total_commission
FROM sales_representatives sr
LEFT JOIN sales_commissions sc ON sc.representative_id = sr.id AND DATE_FORMAT(sc.period_month, '%Y-%m') = ?
WHERE sr.is_active = 1
GROUP BY sr.id
ORDER BY total_sales DESC",
[$month]
);
$reps = $db->select("SELECT id, name_ar, code FROM sales_representatives WHERE is_active = 1 ORDER BY name_ar");
return $this->view('Sales.Views.commissions.report', [
'commissions' => $commissions,
'summary' => $summary,
'reps' => $reps,
'month' => $month,
'representative_id' => $repId,
]);
}
public function customerPricing(Request $request): Response
{
$this->authorize('sales.pricing.view');
$db = App::getInstance()->db();
$prices = $db->select(
"SELECT cp.*, i.name_ar as item_name, m.full_name_ar as member_name, pl.name_ar as list_name
FROM customer_item_prices cp
LEFT JOIN inventory_items i ON i.id = cp.item_id
LEFT JOIN members m ON m.id = cp.member_id
LEFT JOIN customer_price_lists pl ON pl.id = cp.price_list_id
WHERE cp.is_active = 1
ORDER BY cp.created_at DESC
LIMIT 200"
);
return $this->view('Sales.Views.commissions.customer_pricing', ['prices' => $prices]);
}
}
......@@ -23,6 +23,13 @@ return [
['GET', '/sales/packages/{id:\d+}/edit', 'Sales\Controllers\PackageController@edit', ['auth'], 'package.manage'],
['POST', '/sales/packages/{id:\d+}', 'Sales\Controllers\PackageController@update', ['auth', 'csrf'], 'package.manage'],
// Sales Representatives & Commissions
['GET', '/sales/commissions/representatives', 'Sales\Controllers\CommissionController@representatives', ['auth'], 'sales.commission.view'],
['GET', '/sales/commissions/representatives/create', 'Sales\Controllers\CommissionController@createRep', ['auth'], 'sales.commission.manage'],
['POST', '/sales/commissions/representatives', 'Sales\Controllers\CommissionController@storeRep', ['auth', 'csrf'], 'sales.commission.manage'],
['GET', '/sales/commissions/report', 'Sales\Controllers\CommissionController@report', ['auth'], 'sales.commission.view'],
['GET', '/sales/pricing/customer', 'Sales\Controllers\CommissionController@customerPricing', ['auth'], 'sales.pricing.view'],
// Sales Reports
['GET', '/sales/reports/daily', 'Sales\Controllers\SaleReportController@daily', ['auth'], 'report.sales'],
['GET', '/sales/reports/monthly', 'Sales\Controllers\SaleReportController@monthly', ['auth'], 'report.sales'],
......
<?php
declare(strict_types=1);
namespace App\Modules\Sales\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
final class CommissionService
{
public static function registerListeners(): void
{
EventBus::listen('sale.completed', [self::class, 'onSaleCompleted'], 50);
}
public static function onSaleCompleted(array $data): void
{
try {
$saleId = (int) ($data['sale_id'] ?? 0);
if ($saleId <= 0) return;
$db = App::getInstance()->db();
$sale = $db->selectOne("SELECT representative_id, total_amount, sale_date FROM sales WHERE id = ?", [$saleId]);
if (!$sale || !$sale['representative_id']) return;
$repId = (int) $sale['representative_id'];
$rep = $db->selectOne("SELECT * FROM sales_representatives WHERE id = ? AND is_active = 1", [$repId]);
if (!$rep) return;
$totalAmount = (string) $sale['total_amount'];
$commissionAmount = '0.00';
if ($rep['commission_type'] === 'percentage') {
$commissionAmount = bcmul($totalAmount, bcdiv((string) $rep['commission_rate'], '100', 6), 2);
} elseif ($rep['commission_type'] === 'fixed_per_invoice') {
$commissionAmount = (string) $rep['commission_rate'];
}
if (bccomp($commissionAmount, '0', 2) <= 0) return;
$db->insert('sales_commissions', [
'representative_id' => $repId,
'sale_id' => $saleId,
'sale_amount' => $totalAmount,
'commission_rate' => (string) $rep['commission_rate'],
'commission_amount' => $commissionAmount,
'commission_date' => $sale['sale_date'],
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
]);
Logger::info("Commission calculated for sale #{$saleId}: {$commissionAmount}");
} catch (\Throwable $e) {
Logger::error('CommissionService sale.completed failed: ' . $e->getMessage());
}
}
public static function getRepresentativeReport(int $repId, string $fromDate, string $toDate): array
{
$db = App::getInstance()->db();
$commissions = $db->select(
"SELECT sc.*, s.invoice_number
FROM sales_commissions sc
JOIN sales s ON s.id = sc.sale_id
WHERE sc.representative_id = ? AND sc.commission_date BETWEEN ? AND ?
ORDER BY sc.commission_date DESC",
[$repId, $fromDate, $toDate]
);
$totalCommission = '0.00';
$totalSales = '0.00';
foreach ($commissions as $c) {
$totalCommission = bcadd($totalCommission, (string) $c['commission_amount'], 2);
$totalSales = bcadd($totalSales, (string) $c['sale_amount'], 2);
}
return [
'commissions' => $commissions,
'total_commission' => $totalCommission,
'total_sales' => $totalSales,
'count' => count($commissions),
];
}
public static function markAsPaid(array $commissionIds): int
{
$db = App::getInstance()->db();
$count = 0;
foreach ($commissionIds as $id) {
$db->update('sales_commissions', [
'status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
], 'id = ? AND status = ?', [(int) $id, 'pending']);
$count++;
}
return $count;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Sales\Services;
use App\Core\App;
final class CustomerPricingService
{
public static function getPrice(int $itemId, ?int $memberId = null): ?string
{
if (!$memberId) return null;
$db = App::getInstance()->db();
$customPrice = $db->selectOne(
"SELECT cip.price
FROM customer_item_prices cip
JOIN customer_price_lists cpl ON cpl.id = cip.price_list_id
WHERE cip.item_id = ? AND cpl.member_id = ? AND cpl.is_active = 1
ORDER BY cip.id DESC LIMIT 1",
[$itemId, $memberId]
);
return $customPrice ? (string) $customPrice['price'] : null;
}
public static function getPriceList(int $memberId): array
{
$db = App::getInstance()->db();
$list = $db->selectOne(
"SELECT * FROM customer_price_lists WHERE member_id = ? AND is_active = 1 ORDER BY id DESC LIMIT 1",
[$memberId]
);
if (!$list) return [];
return $db->select(
"SELECT cip.*, i.name_ar, i.selling_price as default_price
FROM customer_item_prices cip
JOIN inventory_items i ON i.id = cip.item_id
WHERE cip.price_list_id = ?
ORDER BY i.name_ar",
[(int) $list['id']]
);
}
public static function savePriceList(int $memberId, array $items, ?string $notes = null): int
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$db->query("UPDATE customer_price_lists SET is_active = 0 WHERE member_id = ?", [$memberId]);
$listId = $db->insert('customer_price_lists', [
'member_id' => $memberId,
'name_ar' => 'قائمة أسعار خاصة',
'discount_type' => 'fixed_price',
'notes' => $notes,
'is_active' => 1,
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
]);
foreach ($items as $item) {
$db->insert('customer_item_prices', [
'price_list_id' => $listId,
'item_id' => (int) $item['item_id'],
'price' => (string) $item['price'],
'min_quantity' => $item['min_quantity'] ?? '1.000',
]);
}
return $listId;
}
}
......@@ -8,6 +8,9 @@ use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Inventory\Services\StockService;
use App\Modules\Inventory\Services\BatchService;
use App\Modules\Inventory\Services\BomService;
use App\Modules\Inventory\Services\CreditLimitService;
use App\Modules\Accounting\Services\MultiCurrencyService;
use App\Modules\Payments\Services\PaymentService;
final class SaleService
......@@ -57,9 +60,25 @@ final class SaleService
return ['success' => false, 'error' => 'إجمالي المبيعة يجب أن يكون أكبر من صفر'];
}
// Credit limit check for members
if ($memberId) {
$creditCheck = CreditLimitService::checkCustomerCanTransact($memberId, $totalAmount);
if (!$creditCheck['allowed']) {
return ['success' => false, 'error' => $creditCheck['message']];
}
}
// Multi-currency: resolve exchange rate if non-base currency
$currencyCode = $header['currency_code'] ?? null;
$exchangeRate = '1.000000';
if ($currencyCode && $currencyCode !== 'EGP') {
$rateInfo = MultiCurrencyService::getExchangeRate($currencyCode);
$exchangeRate = $rateInfo['mid_rate'];
}
$db->beginTransaction();
try {
$saleId = $db->insert('sales', [
$saleData = [
'invoice_number' => $invoiceNumber,
'warehouse_id' => $warehouseId,
'customer_type' => $customerType,
......@@ -82,7 +101,17 @@ final class SaleService
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
];
if ($currencyCode) {
$saleData['currency_code'] = $currencyCode;
$saleData['exchange_rate'] = $exchangeRate;
}
if (!empty($header['representative_id'])) {
$saleData['representative_id'] = (int) $header['representative_id'];
}
$saleId = $db->insert('sales', $saleData);
// Process each line item
foreach ($lines as $line) {
......@@ -96,6 +125,15 @@ final class SaleService
// Deduct stock for items (not packages — packages are expanded below)
if ($itemId) {
$bom = BomService::getBomForItem($itemId);
if ($bom) {
// Item has active BOM — deduct components instead
$bomResult = BomService::deductComponents((int) $bom['id'], $lineQty, $warehouseId);
if (!$bomResult['success']) {
throw new \RuntimeException($bomResult['error']);
}
} else {
$item = $db->selectOne("SELECT `tracking_type` FROM `inventory_items` WHERE `id` = ?", [$itemId]);
// FEFO for expiry items
......@@ -112,7 +150,7 @@ final class SaleService
'reference_type' => 'sales',
'reference_id' => $saleId,
]);
$batchId = $alloc['batch_id']; // last batch for record
$batchId = $alloc['batch_id'];
}
} else {
StockService::moveStock([
......@@ -126,6 +164,7 @@ final class SaleService
]);
}
}
}
// If it's a package, expand and deduct component items
$packageId = isset($line['package_id']) ? (int) $line['package_id'] : null;
......@@ -201,7 +240,9 @@ final class SaleService
EventBus::dispatch('sale.completed', [
'sale_id' => $saleId,
'sale_number' => $invoiceNumber,
'invoice_number' => $invoiceNumber,
'member_id' => $memberId,
'customer_type' => $customerType,
'total_amount' => $totalAmount,
]);
......@@ -271,6 +312,9 @@ final class SaleService
EventBus::dispatch('sale.voided', [
'sale_id' => $saleId,
'member_id' => $sale['member_id'] ? (int) $sale['member_id'] : null,
'total_amount' => (string) $sale['total_amount'],
'invoice_number' => $sale['invoice_number'],
'reason' => $reason,
]);
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تقرير العمولات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/sales/commissions/report" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:160px;">
<label class="form-label" style="font-size:12px;">الشهر</label>
<input type="month" name="month" class="form-input" value="<?= e($month ?? date('Y-m')) ?>">
</div>
<div style="min-width:200px;">
<label class="form-label" style="font-size:12px;">المندوب</label>
<select name="representative_id" class="form-select">
<option value="">جميع المندوبين</option>
<?php foreach ($reps as $rep): ?>
<option value="<?= (int) $rep['id'] ?>" <?= ($representative_id ?? '') == $rep['id'] ? 'selected' : '' ?>><?= e($rep['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> عرض</button>
</form>
</div>
<!-- Summary Table -->
<?php if (!empty($summary)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="bar-chart-3" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">ملخص العمولات</h3>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>المندوب</th>
<th>عدد المبيعات</th>
<th>إجمالي المبيعات</th>
<th>إجمالي العمولات</th>
<th>المستهدف</th>
</tr>
</thead>
<tbody>
<?php foreach ($summary as $row): ?>
<tr>
<td style="font-weight:600;"><?= e($row['rep_name']) ?></td>
<td><?= (int) ($row['sale_count'] ?? 0) ?></td>
<td style="direction:ltr;text-align:left;"><?= money($row['total_sales'] ?? 0) ?></td>
<td style="direction:ltr;text-align:left;font-weight:600;color:#059669;"><?= money($row['total_commission'] ?? 0) ?></td>
<td style="direction:ltr;text-align:left;"><?= money($row['target_amount'] ?? 0) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Detail Table -->
<?php if (!empty($commissions)): ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="list" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">تفاصيل العمولات</h3>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>المندوب</th>
<th>رقم الفاتورة</th>
<th>التاريخ</th>
<th>قيمة البيع</th>
<th>نسبة العمولة</th>
<th>مبلغ العمولة</th>
</tr>
</thead>
<tbody>
<?php foreach ($commissions as $comm): ?>
<tr>
<td><?= e($comm['rep_name'] ?? '—') ?></td>
<td><code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($comm['invoice_number'] ?? '—') ?></code></td>
<td><?= e($comm['sale_date'] ?? '—') ?></td>
<td style="direction:ltr;text-align:left;"><?= money($comm['sale_amount'] ?? 0) ?></td>
<td><?= e($comm['commission_rate'] ?? '—') ?>%</td>
<td style="direction:ltr;text-align:left;font-weight:600;color:#059669;"><?= money($comm['commission_amount'] ?? 0) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="file-search" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد عمولات</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0;">لا توجد عمولات مسجلة للفترة المحددة.</p>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مندوبي المبيعات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php if (!empty($reps)): ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الكود</th>
<th>الاسم</th>
<th>الموظف</th>
<th>نوع العمولة</th>
<th>نسبة العمولة</th>
<th>إجمالي المبيعات</th>
<th>إجمالي العمولات</th>
</tr>
</thead>
<tbody>
<?php foreach ($reps as $rep): ?>
<tr>
<td><code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($rep['code']) ?></code></td>
<td style="font-weight:600;"><?= e($rep['name_ar']) ?></td>
<td><?= e($rep['employee_name'] ?? '—') ?></td>
<td>
<?php if (($rep['commission_type'] ?? '') === 'percentage'): ?>
<span style="display:inline-block;padding:2px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#EDE9FE;color:#7C3AED;">نسبة مئوية</span>
<?php elseif (($rep['commission_type'] ?? '') === 'fixed'): ?>
<span style="display:inline-block;padding:2px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#FEF3C7;color:#D97706;">مبلغ ثابت</span>
<?php elseif (($rep['commission_type'] ?? '') === 'tiered'): ?>
<span style="display:inline-block;padding:2px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#D1FAE5;color:#059669;">شرائح</span>
<?php else: ?>
<span style="color:#6B7280;"><?= e($rep['commission_type'] ?? '—') ?></span>
<?php endif; ?>
</td>
<td><?= e($rep['commission_rate'] ?? '—') ?>%</td>
<td style="direction:ltr;text-align:left;font-weight:600;"><?= money($rep['total_sales'] ?? 0) ?></td>
<td style="direction:ltr;text-align:left;font-weight:600;color:#059669;"><?= money($rep['total_commission'] ?? 0) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="users" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا يوجد مندوبين مبيعات</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0;">لم يتم تسجيل أي مندوبين مبيعات بعد.</p>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
......@@ -3,6 +3,13 @@ declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
use App\Modules\Sales\Services\CommissionService;
// ────────────────────────────────────────────────────────────
// Sales — Event Listeners
// ────────────────────────────────────────────────────────────
CommissionService::registerListeners();
// ────────────────────────────────────────────────────────────
// Sales — Permissions
......@@ -14,8 +21,12 @@ PermissionRegistry::register('sales', [
'sales.void' => ['ar' => 'إلغاء عملية بيع', 'en' => 'Void Sale'],
'sales.refund' => ['ar' => 'استرجاع مبيعات', 'en' => 'Refund Sale'],
'package.view' => ['ar' => 'عرض الباقات', 'en' => 'View Packages'],
'package.manage'=> ['ar' => 'إدارة الباقات', 'en' => 'Manage Packages'],
'package.manage' => ['ar' => 'إدارة الباقات', 'en' => 'Manage Packages'],
'report.sales' => ['ar' => 'تقارير المبيعات', 'en' => 'Sales Reports'],
'sales.commission.view' => ['ar' => 'عرض العمولات', 'en' => 'View Commissions'],
'sales.commission.manage' => ['ar' => 'إدارة المندوبين والعمولات','en' => 'Manage Reps & Commissions'],
'sales.pricing.view' => ['ar' => 'عرض تسعير العملاء', 'en' => 'View Customer Pricing'],
'sales.pricing.manage' => ['ar' => 'إدارة تسعير العملاء', 'en' => 'Manage Customer Pricing'],
]);
// ────────────────────────────────────────────────────────────
......@@ -36,5 +47,8 @@ MenuRegistry::register('sales', [
['label_ar' => 'الباقات', 'label_en' => 'Packages', 'route' => '/sales/packages', 'permission' => 'package.view', 'order' => 3],
['label_ar' => 'تقرير يومي', 'label_en' => 'Daily Report', 'route' => '/sales/reports/daily', 'permission' => 'report.sales', 'order' => 4],
['label_ar' => 'تقرير شهري', 'label_en' => 'Monthly Report', 'route' => '/sales/reports/monthly', 'permission' => 'report.sales', 'order' => 5],
['label_ar' => 'المندوبون', 'label_en' => 'Sales Reps', 'route' => '/sales/commissions/representatives', 'permission' => 'sales.commission.view', 'order' => 6],
['label_ar' => 'العمولات', 'label_en' => 'Commissions', 'route' => '/sales/commissions/report', 'permission' => 'sales.commission.view', 'order' => 7],
['label_ar' => 'تسعير العملاء','label_en' => 'Customer Pricing', 'route' => '/sales/pricing/customer', 'permission' => 'sales.pricing.view', 'order' => 8],
],
]);
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE members ADD COLUMN credit_limit DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER membership_value;
ALTER TABLE members ADD COLUMN credit_balance DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER credit_limit;
ALTER TABLE suppliers ADD COLUMN credit_limit DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER payment_terms;
ALTER TABLE suppliers ADD COLUMN credit_balance DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER credit_limit",
'down' => "
ALTER TABLE members DROP COLUMN credit_balance;
ALTER TABLE members DROP COLUMN credit_limit;
ALTER TABLE suppliers DROP COLUMN credit_balance;
ALTER TABLE suppliers DROP COLUMN credit_limit",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE purchase_order_items ADD COLUMN expected_delivery_date DATE NULL AFTER quantity;
ALTER TABLE purchase_order_items ADD COLUMN actual_delivery_date DATE NULL AFTER expected_delivery_date;
ALTER TABLE purchase_order_items ADD COLUMN qty_received DECIMAL(12,3) NOT NULL DEFAULT 0.000 AFTER actual_delivery_date;
ALTER TABLE purchase_order_items ADD COLUMN delivery_status ENUM('pending','partial','complete','overdue') NOT NULL DEFAULT 'pending' AFTER qty_received",
'down' => "
ALTER TABLE purchase_order_items DROP COLUMN delivery_status;
ALTER TABLE purchase_order_items DROP COLUMN qty_received;
ALTER TABLE purchase_order_items DROP COLUMN actual_delivery_date;
ALTER TABLE purchase_order_items DROP COLUMN expected_delivery_date",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE asset_register ADD COLUMN custodian_employee_id INT UNSIGNED NULL AFTER condition_notes;
ALTER TABLE asset_register ADD COLUMN site_location VARCHAR(255) NULL AFTER custodian_employee_id;
ALTER TABLE asset_register ADD COLUMN custody_date DATE NULL AFTER site_location;
CREATE TABLE IF NOT EXISTS asset_custody_history (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
asset_id INT UNSIGNED NOT NULL,
from_employee_id INT UNSIGNED NULL,
to_employee_id INT UNSIGNED NULL,
from_location VARCHAR(255) NULL,
to_location VARCHAR(255) NULL,
transfer_date DATE NOT NULL,
reason VARCHAR(500) NULL,
notes TEXT NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_asset (asset_id),
KEY idx_from_emp (from_employee_id),
KEY idx_to_emp (to_employee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "
DROP TABLE IF EXISTS asset_custody_history;
ALTER TABLE asset_register DROP COLUMN custody_date;
ALTER TABLE asset_register DROP COLUMN site_location;
ALTER TABLE asset_register DROP COLUMN custodian_employee_id",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS customer_transactions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
member_id INT UNSIGNED NOT NULL,
transaction_date DATE NOT NULL,
document_type ENUM('sale','refund','payment','fine','subscription','installment','opening_balance','adjustment') NOT NULL,
document_id INT UNSIGNED NULL,
document_number VARCHAR(50) NULL,
description VARCHAR(500) NOT NULL,
debit DECIMAL(15,2) NOT NULL DEFAULT 0.00,
credit DECIMAL(15,2) NOT NULL DEFAULT 0.00,
balance DECIMAL(15,2) NOT NULL DEFAULT 0.00,
branch_id INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_member (member_id),
KEY idx_date (transaction_date),
KEY idx_doc_type (document_type),
KEY idx_member_date (member_id, transaction_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS supplier_transactions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
supplier_id INT UNSIGNED NOT NULL,
transaction_date DATE NOT NULL,
document_type ENUM('purchase_order','grn','invoice','payment','return','opening_balance','adjustment') NOT NULL,
document_id INT UNSIGNED NULL,
document_number VARCHAR(50) NULL,
description VARCHAR(500) NOT NULL,
debit DECIMAL(15,2) NOT NULL DEFAULT 0.00,
credit DECIMAL(15,2) NOT NULL DEFAULT 0.00,
balance DECIMAL(15,2) NOT NULL DEFAULT 0.00,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_supplier (supplier_id),
KEY idx_date (transaction_date),
KEY idx_supplier_date (supplier_id, transaction_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "
DROP TABLE IF EXISTS supplier_transactions;
DROP TABLE IF EXISTS customer_transactions",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS inventory_opening_balances (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
warehouse_id INT UNSIGNED NOT NULL,
item_id INT UNSIGNED NOT NULL,
quantity DECIMAL(12,3) NOT NULL DEFAULT 0.000,
unit_cost DECIMAL(15,2) NOT NULL DEFAULT 0.00,
total_cost DECIMAL(15,2) NOT NULL DEFAULT 0.00,
balance_date DATE NOT NULL,
batch_reference VARCHAR(50) NULL,
stock_movement_id INT UNSIGNED NULL,
notes TEXT NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_warehouse (warehouse_id),
KEY idx_item (item_id),
KEY idx_date (balance_date),
UNIQUE KEY uk_warehouse_item_date (warehouse_id, item_id, balance_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS inventory_opening_balances",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS instrument_status_history (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
instrument_id INT UNSIGNED NOT NULL,
from_status VARCHAR(30) NULL,
to_status VARCHAR(30) NOT NULL,
transition_date DATE NOT NULL,
bank_account_id INT UNSIGNED NULL,
endorsed_to VARCHAR(255) NULL,
bounce_reason VARCHAR(500) NULL,
notes TEXT NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_instrument (instrument_id),
KEY idx_date (transition_date),
KEY idx_status (to_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE negotiable_instruments ADD COLUMN deposited_date DATE NULL AFTER due_date;
ALTER TABLE negotiable_instruments ADD COLUMN collected_date DATE NULL AFTER deposited_date;
ALTER TABLE negotiable_instruments ADD COLUMN bounced_date DATE NULL AFTER collected_date;
ALTER TABLE negotiable_instruments ADD COLUMN bounce_reason VARCHAR(500) NULL AFTER bounced_date;
ALTER TABLE negotiable_instruments ADD COLUMN endorsed_to VARCHAR(255) NULL AFTER bounce_reason;
ALTER TABLE negotiable_instruments ADD COLUMN endorsed_date DATE NULL AFTER endorsed_to;
ALTER TABLE negotiable_instruments ADD COLUMN portfolio_id INT UNSIGNED NULL AFTER endorsed_date",
'down' => "
ALTER TABLE negotiable_instruments DROP COLUMN portfolio_id;
ALTER TABLE negotiable_instruments DROP COLUMN endorsed_date;
ALTER TABLE negotiable_instruments DROP COLUMN endorsed_to;
ALTER TABLE negotiable_instruments DROP COLUMN bounce_reason;
ALTER TABLE negotiable_instruments DROP COLUMN bounced_date;
ALTER TABLE negotiable_instruments DROP COLUMN collected_date;
ALTER TABLE negotiable_instruments DROP COLUMN deposited_date;
DROP TABLE IF EXISTS instrument_status_history",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS cross_entity_settlements (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
settlement_number VARCHAR(50) NOT NULL,
settlement_date DATE NOT NULL,
from_entity_type ENUM('customer','supplier','bank','safe') NOT NULL,
from_entity_id INT UNSIGNED NOT NULL,
from_entity_name VARCHAR(255) NOT NULL,
to_entity_type ENUM('customer','supplier','bank','safe') NOT NULL,
to_entity_id INT UNSIGNED NOT NULL,
to_entity_name VARCHAR(255) NOT NULL,
amount DECIMAL(15,2) NOT NULL,
purpose ENUM('payment','advance','adjustment','transfer') NOT NULL DEFAULT 'payment',
description VARCHAR(500) NULL,
journal_entry_id INT UNSIGNED NULL,
status ENUM('draft','approved','posted','cancelled') NOT NULL DEFAULT 'draft',
approved_by INT UNSIGNED NULL,
approved_at DATETIME NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_date (settlement_date),
KEY idx_from (from_entity_type, from_entity_id),
KEY idx_to (to_entity_type, to_entity_id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS cross_entity_settlements",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS bank_loans (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
loan_number VARCHAR(50) NOT NULL,
bank_account_id INT UNSIGNED NOT NULL,
loan_type ENUM('term','revolving','overdraft','mortgage') NOT NULL DEFAULT 'term',
principal_amount DECIMAL(15,2) NOT NULL,
interest_rate DECIMAL(5,4) NOT NULL,
term_months INT UNSIGNED NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
monthly_payment DECIMAL(15,2) NOT NULL DEFAULT 0.00,
outstanding_balance DECIMAL(15,2) NOT NULL,
total_interest DECIMAL(15,2) NOT NULL DEFAULT 0.00,
total_paid DECIMAL(15,2) NOT NULL DEFAULT 0.00,
status ENUM('active','paid_off','defaulted','restructured') NOT NULL DEFAULT 'active',
collateral_description TEXT NULL,
notes TEXT NULL,
journal_entry_id INT UNSIGNED NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_loan_number (loan_number),
KEY idx_bank (bank_account_id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS bank_loan_schedule (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
loan_id INT UNSIGNED NOT NULL,
installment_number INT UNSIGNED NOT NULL,
due_date DATE NOT NULL,
principal_amount DECIMAL(15,2) NOT NULL,
interest_amount DECIMAL(15,2) NOT NULL,
total_amount DECIMAL(15,2) NOT NULL,
paid_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00,
paid_date DATE NULL,
status ENUM('upcoming','due','paid','overdue') NOT NULL DEFAULT 'upcoming',
payment_reference VARCHAR(100) NULL,
journal_entry_id INT UNSIGNED NULL,
KEY idx_loan (loan_id),
KEY idx_due_date (due_date),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS daily_cash_movements (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
movement_date DATE NOT NULL,
bank_account_id INT UNSIGNED NULL,
safe_id INT UNSIGNED NULL,
opening_balance DECIMAL(15,2) NOT NULL DEFAULT 0.00,
total_inflows DECIMAL(15,2) NOT NULL DEFAULT 0.00,
total_outflows DECIMAL(15,2) NOT NULL DEFAULT 0.00,
closing_balance DECIMAL(15,2) NOT NULL DEFAULT 0.00,
is_reconciled TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_date_bank (movement_date, bank_account_id),
UNIQUE KEY uk_date_safe (movement_date, safe_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "
DROP TABLE IF EXISTS daily_cash_movements;
DROP TABLE IF EXISTS bank_loan_schedule;
DROP TABLE IF EXISTS bank_loans",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS supplier_price_quotes (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
quote_number VARCHAR(50) NOT NULL,
requisition_id INT UNSIGNED NULL,
supplier_id INT UNSIGNED NOT NULL,
request_date DATE NOT NULL,
response_date DATE NULL,
expiry_date DATE NULL,
status ENUM('requested','received','evaluated','accepted','rejected','expired') NOT NULL DEFAULT 'requested',
delivery_terms VARCHAR(255) NULL,
payment_terms VARCHAR(255) NULL,
total_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00,
currency VARCHAR(3) NOT NULL DEFAULT 'EGP',
notes TEXT NULL,
evaluation_id INT UNSIGNED NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_quote_number (quote_number),
KEY idx_supplier (supplier_id),
KEY idx_requisition (requisition_id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS supplier_quote_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
quote_id INT UNSIGNED NOT NULL,
item_id INT UNSIGNED NOT NULL,
item_description VARCHAR(500) NULL,
quantity DECIMAL(12,3) NOT NULL,
unit_price DECIMAL(15,2) NOT NULL,
total_price DECIMAL(15,2) NOT NULL,
delivery_days INT UNSIGNED NULL,
notes VARCHAR(500) NULL,
KEY idx_quote (quote_id),
KEY idx_item (item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS quote_evaluations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
evaluation_number VARCHAR(50) NOT NULL,
requisition_id INT UNSIGNED NULL,
evaluation_date DATE NOT NULL,
status ENUM('draft','completed','approved') NOT NULL DEFAULT 'draft',
winning_quote_id INT UNSIGNED NULL,
justification TEXT NULL,
purchase_order_id INT UNSIGNED NULL,
evaluated_by INT UNSIGNED NULL,
approved_by INT UNSIGNED NULL,
approved_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_eval_number (evaluation_number),
KEY idx_requisition (requisition_id),
KEY idx_winning_quote (winning_quote_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS supplier_payment_schedules (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
supplier_id INT UNSIGNED NOT NULL,
invoice_id INT UNSIGNED NULL,
schedule_number VARCHAR(50) NOT NULL,
total_amount DECIMAL(15,2) NOT NULL,
installments_count INT UNSIGNED NOT NULL,
status ENUM('active','completed','cancelled') NOT NULL DEFAULT 'active',
notes TEXT NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_supplier (supplier_id),
KEY idx_invoice (invoice_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS supplier_payment_schedule_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
schedule_id INT UNSIGNED NOT NULL,
installment_number INT UNSIGNED NOT NULL,
due_date DATE NOT NULL,
amount DECIMAL(15,2) NOT NULL,
payment_method ENUM('cash','check','transfer') NOT NULL DEFAULT 'cash',
paid_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00,
paid_date DATE NULL,
status ENUM('upcoming','due','paid','overdue') NOT NULL DEFAULT 'upcoming',
payment_id INT UNSIGNED NULL,
KEY idx_schedule (schedule_id),
KEY idx_due_date (due_date),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS item_price_history (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id INT UNSIGNED NOT NULL,
supplier_id INT UNSIGNED NULL,
price_type ENUM('purchase','sale','standard') NOT NULL DEFAULT 'purchase',
price DECIMAL(15,2) NOT NULL,
effective_date DATE NOT NULL,
document_type VARCHAR(50) NULL,
document_id INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_item (item_id),
KEY idx_supplier (supplier_id),
KEY idx_item_supplier (item_id, supplier_id),
KEY idx_date (effective_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "
DROP TABLE IF EXISTS item_price_history;
DROP TABLE IF EXISTS supplier_payment_schedule_items;
DROP TABLE IF EXISTS supplier_payment_schedules;
DROP TABLE IF EXISTS quote_evaluations;
DROP TABLE IF EXISTS supplier_quote_items;
DROP TABLE IF EXISTS supplier_price_quotes",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS hr_overtime_types (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NULL,
rate_multiplier DECIMAL(4,2) NOT NULL DEFAULT 1.50,
max_hours_per_day DECIMAL(4,2) NULL,
max_hours_per_month DECIMAL(5,2) NULL,
requires_approval TINYINT(1) NOT NULL DEFAULT 1,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS hr_overtime_requests (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_id INT UNSIGNED NOT NULL,
overtime_type_id INT UNSIGNED NOT NULL,
request_date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
hours DECIMAL(4,2) NOT NULL,
reason VARCHAR(500) NULL,
status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending',
approved_by INT UNSIGNED NULL,
approved_at DATETIME NULL,
rejection_reason VARCHAR(500) NULL,
payroll_run_id INT UNSIGNED NULL,
calculated_amount DECIMAL(12,2) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_employee (employee_id),
KEY idx_date (request_date),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS hr_attendance_violations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_id INT UNSIGNED NOT NULL,
attendance_id INT UNSIGNED NULL,
violation_date DATE NOT NULL,
violation_type ENUM('late_arrival','early_departure','absence','unauthorized_leave','missing_punch') NOT NULL,
scheduled_time TIME NULL,
actual_time TIME NULL,
difference_minutes INT NOT NULL DEFAULT 0,
is_excused TINYINT(1) NOT NULL DEFAULT 0,
excuse_reason VARCHAR(500) NULL,
penalty_applied TINYINT(1) NOT NULL DEFAULT 0,
penalty_amount DECIMAL(12,2) NULL,
penalty_description VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_employee (employee_id),
KEY idx_date (violation_date),
KEY idx_type (violation_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS hr_permission_requests (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_id INT UNSIGNED NOT NULL,
permission_date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
hours DECIMAL(4,2) NOT NULL,
reason VARCHAR(500) NOT NULL,
status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
approved_by INT UNSIGNED NULL,
approved_at DATETIME NULL,
monthly_total_hours DECIMAL(5,2) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_employee (employee_id),
KEY idx_date (permission_date),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS hr_shifts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
break_start TIME NULL,
break_end TIME NULL,
break_duration_minutes INT UNSIGNED NOT NULL DEFAULT 0,
working_hours DECIMAL(4,2) NOT NULL,
grace_period_minutes INT UNSIGNED NOT NULL DEFAULT 15,
early_leave_threshold_minutes INT UNSIGNED NOT NULL DEFAULT 15,
is_night_shift TINYINT(1) NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS hr_shift_assignments (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_id INT UNSIGNED NOT NULL,
shift_id INT UNSIGNED NOT NULL,
start_date DATE NOT NULL,
end_date DATE NULL,
rotation_pattern ENUM('fixed','weekly','biweekly','monthly') NOT NULL DEFAULT 'fixed',
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_employee (employee_id),
KEY idx_shift (shift_id),
KEY idx_active (is_active, start_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS hr_insurance_config (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
effective_date DATE NOT NULL,
basic_salary_cap DECIMAL(12,2) NOT NULL,
variable_salary_cap DECIMAL(12,2) NOT NULL,
employer_basic_rate DECIMAL(5,4) NOT NULL,
employer_variable_rate DECIMAL(5,4) NOT NULL,
employee_basic_rate DECIMAL(5,4) NOT NULL,
employee_variable_rate DECIMAL(5,4) NOT NULL,
min_subscription_salary DECIMAL(12,2) NOT NULL DEFAULT 0.00,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_effective (effective_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS hr_tax_brackets (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
bracket_order INT UNSIGNED NOT NULL,
from_amount DECIMAL(15,2) NOT NULL,
to_amount DECIMAL(15,2) NOT NULL,
rate DECIMAL(5,4) NOT NULL,
annual_exemption DECIMAL(15,2) NOT NULL DEFAULT 0.00,
effective_date DATE NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_effective (effective_date),
KEY idx_order (bracket_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "
DROP TABLE IF EXISTS hr_tax_brackets;
DROP TABLE IF EXISTS hr_insurance_config;
DROP TABLE IF EXISTS hr_shift_assignments;
DROP TABLE IF EXISTS hr_shifts;
DROP TABLE IF EXISTS hr_permission_requests;
DROP TABLE IF EXISTS hr_attendance_violations;
DROP TABLE IF EXISTS hr_overtime_requests;
DROP TABLE IF EXISTS hr_overtime_types",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS bill_of_materials (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id INT UNSIGNED NOT NULL,
bom_code VARCHAR(50) NOT NULL,
version INT UNSIGNED NOT NULL DEFAULT 1,
name_ar VARCHAR(255) NOT NULL,
name_en VARCHAR(255) NULL,
output_quantity DECIMAL(12,3) NOT NULL DEFAULT 1.000,
is_active TINYINT(1) NOT NULL DEFAULT 1,
notes TEXT NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_bom_code (bom_code),
KEY idx_item (item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS bom_components (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
bom_id INT UNSIGNED NOT NULL,
component_item_id INT UNSIGNED NOT NULL,
quantity DECIMAL(12,3) NOT NULL,
unit VARCHAR(50) NULL,
waste_percentage DECIMAL(5,2) NOT NULL DEFAULT 0.00,
is_optional TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
notes VARCHAR(500) NULL,
KEY idx_bom (bom_id),
KEY idx_component (component_item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS item_units (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id INT UNSIGNED NOT NULL,
unit_name_ar VARCHAR(50) NOT NULL,
unit_name_en VARCHAR(50) NULL,
conversion_factor DECIMAL(12,4) NOT NULL DEFAULT 1.0000,
is_base_unit TINYINT(1) NOT NULL DEFAULT 0,
is_purchase_unit TINYINT(1) NOT NULL DEFAULT 0,
is_sales_unit TINYINT(1) NOT NULL DEFAULT 0,
barcode VARCHAR(50) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_item (item_id),
KEY idx_base (item_id, is_base_unit)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS item_attributes (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NULL,
attribute_type ENUM('color','size','material','weight','custom') NOT NULL DEFAULT 'custom',
possible_values JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS item_attribute_values (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id INT UNSIGNED NOT NULL,
attribute_id INT UNSIGNED NOT NULL,
value VARCHAR(255) NOT NULL,
KEY idx_item (item_id),
KEY idx_attribute (attribute_id),
UNIQUE KEY uk_item_attr (item_id, attribute_id, value)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS item_variants (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id INT UNSIGNED NOT NULL,
variant_code VARCHAR(50) NOT NULL,
variant_name_ar VARCHAR(255) NOT NULL,
attributes JSON NOT NULL,
sku VARCHAR(50) NULL,
barcode VARCHAR(50) NULL,
additional_cost DECIMAL(15,2) NOT NULL DEFAULT 0.00,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_variant_code (variant_code),
KEY idx_item (item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS sales_representatives (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_id INT UNSIGNED NULL,
code VARCHAR(20) NOT NULL,
name_ar VARCHAR(255) NOT NULL,
name_en VARCHAR(255) NULL,
phone VARCHAR(20) NULL,
email VARCHAR(100) NULL,
commission_type ENUM('flat','percentage','tiered') NOT NULL DEFAULT 'percentage',
commission_rate DECIMAL(5,2) NOT NULL DEFAULT 0.00,
target_amount DECIMAL(15,2) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_code (code),
KEY idx_employee (employee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS sales_commissions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
representative_id INT UNSIGNED NOT NULL,
sale_id INT UNSIGNED NOT NULL,
sale_amount DECIMAL(15,2) NOT NULL,
commission_rate DECIMAL(5,2) NOT NULL,
commission_amount DECIMAL(15,2) NOT NULL,
period_month DATE NOT NULL,
status ENUM('calculated','approved','paid') NOT NULL DEFAULT 'calculated',
paid_date DATE NULL,
payment_reference VARCHAR(100) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_rep (representative_id),
KEY idx_sale (sale_id),
KEY idx_period (period_month),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS customer_price_lists (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NULL,
discount_type ENUM('percentage','fixed') NOT NULL DEFAULT 'percentage',
discount_value DECIMAL(10,2) NOT NULL DEFAULT 0.00,
priority INT UNSIGNED NOT NULL DEFAULT 0,
start_date DATE NULL,
end_date DATE NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS customer_item_prices (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
member_id INT UNSIGNED NULL,
price_list_id INT UNSIGNED NULL,
item_id INT UNSIGNED NOT NULL,
category_id INT UNSIGNED NULL,
discount_type ENUM('percentage','fixed') NOT NULL DEFAULT 'percentage',
discount_value DECIMAL(10,2) NOT NULL DEFAULT 0.00,
special_price DECIMAL(15,2) NULL,
start_date DATE NULL,
end_date DATE NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_member (member_id),
KEY idx_item (item_id),
KEY idx_price_list (price_list_id),
KEY idx_category (category_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "
DROP TABLE IF EXISTS customer_item_prices;
DROP TABLE IF EXISTS customer_price_lists;
DROP TABLE IF EXISTS sales_commissions;
DROP TABLE IF EXISTS sales_representatives;
DROP TABLE IF EXISTS item_variants;
DROP TABLE IF EXISTS item_attribute_values;
DROP TABLE IF EXISTS item_attributes;
DROP TABLE IF EXISTS item_units;
DROP TABLE IF EXISTS bom_components;
DROP TABLE IF EXISTS bill_of_materials",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS movement_types (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(30) NOT NULL,
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NULL,
module ENUM('sales','inventory','procurement') NOT NULL,
direction ENUM('in','out','neutral') NOT NULL,
affects_stock TINYINT(1) NOT NULL DEFAULT 1,
posts_to_gl TINYINT(1) NOT NULL DEFAULT 1,
requires_approval TINYINT(1) NOT NULL DEFAULT 0,
creates_ar TINYINT(1) NOT NULL DEFAULT 0,
creates_ap TINYINT(1) NOT NULL DEFAULT 0,
allows_discount TINYINT(1) NOT NULL DEFAULT 1,
allows_tax TINYINT(1) NOT NULL DEFAULT 1,
party_types JSON NULL,
auto_number_prefix VARCHAR(10) NULL,
auto_number_next INT UNSIGNED NOT NULL DEFAULT 1,
print_template VARCHAR(100) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS movement_specifications (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
movement_type_id INT UNSIGNED NOT NULL,
spec_key VARCHAR(50) NOT NULL,
spec_value TEXT NULL,
spec_type ENUM('boolean','string','integer','decimal','account','json') NOT NULL DEFAULT 'string',
description_ar VARCHAR(255) NULL,
KEY idx_movement_type (movement_type_id),
UNIQUE KEY uk_type_key (movement_type_id, spec_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS currencies (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(3) NOT NULL,
name_ar VARCHAR(50) NOT NULL,
name_en VARCHAR(50) NULL,
symbol VARCHAR(10) NOT NULL,
decimal_places TINYINT UNSIGNED NOT NULL DEFAULT 2,
is_base_currency TINYINT(1) NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS exchange_rates (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
currency_id INT UNSIGNED NOT NULL,
rate_date DATE NOT NULL,
buy_rate DECIMAL(15,6) NOT NULL,
sell_rate DECIMAL(15,6) NOT NULL,
mid_rate DECIMAL(15,6) NOT NULL,
source VARCHAR(50) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_currency (currency_id),
KEY idx_date (rate_date),
UNIQUE KEY uk_currency_date (currency_id, rate_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE sales ADD COLUMN currency_code VARCHAR(3) NULL DEFAULT 'EGP' AFTER total_amount;
ALTER TABLE sales ADD COLUMN exchange_rate DECIMAL(15,6) NULL DEFAULT 1.000000 AFTER currency_code;
ALTER TABLE sales ADD COLUMN representative_id INT UNSIGNED NULL AFTER exchange_rate;
ALTER TABLE purchase_orders ADD COLUMN currency_code VARCHAR(3) NULL DEFAULT 'EGP' AFTER total_amount;
ALTER TABLE purchase_orders ADD COLUMN exchange_rate DECIMAL(15,6) NULL DEFAULT 1.000000 AFTER currency_code",
'down' => "
ALTER TABLE purchase_orders DROP COLUMN exchange_rate;
ALTER TABLE purchase_orders DROP COLUMN currency_code;
ALTER TABLE sales DROP COLUMN representative_id;
ALTER TABLE sales DROP COLUMN exchange_rate;
ALTER TABLE sales DROP COLUMN currency_code;
DROP TABLE IF EXISTS exchange_rates;
DROP TABLE IF EXISTS currencies;
DROP TABLE IF EXISTS movement_specifications;
DROP TABLE IF EXISTS movement_types",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS documentary_credits (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
lc_number VARCHAR(50) NOT NULL,
issuing_bank_id INT UNSIGNED NOT NULL,
beneficiary_supplier_id INT UNSIGNED NULL,
beneficiary_name VARCHAR(255) NOT NULL,
amount DECIMAL(15,2) NOT NULL,
currency_code VARCHAR(3) NOT NULL DEFAULT 'EGP',
margin_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00,
margin_percentage DECIMAL(5,2) NOT NULL DEFAULT 0.00,
opening_date DATE NOT NULL,
expiry_date DATE NOT NULL,
shipment_date DATE NULL,
status ENUM('opened','shipped','documents_presented','partial_paid','paid','closed','cancelled') NOT NULL DEFAULT 'opened',
terms TEXT NULL,
purchase_order_id INT UNSIGNED NULL,
vendor_invoice_id INT UNSIGNED NULL,
margin_journal_id INT UNSIGNED NULL,
payment_journal_id INT UNSIGNED NULL,
total_expenses DECIMAL(15,2) NOT NULL DEFAULT 0.00,
notes TEXT NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_lc_number (lc_number),
KEY idx_bank (issuing_bank_id),
KEY idx_supplier (beneficiary_supplier_id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS lc_documents (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
lc_id INT UNSIGNED NOT NULL,
document_type ENUM('invoice','packing_list','bill_of_lading','insurance','certificate_of_origin','inspection','other') NOT NULL,
document_number VARCHAR(100) NULL,
document_date DATE NULL,
received_date DATE NULL,
status ENUM('pending','received','verified','discrepant') NOT NULL DEFAULT 'pending',
discrepancy_notes TEXT NULL,
file_path VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_lc (lc_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS letters_of_guarantee (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
guarantee_number VARCHAR(50) NOT NULL,
bank_account_id INT UNSIGNED NOT NULL,
beneficiary_name VARCHAR(255) NOT NULL,
guarantee_type ENUM('tender','performance','advance_payment','maintenance','customs','other') NOT NULL,
amount DECIMAL(15,2) NOT NULL,
currency_code VARCHAR(3) NOT NULL DEFAULT 'EGP',
margin_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00,
margin_percentage DECIMAL(5,2) NOT NULL DEFAULT 0.00,
commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
commission_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00,
issue_date DATE NOT NULL,
expiry_date DATE NOT NULL,
renewal_date DATE NULL,
status ENUM('requested','issued','active','released','called','expired','renewed') NOT NULL DEFAULT 'requested',
related_contract VARCHAR(255) NULL,
margin_journal_id INT UNSIGNED NULL,
commission_journal_id INT UNSIGNED NULL,
notes TEXT NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_guarantee_number (guarantee_number),
KEY idx_bank (bank_account_id),
KEY idx_type (guarantee_type),
KEY idx_status (status),
KEY idx_expiry (expiry_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS asset_categories (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name_ar VARCHAR(100) NOT NULL,
name_en VARCHAR(100) NULL,
parent_id INT UNSIGNED NULL,
depreciation_method ENUM('straight_line','declining_balance','sum_of_years','units_of_production') NOT NULL DEFAULT 'straight_line',
default_useful_life_months INT UNSIGNED NOT NULL DEFAULT 60,
default_salvage_percentage DECIMAL(5,2) NOT NULL DEFAULT 0.00,
declining_rate DECIMAL(5,2) NULL,
asset_account_id INT UNSIGNED NULL,
depreciation_account_id INT UNSIGNED NULL,
expense_account_id INT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_parent (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS asset_revaluations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
asset_id INT UNSIGNED NOT NULL,
revaluation_date DATE NOT NULL,
old_value DECIMAL(15,2) NOT NULL,
new_value DECIMAL(15,2) NOT NULL,
difference DECIMAL(15,2) NOT NULL,
direction ENUM('upward','downward') NOT NULL,
reason VARCHAR(500) NULL,
appraiser_name VARCHAR(255) NULL,
journal_entry_id INT UNSIGNED NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_asset (asset_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS asset_improvements (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
asset_id INT UNSIGNED NOT NULL,
improvement_date DATE NOT NULL,
description VARCHAR(500) NOT NULL,
cost DECIMAL(15,2) NOT NULL,
extends_life_months INT UNSIGNED NOT NULL DEFAULT 0,
vendor_name VARCHAR(255) NULL,
invoice_number VARCHAR(100) NULL,
journal_entry_id INT UNSIGNED NULL,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_asset (asset_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE asset_register ADD COLUMN category_id INT UNSIGNED NULL AFTER item_id;
ALTER TABLE asset_register ADD COLUMN insurance_policy VARCHAR(100) NULL AFTER site_location;
ALTER TABLE asset_register ADD COLUMN insurance_expiry DATE NULL AFTER insurance_policy;
ALTER TABLE asset_register ADD COLUMN insurance_amount DECIMAL(15,2) NULL AFTER insurance_expiry",
'down' => "
ALTER TABLE asset_register DROP COLUMN insurance_amount;
ALTER TABLE asset_register DROP COLUMN insurance_expiry;
ALTER TABLE asset_register DROP COLUMN insurance_policy;
ALTER TABLE asset_register DROP COLUMN category_id;
DROP TABLE IF EXISTS asset_improvements;
DROP TABLE IF EXISTS asset_revaluations;
DROP TABLE IF EXISTS asset_categories;
DROP TABLE IF EXISTS letters_of_guarantee;
DROP TABLE IF EXISTS lc_documents;
DROP TABLE IF EXISTS documentary_credits",
];
<?php
declare(strict_types=1);
return function (\App\Core\Database $db): void {
$types = [
['code' => 'sales_invoice', 'name_ar' => 'فاتورة مبيعات', 'name_en' => 'Sales Invoice', 'module' => 'sales', 'direction' => 'out', 'affects_stock' => 1, 'posts_to_gl' => 1, 'creates_ar' => 1, 'allows_discount' => 1, 'allows_tax' => 1, 'auto_number_prefix' => 'INV', 'sort_order' => 1],
['code' => 'sales_return', 'name_ar' => 'مرتجع مبيعات', 'name_en' => 'Sales Return', 'module' => 'sales', 'direction' => 'in', 'affects_stock' => 1, 'posts_to_gl' => 1, 'creates_ar' => 1, 'allows_discount' => 0, 'allows_tax' => 1, 'auto_number_prefix' => 'SRT', 'sort_order' => 2],
['code' => 'sales_quotation', 'name_ar' => 'عرض سعر مبيعات', 'name_en' => 'Sales Quotation', 'module' => 'sales', 'direction' => 'neutral', 'affects_stock' => 0, 'posts_to_gl' => 0, 'creates_ar' => 0, 'allows_discount' => 1, 'allows_tax' => 1, 'auto_number_prefix' => 'SQT', 'sort_order' => 3],
['code' => 'purchase_invoice', 'name_ar' => 'فاتورة مشتريات', 'name_en' => 'Purchase Invoice', 'module' => 'procurement', 'direction' => 'in', 'affects_stock' => 1, 'posts_to_gl' => 1, 'creates_ap' => 1, 'allows_discount' => 1, 'allows_tax' => 1, 'auto_number_prefix' => 'PIN', 'sort_order' => 1],
['code' => 'purchase_return', 'name_ar' => 'مرتجع مشتريات', 'name_en' => 'Purchase Return', 'module' => 'procurement', 'direction' => 'out', 'affects_stock' => 1, 'posts_to_gl' => 1, 'creates_ap' => 1, 'allows_discount' => 0, 'allows_tax' => 1, 'auto_number_prefix' => 'PRT', 'sort_order' => 2],
['code' => 'stock_in', 'name_ar' => 'إذن إضافة مخزون', 'name_en' => 'Stock In', 'module' => 'inventory', 'direction' => 'in', 'affects_stock' => 1, 'posts_to_gl' => 0, 'requires_approval' => 1, 'auto_number_prefix' => 'SIN', 'sort_order' => 1],
['code' => 'stock_out', 'name_ar' => 'إذن صرف مخزون', 'name_en' => 'Stock Out', 'module' => 'inventory', 'direction' => 'out', 'affects_stock' => 1, 'posts_to_gl' => 0, 'requires_approval' => 1, 'auto_number_prefix' => 'SOT', 'sort_order' => 2],
['code' => 'stock_transfer', 'name_ar' => 'تحويل بين مخازن', 'name_en' => 'Stock Transfer', 'module' => 'inventory', 'direction' => 'neutral', 'affects_stock' => 1, 'posts_to_gl' => 0, 'requires_approval' => 1, 'auto_number_prefix' => 'STR', 'sort_order' => 3],
['code' => 'stock_adjustment', 'name_ar' => 'تسوية مخزون', 'name_en' => 'Stock Adjustment', 'module' => 'inventory', 'direction' => 'neutral', 'affects_stock' => 1, 'posts_to_gl' => 1, 'requires_approval' => 1, 'auto_number_prefix' => 'ADJ', 'sort_order' => 4],
];
foreach ($types as $type) {
$existing = $db->selectOne("SELECT id FROM movement_types WHERE code = ?", [$type['code']]);
if ($existing) continue;
$db->insert('movement_types', array_merge([
'creates_ar' => 0,
'creates_ap' => 0,
'requires_approval' => 0,
'allows_discount' => 0,
'allows_tax' => 0,
'party_types' => null,
'auto_number_next' => 1,
'is_active' => 1,
], $type));
}
// Seed base currency
$existing = $db->selectOne("SELECT id FROM currencies WHERE code = 'EGP'");
if (!$existing) {
$db->insert('currencies', [
'code' => 'EGP',
'name_ar' => 'جنيه مصري',
'name_en' => 'Egyptian Pound',
'symbol' => 'ج.م',
'decimal_places' => 2,
'is_base_currency' => 1,
'is_active' => 1,
]);
$db->insert('currencies', [
'code' => 'USD',
'name_ar' => 'دولار أمريكي',
'name_en' => 'US Dollar',
'symbol' => '$',
'decimal_places' => 2,
'is_base_currency' => 0,
'is_active' => 1,
]);
$db->insert('currencies', [
'code' => 'EUR',
'name_ar' => 'يورو',
'name_en' => 'Euro',
'symbol' => '€',
'decimal_places' => 2,
'is_base_currency' => 0,
'is_active' => 1,
]);
$db->insert('currencies', [
'code' => 'SAR',
'name_ar' => 'ريال سعودي',
'name_en' => 'Saudi Riyal',
'symbol' => 'ر.س',
'decimal_places' => 2,
'is_base_currency' => 0,
'is_active' => 1,
]);
}
// Seed default overtime types
$existing = $db->selectOne("SELECT id FROM hr_overtime_types LIMIT 1");
if (!$existing) {
$db->insert('hr_overtime_types', ['name_ar' => 'عمل إضافي عادي', 'name_en' => 'Normal Overtime', 'rate_multiplier' => '1.50', 'max_hours_per_day' => '4.00', 'max_hours_per_month' => '50.00']);
$db->insert('hr_overtime_types', ['name_ar' => 'عمل إضافي إجازات', 'name_en' => 'Holiday Overtime', 'rate_multiplier' => '2.00', 'max_hours_per_day' => '8.00', 'max_hours_per_month' => '30.00']);
$db->insert('hr_overtime_types', ['name_ar' => 'عمل إضافي ليلي', 'name_en' => 'Night Overtime', 'rate_multiplier' => '1.75', 'max_hours_per_day' => '6.00', 'max_hours_per_month' => '40.00']);
}
// Seed Egyptian insurance config (2024 rates)
$existing = $db->selectOne("SELECT id FROM hr_insurance_config LIMIT 1");
if (!$existing) {
$db->insert('hr_insurance_config', [
'effective_date' => '2024-01-01',
'basic_salary_cap' => '12600.00',
'variable_salary_cap' => '10080.00',
'employer_basic_rate' => '0.1875',
'employer_variable_rate' => '0.1575',
'employee_basic_rate' => '0.1100',
'employee_variable_rate' => '0.1100',
'min_subscription_salary' => '2000.00',
'is_active' => 1,
]);
}
// Seed Egyptian tax brackets (2024)
$existing = $db->selectOne("SELECT id FROM hr_tax_brackets LIMIT 1");
if (!$existing) {
$brackets = [
['order' => 1, 'from' => '0', 'to' => '40000', 'rate' => '0.0000', 'exemption' => '20000'],
['order' => 2, 'from' => '40000', 'to' => '55000', 'rate' => '0.1000', 'exemption' => '0'],
['order' => 3, 'from' => '55000', 'to' => '70000', 'rate' => '0.1500', 'exemption' => '0'],
['order' => 4, 'from' => '70000', 'to' => '200000', 'rate' => '0.2000', 'exemption' => '0'],
['order' => 5, 'from' => '200000', 'to' => '400000', 'rate' => '0.2250', 'exemption' => '0'],
['order' => 6, 'from' => '400000', 'to' => '600000', 'rate' => '0.2500', 'exemption' => '0'],
['order' => 7, 'from' => '600000', 'to' => '99999999', 'rate' => '0.2750', 'exemption' => '0'],
];
foreach ($brackets as $b) {
$db->insert('hr_tax_brackets', [
'bracket_order' => $b['order'],
'from_amount' => $b['from'],
'to_amount' => $b['to'],
'rate' => $b['rate'],
'annual_exemption' => $b['exemption'],
'effective_date' => '2024-01-01',
'is_active' => 1,
]);
}
}
// Seed default shift
$existing = $db->selectOne("SELECT id FROM hr_shifts LIMIT 1");
if (!$existing) {
$db->insert('hr_shifts', [
'name_ar' => 'الوردية الصباحية',
'name_en' => 'Morning Shift',
'start_time' => '08:00:00',
'end_time' => '16:00:00',
'break_start' => '12:00:00',
'break_end' => '13:00:00',
'break_duration_minutes' => 60,
'working_hours' => '7.00',
'grace_period_minutes' => 15,
'early_leave_threshold_minutes' => 15,
'is_night_shift' => 0,
]);
$db->insert('hr_shifts', [
'name_ar' => 'الوردية المسائية',
'name_en' => 'Evening Shift',
'start_time' => '16:00:00',
'end_time' => '00:00:00',
'break_start' => '19:00:00',
'break_end' => '19:30:00',
'break_duration_minutes' => 30,
'working_hours' => '7.50',
'grace_period_minutes' => 10,
'early_leave_threshold_minutes' => 10,
'is_night_shift' => 1,
]);
}
};
# Comprehensive Implementation Plan
## Features Gap Analysis & Implementation Roadmap
Based on analysis of 15 reference ERP system PDFs (4S Technology) compared against our existing Club ERP system.
---
# PART 1: GAP ANALYSIS
## Features We ALREADY Have (Fully Implemented)
| # | Feature | Our Module | Status |
|---|---------|-----------|--------|
| 1 | General Ledger & Chart of Accounts | Accounting | Full hierarchical COA, GL posting, multi-type accounts |
| 2 | Journal Entries (create/post/reverse) | Accounting | Manual + auto-posting from events |
| 3 | Trial Balance & Financial Statements | Accounting | TB, P&L, Balance Sheet, Consolidated BS |
| 4 | Cost Centers | Accounting | Full CRUD + budget allocation |
| 5 | Budgets (account + cost-center level) | Accounting | With variance analysis |
| 6 | Bank Accounts & Reconciliation | Accounting | Multi-bank, item-level reconciliation |
| 7 | Negotiable Instruments & Portfolios | Accounting | Checks, notes, portfolio management |
| 8 | Fiscal Year Management | Accounting | Create, close, multi-year |
| 9 | Period Closing | Accounting | Month open/close |
| 10 | Accounts Receivable / Payable | Accounting | AR/AP aging reports |
| 11 | Opening Balances | Accounting | Opening entries + snapshots |
| 12 | Multi-dimensional Accounting | Accounting | Configurable dimensions |
| 13 | Employee Management | HR | Full profiles, departments, job titles |
| 14 | Attendance Tracking | HR | Daily bulk entry, monthly views |
| 15 | Leave Management | HR | Request/approve, balance tracking, calendar |
| 16 | Payroll Processing | HR | Multi-period, calculate/approve/pay |
| 17 | Salary Structures & Components | HR | Template-based with components |
| 18 | Employment Contracts | HR | Create, renew, terminate |
| 19 | Social Insurance Records | HR | Form 1 & Form 6 generation |
| 20 | Tax Records | HR | Per-employee tracking |
| 21 | Employee Loans | HR | Request, approve, disburse |
| 22 | End of Service | HR | Calculate, approve, pay |
| 23 | Performance Reviews | HR | Cycles + individual reviews |
| 24 | Disciplinary Actions | HR | With appeals workflow |
| 25 | Inventory Items & Categories | Inventory | Full CRUD, hierarchical categories |
| 26 | Multiple Warehouses | Inventory | Multi-warehouse stock tracking |
| 27 | Stock Movements (in/out) | Inventory | Transaction history, balance tracking |
| 28 | Stock Transfers | Inventory | Inter-warehouse with approval |
| 29 | Stock Audits (Physical Count) | Inventory | Count, variance, adjustment approval |
| 30 | Supplier Management | Inventory | Master data, history |
| 31 | Purchase Orders (basic) | Inventory + Procurement | With approval workflow |
| 32 | Fixed Assets & Depreciation | Inventory | Asset register, depreciation |
| 33 | Low Stock Alerts | Inventory | Reorder level monitoring |
| 34 | Expiry Date Tracking | Inventory | Batch-level expiry |
| 35 | POS / Sales Transactions | Sales | Item selection, customer lookup |
| 36 | Sales Packages/Bundles | Sales | Pre-configured bundles |
| 37 | Sales Refunds | Sales | Partial/full refund |
| 38 | Sales Void | Sales | Transaction cancellation |
| 39 | Purchase Requisitions | Procurement | Create, submit, approve, convert |
| 40 | Goods Received Notes | Procurement | GRN with inspection |
| 41 | Vendor Invoices | Procurement | Entry, verification, approval |
| 42 | Three-Way Matching | Procurement | PR-GRN-Invoice matching |
| 43 | Vendor Payments | Procurement | With approval workflow |
| 44 | Returns to Vendor | Procurement | Full lifecycle tracking |
| 45 | Payment Processing (cash/check/card) | Payments | Multi-method, event-driven |
| 46 | Receipts | Receipts | Auto-generation, printing, voiding |
| 47 | Installment Plans | Installments | Auto-schedule, interest calculation |
| 48 | Cashier Queue | Cashier | Central payment processing |
| 49 | Work Schedules | HR | Template-based |
| 50 | Holidays | HR | Holiday master |
| 51 | Employee Documents | HR | Upload, verify, archive |
| 52 | Daily/Monthly Sales Reports | Sales | By day, month, item |
| 53 | Procurement Reports | Procurement | Volume, performance, overdue |
---
## Features We DON'T Have (Gaps)
### CATEGORY A: High-Impact Business Features (Core ERP Capabilities)
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| A1 | Configurable Movement Specifications Engine (26+ params per movement type) | Sales, Inventory, Purchasing | HIGH | HIGH |
| A2 | Multi-segment hierarchical item codes (XX-XX-XX-XX-XXXXXX) | Basic ERP, Inventory, Sales | MEDIUM | MEDIUM |
| A3 | Bill of Materials (BOM) / Sub-components for composite items | Basic ERP, Inventory | HIGH | HIGH |
| A4 | Supplier Price Quote solicitation & evaluation workflow | Purchasing | HIGH | MEDIUM |
| A5 | Side-by-side quote comparison across suppliers | Purchasing | HIGH | MEDIUM |
| A6 | Credit limit system for customers with auto-calculated allowed limit | Sales | HIGH | LOW |
| A7 | Supplier credit limit with auto-calculated allowed limit | Purchasing | HIGH | LOW |
| A8 | Documentary Credits (اعتمادات مستندية) | Banks | MEDIUM | HIGH |
| A9 | Letter of Guarantee management (خطابات ضمان) | Banks | MEDIUM | HIGH |
| A10 | Bank Loan management | Banks | MEDIUM | MEDIUM |
| A11 | Debit/Credit settlements across entities (cross-entity) | Purchasing, Banks | MEDIUM | MEDIUM |
| A12 | Sales Representative & Commission management | Sales | MEDIUM | MEDIUM |
| A13 | Multi-currency transactions (full support) | Basic ERP | MEDIUM | HIGH |
| A14 | Hijri/Gregorian dual calendar | Basic ERP | LOW | MEDIUM |
| A15 | Franchise-based supplier account calculation (3 methods) | Purchasing | LOW | MEDIUM |
### CATEGORY B: Fixed Assets Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| B1 | Asset categories with configurable depreciation methods per category | Fixed Assets | HIGH | MEDIUM |
| B2 | Asset location/site tracking with transfers | Fixed Assets | MEDIUM | LOW |
| B3 | Asset custody tracking (عهدة) with employee assignment | Fixed Assets | HIGH | LOW |
| B4 | Asset revaluation | Fixed Assets | MEDIUM | MEDIUM |
| B5 | Asset insurance tracking per asset | Fixed Assets | LOW | LOW |
| B6 | Multiple depreciation methods (straight-line, declining balance, production-units, sum-of-years) | Fixed Assets | HIGH | MEDIUM |
| B7 | Asset improvement/addition tracking | Fixed Assets | MEDIUM | LOW |
| B8 | Partial disposal (sell/scrap portion of asset) | Fixed Assets | LOW | MEDIUM |
### CATEGORY C: HR & Payroll Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| C1 | Overtime system with configurable rates per period/type | HR, Wages | HIGH | MEDIUM |
| C2 | Shift management with rotation and assignment | Attendance | MEDIUM | MEDIUM |
| C3 | Fingerprint/biometric device integration | Attendance | HIGH | HIGH |
| C4 | Attendance violation auto-detection (late, early leave, absence) | Attendance | HIGH | MEDIUM |
| C5 | Permission requests (hourly leaves) | Attendance | MEDIUM | LOW |
| C6 | Mission/travel tracking | Attendance | LOW | LOW |
| C7 | Overtime request & approval workflow | Attendance | MEDIUM | LOW |
| C8 | Social insurance auto-calculation (employer + employee shares with legal limits) | Wages | HIGH | MEDIUM |
| C9 | Progressive tax bracket auto-calculation | Wages | HIGH | MEDIUM |
| C10 | Configurable allowance/deduction formulas | Wages | MEDIUM | MEDIUM |
| C11 | Employee bonus system with configurable criteria | Wages | MEDIUM | LOW |
| C12 | Salary hold/release mechanism | Wages | LOW | LOW |
| C13 | Retroactive salary adjustments | Wages | MEDIUM | MEDIUM |
| C14 | Monthly salary comparison report (variance analysis) | Wages | LOW | LOW |
### CATEGORY D: Purchasing & Procurement Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| D1 | Internal Purchase Request → Quote → Evaluation → PO full cycle | Purchasing | HIGH | MEDIUM |
| D2 | Purchase delay/overdue delivery tracking | Purchasing | HIGH | LOW |
| D3 | Item price deviation monitoring & alerts | Purchasing | MEDIUM | LOW |
| D4 | Item vs. budget comparison for purchases | Purchasing | MEDIUM | LOW |
| D5 | Daily item purchases analysis | Purchasing | LOW | LOW |
| D6 | Supplier debt aging report | Purchasing | MEDIUM | LOW |
| D7 | Payment scheduling/installments for suppliers (جدولة) | Purchasing | MEDIUM | MEDIUM |
| D8 | Specific PO balance tracking (qty requested vs received vs remaining, delivery %) | Purchasing | HIGH | LOW |
| D9 | Items linked to suppliers cross-reference report | Purchasing | LOW | LOW |
| D10 | Movement approval workflow (بيان اعتماد الحركات) | Purchasing | MEDIUM | MEDIUM |
### CATEGORY E: Banking Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| E1 | Check deposit and collection lifecycle tracking (issued→deposited→collected/bounced) | Banks | HIGH | MEDIUM |
| E2 | Check endorsement/transfer (تظهير) | Banks | MEDIUM | MEDIUM |
| E3 | Payment note status update with portfolio-based batch processing | Banks, Purchasing | MEDIUM | LOW |
| E4 | Paper/Check multi-criteria inquiry (by serial, number, bank, date, status) | Purchasing | MEDIUM | LOW |
| E5 | Daily cash movement report for safes/treasuries | Banks | MEDIUM | LOW |
| E6 | Payment notes analysis (summary: direct vs indirect, checks vs promissory) | Purchasing | LOW | LOW |
### CATEGORY F: Sales Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| F1 | Customer discount rates (per-customer per-item pricing) | Sales | MEDIUM | MEDIUM |
| F2 | Sales returns with automatic warehouse stock update | Sales | HIGH | LOW |
| F3 | Multiple configurable sales movement types (invoice, return, quotation, etc.) | Sales | MEDIUM | MEDIUM |
| F4 | Sales collection notes/checks from customers | Sales | MEDIUM | MEDIUM |
| F5 | Customer account statement (detailed and summary) | Sales | HIGH | LOW |
| F6 | Customer current position report (all customers snapshot) | Sales | MEDIUM | LOW |
| F7 | Customer movement tracking in period | Sales | LOW | LOW |
### CATEGORY G: Inventory Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| G1 | Opening balance by item per warehouse (inventory initialization) | Inventory | HIGH | LOW |
| G2 | Item price history tracking (first, current, average purchase prices) | Inventory | MEDIUM | LOW |
| G3 | Supplier-to-item code mapping (supplier part numbers) | Inventory | LOW | LOW |
| G4 | Color/variant/attribute tracking per item group | Inventory | MEDIUM | MEDIUM |
| G5 | Multiple units per item with conversion factors | Inventory | HIGH | MEDIUM |
| G6 | Contractor entity type (separate from customer/supplier) | Inventory | LOW | LOW |
| G7 | Previous years' balance auto-carry-forward | Inventory | MEDIUM | LOW |
| G8 | Configurable stock movement types | Inventory | MEDIUM | MEDIUM |
### CATEGORY H: Reporting & Export Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| H1 | Multi-format report export (Excel, PDF, CSV, HTML) | All PDFs | HIGH | MEDIUM |
| H2 | Report designer with configurable parameters | All PDFs | MEDIUM | HIGH |
| H3 | Report print preview with zoom | All PDFs | LOW | LOW |
| H4 | Supplier account statement (detailed and summary formats) | Purchasing | HIGH | LOW |
| H5 | T-account format for supplier/customer reports | Purchasing, Sales | LOW | LOW |
---
# PART 2: IMPLEMENTATION PLAN
## Phase 1: Quick Wins (Low Complexity, High Impact)
**Timeline: 2-3 weeks**
**Focus: Features that add significant value with minimal development effort**
### 1.1 Customer & Supplier Credit Limits (A6, A7)
- Add `credit_limit` column to members/suppliers tables
- Add `allowed_limit` computed field (credit_limit - total_payments)
- Add warning/block when transaction would exceed limit
- UI: Show credit status on member/supplier profile
### 1.2 Purchase Delay Tracking (D2)
- Add `expected_delivery_date` to purchase_order_items
- Create overdue delivery report (filter by date, supplier, item)
- Dashboard widget for overdue items count
### 1.3 PO Balance Tracking (D8)
- Report showing: qty_ordered vs qty_received vs qty_remaining per PO
- Delivery percentage calculation
- Filter by supplier, date range, item group
### 1.4 Customer Account Statement (F5)
- Detailed report: date, document type, description, debit, credit, running balance
- Summary report: totals only
- Filter by date range, member code
### 1.5 Supplier Account Statement (H4)
- Same as F5 but for suppliers
- Include purchase orders, invoices, payments, returns
### 1.6 Sales Returns with Warehouse Update (F2)
- Enhance refund process to auto-update inventory stock
- Create stock movement record on refund
### 1.7 Inventory Opening Balances (G1)
- Screen to enter opening stock quantities per item per warehouse
- Generate stock movement records for initialization
- Useful for new warehouse setup or year-start
### 1.8 Asset Custody Tracking (B3)
- Add `custodian_employee_id` and `location` to asset_registers
- Asset assignment/transfer form
- Report: assets by employee/location
### 1.9 Asset Location Tracking (B2)
- Add `site_location` field to assets
- Transfer form to move asset between locations
- Asset location history log
---
## Phase 2: Financial & Banking Enhancements (Medium Complexity)
**Timeline: 3-4 weeks**
**Focus: Banking operations and financial instruments**
### 2.1 Check Lifecycle Status Tracking (E1)
- Expand negotiable_instruments with status workflow: issued → deposited → collected/bounced/endorsed
- Status transition actions with date tracking
- Status history log table
### 2.2 Check Endorsement (E2)
- Add endorsement capability (transfer check to another party)
- Track endorsement chain
- Update beneficiary on endorsement
### 2.3 Payment Note Portfolio Batch Processing (E3)
- Batch status update for multiple papers in a portfolio
- Select portfolio → select papers → apply status change
- Bulk operations support
### 2.4 Paper/Check Multi-Criteria Inquiry (E4)
- Advanced search screen for all negotiable instruments
- Filter by: serial, check number, bank, date range (issue/maturity), status, party, type
- Results grid with sort/export
### 2.5 Daily Cash Movement Report (E5)
- Safe/treasury daily movement summary
- Opening balance + inflows - outflows = closing balance
- Per-safe breakdown
### 2.6 Debit/Credit Cross-Entity Settlements (A11)
- Settlement transactions between suppliers, customers, banks, safes
- Purpose types: payment, advance, adjustment
- Auto-post journal entries for both parties
### 2.7 Bank Loan Management (A10)
- Loan master: bank, amount, rate, term, start date
- Amortization schedule generation
- Payment tracking against schedule
- Outstanding balance reporting
---
## Phase 3: Procurement Cycle Enhancement (Medium Complexity)
**Timeline: 3-4 weeks**
**Focus: Complete the procurement cycle with quote management**
### 3.1 Supplier Price Quote Request (A4)
- New entity: `supplier_price_quotes`
- Create quote request linked to purchase requisition
- Send to multiple suppliers (track which suppliers were solicited)
- Supplier response entry: per-item pricing, delivery terms
### 3.2 Quote Evaluation & Comparison (A5)
- Side-by-side comparison screen
- Show all quotes for same PR on one page
- Per-item comparison: price, delivery time, terms
- Winner selection with justification
### 3.3 Quote-to-PO Conversion (D1)
- "Issue Purchase Order" action from evaluation screen
- Auto-populate PO from winning quote
- Link chain: PR → Quotes → Evaluation → PO → GRN → Invoice
### 3.4 Supplier Payment Scheduling (D7)
- Payment plans for large invoices
- Schedule: amount, due date, method (cash/check)
- Track paid vs pending installments
- Alert on upcoming/overdue payments
### 3.5 Item Price Deviation Monitoring (D3)
- Track standard/expected price per item per supplier
- Alert when invoice price deviates beyond threshold
- Deviation report: item, supplier, expected, actual, variance %
### 3.6 Purchase vs Budget Comparison (D4)
- Link purchase categories to budget accounts
- Report: budgeted amount vs actual purchases YTD
- Variance highlighting (over/under budget)
---
## Phase 4: HR & Payroll Enhancements (Medium-High Complexity)
**Timeline: 4-5 weeks**
**Focus: Attendance automation, overtime, and payroll calculation improvements**
### 4.1 Overtime System (C1)
- Overtime types: normal, holiday, night
- Configurable rate multipliers per type (1.5x, 2x, 3x)
- Overtime request submission and approval workflow
- Auto-calculate overtime pay in payroll
### 4.2 Attendance Violation Detection (C4)
- Define rules: grace period, late threshold, early leave threshold
- Auto-detect violations from attendance records vs work schedule
- Violation types: late arrival, early departure, absence, unauthorized
- Penalty rules: deductions after X violations per month
### 4.3 Permission Requests - Hourly Leaves (C5)
- Short-duration leave requests (hours, not days)
- Approval workflow
- Deduct from daily attendance hours
- Monthly summary of permissions taken
### 4.4 Social Insurance Auto-Calculation (C8)
- Egyptian social insurance rules engine
- Basic salary cap, variable salary cap
- Employer share % + Employee share %
- Auto-calculate per payroll run
- Generate Form 2 (monthly contribution)
### 4.5 Progressive Tax Calculation (C9)
- Tax brackets configuration (Egyptian tax law)
- Exemptions and deductions configuration
- Annual tax calculation with monthly distribution
- Year-end reconciliation
### 4.6 Shift Management (C2)
- Shift definitions: start time, end time, break times
- Shift rotation patterns (weekly, bi-weekly, monthly)
- Employee-to-shift assignment
- Shift calendar view
### 4.7 Overtime Request & Approval (C7)
- Pre-approval for planned overtime
- Approval workflow (supervisor → HR)
- Link approved overtime to attendance records
---
## Phase 5: Advanced Inventory & Sales Features (High Complexity)
**Timeline: 5-6 weeks**
**Focus: BOM, multi-unit, movement specifications**
### 5.1 Bill of Materials (BOM) (A3)
- New tables: `bill_of_materials`, `bom_components`
- Define composite items with their sub-components
- Component quantities and units
- BOM versioning
- Auto-deduct components on sale/production of parent item
- Component cost roll-up for pricing
### 5.2 Multiple Units per Item with Conversion (G5)
- Table: `item_units` (item_id, unit_name, conversion_factor, is_base_unit)
- Each item can have: piece, box (12 pieces), carton (48 pieces), etc.
- All movements specify unit; system converts to base unit for stock
- Purchase in one unit, sell in another
### 5.3 Item Color/Variant Tracking (G4)
- Configurable attributes per item category
- Attribute types: color, size, material, etc.
- Stock tracked per variant combination
- Item variant matrix (size × color → stock)
### 5.4 Configurable Stock Movement Types (G8)
- User-definable movement types beyond in/out/transfer
- Each type has: name, direction (in/out/neutral), affects_stock, posts_gl, requires_approval
- Movement specifications per type
- Auto-numbering per type
### 5.5 Sales Representative & Commission (A12)
- Sales rep master data
- Rep assignment to customers/transactions
- Commission rates: flat, percentage, tiered (by volume)
- Commission calculation and reporting
- Commission payment tracking
### 5.6 Per-Customer Pricing (F1)
- Customer-specific discount rates per item/category
- Price lists assignable to customer groups
- Priority: customer price > group price > default price
- Discount reason tracking
### 5.7 Sales Collection (Checks from Customers) (F4)
- Record checks/notes received from customers
- Collection portfolio management
- Status tracking: received → deposited → collected/bounced
- Link to customer account
---
## Phase 6: Configurable Movement Engine & Multi-Currency (Highest Complexity)
**Timeline: 6-8 weeks**
**Focus: System-wide configurability engine**
### 6.1 Movement Specifications Engine (A1)
This is the most complex feature - a meta-configuration system that controls behavior of all transaction types across Sales, Inventory, and Purchasing.
**Core concept:** Each "movement type" (e.g., "Sales Invoice", "Purchase Return", "Stock Transfer") has ~26 configurable parameters that control its behavior:
- Auto-numbering (start number, sequence)
- Whether it affects stock (and direction)
- Whether it posts to GL
- Which accounts to post to
- Whether it requires approval
- Which fields are mandatory
- Which party types are allowed (customer, supplier, warehouse)
- Whether it creates AR/AP entries
- Tax handling
- Discount handling
- Print template selection
- Currency handling
**Implementation approach:**
- `movement_types` table: defines all available movement types
- `movement_specifications` table: key-value parameters per movement type
- Engine class that reads specs and controls movement behavior
- UI for admin to configure each movement type's parameters
- All Sales/Inventory/Procurement modules consume specs from this engine
### 6.2 Multi-Currency Full Support (A13)
- Currency master with exchange rates (daily rates)
- Transaction currency vs. reporting currency
- Multi-currency journal entries
- Gain/loss on exchange rate differences
- Reports in both transaction and reporting currencies
- Customer/supplier balances in their currency
- Period-end revaluation of foreign currency balances
### 6.3 Multi-Segment Item Codes (A2)
- Configurable code structure (segments, lengths, separators)
- Hierarchical: Category - Sub-category - Group - Sub-group - Sequence
- Auto-generate next code within segment
- Search by any segment level
- Reports groupable by any segment
---
## Phase 7: Advanced Financial Features (High Complexity)
**Timeline: 4-5 weeks**
**Focus: Documentary credits, guarantees, advanced banking**
### 7.1 Documentary Credits (A8)
- LC master: issuing bank, beneficiary, amount, currency, terms
- LC lifecycle: opened → shipped → documents presented → paid → closed
- Margin tracking (deposit held by bank)
- Expense allocation to landed cost
- Link to purchase orders and vendor invoices
### 7.2 Letter of Guarantee (A9)
- Guarantee master: bank, beneficiary, amount, type (tender/performance/advance)
- Status lifecycle: requested → issued → active → released/called
- Margin/collateral tracking
- Expiry monitoring and renewal
- Commission/fee tracking
### 7.3 Fixed Asset Enhancements (B1, B4, B6, B7)
- Asset categories with per-category depreciation method
- Multiple methods: straight-line, declining balance, sum-of-years, units-of-production
- Asset revaluation (upward/downward with journal entry)
- Asset improvement/addition (capitalize additional costs)
- Asset insurance tracking
### 7.4 Report Export Engine (H1)
- Universal export functionality for all reports
- Formats: Excel (XLSX), PDF, CSV, HTML
- Configurable columns per export
- Scheduled report generation (email delivery)
---
## Phase 8: Reporting & Analytics Layer
**Timeline: 3-4 weeks**
**Focus: Comprehensive reporting across all modules**
### 8.1 Enhanced Purchasing Reports
- Purchase volume by supplier/item/period
- Supplier performance scoring (delivery time, price variance, quality)
- Purchase request follow-up status
- Cost center expense analysis
- Item purchases by supplier level
### 8.2 Enhanced Sales Reports
- Customer current position (all customers snapshot)
- Customer movement tracking in period
- Sales by rep with commission summary
- Item margin analysis
- Customer lifetime value
### 8.3 Enhanced Inventory Reports
- Item price history (first, current, average)
- Stock valuation by method (FIFO, weighted average, LIFO)
- Dead stock / slow-moving items
- Stock turnover analysis
- Supplier vs item cross-reference
### 8.4 Enhanced Financial Reports
- Cash flow statement
- Payment notes analysis (direct vs indirect, type breakdown)
- Treasury position report
- Budget vs actual across all departments
- Monthly comparison report (this month vs last month vs same month last year)
### 8.5 Dashboard Enhancements
- Executive dashboard: revenue, expenses, cash position, AR/AP aging
- Procurement dashboard: pending approvals, overdue deliveries, budget utilization
- HR dashboard: headcount, attendance %, leave utilization, payroll cost trends
- Inventory dashboard: stock value, movement velocity, expiry warnings
---
# PART 3: IMPLEMENTATION PRIORITIES
## Recommended Execution Order
```
Phase 1 (Quick Wins) ← START HERE: 2-3 weeks
Phase 2 (Banking) ← 3-4 weeks
Phase 3 (Procurement Cycle) ← 3-4 weeks
Phase 4 (HR Enhancements) ← 4-5 weeks
Phase 5 (Inventory & Sales) ← 5-6 weeks
Phase 6 (Movement Engine) ← 6-8 weeks (most complex)
Phase 7 (Advanced Financial) ← 4-5 weeks
Phase 8 (Reporting) ← 3-4 weeks
```
**Total estimated timeline: 30-39 weeks (~7-9 months)**
---
## Critical Path Items
These features have dependencies that affect other features:
1. **Movement Specifications Engine (A1)** - Many other features reference configurable movement types. Building this engine first would make phases 3-5 cleaner, but it's also the most complex. Recommendation: build a simplified version first, enhance later.
2. **Multiple Units per Item (G5)** - Affects all inventory movements, sales, and procurement. Should be implemented before BOM.
3. **Multi-Currency (A13)** - Touches nearly every financial transaction. Can be implemented incrementally (start with supplier/customer level, then transactions, then reporting).
4. **Credit Limits (A6, A7)** - Simple to add, but must be enforced across all transaction entry points (sales, procurement, cashier).
---
## Database Schema Additions (Key Tables Needed)
### Phase 1
```sql
-- Customer/Supplier credit limits (add columns to existing tables)
ALTER TABLE members ADD credit_limit DECIMAL(15,2) DEFAULT 0;
ALTER TABLE suppliers ADD credit_limit DECIMAL(15,2) DEFAULT 0;
-- PO delivery tracking
ALTER TABLE purchase_order_items ADD expected_delivery_date DATE NULL;
-- Asset custody
ALTER TABLE asset_registers ADD custodian_employee_id INT NULL;
ALTER TABLE asset_registers ADD site_location VARCHAR(255) NULL;
CREATE TABLE asset_custody_history (...);
```
### Phase 2
```sql
CREATE TABLE negotiable_instrument_status_history (...);
CREATE TABLE cross_entity_settlements (...);
CREATE TABLE bank_loans (...);
CREATE TABLE bank_loan_schedule (...);
```
### Phase 3
```sql
CREATE TABLE supplier_price_quotes (...);
CREATE TABLE supplier_quote_items (...);
CREATE TABLE quote_evaluations (...);
CREATE TABLE supplier_payment_schedules (...);
CREATE TABLE item_price_history (...);
```
### Phase 4
```sql
CREATE TABLE hr_overtime_types (...);
CREATE TABLE hr_overtime_requests (...);
CREATE TABLE hr_attendance_violations (...);
CREATE TABLE hr_permission_requests (...);
CREATE TABLE hr_shifts (...);
CREATE TABLE hr_shift_assignments (...);
CREATE TABLE hr_insurance_config (...);
CREATE TABLE hr_tax_brackets (...);
```
### Phase 5
```sql
CREATE TABLE bill_of_materials (...);
CREATE TABLE bom_components (...);
CREATE TABLE item_units (...);
CREATE TABLE item_attributes (...);
CREATE TABLE item_attribute_values (...);
CREATE TABLE item_variants (...);
CREATE TABLE sales_representatives (...);
CREATE TABLE sales_commissions (...);
CREATE TABLE customer_price_lists (...);
CREATE TABLE customer_item_prices (...);
```
### Phase 6
```sql
CREATE TABLE movement_types (...);
CREATE TABLE movement_specifications (...);
CREATE TABLE currencies (...);
CREATE TABLE exchange_rates (...);
CREATE TABLE item_code_segments (...);
```
### Phase 7
```sql
CREATE TABLE documentary_credits (...);
CREATE TABLE lc_documents (...);
CREATE TABLE letters_of_guarantee (...);
CREATE TABLE asset_categories (...);
CREATE TABLE asset_revaluations (...);
CREATE TABLE asset_improvements (...);
```
---
## Risk Assessment
| Risk | Impact | Mitigation |
|------|--------|-----------|
| Movement engine complexity | Could delay Phase 6 significantly | Start with simplified version; enhance iteratively |
| Multi-currency GL impact | Affects all financial reports | Implement incrementally; start with transaction recording, then reporting |
| BOM stock deduction performance | Complex queries for nested BOMs | Limit BOM depth; cache component lists |
| Credit limit enforcement gaps | Users bypass limits via different screens | Centralize limit check in a service class called by all transaction entry points |
| Attendance device integration | Hardware-dependent, varied protocols | Abstract via interface; support common protocols (ZKTeco, etc.) |
| Data migration for new features | Existing data needs mapping to new structures | Write migration scripts with safe defaults; don't block existing operations |
---
## Success Metrics per Phase
| Phase | Key Metric |
|-------|-----------|
| 1 | Credit limits actively preventing over-credit transactions; PO delivery tracking in use |
| 2 | 90%+ of check lifecycle managed in system vs. manual tracking |
| 3 | Full PR→Quote→Eval→PO cycle operational; deviation alerts active |
| 4 | Overtime auto-calculated in payroll; violation detection reducing manual review |
| 5 | BOM items selling correctly with component deduction; multi-unit purchases flowing |
| 6 | All new movement types configurable without code changes |
| 7 | Documentary credits fully tracked; multi-method depreciation running |
| 8 | Executive dashboards replacing manual Excel reports |
---
## Architecture Notes
All new features should follow existing conventions:
- Module structure: `app/Modules/{Name}/` with Controllers, Models, Services, Views
- Route registration in `Routes.php`
- Permissions registered in `bootstrap.php`
- Events dispatched for cross-module integration (especially GL posting)
- Arabic-first UI with RTL layout
- Plain PDO queries (no ORM relations)
- Migration naming: `Phase_NN_NNN_description.php`
This source diff could not be displayed because it is too large. You can view the blob instead.
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