Commit 23dd0208 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(hr): 2025 Labour Law compliance + employee import + payroll alignment

Phase 1 - Leave System (2025 Law):
- Annual leave: 15d (1st year) → 21d (after 1y) → 30d (10y+/age 50+) → 45d (disability)
- Maternity: 90 → 120 days (4 months), hajj: now PAID with 5y service min
- New leave types: childcare (unpaid, 3 career, 24mo gap), paternity (1d, 3 career), exam
- Casual leave: enforce max 2 consecutive days
- Childcare gap enforcement, service months validation

Phase 2 - Employee Import:
- Extract 75 employees from HR registry Excel → JSON seed data
- 34 Bank of Alexandria accounts extracted for salary transfers
- 14 departments + all job titles auto-created from registry
- Migration adds variable_salary + total_allowances columns

Phase 4 - Payroll Alignment (July 2026 format):
- Solidarity fund: 0.25% of gross (صندوق التكافل)
- Stamp duty: 3% of net (دمغة عادية وإضافية) - now percentage-based
- Emergency fund + VAT config keys added
- calculation_json includes full breakdown

Phase 5 - Bank Transfer Export:
- BankTransferService generates transfer data from payroll runs
- CSV export with BOM for Arabic compatibility
- View + route: /hr/payroll/periods/{id}/bank-transfer

Phase 6 - Performance Evaluation:
- Seed 10-criteria template matching club's official form (100 points)
- Dual evaluator support (direct manager + general supervisor)
- 5-tier rating labels (ضعيف → ممتاز)

Phase 7 - Reports & Forms:
- Work receipt printable form (إقرار استلام العمل)
- Workforce statement report (بيان القوة الفعلية)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 9585e99f
...@@ -457,6 +457,24 @@ class EmployeeProfileController extends Controller ...@@ -457,6 +457,24 @@ class EmployeeProfileController extends Controller
return $errors; return $errors;
} }
public function workReceipt(Request $request, string $id): Response
{
$this->authorize('hr.employee.view');
$profile = HrEmployeeProfile::find((int) $id);
if (!$profile) {
return $this->redirect('/hr/employees')->withError('الموظف غير موجود');
}
$db = App::getInstance()->db();
$jobTitle = $db->selectOne("SELECT name_ar FROM hr_job_titles WHERE id = ?", [$profile->job_title_id ?? 0]);
return $this->view('HR.Views.forms.work-receipt', [
'profile' => $profile,
'job_title' => $jobTitle['name_ar'] ?? '',
]);
}
private function flashErrorsAndRedirect(array $errors, Request $request, string $url): Response private function flashErrorsAndRedirect(array $errors, Request $request, string $url): Response
{ {
$session = App::getInstance()->session(); $session = App::getInstance()->session();
......
...@@ -8,6 +8,7 @@ use App\Core\Request; ...@@ -8,6 +8,7 @@ use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Modules\HR\Models\HrDepartment; use App\Modules\HR\Models\HrDepartment;
use App\Modules\HR\Services\WorkforceReportService;
class HrReportController extends Controller class HrReportController extends Controller
{ {
...@@ -267,4 +268,13 @@ class HrReportController extends Controller ...@@ -267,4 +268,13 @@ class HrReportController extends Controller
'days' => $days, 'days' => $days,
]); ]);
} }
public function workforceStatement(Request $request): Response
{
$this->authorize('hr.report.view');
$data = WorkforceReportService::getWorkforceStatement();
return $this->view('HR.Views.reports.workforce_statement', $data);
}
} }
...@@ -13,6 +13,7 @@ use App\Modules\HR\Models\HrPayrollRun; ...@@ -13,6 +13,7 @@ use App\Modules\HR\Models\HrPayrollRun;
use App\Modules\HR\Models\HrEmployeeProfile; use App\Modules\HR\Models\HrEmployeeProfile;
use App\Modules\HR\Services\PayrollCalculationService; use App\Modules\HR\Services\PayrollCalculationService;
use App\Modules\HR\Services\SalarySlipService; use App\Modules\HR\Services\SalarySlipService;
use App\Modules\HR\Services\BankTransferService;
use App\Core\EventBus; use App\Core\EventBus;
class PayrollController extends Controller class PayrollController extends Controller
...@@ -300,6 +301,35 @@ class PayrollController extends Controller ...@@ -300,6 +301,35 @@ class PayrollController extends Controller
return $this->view('HR.Views.payroll.salary_slip', $slipData); return $this->view('HR.Views.payroll.salary_slip', $slipData);
} }
public function bankTransfer(Request $request, string $id): Response
{
$this->authorize('hr.payroll.view');
$result = BankTransferService::generateTransferData((int) $id);
if (!$result['success']) {
return $this->redirect('/hr/payroll/periods/' . $id)->withError($result['error']);
}
return $this->view('HR.Views.payroll.bank_transfer', $result);
}
public function bankTransferCsv(Request $request, string $id): Response
{
$this->authorize('hr.payroll.view');
$csv = BankTransferService::generateCsv((int) $id);
if ($csv === null) {
return $this->redirect('/hr/payroll/periods/' . $id)->withError('لا يوجد بيانات لتصدير كشف البنك');
}
$period = App::getInstance()->db()->selectOne("SELECT * FROM hr_payroll_periods WHERE id = ?", [(int) $id]);
$filename = 'bank_transfer_' . ($period['year'] ?? '') . '_' . ($period['month'] ?? '') . '.csv';
return (new Response($csv, 200))
->header('Content-Type', 'text/csv; charset=utf-8')
->header('Content-Disposition', 'attachment; filename="' . $filename . '"');
}
public function mySlips(Request $request): Response public function mySlips(Request $request): Response
{ {
$employee = $this->currentEmployee(); $employee = $this->currentEmployee();
......
...@@ -35,6 +35,7 @@ return [ ...@@ -35,6 +35,7 @@ return [
['GET', '/hr/employees/{id:\d+}/salary', 'HR\Controllers\EmployeeProfileController@salary', ['auth'], 'hr.employee.view_salary'], ['GET', '/hr/employees/{id:\d+}/salary', 'HR\Controllers\EmployeeProfileController@salary', ['auth'], 'hr.employee.view_salary'],
['POST', '/hr/employees/{id:\d+}/salary', 'HR\Controllers\EmployeeProfileController@updateSalary',['auth', 'csrf'], 'hr.employee.manage_salary'], ['POST', '/hr/employees/{id:\d+}/salary', 'HR\Controllers\EmployeeProfileController@updateSalary',['auth', 'csrf'], 'hr.employee.manage_salary'],
['GET', '/hr/employees/search-json', 'HR\Controllers\EmployeeProfileController@searchJson', ['auth'], 'hr.employee.view'], ['GET', '/hr/employees/search-json', 'HR\Controllers\EmployeeProfileController@searchJson', ['auth'], 'hr.employee.view'],
['GET', '/hr/employees/{id:\d+}/work-receipt', 'HR\Controllers\EmployeeProfileController@workReceipt',['auth'], 'hr.employee.view'],
// ── Contracts ── // ── Contracts ──
['GET', '/hr/contracts', 'HR\Controllers\ContractController@index', ['auth'], 'hr.contract.view'], ['GET', '/hr/contracts', 'HR\Controllers\ContractController@index', ['auth'], 'hr.contract.view'],
...@@ -99,6 +100,8 @@ return [ ...@@ -99,6 +100,8 @@ return [
['GET', '/hr/payroll/runs/{id:\d+}', 'HR\Controllers\PayrollController@showRun', ['auth'], 'hr.payroll.view'], ['GET', '/hr/payroll/runs/{id:\d+}', 'HR\Controllers\PayrollController@showRun', ['auth'], 'hr.payroll.view'],
['GET', '/hr/payroll/runs/{id:\d+}/slip', 'HR\Controllers\PayrollController@salarySlip', ['auth'], 'hr.payroll.view'], ['GET', '/hr/payroll/runs/{id:\d+}/slip', 'HR\Controllers\PayrollController@salarySlip', ['auth'], 'hr.payroll.view'],
['GET', '/hr/payroll/my-slips', 'HR\Controllers\PayrollController@mySlips', ['auth'], 'hr.payslip.view_own'], ['GET', '/hr/payroll/my-slips', 'HR\Controllers\PayrollController@mySlips', ['auth'], 'hr.payslip.view_own'],
['GET', '/hr/payroll/periods/{id:\d+}/bank-transfer', 'HR\Controllers\PayrollController@bankTransfer', ['auth'], 'hr.payroll.view'],
['GET', '/hr/payroll/periods/{id:\d+}/bank-transfer/csv','HR\Controllers\PayrollController@bankTransferCsv', ['auth'], 'hr.payroll.view'],
// ── Insurance ── // ── Insurance ──
['GET', '/hr/insurance', 'HR\Controllers\InsuranceController@index', ['auth'], 'hr.insurance.view'], ['GET', '/hr/insurance', 'HR\Controllers\InsuranceController@index', ['auth'], 'hr.insurance.view'],
...@@ -216,4 +219,5 @@ return [ ...@@ -216,4 +219,5 @@ return [
['GET', '/hr/reports/tax-summary', 'HR\Controllers\HrReportController@taxSummary', ['auth'], 'hr.report.view'], ['GET', '/hr/reports/tax-summary', 'HR\Controllers\HrReportController@taxSummary', ['auth'], 'hr.report.view'],
['GET', '/hr/reports/loan-summary', 'HR\Controllers\HrReportController@loanSummary', ['auth'], 'hr.report.view'], ['GET', '/hr/reports/loan-summary', 'HR\Controllers\HrReportController@loanSummary', ['auth'], 'hr.report.view'],
['GET', '/hr/reports/contract-expiry', 'HR\Controllers\HrReportController@contractExpiry', ['auth'], 'hr.report.view'], ['GET', '/hr/reports/contract-expiry', 'HR\Controllers\HrReportController@contractExpiry', ['auth'], 'hr.report.view'],
['GET', '/hr/reports/workforce-statement', 'HR\Controllers\HrReportController@workforceStatement',['auth'], 'hr.report.view'],
]; ];
<?php
declare(strict_types=1);
namespace App\Modules\HR\Services;
use App\Core\App;
final class BankTransferService
{
public static function generateTransferData(int $periodId): array
{
$db = App::getInstance()->db();
$period = $db->selectOne("SELECT * FROM hr_payroll_periods WHERE id = ?", [$periodId]);
if (!$period) {
return ['success' => false, 'error' => 'فترة الرواتب غير موجودة'];
}
$rows = $db->select(
"SELECT pr.net_salary, pr.employee_number,
ep.first_name_ar, ep.last_name_ar,
ep.national_id, ep.bank_name, ep.bank_account_number
FROM hr_payroll_runs pr
JOIN hr_employee_profiles ep ON ep.id = pr.employee_profile_id
WHERE pr.period_id = ? AND pr.status IN ('calculated', 'approved', 'paid')
AND ep.bank_account_number IS NOT NULL AND ep.bank_account_number != ''
AND ep.is_archived = 0
ORDER BY ep.first_name_ar ASC",
[$periodId]
);
if (empty($rows)) {
return ['success' => false, 'error' => 'لا يوجد موظفون لديهم حسابات بنكية لهذه الفترة'];
}
$transfers = [];
$totalAmount = '0.00';
foreach ($rows as $row) {
$name = trim(($row['first_name_ar'] ?? '') . ' ' . ($row['last_name_ar'] ?? ''));
$transfers[] = [
'employee_number' => $row['employee_number'],
'employee_name' => $name,
'national_id' => $row['national_id'] ?? '',
'bank_name' => $row['bank_name'] ?? 'بنك الاسكندرية',
'bank_account_number'=> $row['bank_account_number'],
'net_salary' => $row['net_salary'],
];
$totalAmount = bcadd($totalAmount, $row['net_salary'], 2);
}
return [
'success' => true,
'period' => $period,
'transfers' => $transfers,
'total_amount' => $totalAmount,
'count' => count($transfers),
];
}
public static function generateCsv(int $periodId): ?string
{
$result = self::generateTransferData($periodId);
if (!$result['success']) {
return null;
}
$lines = [];
$lines[] = implode(',', ['م', 'اسم الموظف', 'الرقم القومي', 'رقم الحساب', 'صافي الراتب']);
$i = 1;
foreach ($result['transfers'] as $t) {
$lines[] = implode(',', [
$i,
'"' . str_replace('"', '""', $t['employee_name']) . '"',
$t['national_id'],
$t['bank_account_number'],
$t['net_salary'],
]);
$i++;
}
$lines[] = implode(',', ['', '', '', 'الإجمالي', $result['total_amount']]);
return "\xEF\xBB\xBF" . implode("\r\n", $lines);
}
}
...@@ -15,12 +15,15 @@ use App\Modules\Workflow\Services\WorkflowEngine; ...@@ -15,12 +15,15 @@ use App\Modules\Workflow\Services\WorkflowEngine;
/** /**
* Leave Entitlement & Management Service * Leave Entitlement & Management Service
* *
* Egyptian Labour Law (Art. 47-54, 91): * Egyptian Labour Law 2025 (New Law):
* Annual: 21 days (<10y), 30 days (>=10y or age>=50), 45 days (hazardous) * Annual: 15 days (1st year), 21 days (after 1y), 30 days (>=10y or age>=50), 45 days (disability)
* Casual: 7 days/year, max 2 consecutive * Casual: 7 days/year, max 2 consecutive
* Sick: 75% first 3 months, 85% next 3, then unpaid * Sick: 75% first 3 months, 85% next 3, then unpaid
* Maternity: 90 days paid, max 3 career, termination protection * Maternity: 4 months (120 days) paid at 100% comprehensive salary, max 3 career
* Hajj: 30 days unpaid, once in career * Hajj: 30 days PAID, once in career, requires 5 years service
* Childcare: unpaid, max 3 career, 1y service, 24-month gap between uses (female)
* Paternity: 1 day paid, max 3 career (male)
* Exam: paid, actual days, requires 10-day advance schedule, not deducted from annual
* Carry-over: max 3 years accumulation, min 6 consecutive/year * Carry-over: max 3 years accumulation, min 6 consecutive/year
*/ */
final class LeaveService final class LeaveService
...@@ -30,9 +33,16 @@ final class LeaveService ...@@ -30,9 +33,16 @@ final class LeaveService
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$hireDate = $profile['hire_date'] ?? null; $hireDate = $profile['hire_date'] ?? null;
// Load configurable tier values
$daysFirstYear = '15.0';
$daysUnder10 = '21.0'; $daysUnder10 = '21.0';
$daysOver10 = '30.0'; $daysOver10 = '30.0';
$daysDisability = '45.0';
$row = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.leave.annual_first_year']);
if ($row && $row['config_value'] !== '') {
$daysFirstYear = number_format((float) $row['config_value'], 1, '.', '');
}
$row = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.leave.annual_days_under_10y']); $row = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.leave.annual_days_under_10y']);
if ($row && $row['config_value'] !== '') { if ($row && $row['config_value'] !== '') {
$daysUnder10 = number_format((float) $row['config_value'], 1, '.', ''); $daysUnder10 = number_format((float) $row['config_value'], 1, '.', '');
...@@ -41,21 +51,37 @@ final class LeaveService ...@@ -41,21 +51,37 @@ final class LeaveService
if ($row && $row['config_value'] !== '') { if ($row && $row['config_value'] !== '') {
$daysOver10 = number_format((float) $row['config_value'], 1, '.', ''); $daysOver10 = number_format((float) $row['config_value'], 1, '.', '');
} }
$row = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.leave.annual_disability']);
if ($row && $row['config_value'] !== '') {
$daysDisability = number_format((float) $row['config_value'], 1, '.', '');
}
// Disability override — Art. 47 (2025 Law): 45 days regardless of tenure
if (!empty($profile['has_disability']) && (int) $profile['has_disability'] === 1) {
return $daysDisability;
}
if (!$hireDate) { if (!$hireDate) {
return $daysUnder10; return $daysFirstYear;
} }
$yearsOfService = (float) HrEmployeeProfile::getYearsOfService($hireDate); $yearsOfService = (float) HrEmployeeProfile::getYearsOfService($hireDate);
$age = self::calculateAge($profile['date_of_birth'] ?? ''); $age = self::calculateAge($profile['date_of_birth'] ?? '');
// Tier 3: 10+ years of service OR age 50+
if ($yearsOfService >= 10 || $age >= 50) { if ($yearsOfService >= 10 || $age >= 50) {
return $daysOver10; return $daysOver10;
} }
// Tier 2: after completing 1 year
if ($yearsOfService >= 1) {
return $daysUnder10; return $daysUnder10;
} }
// Tier 1: first year of employment
return $daysFirstYear;
}
public static function submitRequest(array $data): array public static function submitRequest(array $data): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
...@@ -126,7 +152,7 @@ final class LeaveService ...@@ -126,7 +152,7 @@ final class LeaveService
} }
} }
// Career limits (maternity, hajj) // Career limits (maternity, hajj, childcare, paternity)
if ($leaveType['career_max_times']) { if ($leaveType['career_max_times']) {
$careerCount = $db->selectOne( $careerCount = $db->selectOne(
"SELECT COUNT(*) as cnt FROM hr_leave_requests "SELECT COUNT(*) as cnt FROM hr_leave_requests
...@@ -138,6 +164,41 @@ final class LeaveService ...@@ -138,6 +164,41 @@ final class LeaveService
} }
} }
// Minimum service months (hajj = 60 months, childcare = 12 months)
if (!empty($leaveType['min_service_months']) && (int) $leaveType['min_service_months'] > 0) {
$serviceMonths = (int) (HrEmployeeProfile::getYearsOfService($profile->hire_date ?? '') * 12);
if ($serviceMonths < (int) $leaveType['min_service_months']) {
$requiredYears = number_format((int) $leaveType['min_service_months'] / 12, 1);
return ['success' => false, 'error' => 'يشترط مدة خدمة لا تقل عن ' . $requiredYears . ' سنة للحصول على هذه الإجازة'];
}
}
// Childcare: enforce 24-month gap between uses
$rules = !empty($leaveType['rules_json']) ? json_decode($leaveType['rules_json'], true) : [];
if (!empty($rules['min_gap_months_between_uses'])) {
$lastApproved = $db->selectOne(
"SELECT end_date FROM hr_leave_requests
WHERE employee_profile_id = ? AND leave_type_id = ? AND status = 'approved' AND is_archived = 0
ORDER BY end_date DESC LIMIT 1",
[$profileId, $leaveTypeId]
);
if ($lastApproved) {
$lastEnd = new \DateTime($lastApproved['end_date']);
$requestStart = new \DateTime($startDate);
$monthsDiff = (int) $lastEnd->diff($requestStart)->m + ((int) $lastEnd->diff($requestStart)->y * 12);
if ($monthsDiff < (int) $rules['min_gap_months_between_uses']) {
return ['success' => false, 'error' => 'يجب مرور ' . $rules['min_gap_months_between_uses'] . ' شهر على الأقل بين كل استخدام لهذا النوع من الإجازات'];
}
}
}
// Casual leave: max consecutive days validation
if ($leaveType['code'] === 'casual' && !empty($leaveType['max_consecutive_days'])) {
if (bccomp($totalDays, (string) $leaveType['max_consecutive_days'], 1) > 0) {
return ['success' => false, 'error' => 'الحد الأقصى للإجازة العارضة المتصلة ' . $leaveType['max_consecutive_days'] . ' يوم'];
}
}
$db->beginTransaction(); $db->beginTransaction();
try { try {
$requestId = $db->insert('hr_leave_requests', [ $requestId = $db->insert('hr_leave_requests', [
......
...@@ -312,33 +312,21 @@ final class PayrollCalculationService ...@@ -312,33 +312,21 @@ final class PayrollCalculationService
'sort_order' => $sortOrder, 'sort_order' => $sortOrder,
]; ];
// ── Step 8b: Regular Stamp Duty deduction ── // ── Step 8b: Solidarity Fund (صندوق التكافل) — % of gross ──
$regularStampRow = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.deduction.regular_stamp_duty']); $solidarityRow = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.deduction.solidarity_fund_rate']);
$regularStampDeduction = $regularStampRow ? $regularStampRow['config_value'] : '0.00'; $solidarityRate = ($solidarityRow && $solidarityRow['config_value'] !== '') ? $solidarityRow['config_value'] : '0.0025';
$solidarityDeduction = bcmul($grossEarnings, $solidarityRate, 2);
$sortOrder++; $sortOrder++;
$componentLog[] = [ $componentLog[] = [
'component_code' => 'REGULAR_STAMP_DED', 'component_code' => 'SOLIDARITY_FUND_DED',
'name_ar' => 'الدمغة العادية', 'name_ar' => 'مساهمة صندوق التكافل',
'type' => 'deduction', 'type' => 'deduction',
'amount' => $regularStampDeduction, 'amount' => $solidarityDeduction,
'sort_order' => $sortOrder, 'sort_order' => $sortOrder,
]; ];
// ── Step 8c: Additional Stamp Duty deduction ── // ── Step 8c: External Loan Deduction (financing companies & bank loans) ──
$additionalStampRow = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.deduction.additional_stamp_duty']);
$additionalStampDeduction = $additionalStampRow ? $additionalStampRow['config_value'] : '0.00';
$sortOrder++;
$componentLog[] = [
'component_code' => 'ADDITIONAL_STAMP_DED',
'name_ar' => 'الدمغة الإضافية',
'type' => 'deduction',
'amount' => $additionalStampDeduction,
'sort_order' => $sortOrder,
];
// ── Step 8d: External Loan Deduction (financing companies & bank loans) ──
$externalLoanRow = $db->selectOne( $externalLoanRow = $db->selectOne(
"SELECT COALESCE(SUM(monthly_salary_impact), 0) AS total_external FROM hr_employee_loans WHERE employee_profile_id = ? AND loan_type IN ('financing_company', 'bank_loan') AND status IN ('approved', 'disbursed', 'repaying') AND is_archived = 0", "SELECT COALESCE(SUM(monthly_salary_impact), 0) AS total_external FROM hr_employee_loans WHERE employee_profile_id = ? AND loan_type IN ('financing_company', 'bank_loan') AND status IN ('approved', 'disbursed', 'repaying') AND is_archived = 0",
[$profileId] [$profileId]
...@@ -354,13 +342,13 @@ final class PayrollCalculationService ...@@ -354,13 +342,13 @@ final class PayrollCalculationService
'sort_order' => $sortOrder, 'sort_order' => $sortOrder,
]; ];
// ── Step 9: Net salary ── // ── Step 9: Net before stamp duty ──
$otherDeductions = bcadd( $otherDeductions = bcadd(
bcadd(bcadd($martyrsFundDeduction, $regularStampDeduction, 2), $additionalStampDeduction, 2), bcadd($martyrsFundDeduction, $solidarityDeduction, 2),
$externalLoanDeduction, $externalLoanDeduction,
2 2
); );
$totalDeductions = bcadd( $totalDeductionsBeforeStamp = bcadd(
bcadd( bcadd(
bcadd(bcadd(bcadd($insuranceEmployee, $taxAmount, 2), $loanDeduction, 2), $penaltyDeduction, 2), bcadd(bcadd(bcadd($insuranceEmployee, $taxAmount, 2), $loanDeduction, 2), $penaltyDeduction, 2),
$absenceDeduction, $absenceDeduction,
...@@ -369,11 +357,35 @@ final class PayrollCalculationService ...@@ -369,11 +357,35 @@ final class PayrollCalculationService
$otherDeductions, $otherDeductions,
2 2
); );
$netSalary = bcsub($grossEarnings, $totalDeductions, 2); $netBeforeStamp = bcsub($grossEarnings, $totalDeductionsBeforeStamp, 2);
if (bccomp($netBeforeStamp, '0', 2) < 0) {
$netBeforeStamp = '0.00';
}
// ── Step 9a: Stamp Duty (دمغة عادية وإضافية) — % of net ──
$stampRow = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = ?", ['hr.deduction.regular_stamp_duty']);
$stampRate = ($stampRow && $stampRow['config_value'] !== '' && $stampRow['config_value'] !== '0') ? $stampRow['config_value'] : '0.03';
$stampDeduction = bcmul($netBeforeStamp, $stampRate, 2);
$sortOrder++;
$componentLog[] = [
'component_code' => 'STAMP_DUTY_DED',
'name_ar' => 'دمغة عادية وإضافية',
'type' => 'deduction',
'amount' => $stampDeduction,
'sort_order' => $sortOrder,
];
// ── Step 9b: Final net salary ──
$totalDeductions = bcadd($totalDeductionsBeforeStamp, $stampDeduction, 2);
$netSalary = bcsub($netBeforeStamp, $stampDeduction, 2);
if (bccomp($netSalary, '0', 2) < 0) { if (bccomp($netSalary, '0', 2) < 0) {
$netSalary = '0.00'; $netSalary = '0.00';
} }
// Include stamp in other_deductions for storage
$otherDeductions = bcadd($otherDeductions, $stampDeduction, 2);
// ── Step 10: Persist records ── // ── Step 10: Persist records ──
$calculationJson = json_encode([ $calculationJson = json_encode([
'overtime' => $overtime, 'overtime' => $overtime,
...@@ -381,6 +393,8 @@ final class PayrollCalculationService ...@@ -381,6 +393,8 @@ final class PayrollCalculationService
'tax' => $tax, 'tax' => $tax,
'attendance' => $attendance, 'attendance' => $attendance,
'working_days' => $workingDays, 'working_days' => $workingDays,
'solidarity_fund' => ['rate' => $solidarityRate, 'amount' => $solidarityDeduction],
'stamp_duty' => ['rate' => $stampRate, 'amount' => $stampDeduction, 'base' => $netBeforeStamp],
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
$db->beginTransaction(); $db->beginTransaction();
......
<?php
declare(strict_types=1);
namespace App\Modules\HR\Services;
use App\Core\App;
final class WorkforceReportService
{
public static function getWorkforceStatement(): array
{
$db = App::getInstance()->db();
$departments = $db->select(
"SELECT d.id, d.name_ar, d.name_en, d.staffing_capacity,
COUNT(CASE WHEN ep.workforce_type = 'insured' AND ep.employment_status = 'active' THEN 1 END) AS insured_count,
COUNT(CASE WHEN ep.workforce_type = 'part_time' AND ep.employment_status = 'active' THEN 1 END) AS part_time_count,
COUNT(CASE WHEN ep.workforce_type NOT IN ('insured', 'part_time') AND ep.employment_status = 'active' THEN 1 END) AS other_count,
COUNT(CASE WHEN ep.employment_status = 'active' THEN 1 END) AS total_active
FROM hr_departments d
LEFT JOIN hr_employee_profiles ep ON ep.department_id = d.id AND ep.is_archived = 0
WHERE d.is_archived = 0 AND d.is_active = 1
GROUP BY d.id, d.name_ar, d.name_en, d.staffing_capacity
ORDER BY d.sort_order ASC, d.name_ar ASC",
[]
);
$totals = [
'insured_count' => 0,
'part_time_count' => 0,
'other_count' => 0,
'total_active' => 0,
'capacity' => 0,
'vacancies' => 0,
];
foreach ($departments as &$dept) {
$capacity = (int) ($dept['staffing_capacity'] ?? 0);
$active = (int) $dept['total_active'];
$dept['vacancies'] = max(0, $capacity - $active);
$totals['insured_count'] += (int) $dept['insured_count'];
$totals['part_time_count'] += (int) $dept['part_time_count'];
$totals['other_count'] += (int) $dept['other_count'];
$totals['total_active'] += $active;
$totals['capacity'] += $capacity;
$totals['vacancies'] += $dept['vacancies'];
}
unset($dept);
return [
'departments' => $departments,
'totals' => $totals,
'generated_at'=> date('Y-m-d H:i:s'),
];
}
}
<?php
/** @var \App\Modules\HR\Models\HrEmployeeProfile $profile */
/** @var string $job_title */
$__template->layout('Layout.print');
$__template->section('title', 'إقرار استلام العمل');
$__template->section('content');
?>
<div class="print-page" style="direction: rtl; text-align: right; font-family: 'Cairo', sans-serif; max-width: 700px; margin: 0 auto; padding: 40px;">
<div style="text-align: center; margin-bottom: 40px;">
<h3 style="margin: 0;">شركة أوسي سبورت للخدمات الرياضية</h3>
<p style="margin: 5px 0; color: #666;">نادي النادي فرع شيراتون</p>
<hr style="border: 1px solid #333; margin: 15px 0;">
<h2 style="margin: 20px 0;">إقـــــرار إســــــــــتلام العمــــــــــل</h2>
</div>
<div style="font-size: 16px; line-height: 2.2;">
<p>
أقــــــــــر أنا / <strong style="border-bottom: 1px dotted #000; padding: 0 20px;"><?= e(trim(($profile->first_name_ar ?? '') . ' ' . ($profile->last_name_ar ?? ''))) ?></strong>
</p>
<p>
الرقم القومي / <strong style="border-bottom: 1px dotted #000; padding: 0 20px;"><?= e($profile->national_id ?? '......................') ?></strong>
</p>
<p>
العنــــــــــوان / <strong style="border-bottom: 1px dotted #000; padding: 0 20px;"><?= e($profile->address ?? '......................') ?></strong>
</p>
<p style="margin-top: 30px;">
بأنني قد أستلمت عملي بشركة أوسي سبورت للخدمات الرياضية ( نادي النادي فرع شيراتون )
</p>
<p>
وذلك لأداء مهام وظيفة / <strong style="border-bottom: 1px dotted #000; padding: 0 20px;"><?= e($job_title) ?></strong>
</p>
<p>
إعتبـــــــاراً من <strong style="border-bottom: 1px dotted #000; padding: 0 20px;"><?= e($profile->hire_date ?? '...../...../......') ?></strong>
</p>
</div>
<div style="margin-top: 80px; text-align: center;">
<p style="font-weight: bold;">المـــــقر بـــما فيــــــــــــه</p>
<br><br><br>
<p>التوقيع: ................................................</p>
</div>
</div>
<style>
@media print {
body { margin: 0; }
.print-page { padding: 20px !important; }
.no-print { display: none !important; }
}
</style>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>كشف تحويل مرتبات بنك الاسكندرية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/hr/payroll/periods/<?= (int) $period['id'] ?>/bank-transfer/csv" class="btn btn-success">تصدير CSV</a>
<a href="/hr/payroll/periods/<?= (int) $period['id'] ?>" class="btn btn-secondary">رجوع</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div style="padding:16px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<div>
<h3 style="margin:0;font-size:16px;">كشف مرتبات بنك الاسكندرية — <?= e($period['year'] ?? '') ?>/<?= str_pad((string) ($period['month'] ?? ''), 2, '0', STR_PAD_LEFT) ?></h3>
<p style="margin:4px 0 0;font-size:13px;color:#6B7280;"><?= $count ?> موظف — إجمالي <?= money($total_amount) ?></p>
</div>
<button onclick="window.print()" class="btn btn-sm btn-outline-primary no-print">طباعة</button>
</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 $i = 1; foreach ($transfers as $t): ?>
<tr>
<td><?= $i++ ?></td>
<td style="font-weight:600;"><?= e($t['employee_name']) ?></td>
<td style="font-family:monospace;direction:ltr;"><?= e($t['national_id']) ?></td>
<td style="font-family:monospace;direction:ltr;"><?= e($t['bank_account_number']) ?></td>
<td style="font-weight:700;"><?= money($t['net_salary']) ?></td>
</tr>
<?php endforeach; ?>
<tr style="font-weight:700;background:#F3F4F6;">
<td colspan="4">الإجمالي</td>
<td><?= money($total_amount) ?></td>
</tr>
</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="/hr/reports" class="btn btn-secondary">رجوع</a><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:20px;">
<div class="card" style="padding:16px;text-align:center;"><div style="font-size:13px;color:#6B7280;">إجمالي العاملين</div><div style="font-size:24px;font-weight:700;"><?= (int) ($totals['total_active'] ?? 0) ?></div></div>
<div class="card" style="padding:16px;text-align:center;"><div style="font-size:13px;color:#6B7280;">مؤمن عليهم</div><div style="font-size:24px;font-weight:700;color:#059669;"><?= (int) ($totals['insured_count'] ?? 0) ?></div></div>
<div class="card" style="padding:16px;text-align:center;"><div style="font-size:13px;color:#6B7280;">غير متفرغ</div><div style="font-size:24px;font-weight:700;color:#D97706;"><?= (int) ($totals['part_time_count'] ?? 0) ?></div></div>
<div class="card" style="padding:16px;text-align:center;"><div style="font-size:13px;color:#6B7280;">الشواغر</div><div style="font-size:24px;font-weight:700;color:#DC2626;"><?= (int) ($totals['vacancies'] ?? 0) ?></div></div>
</div>
<div class="card">
<div style="padding:16px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:16px;">بيان القوة الفعلية — <?= arabic_date(date('Y-m-d')) ?></h3>
<button onclick="window.print()" class="btn btn-sm btn-outline-primary no-print">طباعة</button>
</div>
<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($departments)): ?>
<tr><td colspan="7" style="text-align:center;padding:40px;color:#9CA3AF;">لا توجد بيانات</td></tr>
<?php else: ?>
<?php foreach ($departments as $d): ?>
<tr>
<td style="font-weight:600;"><?= e($d['name_ar'] ?? '') ?></td>
<td><?= (int) $d['insured_count'] ?></td>
<td><?= (int) $d['part_time_count'] ?></td>
<td><?= (int) $d['other_count'] ?></td>
<td style="font-weight:700;"><?= (int) $d['total_active'] ?></td>
<td><?= (int) ($d['staffing_capacity'] ?? 0) ?></td>
<td style="color:<?= (int) $d['vacancies'] > 0 ? '#DC2626' : '#059669' ?>;"><?= (int) $d['vacancies'] ?></td>
</tr>
<?php endforeach; ?>
<tr style="font-weight:700;background:#F3F4F6;">
<td>الإجمالي</td>
<td><?= (int) ($totals['insured_count'] ?? 0) ?></td>
<td><?= (int) ($totals['part_time_count'] ?? 0) ?></td>
<td><?= (int) ($totals['other_count'] ?? 0) ?></td>
<td><?= (int) ($totals['total_active'] ?? 0) ?></td>
<td><?= (int) ($totals['capacity'] ?? 0) ?></td>
<td style="color:#DC2626;"><?= (int) ($totals['vacancies'] ?? 0) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$departments = [
['code' => 'EXEC', 'name_ar' => 'الإدارة العليا', 'name_en' => 'Executive Management'],
['code' => 'TECH', 'name_ar' => 'المكتب الفني', 'name_en' => 'Technical Office'],
['code' => 'SPORT', 'name_ar' => 'ادارة النشاط الرياضي', 'name_en' => 'Sports Activities'],
['code' => 'PR', 'name_ar' => 'ادارة العلاقات العامة', 'name_en' => 'Public Relations'],
['code' => 'FIN', 'name_ar' => 'الادارة المالية', 'name_en' => 'Finance'],
['code' => 'STORE', 'name_ar' => 'ادارة المخازن', 'name_en' => 'Warehouse'],
['code' => 'HR', 'name_ar' => 'ادارة الموارد البشرية', 'name_en' => 'Human Resources'],
['code' => 'LEGAL', 'name_ar' => 'ادارة الشئون القانونية', 'name_en' => 'Legal Affairs'],
['code' => 'SUPER', 'name_ar' => 'ادارة الاشراف والمتابعة', 'name_en' => 'Supervision & Follow-up'],
['code' => 'MKTG', 'name_ar' => 'ادارة التسويق والمبيعات', 'name_en' => 'Marketing & Sales'],
['code' => 'MEMB', 'name_ar' => 'ادارة اشتراكات العضوية', 'name_en' => 'Membership Subscriptions'],
['code' => 'IT', 'name_ar' => 'ادارة النظم والمعلومات', 'name_en' => 'IT & Systems'],
['code' => 'MED', 'name_ar' => 'الادارة الطبية', 'name_en' => 'Medical'],
['code' => 'MOSQ', 'name_ar' => 'المسجد', 'name_en' => 'Mosque'],
];
foreach ($departments as $dept) {
$existing = $db->selectOne(
"SELECT id FROM hr_departments WHERE department_code = ? AND is_archived = 0",
[$dept['code']]
);
if (!$existing) {
$db->insert('hr_departments', [
'department_code' => $dept['code'],
'department_type' => 'edara',
'name_ar' => $dept['name_ar'],
'name_en' => $dept['name_en'],
'parent_id' => null,
'is_active' => 1,
'sort_order' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
// Extract distinct job titles from the registry JSON
$jsonPath = __DIR__ . '/data/employees_registry.json';
if (!file_exists($jsonPath)) {
return;
}
$employees = json_decode(file_get_contents($jsonPath), true);
$titles = [];
foreach ($employees as $emp) {
$title = trim($emp['job_title'] ?? '');
if ($title && !in_array($title, $titles, true)) {
$titles[] = $title;
}
}
$codeCounter = 1;
foreach ($titles as $title) {
$existing = $db->selectOne(
"SELECT id FROM hr_job_titles WHERE name_ar = ? AND is_archived = 0",
[$title]
);
if (!$existing) {
$code = 'JOB' . str_pad((string) $codeCounter, 3, '0', STR_PAD_LEFT);
$db->insert('hr_job_titles', [
'title_code' => $code,
'name_ar' => $title,
'name_en' => null,
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
$codeCounter++;
}
};
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$jsonPath = __DIR__ . '/data/employees_registry.json';
$bankPath = __DIR__ . '/data/bank_accounts.json';
if (!file_exists($jsonPath)) {
echo "ERROR: employees_registry.json not found\n";
return;
}
$employees = json_decode(file_get_contents($jsonPath), true);
$bankData = file_exists($bankPath) ? json_decode(file_get_contents($bankPath), true) : [];
// Index bank accounts by national_id
$bankByNid = [];
foreach ($bankData as $b) {
$nid = trim($b['national_id'] ?? '');
if ($nid) {
$bankByNid[$nid] = $b['bank_account'] ?? '';
}
}
// Cache department IDs by name_ar
$deptRows = $db->select("SELECT id, name_ar FROM hr_departments WHERE is_archived = 0", []);
$deptMap = [];
foreach ($deptRows as $row) {
$deptMap[$row['name_ar']] = (int) $row['id'];
}
// Cache job title IDs by name_ar
$titleRows = $db->select("SELECT id, name_ar FROM hr_job_titles WHERE is_archived = 0", []);
$titleMap = [];
foreach ($titleRows as $row) {
$titleMap[$row['name_ar']] = (int) $row['id'];
}
// Get next employee_number sequence
$lastNum = $db->selectOne(
"SELECT employee_number FROM hr_employee_profiles ORDER BY CAST(SUBSTRING(employee_number, 4) AS UNSIGNED) DESC LIMIT 1",
[]
);
$nextSeq = 1;
if ($lastNum && preg_match('/EMP(\d+)/', $lastNum['employee_number'], $m)) {
$nextSeq = (int) $m[1] + 1;
}
$imported = 0;
$skipped = 0;
foreach ($employees as $emp) {
$nationalId = trim($emp['national_id'] ?? '');
// Skip employees without hire date (ministry secondees without data)
if (empty($emp['hire_date']) || $emp['hire_date'] === 'وزارة') {
$skipped++;
continue;
}
// Skip if already exists by national_id or employee_code
if ($nationalId) {
$exists = $db->selectOne(
"SELECT id FROM hr_employee_profiles WHERE national_id = ? AND is_archived = 0",
[$nationalId]
);
if ($exists) {
$skipped++;
continue;
}
}
// Parse name (full name in one field)
$fullName = trim($emp['name']);
$nameParts = preg_split('/\s+/', $fullName, 2);
$firstName = $nameParts[0] ?? $fullName;
$lastName = $nameParts[1] ?? '';
// Clean leading title prefixes (ك/, ك /)
$firstName = preg_replace('/^ك\s*\/\s*/', '', $firstName);
$fullName = preg_replace('/^ك\s*\/\s*/', '', $fullName);
$nameParts = preg_split('/\s+/', trim($fullName), 2);
$firstName = $nameParts[0] ?? $fullName;
$lastName = $nameParts[1] ?? '';
// Department lookup
$deptId = $deptMap[$emp['department']] ?? null;
// Job title lookup
$jobTitleId = $titleMap[$emp['job_title']] ?? null;
// Employee number
$employeeNumber = 'EMP' . str_pad((string) $nextSeq, 4, '0', STR_PAD_LEFT);
$nextSeq++;
// Workforce type mapping
$workforceType = match ($emp['workforce_type']) {
'insured' => 'insured',
'part_time' => 'part_time',
'seconded' => 'part_time',
default => 'insured',
};
// Bank info
$bankAccount = $bankByNid[$nationalId] ?? '';
// Calculate insurable salary (same as basic for insured, 0 for others)
$salary = (float) ($emp['salary'] ?? 0);
$insurableSalary = ($workforceType === 'insured' && $salary > 0) ? $salary : 0.00;
$hireDate = $emp['hire_date'];
if (strlen($hireDate) > 10) {
$hireDate = substr($hireDate, 0, 10);
}
$insuranceDate = $emp['insurance_date'] ?? '';
if (strlen($insuranceDate) > 10) {
$insuranceDate = substr($insuranceDate, 0, 10);
}
$dob = $emp['date_of_birth'] ?? '';
if (strlen($dob) > 10) {
$dob = substr($dob, 0, 10);
}
$gradYear = $emp['graduation_year'] ?? null;
if ($gradYear && (int) $gradYear > 2030) {
$gradYear = null;
}
$data = [
'employee_id' => 0,
'employee_number' => $employeeNumber,
'fingerprint_code' => $emp['employee_code'] ?: null,
'first_name_ar' => $firstName,
'last_name_ar' => $lastName,
'phone' => $emp['phone'] ?: null,
'national_id' => $nationalId ?: null,
'date_of_birth' => $dob ?: null,
'gender' => $emp['gender'],
'nationality' => 'egyptian',
'education_qualification' => $emp['qualification'] ?: null,
'education_graduation_year' => $gradYear ? (int) $gradYear : null,
'education_university' => $emp['university'] ?: null,
'education_grade' => $emp['grade'] ?: null,
'address' => $emp['address'] ?: null,
'department_id' => $deptId,
'job_title_id' => $jobTitleId,
'hire_date' => $hireDate,
'employment_type' => 'full_time',
'workforce_type' => $workforceType,
'employment_status' => 'active',
'basic_salary' => number_format($salary, 2, '.', ''),
'insurable_salary' => number_format($insurableSalary, 2, '.', ''),
'bank_name' => $bankAccount ? 'بنك الاسكندرية' : null,
'bank_account_number' => $bankAccount ?: null,
'insurance_number' => $emp['insurance_number'] ?: null,
'insurance_start_date' => $insuranceDate ?: null,
'military_status' => $emp['military_status'] ?: null,
'has_disability' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
$db->insert('hr_employee_profiles', $data);
$imported++;
}
echo "Employee import complete: {$imported} imported, {$skipped} skipped\n";
};
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
// Seed the standard 10-criteria performance evaluation template
// from the club's official evaluation form (تعديل تقييم الاداء)
$criteriaJson = json_encode([
'criteria' => [
['id' => 1, 'name_ar' => 'الإلمام بالمهام الوظيفية وسرعة ودقة إنجاز الأعمال', 'max_score' => 10],
['id' => 2, 'name_ar' => 'المشاركة والعمل ضمن فريق واحترام أوامر الرؤساء', 'max_score' => 10],
['id' => 3, 'name_ar' => 'القدرة على تحديد خطوط العمل والبرنامج الزمني', 'max_score' => 10],
['id' => 4, 'name_ar' => 'الحفاظ على المظهر العام والالتزام بمكان العمل', 'max_score' => 10],
['id' => 5, 'name_ar' => 'تحمل ضغط العمل في الأوقات الرسمية والإضافية', 'max_score' => 10],
['id' => 6, 'name_ar' => 'القدرة على نقل الخبرة في مجال التخصص', 'max_score' => 10],
['id' => 7, 'name_ar' => 'الرغبة في التعلم والتطوير وطرح أفكار جديدة', 'max_score' => 10],
['id' => 8, 'name_ar' => 'مدى الالتزام بتطبيق اللوائح والقوانين والأوامر الإدارية', 'max_score' => 10],
['id' => 9, 'name_ar' => 'القدرة على التواصل مع الآخرين (الموظفين – الأعضاء)', 'max_score' => 10],
['id' => 10, 'name_ar' => 'القدرة على العمل بدون إشراف وتحمل مسؤولية أعلى', 'max_score' => 10],
],
'total_max_score' => 100,
'rating_labels' => [
['min' => 0, 'max' => 49, 'label_ar' => 'ضعيف', 'label_en' => 'Weak'],
['min' => 50, 'max' => 64, 'label_ar' => 'مقبول', 'label_en' => 'Acceptable'],
['min' => 65, 'max' => 79, 'label_ar' => 'جيد', 'label_en' => 'Good'],
['min' => 80, 'max' => 89, 'label_ar' => 'جيد جداً', 'label_en' => 'Very Good'],
['min' => 90, 'max' => 100, 'label_ar' => 'ممتاز', 'label_en' => 'Excellent'],
],
'evaluators' => [
['role' => 'direct_manager', 'label_ar' => 'المدير المباشر'],
['role' => 'general_supervisor', 'label_ar' => 'المشرف العام'],
],
'signatories' => [
['role' => 'employee', 'label_ar' => 'توقيع الموظف'],
['role' => 'hr_manager', 'label_ar' => 'مدير الموارد البشرية'],
['role' => 'general_supervisor', 'label_ar' => 'المشرف العام'],
['role' => 'board_chairman', 'label_ar' => 'رئيس مجلس الإدارة'],
['role' => 'executive_director', 'label_ar' => 'المدير التنفيذي'],
],
], JSON_UNESCAPED_UNICODE);
$exists = $db->selectOne(
"SELECT 1 FROM system_config WHERE config_key = 'hr.performance.criteria_template' LIMIT 1",
[]
);
if (!$exists) {
$db->insert('system_config', [
'config_key' => 'hr.performance.criteria_template',
'config_value' => $criteriaJson,
'config_type' => 'json',
'group_name' => 'hr',
'description_ar' => 'قالب معايير تقييم الأداء (10 معايير × 10 درجات)',
'description_en' => 'Performance evaluation criteria template',
'is_editable' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
} else {
$db->query(
"UPDATE system_config SET config_value = ?, updated_at = NOW() WHERE config_key = 'hr.performance.criteria_template'",
[$criteriaJson]
);
}
};
[
{
"name": "مجدي فتحي محمد الخمري",
"national_id": "28103271700511",
"bank_account": "165006228001"
},
{
"name": "محمد سعد محمد وهبه",
"national_id": "28806272102457",
"bank_account": "165012918001"
},
{
"name": "محمد عبد الحميد محمد جويده",
"national_id": "29108131501417",
"bank_account": "165010437001"
},
{
"name": "شيماء حمدى طاهر محمد",
"national_id": "30004060103224",
"bank_account": "165010265001"
},
{
"name": "هانى محمد ابراهيم محمد",
"national_id": "28502080103671",
"bank_account": "165010435001"
},
{
"name": "مصطفى سامى احمد محمد",
"national_id": "29912280100658",
"bank_account": "165010251001"
},
{
"name": "اسامه هاشم حسن محمد",
"national_id": "26901290101111",
"bank_account": "148009547001"
},
{
"name": "ريهام طلعت محمود عبدالرحيم",
"national_id": "28803300102043",
"bank_account": "165010353001"
},
{
"name": "محمود محمد اشرف محمود",
"national_id": "27412060104679",
"bank_account": "165010434001"
},
{
"name": "محمد يحيى عتريس محمد",
"national_id": "28408280102193",
"bank_account": "165010249001"
},
{
"name": "زياد فرج احمد الشحات",
"national_id": "29909150108311",
"bank_account": "165010255001"
},
{
"name": "هالة كارم محمد الحسيني",
"national_id": "28109081700122",
"bank_account": "165012921001"
},
{
"name": "مصطفي سيد علي عبدالقادر",
"national_id": "28708110102117",
"bank_account": "165012916001"
},
{
"name": "مصطفي خالد محمد علي",
"national_id": "30002150100078",
"bank_account": "165012919001"
},
{
"name": "ميادة محمد خضير حسن",
"national_id": "30005251401264",
"bank_account": "165012924001"
},
{
"name": "رشا بخيت عبد الشفاق فراج",
"national_id": "28109101302366",
"bank_account": "165012915001"
},
{
"name": "احمد حسين عبدالمنعم احمد",
"national_id": "28611230101013",
"bank_account": "165012908001"
},
{
"name": "اسلام محمد احمد محمد",
"national_id": "29011160100835",
"bank_account": "165012911001"
},
{
"name": "احمد عبد الله احمد بكير",
"national_id": "28204051701518",
"bank_account": "165012907001"
},
{
"name": "مراد طارق محمود صالح",
"national_id": "28109160102371",
"bank_account": "165012917001"
},
{
"name": "يوسف محمد مصطفي يوسف",
"national_id": "29911090103514",
"bank_account": "165012922001"
},
{
"name": "احمد أنور محمد محمد عبدالكريم",
"national_id": "29003010201673",
"bank_account": "165012906001"
},
{
"name": "هبه محمود محمود مرسي",
"national_id": "27808080100166",
"bank_account": "165010237002"
},
{
"name": "منار هشام محمد محمود",
"national_id": "30002122101042",
"bank_account": "165012923001"
},
{
"name": "أحمد نبيل محمود زاخر",
"national_id": "29211232400773",
"bank_account": "165012943001"
},
{
"name": "أحمد محمد سعد عبد العزيز",
"national_id": "29506030100697",
"bank_account": "165012939001"
},
{
"name": "أمل إبراهيم حسين بسيونى عيد",
"national_id": "28203121701123",
"bank_account": "165012938001"
},
{
"name": "اسماعيل محمد إبراهيم عمارة",
"national_id": "28401290102031",
"bank_account": "165010440001"
},
{
"name": "احمد إبراهيم الشحات عبد السميع",
"national_id": "28303151203572",
"bank_account": "165010350001"
},
{
"name": "اسلام محمد حسن عمر",
"national_id": "29812042100572",
"bank_account": "165012997001"
},
{
"name": "رؤى محمد حسن",
"national_id": "29406130100142",
"bank_account": "165010236001"
},
{
"name": "عمر محمد طاهر عبد الجليل",
"national_id": "29904040100976",
"bank_account": "165010259001"
},
{
"name": "محمد عبد المنعم محمود عبد اللاه",
"national_id": "28803201306656",
"bank_account": "165010257001"
},
{
"name": "اسلام محمد مصطفي يوسف",
"national_id": "29804090100271",
"bank_account": "165013055001"
}
]
\ No newline at end of file
This diff is collapsed.
...@@ -208,6 +208,16 @@ The `members` table is the central hub. Nearly every module in the ERP reference ...@@ -208,6 +208,16 @@ The `members` table is the central hub. Nearly every module in the ERP reference
- BillingService (discount line item) - BillingService (discount line item)
- Pricing/SpecialDiscountService (bonus free subscription years) - Pricing/SpecialDiscountService (bonus free subscription years)
### `regulatory_discounts` (bylaw discounts — Arts 97-102, 110)
- Pricing/RegulatoryDiscountService (eligibility evaluation)
- Pricing/PricingEngine (applyRegulatoryDiscount integration)
- Members (cross-branch verification via members table)
- HR (club employee verification via hr_employee_profiles)
### `regulatory_discount_applications` (audit trail)
- Pricing/RegulatoryDiscountController (approval workflow)
- Members (member_id FK for applied discounts)
--- ---
## Workflow Dependencies ## Workflow Dependencies
...@@ -307,3 +317,86 @@ This is application-level validation, NOT database-level cross-table constraints ...@@ -307,3 +317,86 @@ This is application-level validation, NOT database-level cross-table constraints
- `payment.*` → Payments module - `payment.*` → Payments module
Super admin check: `employee_roles JOIN roles WHERE role_code = 'super_admin'` Super admin check: `employee_roles JOIN roles WHERE role_code = 'super_admin'`
---
## HR Module Dependencies
### HR Module
| Depends On | How |
|------------|-----|
| `employees` table (Auth) | `employee_id` FK — links HR profile to system login |
| Workflow | Approval chains: `hr_leave_approval`, `hr_loan_approval`, `hr_contract_approval` |
| Members (NationalIdParser) | Parses 14-digit national ID for DOB + gender during employee creation |
| `system_config` | 40+ HR config keys for all calculations |
### Modules That Depend On HR
| Module | How |
|--------|-----|
| Accounting (bootstrap) | Listens to `hr.payroll.paid` → auto-posts salary journal entry |
| Accounting (GLSyncService) | Reads `hr_payroll_runs` WHERE status='paid' for batch GL sync |
| Pricing (RegulatoryDiscounts) | Reads `hr_employee_profiles` to verify club employee status for Art.102 discount |
### HR Events → Listeners
| Event | Dispatched By | Listened By |
|-------|--------------|-------------|
| `hr.leave.submitted` | LeaveService | — |
| `hr.leave.approved` | LeaveService | HR bootstrap → SMS dispatch |
| `hr.leave.rejected` | LeaveService | HR bootstrap → SMS dispatch |
| `hr.payroll.paid` | PayrollController | HR bootstrap → SMS; Accounting → journal |
| `hr.loan.approved` | LoanService | HR bootstrap → SMS dispatch |
| `hr.contract.renewed` | ContractService | — |
| `hr.contract.terminated` | ContractService | — |
| `hr.attendance.recorded` | AttendanceController | HR bootstrap → AttendanceViolationService |
| `attendance.violations_detected` | AttendanceViolationService | — |
### HR Database Dependencies (Shared Read)
| Table | Read By |
|-------|---------|
| `employees` | HR (employee_id link) |
| `system_config` (hr.* keys) | All HR services (insurance, tax, leave, overtime, discipline, EOS) |
| `hr_employee_profiles` | Pricing/RegulatoryDiscounts (employee verification) |
| `hr_payroll_runs` | Accounting/GLSyncService (salary journal sync) |
### If `hr_employee_profiles.basic_salary` changes:
- Payroll: next period calculates new gross/net
- Insurance: new contribution amounts
- Tax: new bracket placement
- Loans: installment vs salary ratio affected
- End of Service: settlement based on last salary
### If `hr_employee_profiles.employment_status` changes to terminated/resigned:
- Active contract should be terminated
- Leave balances frozen
- Outstanding loans may be deducted from EOS settlement
- Biometric punches ignored
- Payroll excludes from next period
### If insurance config (rates/caps) changes:
- Applies to next payroll calculation
- Previous periods' records are immutable
- Form 1/2/6 reports reflect current config
### HR Permission Keys
- `hr.department.*` — Department management
- `hr.job_title.*` — Job titles
- `hr.employee.*` — Employee profiles & salary
- `hr.contract.*` — Contracts
- `hr.attendance.*` — Attendance
- `hr.leave.*` — Leaves
- `hr.payroll.*` — Payroll
- `hr.insurance.*` — Insurance
- `hr.tax.*` — Tax
- `hr.disciplinary.*` — Disciplinary
- `hr.loan.*` — Loans
- `hr.eos.*` — End of Service
- `hr.performance.*` — Performance
- `hr.document.*` — Documents
- `hr.holiday.*` — Holidays
- `hr.schedule.*` — Work schedules
- `hr.overtime.*` — Overtime
- `hr.shifts.*` — Shifts
- `hr.permissions.*` — Permission requests (hourly leaves)
- `hr.biometric.*` — Biometric devices
- `hr.report.*` — Reports
- `hr.payslip.view_own` — Self-service payslip
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