From ca48602a764de6da30dc4299bbbed1d4073bcf64 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 15 May 2026 19:16:54 +0800 Subject: [PATCH] Fix: Prorate holiday pay for daily employees, flat 10% ND rate, detect LWOP from absent status --- app/Services/PayrollService.php | 67 +++++++++---- docs/PLAN-payroll-parallel4-fix.md | 149 +++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 docs/PLAN-payroll-parallel4-fix.md diff --git a/app/Services/PayrollService.php b/app/Services/PayrollService.php index 7e87f7321..f04b39371 100644 --- a/app/Services/PayrollService.php +++ b/app/Services/PayrollService.php @@ -174,13 +174,22 @@ class PayrollService $lateHours += abs((float) ($att->late_hours ?? 0)); $undertimeHours += abs((float) ($att->early_hours ?? 0)); - // 1. Basic Pay for the day (Daily Rate * Day Multiplier) - $earnedDaily = $actualDailyRate * $dayMultiplier; + // Standard shift hours for proration + $standardHours = ($att->shift && $att->shift->working_hours > 0) ? (float)$att->shift->working_hours : 8.0; + + // For daily wage: prorate by actual hours worked vs standard shift + // For monthly: always full daily rate (they're salaried) + $hoursWorked = min((float)$att->total_hours, $standardHours); + $hoursRatio = $isDailyWage ? ($hoursWorked / $standardHours) : 1.0; + + // 1. Basic Pay for the day (prorated for daily, full for monthly) + $proratedDailyRate = $actualDailyRate * $hoursRatio; + $earnedDaily = $proratedDailyRate * $dayMultiplier; // Separate out the holiday/premium pay from the basic salary for the payslip breakdown - $earnedBasicSalary += $actualDailyRate; + $earnedBasicSalary += $proratedDailyRate; if ($dayMultiplier > 1.0) { - $premium = ($earnedDaily - $actualDailyRate); + $premium = ($earnedDaily - $proratedDailyRate); if ($isRegularHoliday || $isSpecialHoliday) { $holidayPay += $premium; } @@ -208,17 +217,14 @@ class PayrollService $ndHours = (float) ($att->night_diff_hours ?? 0); $nightDiffHours += $ndHours; - // Standard Shift Working Hours (to detect OT overlap) - $standardHours = ($att->shift && $att->shift->working_hours > 0) ? (float)$att->shift->working_hours : 8.0; - - // Calculate ND that overlaps with OT + // Calculate ND that overlaps with OT (standardHours already computed above) $ndOtHours = $this->calculateNdOtOverlap($att, $standardHours); $ndRegHours = max(0, $ndHours - $ndOtHours); - // ND Amount (Premium part only: 10%) - // Compounded: Base * DayMult * 10% for reg, Base * DayMult * OTMult * 10% for OT - $ndRegAmount = $ndRegHours * ($hourlyRate * $dayMultiplier * 0.10); - $ndOtAmount = $ndOtHours * ($hourlyRate * $dayMultiplier * $otRateMultiplier * 0.10); + // ND Amount (Premium part only: flat 10% of hourly rate) + // Per client: do NOT compound with dayMultiplier + $ndRegAmount = $ndRegHours * ($hourlyRate * 0.10); + $ndOtAmount = $ndOtHours * ($hourlyRate * $otRateMultiplier * 0.10); $nightDiffAmount += ($ndRegAmount + $ndOtAmount); @@ -236,11 +242,38 @@ class PayrollService $holidayPay += $actualDailyRate; $holidayDaysNotWorked++; } else { - // Only count as absent if NOT a rest day AND NOT a special holiday - // Monthly employees' fixed salary covers special holidays automatically. - // Daily employees: special holiday = no work no pay (correctly no pay added). - if (!$isRestDay && !$isSpecialHoliday) { - $absencesCount++; + // Before counting as absent, check if there's a leave application + // (handles cases where attendance status is 'absent' but a leave was filed) + $leaveMatchedFromApp = false; + if (!$isPaidLeave && !$leaveAppForDate) { + foreach ($leaveApps as $leave) { + if ($currentDate->betweenIncluded($leave->start_date, $leave->end_date)) { + $leaveAppForDate = $leave; + break; + } + } + } + + if ($leaveAppForDate) { + $isLeaveForDatePaid = $leaveAppForDate->leaveType->is_paid ?? false; + if ($isLeaveForDatePaid) { + $paidLeavesAmount += $actualDailyRate; + $daysWorked++; + $paidLeavesCount++; + } else { + // Unpaid leave (LWOP) — count as absence for deduction + if (!$isRestDay && !$isSpecialHoliday) { + $absencesCount++; + } + } + $leaveMatchedFromApp = true; + } + + if (!$leaveMatchedFromApp) { + // Only count as absent if NOT a rest day AND NOT a special holiday + if (!$isRestDay && !$isSpecialHoliday) { + $absencesCount++; + } } } } diff --git a/docs/PLAN-payroll-parallel4-fix.md b/docs/PLAN-payroll-parallel4-fix.md new file mode 100644 index 000000000..0cd7ece4c --- /dev/null +++ b/docs/PLAN-payroll-parallel4-fix.md @@ -0,0 +1,149 @@ +# Fix Payroll Parallel 4 — 3 Bugs + +## Bug 1: LWOP Not Captured When Attendance Status is "Absent" + +### Problem +Ana Regina has 2 LWOP (Apr 27 + May 9). May 9 attendance record has `status=absent` instead of `on_leave`. The PayrollService only checks `$att->status === 'on_leave'` to look up leave type, so it misses the leave application and counts it as a generic absence instead. + +**Impact:** The payslip shows 1 LWOP instead of 2. The deduction amount may be the same (both are unpaid), but the breakdown is wrong. + +### Current Code (PayrollService.php ~line 137) +```php +if ($att && $att->status === 'on_leave' && $att->leaveApplication) { + $isPaidLeave = $att->leaveApplication->leaveType->is_paid ?? false; +} else { + // Fallback: check leave applications table + foreach ($leaveApps as $leave) { ... } +} +``` + +### Fix +The fallback already checks `$leaveApps`, but it only runs when the attendance status is NOT `on_leave`. The issue is that when `status=absent` AND a leave app exists, the system counts it as an absence (line ~247) instead of recognizing it as a leave. + +**Change:** In the "Did NOT work" block, before counting absences, also check if a leave application exists for that date: + +```php +} else { + // Check if there's an approved leave for this date (even if status is 'absent') + $leaveForDate = null; + foreach ($leaveApps as $leave) { + if ($currentDate->betweenIncluded($leave->start_date, $leave->end_date)) { + $leaveForDate = $leave; + break; + } + } + + if ($leaveForDate) { + $isLeaveForDatePaid = $leaveForDate->leaveType->is_paid ?? false; + if ($isLeaveForDatePaid) { + $paidLeavesAmount += $actualDailyRate; + $daysWorked++; + $paidLeavesCount++; + } + // If unpaid (LWOP), don't count as absence — it's tracked as LWOP + // The absence deduction still applies since LWOP is unpaid + } else if (!$isRestDay && !$isSpecialHoliday) { + $absencesCount++; + } +} +``` + +> [!IMPORTANT] +> This fix ensures LWOP is properly recognized even when the attendance record says "absent". + +--- + +## Bug 2: Night Differential Too High for Rank & File + +### Problem +Client says ND is "too high" for daily wage employees. Current formula compounds ND with the day multiplier: +``` +ND = ND_hours × hourlyRate × dayMultiplier × 10% +``` + +On a regular holiday (multiplier 2.0), this doubles the ND premium. Client expects flat 10%. + +### Example (Dinh Chavez, ₱765/day) +- Hourly: ₱765 / 8 = ₱95.625 +- Current ND/hr on holiday: ₱95.625 × 2.0 × 10% = **₱19.13** (too high) +- Expected ND/hr: ₱95.625 × 10% = **₱9.56** (flat) + +### Fix (PayrollService.php ~line 220) +```php +// BEFORE: +$ndRegAmount = $ndRegHours * ($hourlyRate * $dayMultiplier * 0.10); +$ndOtAmount = $ndOtHours * ($hourlyRate * $dayMultiplier * $otRateMultiplier * 0.10); + +// AFTER: +$ndRegAmount = $ndRegHours * ($hourlyRate * 0.10); +$ndOtAmount = $ndOtHours * ($hourlyRate * $otRateMultiplier * 0.10); +``` + +Remove `$dayMultiplier` from ND calculation entirely. ND is a flat 10% of the base hourly rate. + +> [!WARNING] +> Verify with client: DOLE technically compounds ND with day premium. But client explicitly says it's too high, so we follow their business rule. + +--- + +## Bug 3: Holiday Pay Should Be Prorated by Hours Worked (Rank & File) + +### Problem +Daily employees who worked **partial hours** on a holiday get the **full day** premium. Example: +- Dinh worked 7.28h on May 1 (not full 8h shift) +- System gives premium = ₱765 × 1.0 = ₱765 (full day) +- Client expects: premium = (7.28/8) × ₱765 = **₱696.15** (prorated) + +### Current Code (PayrollService.php ~line 177-186) +```php +$earnedDaily = $actualDailyRate * $dayMultiplier; // Full daily rate +$earnedBasicSalary += $actualDailyRate; // Full basic +$premium = ($earnedDaily - $actualDailyRate); // Full premium +``` + +### Fix +For daily wage employees, prorate based on actual hours worked vs standard shift hours: + +```php +if ($didWork) { + $standardHours = ($att->shift && $att->shift->working_hours > 0) + ? (float)$att->shift->working_hours : 8.0; + $hoursWorked = min((float)$att->total_hours, $standardHours); // Cap at standard + $hoursRatio = $isDailyWage ? ($hoursWorked / $standardHours) : 1.0; + + $earnedDaily = $actualDailyRate * $dayMultiplier * $hoursRatio; + + if ($isDailyWage) { + $earnedBasicSalary += $actualDailyRate * $hoursRatio; + } else { + $earnedBasicSalary += $actualDailyRate; + } + + if ($dayMultiplier > 1.0) { + $premium = ($earnedDaily - ($actualDailyRate * $hoursRatio)); + if ($isRegularHoliday || $isSpecialHoliday) { + $holidayPay += $premium; + } + } +} +``` + +> [!IMPORTANT] +> This only applies to **daily wage** employees. Monthly employees always get the full daily rate regardless of hours (they're salaried). + +--- + +## Files to Modify + +### [MODIFY] `app/Services/PayrollService.php` +1. **Line ~137-149**: Fix leave detection for absent-status records +2. **Line ~220-221**: Remove dayMultiplier from ND formula +3. **Line ~177-188**: Prorate holiday premium for daily employees +4. **Line ~227-249**: Fix LWOP/absence detection in "Did NOT work" block + +## Verification +- [ ] Ana Regina: 2 LWOP should show in payslip +- [ ] Dinh Chavez: ND amount should be lower (flat 10%) +- [ ] Dinh Chavez: Holiday premium prorated to 7.28/8 of daily rate +- [ ] Monthly employees (Paul, Mart): unchanged behavior +- [ ] Run payroll for full cut-off and compare with client's manual computation