Fix: Prorate holiday pay for daily employees, flat 10% ND rate, detect LWOP from absent status

This commit is contained in:
2026-05-15 19:16:54 +08:00
parent 6f901c2e72
commit ca48602a76
2 changed files with 199 additions and 17 deletions

View File

@@ -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++;
}
}
}
}

View File

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