From 8404b8af41ddd162cf0df117e6dc9d154502b9b1 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sat, 4 Jul 2026 15:05:25 +0700 Subject: [PATCH] feat: enhance ApplyAttendancePenalties command with transaction management and system user tracking; refactor attendance penalty logic for improved clarity and efficiency; update PayrollService to utilize transaction handling for adjustments; schedule attendance penalties application in app bootstrap --- .../Commands/ApplyAttendancePenalties.php | 223 ++++++++++-------- app/Models/Payroll.php | 1 + app/Services/Finance/PayrollService.php | 121 +++------- bootstrap/app.php | 4 + .../js/components/button/RowAdjustAction.vue | 6 +- 5 files changed, 168 insertions(+), 187 deletions(-) diff --git a/app/Console/Commands/ApplyAttendancePenalties.php b/app/Console/Commands/ApplyAttendancePenalties.php index 49f1684..343e74b 100644 --- a/app/Console/Commands/ApplyAttendancePenalties.php +++ b/app/Console/Commands/ApplyAttendancePenalties.php @@ -3,14 +3,18 @@ namespace App\Console\Commands; use App\Enums\PayrollAdjustmentType; +use App\Enums\Role; use App\Models\Attendance; use App\Models\Employee; use App\Models\LeaveRequest; use App\Models\Payroll; use App\Models\PayrollPeriod; +use App\Models\User; use App\Settings\HrSettings; use Carbon\Carbon; use Illuminate\Console\Command; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; class ApplyAttendancePenalties extends Command { @@ -36,11 +40,16 @@ public function handle(): int } $scheduledCheckIn = Carbon::createFromFormat('Y-m-d H:i', $date->format('Y-m-d').' '.$scheduledTime); + $createdBy = $this->resolveSystemUser(); + + if ($createdBy === null) { + $this->error('Tidak ditemukan user sistem (developer/owner) untuk mencatat penalti.'); + + return self::FAILURE; + } $employees = Employee::query() - ->whereHas('user', function ($query) { - $query->active(); - }) + ->whereHas('user', fn ($query) => $query->active()) ->get(); $lateCount = 0; @@ -48,106 +57,118 @@ public function handle(): int $skippedLeave = 0; $skippedDuplicate = 0; - foreach ($employees as $employee) { - $hasLeave = LeaveRequest::query() - ->approved() - ->where('employee_id', $employee->id) - ->whereDate('start_date', '<=', $date) - ->whereDate('end_date', '>=', $date) - ->exists(); + try { + DB::transaction(function () use ($employees, $date, $scheduledCheckIn, $latePenalty, $absentPenalty, $createdBy, &$lateCount, &$absentCount, &$skippedLeave, &$skippedDuplicate): void { + foreach ($employees as $employee) { + $hasLeave = LeaveRequest::query() + ->approved() + ->where('employee_id', $employee->id) + ->whereDate('start_date', '<=', $date) + ->whereDate('end_date', '>=', $date) + ->exists(); - if ($hasLeave) { - $skippedLeave++; + if ($hasLeave) { + $skippedLeave++; - continue; - } + continue; + } - $attendance = Attendance::query() - ->where('employee_id', $employee->id) - ->whereDate('attendance_date', $date) - ->first(); + $attendance = Attendance::query() + ->where('employee_id', $employee->id) + ->whereDate('attendance_date', $date) + ->first(); - if ($attendance === null) { - if ($absentPenalty <= 0) { - continue; + if ($attendance === null) { + if ($absentPenalty <= 0) { + continue; + } + + $payroll = $this->findOpenPayroll($employee->id); + + if ($payroll === null) { + continue; + } + + $alreadyExists = $payroll->adjustments() + ->whereNull('attendance_id') + ->deduction() + ->where('description', 'like', "%Bolos {$date->format('d/m/Y')}%") + ->exists(); + + if ($alreadyExists) { + $skippedDuplicate++; + + continue; + } + + $payroll->adjustments()->create([ + 'type' => PayrollAdjustmentType::DEDUCTION, + 'amount' => $absentPenalty, + 'description' => "Bolos {$date->format('d/m/Y')}", + 'created_by_id' => $createdBy->id, + ]); + + $payroll->load('adjustments'); + $payroll->recalculateAmounts(); + $payroll->save(); + + $absentCount++; + + continue; + } + + if ($latePenalty <= 0) { + continue; + } + + $checkInTime = Carbon::parse($attendance->check_in_at); + + if ($checkInTime->lte($scheduledCheckIn)) { + continue; + } + + $payroll = $this->findOpenPayroll($employee->id); + + if ($payroll === null) { + continue; + } + + $alreadyExists = $payroll->adjustments() + ->where('attendance_id', $attendance->id) + ->where('type', PayrollAdjustmentType::DEDUCTION) + ->exists(); + + if ($alreadyExists) { + $skippedDuplicate++; + + continue; + } + + $minutesLate = (int) $scheduledCheckIn->diffInMinutes($checkInTime); + + $payroll->adjustments()->create([ + 'type' => PayrollAdjustmentType::DEDUCTION, + 'amount' => $latePenalty, + 'description' => "Terlambat {$date->format('d/m/Y')} ({$minutesLate} menit)", + 'attendance_id' => $attendance->id, + 'created_by_id' => $createdBy->id, + ]); + + $payroll->load('adjustments'); + $payroll->recalculateAmounts(); + $payroll->save(); + + $lateCount++; } - - $payroll = $this->findOpenPayroll($employee->id); - - if ($payroll === null) { - continue; - } - - $alreadyExists = $payroll->adjustments() - ->where('attendance_id', null) - ->deduction() - ->where('description', 'like', "%Bolos {$date->format('d/m/Y')}%") - ->exists(); - - if ($alreadyExists) { - $skippedDuplicate++; - - continue; - } - - $payroll->adjustments()->create([ - 'type' => PayrollAdjustmentType::DEDUCTION, - 'amount' => $absentPenalty, - 'description' => "Bolos {$date->format('d/m/Y')}", - 'created_by_id' => 1, - ]); - - $payroll->load('adjustments'); - $payroll->recalculateAmounts(); - $payroll->save(); - - $absentCount++; - - continue; - } - - if ($latePenalty <= 0) { - continue; - } - - $checkInTime = Carbon::parse($attendance->check_in_at); - - if ($checkInTime->lte($scheduledCheckIn)) { - continue; - } - - $payroll = $this->findOpenPayroll($employee->id); - - if ($payroll === null) { - continue; - } - - $alreadyExists = $payroll->adjustments() - ->where('attendance_id', $attendance->id) - ->where('type', PayrollAdjustmentType::DEDUCTION) - ->exists(); - - if ($alreadyExists) { - $skippedDuplicate++; - - continue; - } - - $minutesLate = (int) $scheduledCheckIn->diffInMinutes($checkInTime); - - $payroll->adjustments()->create([ - 'type' => PayrollAdjustmentType::DEDUCTION, - 'amount' => $latePenalty, - 'description' => "Terlambat {$date->format('d/m/Y')} ({$minutesLate} menit)", - 'attendance_id' => $attendance->id, - 'created_by_id' => 1, + }); + } catch (\Throwable $e) { + Log::error("Gagal menerapkan penalti presensi: {$e->getMessage()}", [ + 'trace' => $e->getTraceAsString(), ]); - $payroll->load('adjustments'); - $payroll->recalculateAmounts(); - $payroll->save(); + $this->error('Gagal menerapkan penalti presensi: '.$e->getMessage()); - $lateCount++; + return self::FAILURE; } $this->info("Selesai memproses tanggal {$date->format('d/m/Y')}:"); @@ -161,9 +182,7 @@ public function handle(): int private function findOpenPayroll(int $employeeId): ?Payroll { - $period = PayrollPeriod::query() - ->open() - ->first(); + $period = PayrollPeriod::query()->open()->first(); if ($period === null) { return null; @@ -174,4 +193,12 @@ private function findOpenPayroll(int $employeeId): ?Payroll ->where('employee_id', $employeeId) ->first(); } + + private function resolveSystemUser(): ?User + { + return User::query() + ->whereHas('roles', fn ($query) => $query->whereIn('name', [Role::DEVELOPER->value, Role::OWNER->value])) + ->first() + ?? User::query()->first(); + } } diff --git a/app/Models/Payroll.php b/app/Models/Payroll.php index 487bd1f..4129c60 100644 --- a/app/Models/Payroll.php +++ b/app/Models/Payroll.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\EmployeeAdvanceStatus; use App\Enums\PayrollAdjustmentType; use App\Enums\PayrollStatus; use App\Models\Concerns\InteractsWithActivityLog; diff --git a/app/Services/Finance/PayrollService.php b/app/Services/Finance/PayrollService.php index 2e65746..79e43a4 100644 --- a/app/Services/Finance/PayrollService.php +++ b/app/Services/Finance/PayrollService.php @@ -13,17 +13,17 @@ use App\Models\PayrollAdjustment; use App\Models\PayrollPeriod; use App\Models\User; +use App\Services\Concerns\RunsInTransaction; use App\Services\System\PushNotificationService; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; -use Illuminate\Validation\ValidationException; class PayrollService { + use RunsInTransaction; + public function __construct( private readonly CashService $cashService, private readonly PushNotificationService $pushNotificationService, @@ -108,8 +108,8 @@ public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod ?? User::query()->whereHas('roles', fn (Builder $roleQuery) => $roleQuery->whereIn('name', [Role::DEVELOPER->value, Role::OWNER->value]))->first() ?? User::query()->first(); - try { - $period = DB::transaction(function () use ($user): PayrollPeriod { + $period = $this->runInTransaction( + function () use ($user): PayrollPeriod { $now = now(); $year = $now->year; $month = $now->month; @@ -157,18 +157,9 @@ public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod $this->generatePayrollsForPeriod($period); return $period->fresh(); - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal membuka periode payroll: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal membuka periode payroll', + ); return $period; } @@ -205,9 +196,8 @@ public function generatePayrollsForPeriod(PayrollPeriod $period): void public function addAdjustment(Payroll $payroll, array $validated, User $user): void { - - try { - DB::transaction(function () use ($payroll, $validated, $user): void { + $this->runInTransaction( + function () use ($payroll, $validated, $user): void { $payroll->adjustments()->create([ 'type' => PayrollAdjustmentType::from($validated['type']), 'amount' => (int) $validated['amount'], @@ -218,18 +208,9 @@ public function addAdjustment(Payroll $payroll, array $validated, User $user): v $payroll->load('adjustments'); $payroll->recalculateAmounts(); $payroll->save(); - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal menambahkan penyesuaian gaji: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal menambahkan penyesuaian gaji', + ); if ($payroll->employee?->user_id) { $typeLabel = PayrollAdjustmentType::from($validated['type'])->label(); @@ -248,8 +229,8 @@ public function updateAdjustment(PayrollAdjustment $adjustment, array $validated $payroll = $adjustment->payroll; $payroll->loadMissing(['payrollPeriod', 'employee.user']); - try { - DB::transaction(function () use ($payroll, $adjustment, $validated): void { + $this->runInTransaction( + function () use ($payroll, $adjustment, $validated): void { $adjustment->type = PayrollAdjustmentType::from($validated['type']); $adjustment->amount = (int) $validated['amount']; $adjustment->description = $validated['description']; @@ -258,18 +239,9 @@ public function updateAdjustment(PayrollAdjustment $adjustment, array $validated $payroll->load('adjustments'); $payroll->recalculateAmounts(); $payroll->save(); - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal memperbarui penyesuaian gaji: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal memperbarui penyesuaian gaji', + ); if ($payroll->employee?->user_id) { $typeLabel = PayrollAdjustmentType::from($validated['type'])->label(); @@ -288,25 +260,16 @@ public function deleteAdjustment(PayrollAdjustment $adjustment): void $payroll = $adjustment->payroll; $payroll->loadMissing(['payrollPeriod', 'employee.user']); - try { - DB::transaction(function () use ($payroll, $adjustment): void { + $this->runInTransaction( + function () use ($payroll, $adjustment): void { $adjustment->delete(); $payroll->load('adjustments'); $payroll->recalculateAmounts(); $payroll->save(); - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal menghapus penyesuaian gaji: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal menghapus penyesuaian gaji', + ); if ($payroll->employee?->user_id) { $this->pushNotificationService->sendToUser( @@ -323,32 +286,23 @@ public function pay(Payroll $payroll, User $user): void $payroll->loadMissing(['payrollPeriod', 'employee.user.profile']); if ($payroll->total_amount <= 0) { - try { - DB::transaction(function () use ($payroll, $user): void { + $this->runInTransaction( + function () use ($payroll, $user): void { $payroll->status = PayrollStatus::PAID; $payroll->paid_at = now(); $payroll->paid_by_id = $user->id; $payroll->save(); $this->settleKasbonFromPayroll($payroll, $user); - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal membayar gaji (total 0): '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal membayar gaji (total 0)', + ); return; } - try { - DB::transaction(function () use ($payroll, $user): void { + $this->runInTransaction( + function () use ($payroll, $user): void { $description = sprintf( 'Pembayaran gaji: %s (%s)', $payroll->employeeName, @@ -369,18 +323,9 @@ public function pay(Payroll $payroll, User $user): void $payroll->save(); $this->settleKasbonFromPayroll($payroll, $user); - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal membayar gaji: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal membayar gaji', + ); if ($payroll->employee?->user_id) { $this->pushNotificationService->sendToUser( diff --git a/bootstrap/app.php b/bootstrap/app.php index b1f8a06..14864bb 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -43,4 +43,8 @@ $schedule->command('payroll:open-period') ->monthlyOn(1, '00:05') ->timezone('Asia/Jakarta'); + + $schedule->command('attendance:apply-penalties') + ->dailyAt('23:55') + ->timezone('Asia/Jakarta'); })->create(); diff --git a/resources/js/components/button/RowAdjustAction.vue b/resources/js/components/button/RowAdjustAction.vue index cb83b65..0732d26 100644 --- a/resources/js/components/button/RowAdjustAction.vue +++ b/resources/js/components/button/RowAdjustAction.vue @@ -7,12 +7,16 @@ defineProps<{ tooltip?: string; disabled?: boolean; }>(); + +const emit = defineEmits<{ + click: []; +}>();