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

This commit is contained in:
Yoga Pangestu 2026-07-04 15:05:25 +07:00
parent 37d9ac10a6
commit 8404b8af41
5 changed files with 168 additions and 187 deletions

View File

@ -3,14 +3,18 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Enums\PayrollAdjustmentType; use App\Enums\PayrollAdjustmentType;
use App\Enums\Role;
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\Employee; use App\Models\Employee;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Models\Payroll; use App\Models\Payroll;
use App\Models\PayrollPeriod; use App\Models\PayrollPeriod;
use App\Models\User;
use App\Settings\HrSettings; use App\Settings\HrSettings;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ApplyAttendancePenalties extends Command 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); $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() $employees = Employee::query()
->whereHas('user', function ($query) { ->whereHas('user', fn ($query) => $query->active())
$query->active();
})
->get(); ->get();
$lateCount = 0; $lateCount = 0;
@ -48,106 +57,118 @@ public function handle(): int
$skippedLeave = 0; $skippedLeave = 0;
$skippedDuplicate = 0; $skippedDuplicate = 0;
foreach ($employees as $employee) { try {
$hasLeave = LeaveRequest::query() DB::transaction(function () use ($employees, $date, $scheduledCheckIn, $latePenalty, $absentPenalty, $createdBy, &$lateCount, &$absentCount, &$skippedLeave, &$skippedDuplicate): void {
->approved() foreach ($employees as $employee) {
->where('employee_id', $employee->id) $hasLeave = LeaveRequest::query()
->whereDate('start_date', '<=', $date) ->approved()
->whereDate('end_date', '>=', $date) ->where('employee_id', $employee->id)
->exists(); ->whereDate('start_date', '<=', $date)
->whereDate('end_date', '>=', $date)
->exists();
if ($hasLeave) { if ($hasLeave) {
$skippedLeave++; $skippedLeave++;
continue; continue;
} }
$attendance = Attendance::query() $attendance = Attendance::query()
->where('employee_id', $employee->id) ->where('employee_id', $employee->id)
->whereDate('attendance_date', $date) ->whereDate('attendance_date', $date)
->first(); ->first();
if ($attendance === null) { if ($attendance === null) {
if ($absentPenalty <= 0) { if ($absentPenalty <= 0) {
continue; 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); } catch (\Throwable $e) {
Log::error("Gagal menerapkan penalti presensi: {$e->getMessage()}", [
if ($payroll === null) { 'trace' => $e->getTraceAsString(),
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,
]); ]);
$payroll->load('adjustments'); $this->error('Gagal menerapkan penalti presensi: '.$e->getMessage());
$payroll->recalculateAmounts();
$payroll->save();
$lateCount++; return self::FAILURE;
} }
$this->info("Selesai memproses tanggal {$date->format('d/m/Y')}:"); $this->info("Selesai memproses tanggal {$date->format('d/m/Y')}:");
@ -161,9 +182,7 @@ public function handle(): int
private function findOpenPayroll(int $employeeId): ?Payroll private function findOpenPayroll(int $employeeId): ?Payroll
{ {
$period = PayrollPeriod::query() $period = PayrollPeriod::query()->open()->first();
->open()
->first();
if ($period === null) { if ($period === null) {
return null; return null;
@ -174,4 +193,12 @@ private function findOpenPayroll(int $employeeId): ?Payroll
->where('employee_id', $employeeId) ->where('employee_id', $employeeId)
->first(); ->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();
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\PayrollAdjustmentType; use App\Enums\PayrollAdjustmentType;
use App\Enums\PayrollStatus; use App\Enums\PayrollStatus;
use App\Models\Concerns\InteractsWithActivityLog; use App\Models\Concerns\InteractsWithActivityLog;

View File

@ -13,17 +13,17 @@
use App\Models\PayrollAdjustment; use App\Models\PayrollAdjustment;
use App\Models\PayrollPeriod; use App\Models\PayrollPeriod;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\RunsInTransaction;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class PayrollService class PayrollService
{ {
use RunsInTransaction;
public function __construct( public function __construct(
private readonly CashService $cashService, private readonly CashService $cashService,
private readonly PushNotificationService $pushNotificationService, 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()->whereHas('roles', fn (Builder $roleQuery) => $roleQuery->whereIn('name', [Role::DEVELOPER->value, Role::OWNER->value]))->first()
?? User::query()->first(); ?? User::query()->first();
try { $period = $this->runInTransaction(
$period = DB::transaction(function () use ($user): PayrollPeriod { function () use ($user): PayrollPeriod {
$now = now(); $now = now();
$year = $now->year; $year = $now->year;
$month = $now->month; $month = $now->month;
@ -157,18 +157,9 @@ public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod
$this->generatePayrollsForPeriod($period); $this->generatePayrollsForPeriod($period);
return $period->fresh(); return $period->fresh();
}); },
} catch (ValidationException $e) { 'Gagal membuka periode payroll',
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.',
]);
}
return $period; return $period;
} }
@ -205,9 +196,8 @@ public function generatePayrollsForPeriod(PayrollPeriod $period): void
public function addAdjustment(Payroll $payroll, array $validated, User $user): void public function addAdjustment(Payroll $payroll, array $validated, User $user): void
{ {
$this->runInTransaction(
try { function () use ($payroll, $validated, $user): void {
DB::transaction(function () use ($payroll, $validated, $user): void {
$payroll->adjustments()->create([ $payroll->adjustments()->create([
'type' => PayrollAdjustmentType::from($validated['type']), 'type' => PayrollAdjustmentType::from($validated['type']),
'amount' => (int) $validated['amount'], 'amount' => (int) $validated['amount'],
@ -218,18 +208,9 @@ public function addAdjustment(Payroll $payroll, array $validated, User $user): v
$payroll->load('adjustments'); $payroll->load('adjustments');
$payroll->recalculateAmounts(); $payroll->recalculateAmounts();
$payroll->save(); $payroll->save();
}); },
} catch (ValidationException $e) { 'Gagal menambahkan penyesuaian gaji',
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.',
]);
}
if ($payroll->employee?->user_id) { if ($payroll->employee?->user_id) {
$typeLabel = PayrollAdjustmentType::from($validated['type'])->label(); $typeLabel = PayrollAdjustmentType::from($validated['type'])->label();
@ -248,8 +229,8 @@ public function updateAdjustment(PayrollAdjustment $adjustment, array $validated
$payroll = $adjustment->payroll; $payroll = $adjustment->payroll;
$payroll->loadMissing(['payrollPeriod', 'employee.user']); $payroll->loadMissing(['payrollPeriod', 'employee.user']);
try { $this->runInTransaction(
DB::transaction(function () use ($payroll, $adjustment, $validated): void { function () use ($payroll, $adjustment, $validated): void {
$adjustment->type = PayrollAdjustmentType::from($validated['type']); $adjustment->type = PayrollAdjustmentType::from($validated['type']);
$adjustment->amount = (int) $validated['amount']; $adjustment->amount = (int) $validated['amount'];
$adjustment->description = $validated['description']; $adjustment->description = $validated['description'];
@ -258,18 +239,9 @@ public function updateAdjustment(PayrollAdjustment $adjustment, array $validated
$payroll->load('adjustments'); $payroll->load('adjustments');
$payroll->recalculateAmounts(); $payroll->recalculateAmounts();
$payroll->save(); $payroll->save();
}); },
} catch (ValidationException $e) { 'Gagal memperbarui penyesuaian gaji',
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.',
]);
}
if ($payroll->employee?->user_id) { if ($payroll->employee?->user_id) {
$typeLabel = PayrollAdjustmentType::from($validated['type'])->label(); $typeLabel = PayrollAdjustmentType::from($validated['type'])->label();
@ -288,25 +260,16 @@ public function deleteAdjustment(PayrollAdjustment $adjustment): void
$payroll = $adjustment->payroll; $payroll = $adjustment->payroll;
$payroll->loadMissing(['payrollPeriod', 'employee.user']); $payroll->loadMissing(['payrollPeriod', 'employee.user']);
try { $this->runInTransaction(
DB::transaction(function () use ($payroll, $adjustment): void { function () use ($payroll, $adjustment): void {
$adjustment->delete(); $adjustment->delete();
$payroll->load('adjustments'); $payroll->load('adjustments');
$payroll->recalculateAmounts(); $payroll->recalculateAmounts();
$payroll->save(); $payroll->save();
}); },
} catch (ValidationException $e) { 'Gagal menghapus penyesuaian gaji',
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.',
]);
}
if ($payroll->employee?->user_id) { if ($payroll->employee?->user_id) {
$this->pushNotificationService->sendToUser( $this->pushNotificationService->sendToUser(
@ -323,32 +286,23 @@ public function pay(Payroll $payroll, User $user): void
$payroll->loadMissing(['payrollPeriod', 'employee.user.profile']); $payroll->loadMissing(['payrollPeriod', 'employee.user.profile']);
if ($payroll->total_amount <= 0) { if ($payroll->total_amount <= 0) {
try { $this->runInTransaction(
DB::transaction(function () use ($payroll, $user): void { function () use ($payroll, $user): void {
$payroll->status = PayrollStatus::PAID; $payroll->status = PayrollStatus::PAID;
$payroll->paid_at = now(); $payroll->paid_at = now();
$payroll->paid_by_id = $user->id; $payroll->paid_by_id = $user->id;
$payroll->save(); $payroll->save();
$this->settleKasbonFromPayroll($payroll, $user); $this->settleKasbonFromPayroll($payroll, $user);
}); },
} catch (ValidationException $e) { 'Gagal membayar gaji (total 0)',
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.',
]);
}
return; return;
} }
try { $this->runInTransaction(
DB::transaction(function () use ($payroll, $user): void { function () use ($payroll, $user): void {
$description = sprintf( $description = sprintf(
'Pembayaran gaji: %s (%s)', 'Pembayaran gaji: %s (%s)',
$payroll->employeeName, $payroll->employeeName,
@ -369,18 +323,9 @@ public function pay(Payroll $payroll, User $user): void
$payroll->save(); $payroll->save();
$this->settleKasbonFromPayroll($payroll, $user); $this->settleKasbonFromPayroll($payroll, $user);
}); },
} catch (ValidationException $e) { 'Gagal membayar gaji',
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.',
]);
}
if ($payroll->employee?->user_id) { if ($payroll->employee?->user_id) {
$this->pushNotificationService->sendToUser( $this->pushNotificationService->sendToUser(

View File

@ -43,4 +43,8 @@
$schedule->command('payroll:open-period') $schedule->command('payroll:open-period')
->monthlyOn(1, '00:05') ->monthlyOn(1, '00:05')
->timezone('Asia/Jakarta'); ->timezone('Asia/Jakarta');
$schedule->command('attendance:apply-penalties')
->dailyAt('23:55')
->timezone('Asia/Jakarta');
})->create(); })->create();

View File

@ -7,12 +7,16 @@ defineProps<{
tooltip?: string; tooltip?: string;
disabled?: boolean; disabled?: boolean;
}>(); }>();
const emit = defineEmits<{
click: [];
}>();
</script> </script>
<template> <template>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" :disabled="disabled"> <Button variant="ghost" size="icon" class="size-8" :disabled="disabled" @click="emit('click')">
<SlidersHorizontal class="size-4" /> <SlidersHorizontal class="size-4" />
<span class="sr-only">{{ tooltip || 'Sesuaikan' }}</span> <span class="sr-only">{{ tooltip || 'Sesuaikan' }}</span>
</Button> </Button>