Sep 11 - Reupload the code

This commit is contained in:
2026-09-11 22:42:45 -04:00
commit d92cff81e5
130 changed files with 73508 additions and 0 deletions
+838
View File
@@ -0,0 +1,838 @@
#!/usr/bin/env python3
"""
Working Hours Calculator for Employee Payroll
============================================
This module implements the working hours calculation logic
based on the Java files provided. It handles:
- Daily time calculations with travel time options
- Weekly regular and overtime hours
- Record pairing (check-in/check-out)
- Missing punch detection
- Quarter-hour rounding
- SP/PW/PT/C (Special Project/Periodic Work/Part-Time/Covering) support with consolidation
Based on the Java classes:
- DailyTimeCalculator.java
- WeeklyTimeCalculator.java
- PayrollReport.java
"""
from datetime import datetime, timedelta, time
from typing import List, Dict, Optional, Tuple, Any
from dataclasses import dataclass
import math
import re
import logging
from logger_handler import log_database_operations
_calc_logger = logging.getLogger('qr_attendance_app')
# Constants from Java implementation
RECORD_GROUPING_MAX_MINUTES = 60 * 6 # 6 hours
MAX_REGULAR_TIME_MINUTES = 60 * 40 # 40 hours per week
# ---------------------------------------------------------------------------
# Time rounding utilities (ported from SingleCheckInCalculator)
# ---------------------------------------------------------------------------
def round_time_to_quarter_hour(minutes: float) -> float:
"""
Round a duration in minutes to the nearest quarter hour using
7.5-minute boundary increments.
Rules:
0:00 0:07 → 0:00
0:08 0:22 → 0:15
0:23 0:37 → 0:30
0:38 0:52 → 0:45
0:53 1:07 → 1:00
"""
if minutes < 0:
return 0.0
hours = int(minutes // 60)
minutes_in_hour = minutes % 60
if minutes_in_hour <= 7:
rounded_in_hour = 0
elif minutes_in_hour <= 22:
rounded_in_hour = 15
elif minutes_in_hour <= 37:
rounded_in_hour = 30
elif minutes_in_hour <= 52:
rounded_in_hour = 45
else: # 53 59
hours += 1
rounded_in_hour = 0
return hours * 60 + rounded_in_hour
def convert_minutes_to_base100(minutes: float) -> float:
"""
Convert a duration in minutes to base-100 hours
(each fractional hour expressed as hundredths, not sixtieths).
Example: 90 min → 1.50 base-100 hours
"""
if minutes < 0:
return 0.0
decimal_hours = minutes / 60.0
whole_hours = int(decimal_hours)
fractional_hours = decimal_hours - whole_hours
base100_fraction = fractional_hours * 100
return whole_hours + (base100_fraction / 100)
def round_base100_hours(base100_hours: float) -> float:
"""
Round base-100 hours to the nearest quarter (.00 / .25 / .50 / .75)
using 12.5-unit thresholds.
Examples:
4.12 → 4.00
4.18 → 4.25
8.02 → 8.00
8.87 → 9.00
"""
if base100_hours < 0:
return 0.0
whole_hours = int(base100_hours)
fractional_part = (base100_hours - whole_hours) * 100
if fractional_part < 12.5:
rounded_fraction = 0
elif fractional_part < 37.5:
rounded_fraction = 25
elif fractional_part < 62.5:
rounded_fraction = 50
elif fractional_part < 87.5:
rounded_fraction = 75
else:
whole_hours += 1
rounded_fraction = 0
return round(whole_hours + (rounded_fraction / 100), 2)
# ---------------------------------------------------------------------------
def parse_employee_id_for_work_type(employee_id: str) -> Tuple[str, str]:
"""
Parse employee ID to extract base ID and work type (supports SP, PW, PT, C)
Handles multiple formats:
- Suffix with space: "1234 SP", "1234 PW", "1234 PT", "1234 C"
- Suffix without space: "1234SP", "1234PW", "1234PT", "1234C"
- Prefix with space: "SP 1234", "PW 1234", "PT 1234", "C 1234"
- Prefix without space: "SP1234", "PW1234", "PT1234", "C1234"
Args:
employee_id: Employee ID string in any of the above formats
Returns:
Tuple of (base_employee_id, work_type)
work_type is one of: 'regular', 'SP', 'PW', 'PT', 'C'
"""
if not employee_id:
return str(employee_id), 'regular'
employee_id_clean = str(employee_id).strip().upper()
# Define work type codes
work_type_codes = ['SP', 'PW', 'PT', 'C']
for work_type in work_type_codes:
# Pattern 1: Suffix with optional space - "1234 SP" or "1234SP"
suffix_pattern = rf'^(\d+)\s*{work_type}$'
suffix_match = re.match(suffix_pattern, employee_id_clean)
if suffix_match:
return suffix_match.group(1), work_type
# Pattern 2: Prefix with optional space - "SP 1234" or "SP1234"
prefix_pattern = rf'^{work_type}\s*(\d+)$'
prefix_match = re.match(prefix_pattern, employee_id_clean)
if prefix_match:
return prefix_match.group(1), work_type
# Default to regular work
return employee_id_clean, 'regular'
@dataclass
class AttendanceRecord:
"""Represents a single attendance record"""
id: int
employee_id: str
check_in_date: datetime
check_in_time: time
location_name: str
record_type: str = 'check_in' # 'check_in' or 'check_out'
timestamp: datetime = None
action_description: str = ''
def __post_init__(self):
if self.timestamp is None:
# Combine date and time for timestamp
self.timestamp = datetime.combine(self.check_in_date, self.check_in_time)
@dataclass
class RecordPair:
"""Represents a paired check-in/check-out record"""
check_in: Optional[AttendanceRecord]
check_out: Optional[AttendanceRecord]
is_miss_punch: bool = False
date: datetime = None
location: str = ""
def __post_init__(self):
if self.check_in:
self.date = self.check_in.check_in_date
self.location = self.check_in.location_name
elif self.check_out:
self.date = self.check_out.check_in_date
self.location = self.check_out.location_name
@property
def duration_minutes(self) -> int:
"""Calculate duration in minutes between check-in and check-out"""
if self.is_miss_punch or not self.check_in or not self.check_out:
return -1
duration = self.check_out.timestamp - self.check_in.timestamp
return int(duration.total_seconds() / 60)
class TimeCalculator:
"""Base time calculator with rounding functionality"""
@staticmethod
def round_time_to_nearest_quarter_hour(minutes: int) -> int:
"""Round time to nearest quarter hour (15 minutes)"""
if minutes < 0:
return minutes # Keep negative values for miss punches
# Round to nearest 15-minute interval
return round(minutes / 15) * 15
class DailyTimeCalculator(TimeCalculator):
"""Calculate daily working hours with travel time options"""
def __init__(self):
self.record_pairs: List[RecordPair] = []
def add_record_pair(self, pair: RecordPair):
"""Add a record pair to the daily calculation"""
self.record_pairs.append(pair)
def get_minutes_total_exclude_travel_time(self) -> float:
"""
Calculate total minutes excluding travel time.
Returns -1 for miss punches, otherwise returns the quarter-hour-
rounded total minutes (using the 7.5-minute boundary rule).
"""
minute_total = 0
for pair in self.record_pairs:
if not pair.is_miss_punch:
minute_total += pair.duration_minutes
else:
return -1 # Miss punch detected
# Use the 7.5-minute boundary rounding (matches SingleCheckInCalculator)
return round_time_to_quarter_hour(minute_total)
def get_base100_hours(self) -> float:
"""
Return daily total as base-100 rounded hours.
Returns 0.0 for miss punches.
"""
rounded_minutes = self.get_minutes_total_exclude_travel_time()
if rounded_minutes < 0:
return 0.0
base100 = convert_minutes_to_base100(rounded_minutes)
return round_base100_hours(base100)
class WeeklyTimeCalculator(TimeCalculator):
"""Calculate weekly regular and overtime hours"""
def __init__(self):
self.daily_calculators: List[DailyTimeCalculator] = []
self.total_minutes = 0
self.regular_minutes = 0
self.overtime_minutes = 0
def add_daily_calculator(self, daily_calc: DailyTimeCalculator):
"""Add a daily time calculator to the weekly calculation"""
self.daily_calculators.append(daily_calc)
def calculate_time(self):
"""Calculate weekly totals with regular and overtime split"""
self.total_minutes = 0
for daily_calc in self.daily_calculators:
daily_minutes = daily_calc.get_minutes_total_exclude_travel_time()
if daily_minutes > 0:
self.total_minutes += daily_minutes
# Calculate regular and overtime
if self.total_minutes > MAX_REGULAR_TIME_MINUTES:
self.regular_minutes = MAX_REGULAR_TIME_MINUTES
else:
self.regular_minutes = self.total_minutes
self.overtime_minutes = self.total_minutes - self.regular_minutes
@property
def total_hours(self) -> float:
"""Get total hours as decimal"""
return self.total_minutes / 60.0
@property
def regular_hours(self) -> float:
"""Get regular hours as decimal"""
return self.regular_minutes / 60.0
@property
def overtime_hours(self) -> float:
"""Get overtime hours as decimal"""
return self.overtime_minutes / 60.0
class RecordPairBuilder:
"""Builds record pairs from attendance records"""
@staticmethod
def build_pairs_from_records(records: List[AttendanceRecord]) -> List[RecordPair]:
"""
Build check-in/check-out pairs from a list of attendance records
Args:
records: List of AttendanceRecord objects, should be for a single day
Returns:
List of RecordPair objects
"""
if not records:
return []
# Sort by timestamp
sorted_records = sorted(records, key=lambda r: r.timestamp)
pairs = []
i = 0
while i < len(sorted_records):
current_record = sorted_records[i]
# Check if this is a check-in
if current_record.record_type == 'check_in':
# Look for matching check-out
check_out_record = None
is_miss_punch = False
j = i + 1
while j < len(sorted_records):
next_record = sorted_records[j]
if next_record.record_type == 'check_out':
check_out_record = next_record
i = j + 1 # Move past the check-out
break
elif next_record.record_type == 'check_in':
# Another IN, keep looking
j += 1
# If no OUT found, it's an incomplete pair (missed punch)
if check_out_record is None:
is_miss_punch = True
i += 1
# Create the pair
pair = RecordPair(
check_in=current_record,
check_out=check_out_record,
is_miss_punch=is_miss_punch
)
pairs.append(pair)
else:
# Orphaned check-out (OUT without preceding IN)
pair = RecordPair(
check_in=None,
check_out=current_record,
is_miss_punch=True
)
pairs.append(pair)
i += 1
return pairs
class WorkingHoursCalculator:
"""
Main calculator for employee working hours with SP/PW/PT/C support.
This calculator consolidates employees by base ID, grouping records for
1234, 1234 SP, 1234 PW, 1234 PT under base employee 1234.
"""
def __init__(self):
pass
@log_database_operations('working_hours_calculation')
def calculate_employee_hours(self, employee_id: str, start_date: datetime, end_date: datetime,
attendance_records: List[Dict]) -> Dict[str, Any]:
"""
Calculate working hours for an employee over a date range with SP/PW/PT/C support.
This method consolidates all records for base employee ID including SP, PW, PT variants.
Args:
employee_id: Base employee ID (without SP/PW/PT suffix)
start_date: Start date for calculation
end_date: End date for calculation
attendance_records: List of attendance records from database
Returns:
Dictionary containing daily and weekly hour calculations with SP/PW/PT/C breakdown
"""
try:
_calc_logger.debug(f"Calculating hours for base employee {employee_id} with SP/PW/PT/C support")
# Parse base employee ID
base_employee_id, _ = parse_employee_id_for_work_type(employee_id)
# Filter and categorize records by work type
records_by_type = {'regular': [], 'SP': [], 'PW': [], 'PT': [], 'C': []}
for record in attendance_records:
try:
# Extract employee_id from record
if hasattr(record, '__dict__'):
record_emp_id = str(getattr(record, 'employee_id', '')).strip()
record_date = getattr(record, 'check_in_date', None)
record_time = getattr(record, 'check_in_time', None)
location = getattr(record, 'location_name', 'Unknown Location')
record_id = getattr(record, 'id', 0)
record_type = getattr(record, 'record_type', 'check_in')
action_desc = getattr(record, 'action_description', '')
else:
record_emp_id = str(record.get('employee_id', '')).strip()
record_date = record.get('check_in_date')
record_time = record.get('check_in_time')
location = record.get('location_name', 'Unknown Location')
record_id = record.get('id', 0)
record_type = record.get('record_type', 'check_in')
action_desc = record.get('action_description', '')
# Skip invalid records
if not record_emp_id or record_date is None or record_time is None:
continue
# Parse work type from record's employee ID
record_base_id, work_type = parse_employee_id_for_work_type(record_emp_id)
# Only include records for this base employee
if record_base_id == base_employee_id:
# Determine record type from action_description if available
if action_desc:
action_lower = action_desc.lower()
if 'out' in action_lower or 'checkout' in action_lower:
record_type = 'check_out'
else:
record_type = 'check_in'
# Create AttendanceRecord object
att_record = AttendanceRecord(
id=record_id,
employee_id=record_emp_id,
check_in_date=record_date if isinstance(record_date, datetime) else datetime.combine(record_date, datetime.min.time()),
check_in_time=record_time,
location_name=location,
record_type=record_type,
action_description=action_desc
)
records_by_type[work_type].append(att_record)
except Exception as record_error:
_calc_logger.warning(f"Error processing attendance record: {record_error}")
continue
total_records = sum(len(records_by_type[wt]) for wt in records_by_type)
_calc_logger.debug(
f"Employee {employee_id}: {total_records} records found — "
f"Regular: {len(records_by_type['regular'])}, SP: {len(records_by_type['SP'])}, "
f"PW: {len(records_by_type['PW'])}, PT: {len(records_by_type['PT'])}, "
f"C: {len(records_by_type['C'])}"
)
# Group records by date for each work type
daily_records_by_type = {wt: {} for wt in ['regular', 'SP', 'PW', 'PT', 'C']}
for work_type in ['regular', 'SP', 'PW', 'PT', 'C']:
for record in records_by_type[work_type]:
date_key = record.check_in_date.strftime('%Y-%m-%d') if isinstance(record.check_in_date, datetime) else record.check_in_date.strftime('%Y-%m-%d')
if date_key not in daily_records_by_type[work_type]:
daily_records_by_type[work_type][date_key] = []
daily_records_by_type[work_type][date_key].append(record)
# ---------------------------------------------------------------
# OVERNIGHT SHIFT DETECTION
# If a late-evening check-in (>= 18:00) on Day N has no matching
# check-out on the same day, AND there is an early-morning check-out
# (<= 06:00) on Day N+1 that is itself unpaired, re-assign that
# check-out record to Day N so the pair resolves correctly.
# Hours are attributed to the earlier day (Day N).
# ---------------------------------------------------------------
OVERNIGHT_CHECKIN_HOUR = 19 # Check-in must be at or after 7 PM
OVERNIGHT_CHECKOUT_HOUR = 3 # Check-out must be at or before 3 AM
for work_type in ['regular', 'SP', 'PW', 'PT', 'C']:
all_dates = sorted(daily_records_by_type[work_type].keys())
for i, date_key in enumerate(all_dates):
# Guard: date_key may have been deleted by a prior iteration when all
# its records were moved to the previous day's bucket.
# Without this check, iterating the stale all_dates snapshot raises KeyError,
# which is silently caught by the outer try/except and returns an empty
# daily_hours dict — causing the employee to show zero rows in the export.
if date_key not in daily_records_by_type[work_type]:
continue
day_records = daily_records_by_type[work_type][date_key]
# Count unpaired check-ins (late evening)
check_ins = [r for r in day_records if r.record_type == 'check_in']
check_outs = [r for r in day_records if r.record_type == 'check_out']
# Early-morning OUTs on Day N (hour <= OVERNIGHT_CHECKOUT_HOUR) are
# themselves overnight orphans from Day N-1. Counting them as regular
# Day N outs inflates the out-count and makes the day appear balanced,
# suppressing overnight detection for the late IN that actually needs
# a next-day OUT. Exclude them from the balance comparison.
check_outs_non_early = [
r for r in check_outs
if (r.check_in_time.hour if isinstance(r.check_in_time, time) else r.timestamp.hour)
> OVERNIGHT_CHECKOUT_HOUR
]
# Any unmatched check-ins that started late in the evening?
unmatched_late_ins = []
for ci in check_ins:
ci_hour = ci.check_in_time.hour if isinstance(ci.check_in_time, time) else ci.timestamp.hour
if ci_hour >= OVERNIGHT_CHECKIN_HOUR:
# Use non-early outs so orphaned early-morning OUTs from
# the prior night do not mask an unmatched late IN.
if len(check_outs_non_early) < len(check_ins):
unmatched_late_ins.append(ci)
if not unmatched_late_ins:
continue
# Look at the next calendar day
if i + 1 >= len(all_dates):
continue
next_date_key = all_dates[i + 1]
# Guard: next_date_key may also have been deleted by a prior iteration
if next_date_key not in daily_records_by_type[work_type]:
continue
# Verify it is truly the next day
from datetime import date as date_type
day_n = datetime.strptime(date_key, '%Y-%m-%d').date()
day_n1 = datetime.strptime(next_date_key, '%Y-%m-%d').date()
if (day_n1 - day_n).days != 1:
continue
next_day_records = daily_records_by_type[work_type][next_date_key]
next_check_outs = [r for r in next_day_records if r.record_type == 'check_out']
next_check_ins = [r for r in next_day_records if r.record_type == 'check_in']
# Identify early-morning check-outs on Day N+1 that are orphaned
orphaned_early_outs = []
for co in next_check_outs:
co_hour = co.check_in_time.hour if isinstance(co.check_in_time, time) else co.timestamp.hour
if co_hour <= OVERNIGHT_CHECKOUT_HOUR:
# Considered orphaned if there are fewer or equal check-ins to cover it
if len(next_check_ins) < len(next_check_outs):
orphaned_early_outs.append(co)
# Move orphaned early check-outs from Day N+1 → Day N
for co in orphaned_early_outs[:len(unmatched_late_ins)]:
_calc_logger.info(
f"Overnight shift detected for work_type={work_type} on {date_key}: "
f"moving check-out {co.check_in_time} from {next_date_key} -> {date_key}"
)
daily_records_by_type[work_type][date_key].append(co)
daily_records_by_type[work_type][next_date_key].remove(co)
# Clean up empty buckets on Day N+1
if not daily_records_by_type[work_type][next_date_key]:
del daily_records_by_type[work_type][next_date_key]
# ---------------------------------------------------------------
# END OVERNIGHT SHIFT DETECTION
# ---------------------------------------------------------------
# Calculate daily hours for each work type
daily_hours = {}
weekly_hours = []
current_week_hours = {'regular': 0, 'SP': 0, 'PW': 0, 'PT': 0, 'C': 0}
current_date = start_date
if isinstance(current_date, datetime):
current_date = current_date.date() if hasattr(current_date, 'date') else current_date
end_date_val = end_date
if isinstance(end_date_val, datetime):
end_date_val = end_date_val.date() if hasattr(end_date_val, 'date') else end_date_val
while current_date <= end_date_val:
date_key = current_date.strftime('%Y-%m-%d')
# Calculate hours for each work type on this day
hours_by_type = {}
is_miss_punch_by_type = {}
records_count_by_type = {}
for work_type in ['regular', 'SP', 'PW', 'PT', 'C']:
day_records = daily_records_by_type[work_type].get(date_key, [])
records_count_by_type[work_type] = len(day_records)
if day_records:
# Build pairs and calculate hours
daily_calc = DailyTimeCalculator()
pairs = RecordPairBuilder.build_pairs_from_records(day_records)
for pair in pairs:
daily_calc.add_record_pair(pair)
total_minutes = daily_calc.get_minutes_total_exclude_travel_time()
if total_minutes < 0:
hours_by_type[work_type] = 0.0
is_miss_punch_by_type[work_type] = True
else:
# Apply full rounding pipeline:
# 1. round_time_to_quarter_hour (already done inside get_minutes_total)
# 2. convert to base-100
# 3. round_base100_hours
base100 = convert_minutes_to_base100(total_minutes)
hours_by_type[work_type] = round_base100_hours(base100)
is_miss_punch_by_type[work_type] = False
else:
hours_by_type[work_type] = 0.0
is_miss_punch_by_type[work_type] = False
# Store daily data with SP/PW/PT/C breakdown
total_day_hours = sum(hours_by_type.values())
total_records_count = sum(records_count_by_type.values())
daily_hours[date_key] = {
'total_minutes': int(total_day_hours * 60),
'total_hours': total_day_hours,
'regular_hours': hours_by_type['regular'],
'sp_hours': hours_by_type['SP'],
'pw_hours': hours_by_type['PW'],
'pt_hours': hours_by_type['PT'],
'c_hours': hours_by_type['C'],
'is_miss_punch': any(is_miss_punch_by_type.values()),
'records_count': total_records_count,
'miss_punch_details': {
'regular': is_miss_punch_by_type['regular'],
'SP': is_miss_punch_by_type['SP'],
'PW': is_miss_punch_by_type['PW'],
'PT': is_miss_punch_by_type['PT'],
'C': is_miss_punch_by_type['C']
}
}
# Accumulate weekly hours by type
for work_type in ['regular', 'SP', 'PW', 'PT', 'C']:
current_week_hours[work_type] += hours_by_type[work_type]
# Check for end of week (Sunday) or end of period
is_end_of_week = current_date.weekday() == 6
is_end_of_period = current_date >= end_date_val
if is_end_of_week or is_end_of_period:
# Calculate weekly totals
week_regular_total = current_week_hours['regular']
week_sp_total = current_week_hours['SP']
week_pw_total = current_week_hours['PW']
week_pt_total = current_week_hours['PT']
week_c_total = current_week_hours['C']
week_total = week_regular_total + week_sp_total + week_pw_total + week_pt_total + week_c_total
# Only regular hours count toward overtime (40 hour rule)
week_regular_hours = min(week_regular_total, 40.0)
week_overtime_hours = max(0, week_regular_total - 40.0)
# Apply round_base100_hours to all weekly totals
week_total_r = round_base100_hours(week_total)
week_regular_r = round_base100_hours(week_regular_hours)
week_overtime_r = round_base100_hours(week_overtime_hours)
week_sp_r = round_base100_hours(week_sp_total)
week_pw_r = round_base100_hours(week_pw_total)
week_pt_r = round_base100_hours(week_pt_total)
week_c_r = round_base100_hours(week_c_total)
weekly_hours.append({
'total_hours': week_total_r,
'regular_hours': week_regular_r,
'overtime_hours': week_overtime_r,
'sp_hours': week_sp_r,
'pw_hours': week_pw_r,
'pt_hours': week_pt_r,
'c_hours': week_c_r,
'total_minutes': int(week_total_r * 60),
'regular_minutes': int(week_regular_r * 60),
'overtime_minutes': int(week_overtime_r * 60),
'sp_minutes': int(week_sp_r * 60),
'pw_minutes': int(week_pw_r * 60),
'pt_minutes': int(week_pt_r * 60),
'c_minutes': int(week_c_r * 60),
})
# Reset for next week
current_week_hours = {'regular': 0, 'SP': 0, 'PW': 0, 'PT': 0, 'C': 0}
current_date += timedelta(days=1)
# Calculate grand totals — apply round_base100_hours to each sum
grand_total_hours = round_base100_hours(sum(week['total_hours'] for week in weekly_hours))
grand_regular_hours = round_base100_hours(sum(week['regular_hours'] for week in weekly_hours))
grand_overtime_hours = round_base100_hours(sum(week['overtime_hours'] for week in weekly_hours))
grand_sp_hours = round_base100_hours(sum(week['sp_hours'] for week in weekly_hours))
grand_pw_hours = round_base100_hours(sum(week['pw_hours'] for week in weekly_hours))
grand_pt_hours = round_base100_hours(sum(week.get('pt_hours', 0) for week in weekly_hours))
grand_c_hours = round_base100_hours(sum(week.get('c_hours', 0) for week in weekly_hours))
_calc_logger.info(
f"Employee {employee_id}: Total={grand_total_hours:.2f}h "
f"(Regular={grand_regular_hours:.2f}h, OT={grand_overtime_hours:.2f}h, "
f"SP={grand_sp_hours:.2f}h, PW={grand_pw_hours:.2f}h, PT={grand_pt_hours:.2f}h, "
f"C={grand_c_hours:.2f}h)"
)
return {
'employee_id': employee_id,
'base_employee_id': base_employee_id,
'start_date': start_date.strftime('%Y-%m-%d') if hasattr(start_date, 'strftime') else str(start_date),
'end_date': end_date.strftime('%Y-%m-%d') if hasattr(end_date, 'strftime') else str(end_date),
'daily_hours': daily_hours,
'weekly_hours': weekly_hours,
'grand_totals': {
'total_hours': grand_total_hours,
'regular_hours': grand_regular_hours,
'overtime_hours': grand_overtime_hours,
'sp_hours': grand_sp_hours,
'pw_hours': grand_pw_hours,
'pt_hours': grand_pt_hours,
'c_hours': grand_c_hours,
'total_minutes': int(grand_total_hours * 60),
'regular_minutes': int(grand_regular_hours * 60),
'overtime_minutes': int(grand_overtime_hours * 60),
'sp_minutes': int(grand_sp_hours * 60),
'pw_minutes': int(grand_pw_hours * 60),
'pt_minutes': int(grand_pt_hours * 60),
'c_minutes': int(grand_c_hours * 60),
}
}
except Exception as e:
_calc_logger.error(f"Error calculating working hours for employee {employee_id}: {e}", exc_info=True)
raise e
def calculate_all_employees_hours(self, start_date: datetime, end_date: datetime,
attendance_records: List[Dict]) -> Dict[str, Any]:
"""
Calculate working hours for all employees in the given period.
This method consolidates employees by base ID, so records for 1234, 1234 SP,
1234 PW, 1234 PT, 1234 C will all be grouped under base employee 1234.
Args:
start_date: Start date for calculation
end_date: End date for calculation
attendance_records: List of attendance records from database
Returns:
Dictionary containing hours data for all employees with SP/PW/PT/C breakdown
"""
try:
_calc_logger.info("Starting hours calculation for all employees with SP/PW/PT/C consolidation")
# Get unique BASE employee IDs (consolidate SP/PW/PT/C variants)
base_employee_ids = set()
for record in attendance_records:
try:
if hasattr(record, '__dict__'):
employee_id = str(getattr(record, 'employee_id', '')).strip()
else:
employee_id = str(record.get('employee_id', '')).strip()
if employee_id:
base_id, _ = parse_employee_id_for_work_type(employee_id)
if base_id:
base_employee_ids.add(base_id)
except Exception as e:
_calc_logger.warning(f"Error processing employee ID during consolidation: {e}")
continue
_calc_logger.info(f"Found {len(base_employee_ids)} unique base employees (after SP/PW/PT/C consolidation)")
results = {}
for base_emp_id in sorted(base_employee_ids):
try:
_calc_logger.debug(f"Processing base employee {base_emp_id}")
results[base_emp_id] = self.calculate_employee_hours(
base_emp_id, start_date, end_date, attendance_records
)
except Exception as e:
_calc_logger.error(f"Error processing employee {base_emp_id}: {e}", exc_info=True)
# Return empty result for this employee
results[base_emp_id] = {
'employee_id': base_emp_id,
'base_employee_id': base_emp_id,
'start_date': start_date.strftime('%Y-%m-%d') if hasattr(start_date, 'strftime') else str(start_date),
'end_date': end_date.strftime('%Y-%m-%d') if hasattr(end_date, 'strftime') else str(end_date),
'daily_hours': {},
'weekly_hours': [],
'grand_totals': {
'total_hours': 0.0,
'regular_hours': 0.0,
'overtime_hours': 0.0,
'sp_hours': 0.0,
'pw_hours': 0.0,
'pt_hours': 0.0,
'c_hours': 0.0,
'total_minutes': 0,
'regular_minutes': 0,
'overtime_minutes': 0,
'sp_minutes': 0,
'pw_minutes': 0,
'pt_minutes': 0,
'c_minutes': 0
}
}
continue
return {
'calculation_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'period_start': start_date.strftime('%Y-%m-%d') if hasattr(start_date, 'strftime') else str(start_date),
'period_end': end_date.strftime('%Y-%m-%d') if hasattr(end_date, 'strftime') else str(end_date),
'employee_count': len(base_employee_ids),
'employees': results
}
except Exception as e:
_calc_logger.error(f"Error calculating hours for all employees: {e}", exc_info=True)
raise e