Fix: Prorate holiday pay for daily employees, flat 10% ND rate, detect LWOP from absent status
This commit is contained in:
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
149
docs/PLAN-payroll-parallel4-fix.md
Normal file
149
docs/PLAN-payroll-parallel4-fix.md
Normal 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
|
||||
Reference in New Issue
Block a user