base commit
This commit is contained in:
48
app/Services/Employee/EmployeeService.php
Normal file
48
app/Services/Employee/EmployeeService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
34
app/Services/Employee/EmployeeStatisticsService.php
Normal file
34
app/Services/Employee/EmployeeStatisticsService.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
58
app/Services/Export/PdfExportService.php
Normal file
58
app/Services/Export/PdfExportService.php
Normal 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");
|
||||
}
|
||||
}
|
||||
99
app/Services/Import/BonusImportTransformer.php
Normal file
99
app/Services/Import/BonusImportTransformer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
156
app/Services/Import/EmployeeImportTransformer.php
Normal file
156
app/Services/Import/EmployeeImportTransformer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
219
app/Services/Import/ImportService.php
Normal file
219
app/Services/Import/ImportService.php
Normal 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),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
100
app/Services/Import/ReportImportTransformer.php
Normal file
100
app/Services/Import/ReportImportTransformer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
123
app/Services/Import/VacationImportTransformer.php
Normal file
123
app/Services/Import/VacationImportTransformer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/Services/Leave/LeaveDaysCalculator.php
Normal file
41
app/Services/Leave/LeaveDaysCalculator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
30
app/Services/Leave/UnpaidLeaveService.php
Normal file
30
app/Services/Leave/UnpaidLeaveService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
app/Services/Vacation/VacationBalanceService.php
Normal file
33
app/Services/Vacation/VacationBalanceService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
66
app/Services/Vacation/VacationService.php
Normal file
66
app/Services/Vacation/VacationService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user