base commit

This commit is contained in:
Mekan1206
2026-07-30 17:24:40 +05:00
commit a794dacd39
345 changed files with 29597 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Services\Employee;
use App\Enums\EmploymentStatus;
use App\Models\Employee;
use App\Models\User;
use App\Notifications\EmployeeTerminatedNotification;
use Carbon\Carbon;
use Illuminate\Support\Facades\Notification;
class EmployeeService
{
/**
* @param array<string, mixed> $data
*/
public function create(array $data): Employee
{
return Employee::create($data);
}
/**
* @param array<string, mixed> $data
*/
public function update(Employee $employee, array $data): Employee
{
$employee->update($data);
return $employee->refresh();
}
public function terminate(Employee $employee, ?Carbon $date = null): Employee
{
$employee->update([
'employment_status' => EmploymentStatus::Terminated,
'termination_date' => $date ?? now(),
]);
Notification::send(
User::role('hr_manager')->get(),
new EmployeeTerminatedNotification($employee),
);
return $employee->refresh();
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Services\Employee;
use App\DTOs\EmployeeSummaryDto;
use App\Enums\ApprovalStatus;
use App\Models\Employee;
use App\Services\Vacation\VacationBalanceService;
class EmployeeStatisticsService
{
public function __construct(
private readonly VacationBalanceService $vacationBalanceService,
) {}
public function summary(Employee $employee): EmployeeSummaryDto
{
return new EmployeeSummaryDto(
vacationTaken: $this->vacationBalanceService->takenAnnualDays($employee),
vacationRemaining: $this->vacationBalanceService->remainingAnnualDays($employee),
sickLeaveCount: $employee->sickLeaves()->count(),
reportsCount: $employee->disciplinaryReports()->count(),
bonusesTotal: (float) $employee->bonuses()->sum('amount'),
giftsCount: $employee->gifts()->count(),
upcomingLeave: $employee->vacations()
->where('status', ApprovalStatus::Approved)
->where('start_date', '>=', now()->toDateString())
->orderBy('start_date')
->get(),
);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Services\Export;
use App\Models\Employee;
use App\Services\Employee\EmployeeStatisticsService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
final class PdfExportService
{
public function __construct(
private readonly EmployeeStatisticsService $statisticsService,
) {}
public function employeeProfile(Employee $employee): Response
{
$employee->load(['department', 'position', 'shift']);
$summary = $this->statisticsService->summary($employee);
return Pdf::loadView('exports.employee-profile', [
'employee' => $employee,
'summary' => $summary,
])->download("employee-{$employee->employee_number}.pdf");
}
/**
* @param Collection<int, Employee> $employees
*/
public function employeeList(Collection $employees): Response
{
$employees->load(['department', 'position', 'shift']);
return Pdf::loadView('exports.employee-list', [
'employees' => $employees,
'generatedAt' => now(),
])->download('employees-'.now()->format('Y-m-d').'.pdf');
}
public function leaveSummary(Employee $employee): Response
{
$employee->load([
'vacations' => fn ($query) => $query->latest('start_date')->limit(20),
'sickLeaves' => fn ($query) => $query->latest('start_date')->limit(20),
'unpaidLeaves' => fn ($query) => $query->latest('start_date')->limit(20),
]);
$summary = $this->statisticsService->summary($employee);
return Pdf::loadView('exports.leave-summary', [
'employee' => $employee,
'summary' => $summary,
])->download("leave-summary-{$employee->employee_number}.pdf");
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
use App\DTOs\Import\BonusRowDto;
use App\Enums\BonusType;
use App\Models\Bonus;
use App\Models\Employee;
use Illuminate\Support\Str;
class BonusImportTransformer
{
/**
* @return array<string, mixed>
*/
public function toAttributes(BonusRowDto $dto): array
{
return [
'employee_id' => $this->resolveEmployeeId($dto->employeeNumber),
'bonus_date' => $this->parseDate($dto->bonusDate),
'bonus_type' => $this->resolveBonusType($dto->bonusType),
'amount' => $dto->amount ?? 0,
'reason' => $dto->reason,
];
}
/**
* @return array<int, string>
*/
public function validate(BonusRowDto $dto): array
{
$errors = [];
if ($dto->employeeNumber === '') {
$errors[] = 'Employee number is required.';
} elseif ($this->resolveEmployeeId($dto->employeeNumber) === null) {
$errors[] = "Employee \"{$dto->employeeNumber}\" was not found.";
}
if ($dto->bonusDate === null) {
$errors[] = 'Bonus date is required.';
}
if ($dto->amount === null) {
$errors[] = 'Amount is required.';
}
return $errors;
}
public function isDuplicate(BonusRowDto $dto): bool
{
$employeeId = $this->resolveEmployeeId($dto->employeeNumber);
$bonusDate = $this->parseDate($dto->bonusDate);
if ($employeeId === null || $bonusDate === null || $dto->amount === null) {
return false;
}
return Bonus::query()
->where('employee_id', $employeeId)
->whereDate('bonus_date', $bonusDate)
->where('amount', $dto->amount)
->exists();
}
private function resolveEmployeeId(string $employeeNumber): ?int
{
return Employee::query()
->where('employee_number', $employeeNumber)
->value('id');
}
private function resolveBonusType(?string $value): BonusType
{
if ($value === null || trim($value) === '') {
return BonusType::Other;
}
$normalized = Str::lower(trim($value));
return BonusType::tryFrom($normalized) ?? BonusType::Other;
}
private function parseDate(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
try {
return \Carbon\Carbon::parse($value)->toDateString();
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
use App\DTOs\Import\EmployeeRowDto;
use App\Enums\EmploymentStatus;
use App\Enums\Gender;
use App\Models\Department;
use App\Models\Employee;
use App\Models\Position;
use App\Models\Shift;
use Carbon\Carbon;
use Illuminate\Support\Str;
class EmployeeImportTransformer
{
/**
* @return array<string, mixed>
*/
public function toAttributes(EmployeeRowDto $dto): array
{
return [
'employee_number' => $dto->employeeNumber,
'full_name' => $dto->fullName,
'national_id' => $dto->nationalId,
'gender' => $this->resolveGender($dto->gender),
'birth_date' => $this->parseDate($dto->birthDate),
'phone' => $dto->phone ?? config('hr.default_phone'),
'address' => $dto->address,
'department_id' => $this->resolveDepartmentId($dto->department),
'position_id' => $this->resolvePositionId($dto->position),
'shift_id' => $this->resolveShiftId($dto->shift),
'employment_status' => $this->resolveEmploymentStatus($dto->employmentStatus),
'hire_date' => $this->parseDate($dto->hireDate),
'notes' => $dto->notes,
];
}
/**
* @return array<int, string>
*/
public function validate(EmployeeRowDto $dto): array
{
$errors = [];
if ($dto->employeeNumber === '') {
$errors[] = 'Employee number is required.';
}
if ($dto->fullName === '') {
$errors[] = 'Full name is required.';
}
if ($dto->department && $this->resolveDepartmentId($dto->department) === null) {
$errors[] = "Department \"{$dto->department}\" was not found.";
}
if ($dto->position && $this->resolvePositionId($dto->position) === null) {
$errors[] = "Position \"{$dto->position}\" was not found.";
}
if ($dto->shift && $this->resolveShiftId($dto->shift) === null) {
$errors[] = "Shift \"{$dto->shift}\" was not found.";
}
return $errors;
}
public function isDuplicate(EmployeeRowDto $dto): bool
{
return Employee::query()
->where(function ($query) use ($dto): void {
$query->where('employee_number', $dto->employeeNumber);
if ($dto->nationalId) {
$query->orWhere('national_id', $dto->nationalId);
}
})
->exists();
}
private function resolveGender(?string $value): ?Gender
{
if ($value === null) {
return null;
}
$normalized = Str::lower(trim($value));
return Gender::tryFrom($normalized)
?? match ($normalized) {
'm', 'man' => Gender::Male,
'f', 'woman' => Gender::Female,
default => null,
};
}
private function resolveEmploymentStatus(?string $value): EmploymentStatus
{
if ($value === null || trim($value) === '') {
return EmploymentStatus::Active;
}
$normalized = Str::lower(str_replace(' ', '_', trim($value)));
return EmploymentStatus::tryFrom($normalized) ?? EmploymentStatus::Active;
}
private function resolveDepartmentId(?string $value): ?int
{
if ($value === null || trim($value) === '') {
return null;
}
return Department::query()
->where('name', $value)
->orWhere('code', $value)
->value('id');
}
private function resolvePositionId(?string $value): ?int
{
if ($value === null || trim($value) === '') {
return null;
}
return Position::query()->where('name', $value)->value('id');
}
private function resolveShiftId(?string $value): ?int
{
if ($value === null || trim($value) === '') {
return null;
}
return Shift::query()
->where('name', $value)
->orWhere('code', $value)
->value('id');
}
private function parseDate(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
try {
return Carbon::parse($value)->toDateString();
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
use App\DTOs\Import\BonusRowDto;
use App\DTOs\Import\EmployeeRowDto;
use App\DTOs\Import\ImportResultDto;
use App\DTOs\Import\ReportRowDto;
use App\DTOs\Import\VacationRowDto;
use App\Enums\ImportType;
use App\Models\Bonus;
use App\Models\DisciplinaryReport;
use App\Models\Employee;
use App\Models\Vacation;
use App\Services\Employee\EmployeeService;
use PhpOffice\PhpSpreadsheet\IOFactory;
class ImportService
{
public function __construct(
private readonly EmployeeImportTransformer $employeeTransformer,
private readonly VacationImportTransformer $vacationTransformer,
private readonly BonusImportTransformer $bonusTransformer,
private readonly ReportImportTransformer $reportTransformer,
private readonly EmployeeService $employeeService,
) {}
/**
* @return array<int, string>
*/
public function readHeaders(string $path): array
{
$sheet = IOFactory::load($path)->getActiveSheet();
$headers = [];
foreach ($sheet->getRowIterator(1, 1) as $row) {
foreach ($row->getCellIterator() as $cell) {
$value = trim((string) $cell->getValue());
if ($value !== '') {
$headers[] = $value;
}
}
}
return $headers;
}
/**
* @return array<int, array<string, mixed>>
*/
public function readRows(string $path, ?int $limit = null): array
{
$sheet = IOFactory::load($path)->getActiveSheet();
$headers = $this->readHeaders($path);
$rows = [];
$highestRow = $sheet->getHighestDataRow();
for ($rowIndex = 2; $rowIndex <= $highestRow; $rowIndex++) {
$rowData = [];
$isEmpty = true;
foreach ($headers as $columnIndex => $header) {
$columnLetter = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($columnIndex + 1);
$value = $sheet->getCell($columnLetter . $rowIndex)->getFormattedValue();
$rowData[$header] = is_string($value) ? trim($value) : $value;
if ($rowData[$header] !== null && $rowData[$header] !== '') {
$isEmpty = false;
}
}
if (! $isEmpty) {
$rows[] = $rowData;
}
if ($limit !== null && count($rows) >= $limit) {
break;
}
}
return $rows;
}
/**
* @param array<string, string|null> $columnMapping
* @return array<int, array<string, mixed>>
*/
public function mapRows(array $rows, array $columnMapping): array
{
return array_map(function (array $row) use ($columnMapping): array {
$mapped = [];
foreach ($columnMapping as $field => $header) {
if ($header === null || $header === '') {
continue;
}
$mapped[$field] = $row[$header] ?? null;
}
return $mapped;
}, $rows);
}
/**
* @param array<int, array<string, mixed>> $mappedRows
* @return array<int, array{row: int, errors: array<int, string>}>
*/
public function validatePreview(ImportType $type, array $mappedRows, int $limit = 10): array
{
$previewRows = array_slice($mappedRows, 0, $limit);
$results = [];
foreach ($previewRows as $index => $row) {
$results[] = [
'row' => $index + 2,
'errors' => $this->validateRow($type, $row),
];
}
return $results;
}
/**
* @param array<int, array<string, mixed>> $mappedRows
*/
public function import(ImportType $type, array $mappedRows, ?int $userId = null): ImportResultDto
{
$imported = 0;
$skipped = 0;
$failed = 0;
$errors = [];
foreach ($mappedRows as $index => $row) {
$rowNumber = $index + 2;
$validationErrors = $this->validateRow($type, $row);
if ($validationErrors !== []) {
$failed++;
$errors[] = "Row {$rowNumber}: " . implode(' ', $validationErrors);
continue;
}
if ($this->isDuplicate($type, $row)) {
$skipped++;
continue;
}
try {
$this->persistRow($type, $row, $userId);
$imported++;
} catch (\Throwable $exception) {
$failed++;
$errors[] = "Row {$rowNumber}: {$exception->getMessage()}";
}
}
return new ImportResultDto(
total: count($mappedRows),
imported: $imported,
skipped: $skipped,
failed: $failed,
errors: array_slice($errors, 0, 50),
);
}
/**
* @param array<string, mixed> $row
* @return array<int, string>
*/
private function validateRow(ImportType $type, array $row): array
{
return match ($type) {
ImportType::Employees => $this->employeeTransformer->validate(EmployeeRowDto::fromMappedRow($row)),
ImportType::Vacations => $this->vacationTransformer->validate(VacationRowDto::fromMappedRow($row)),
ImportType::Bonuses => $this->bonusTransformer->validate(BonusRowDto::fromMappedRow($row)),
ImportType::Reports => $this->reportTransformer->validate(ReportRowDto::fromMappedRow($row)),
};
}
/**
* @param array<string, mixed> $row
*/
private function isDuplicate(ImportType $type, array $row): bool
{
return match ($type) {
ImportType::Employees => $this->employeeTransformer->isDuplicate(EmployeeRowDto::fromMappedRow($row)),
ImportType::Vacations => $this->vacationTransformer->isDuplicate(VacationRowDto::fromMappedRow($row)),
ImportType::Bonuses => $this->bonusTransformer->isDuplicate(BonusRowDto::fromMappedRow($row)),
ImportType::Reports => $this->reportTransformer->isDuplicate(ReportRowDto::fromMappedRow($row)),
};
}
/**
* @param array<string, mixed> $row
*/
private function persistRow(ImportType $type, array $row, ?int $userId): void
{
match ($type) {
ImportType::Employees => $this->employeeService->create(
$this->employeeTransformer->toAttributes(EmployeeRowDto::fromMappedRow($row)),
),
ImportType::Vacations => Vacation::create(
$this->vacationTransformer->toAttributes(VacationRowDto::fromMappedRow($row)),
),
ImportType::Bonuses => Bonus::create(
$this->bonusTransformer->toAttributes(BonusRowDto::fromMappedRow($row)),
),
ImportType::Reports => DisciplinaryReport::create(
$this->reportTransformer->toAttributes(ReportRowDto::fromMappedRow($row), $userId),
),
};
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
use App\DTOs\Import\ReportRowDto;
use App\Enums\DisciplinarySeverity;
use App\Models\DisciplinaryReport;
use App\Models\Employee;
use Illuminate\Support\Str;
class ReportImportTransformer
{
/**
* @return array<string, mixed>
*/
public function toAttributes(ReportRowDto $dto, ?int $createdBy = null): array
{
return [
'employee_id' => $this->resolveEmployeeId($dto->employeeNumber),
'report_date' => $this->parseDate($dto->reportDate),
'title' => $dto->title,
'description' => $dto->description,
'severity' => $this->resolveSeverity($dto->severity),
'created_by' => $createdBy,
];
}
/**
* @return array<int, string>
*/
public function validate(ReportRowDto $dto): array
{
$errors = [];
if ($dto->employeeNumber === '') {
$errors[] = 'Employee number is required.';
} elseif ($this->resolveEmployeeId($dto->employeeNumber) === null) {
$errors[] = "Employee \"{$dto->employeeNumber}\" was not found.";
}
if ($dto->reportDate === null) {
$errors[] = 'Report date is required.';
}
if ($dto->title === null || trim($dto->title) === '') {
$errors[] = 'Title is required.';
}
return $errors;
}
public function isDuplicate(ReportRowDto $dto): bool
{
$employeeId = $this->resolveEmployeeId($dto->employeeNumber);
$reportDate = $this->parseDate($dto->reportDate);
if ($employeeId === null || $reportDate === null || $dto->title === null) {
return false;
}
return DisciplinaryReport::query()
->where('employee_id', $employeeId)
->whereDate('report_date', $reportDate)
->where('title', $dto->title)
->exists();
}
private function resolveEmployeeId(string $employeeNumber): ?int
{
return Employee::query()
->where('employee_number', $employeeNumber)
->value('id');
}
private function resolveSeverity(?string $value): DisciplinarySeverity
{
if ($value === null || trim($value) === '') {
return DisciplinarySeverity::Low;
}
$normalized = Str::lower(trim($value));
return DisciplinarySeverity::tryFrom($normalized) ?? DisciplinarySeverity::Low;
}
private function parseDate(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
try {
return \Carbon\Carbon::parse($value)->toDateString();
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
use App\DTOs\Import\VacationRowDto;
use App\Enums\ApprovalStatus;
use App\Enums\VacationType;
use App\Models\Employee;
use App\Models\Vacation;
use Carbon\Carbon;
use Illuminate\Support\Str;
class VacationImportTransformer
{
/**
* @return array<string, mixed>
*/
public function toAttributes(VacationRowDto $dto): array
{
$employeeId = $this->resolveEmployeeId($dto->employeeNumber);
return [
'employee_id' => $employeeId,
'type' => $this->resolveType($dto->type),
'start_date' => $this->parseDate($dto->startDate),
'end_date' => $this->parseDate($dto->endDate),
'return_date' => $this->parseDate($dto->returnDate),
'days' => $dto->days,
'status' => $this->resolveStatus($dto->status),
'reason' => $dto->reason,
];
}
/**
* @return array<int, string>
*/
public function validate(VacationRowDto $dto): array
{
$errors = [];
if ($dto->employeeNumber === '') {
$errors[] = 'Employee number is required.';
} elseif ($this->resolveEmployeeId($dto->employeeNumber) === null) {
$errors[] = "Employee \"{$dto->employeeNumber}\" was not found.";
}
if ($dto->startDate === null) {
$errors[] = 'Start date is required.';
}
if ($dto->endDate === null) {
$errors[] = 'End date is required.';
}
return $errors;
}
public function isDuplicate(VacationRowDto $dto): bool
{
$employeeId = $this->resolveEmployeeId($dto->employeeNumber);
if ($employeeId === null) {
return false;
}
$startDate = $this->parseDate($dto->startDate);
$endDate = $this->parseDate($dto->endDate);
if ($startDate === null || $endDate === null) {
return false;
}
return Vacation::query()
->where('employee_id', $employeeId)
->whereDate('start_date', $startDate)
->whereDate('end_date', $endDate)
->exists();
}
private function resolveEmployeeId(string $employeeNumber): ?int
{
return Employee::query()
->where('employee_number', $employeeNumber)
->value('id');
}
private function resolveType(?string $value): VacationType
{
if ($value === null || trim($value) === '') {
return VacationType::Annual;
}
$normalized = Str::lower(trim($value));
return VacationType::tryFrom($normalized) ?? VacationType::Annual;
}
private function resolveStatus(?string $value): ApprovalStatus
{
if ($value === null || trim($value) === '') {
return ApprovalStatus::Pending;
}
$normalized = Str::lower(trim($value));
return ApprovalStatus::tryFrom($normalized) ?? ApprovalStatus::Pending;
}
private function parseDate(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
try {
return Carbon::parse($value)->toDateString();
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\Leave;
use Carbon\Carbon;
class LeaveDaysCalculator
{
public function between(Carbon $start, Carbon $end): int
{
$startDate = $start->copy()->startOfDay();
$endDate = $end->copy()->startOfDay();
if ($endDate->lt($startDate)) {
return 0;
}
return match (config('hr.leave_calculation')) {
'business' => $this->businessDaysBetween($startDate, $endDate),
default => (int) ($startDate->diffInDays($endDate) + 1),
};
}
private function businessDaysBetween(Carbon $startDate, Carbon $endDate): int
{
$days = 0;
$current = $startDate->copy();
while ($current->lte($endDate)) {
if (! $current->isWeekend()) {
$days++;
}
$current->addDay();
}
return $days;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Services\Leave;
use App\Enums\ApprovalStatus;
use App\Models\UnpaidLeave;
use App\Models\User;
class UnpaidLeaveService
{
public function approve(UnpaidLeave $unpaidLeave, User $approver): void
{
$unpaidLeave->update([
'status' => ApprovalStatus::Approved,
'approved_by' => $approver->id,
'approved_at' => now(),
]);
}
public function reject(UnpaidLeave $unpaidLeave, User $approver): void
{
$unpaidLeave->update([
'status' => ApprovalStatus::Rejected,
'approved_by' => $approver->id,
'approved_at' => now(),
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Services\Vacation;
use App\Enums\ApprovalStatus;
use App\Enums\VacationType;
use App\Models\Employee;
class VacationBalanceService
{
public function entitlement(): int
{
return (int) config('hr.annual_leave_days');
}
public function takenAnnualDays(Employee $employee, ?int $year = null): int
{
$year ??= (int) now()->year;
return (int) $employee->vacations()
->where('type', VacationType::Annual)
->where('status', ApprovalStatus::Approved)
->whereYear('start_date', $year)
->sum('days');
}
public function remainingAnnualDays(Employee $employee): int
{
return $this->entitlement() - $this->takenAnnualDays($employee);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Services\Vacation;
use App\Enums\ApprovalStatus;
use App\Models\User;
use App\Models\Vacation;
use App\Notifications\VacationApprovedNotification;
use App\Notifications\VacationSubmittedNotification;
use App\Services\Leave\LeaveDaysCalculator;
use Carbon\Carbon;
use Illuminate\Support\Facades\Notification;
class VacationService
{
public function __construct(
private readonly LeaveDaysCalculator $leaveDaysCalculator,
) {}
/**
* @param array<string, mixed> $data
*/
public function submit(array $data): Vacation
{
$startDate = Carbon::parse($data['start_date']);
$endDate = Carbon::parse($data['end_date']);
$vacation = Vacation::create([
...$data,
'days' => $this->leaveDaysCalculator->between($startDate, $endDate),
'status' => ApprovalStatus::Pending,
]);
Notification::send(
User::role('hr_manager')->get(),
new VacationSubmittedNotification($vacation),
);
return $vacation;
}
public function approve(Vacation $vacation, User $approver): void
{
$vacation->update([
'status' => ApprovalStatus::Approved,
'approved_by' => $approver->id,
'approved_at' => now(),
]);
Notification::send(
User::role('hr_manager')->get(),
new VacationApprovedNotification($vacation),
);
}
public function reject(Vacation $vacation, User $approver): void
{
$vacation->update([
'status' => ApprovalStatus::Rejected,
'approved_by' => $approver->id,
'approved_at' => now(),
]);
}
}