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;
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();
}
}

View File

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

View File

@ -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(

View File

@ -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();

View File

@ -7,12 +7,16 @@ defineProps<{
tooltip?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
click: [];
}>();
</script>
<template>
<Tooltip>
<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" />
<span class="sr-only">{{ tooltip || 'Sesuaikan' }}</span>
</Button>