Update payroll export

This commit is contained in:
2025-08-23 09:54:54 -04:00
parent cd59f8f30a
commit 95f7a5d07f
2 changed files with 292 additions and 82 deletions
+1 -1
View File
@@ -4541,7 +4541,7 @@ def export_payroll_excel():
excel_file = exporter.create_template_format_report(
start_date, end_date, attendance_records, employee_names, include_travel_time, project_name
)
filename_prefix = 'template_hours_report'
filename_prefix = 'time_attendance_report'
else:
excel_file = exporter.create_payroll_report(
start_date, end_date, attendance_records, employee_names, include_travel_time
+265 -55
View File
@@ -707,14 +707,14 @@ class PayrollExcelExporter:
def _write_employee_section(self, worksheet, employee_id: str, employee_name: str,
emp_data: Dict, start_date: datetime, end_date: datetime,
attendance_records: List[Dict], start_row: int) -> int:
"""Write individual employee section and return next row number"""
"""Write individual employee section with multiple pairs per day, daily totals, weekly totals, and grand total"""
current_row = start_row
# Employee info header (merged across columns A-O)
employee_info = f"Employee ID {employee_id}: {employee_name}"
cell = worksheet.cell(row=current_row, column=1, value=employee_info)
self._apply_style(cell, self.template_employee_style)
worksheet.merge_cells(f'A{current_row}:Q{current_row}')
worksheet.merge_cells(f'A{current_row}:N{current_row}')
current_row += 1
# Column headers for this employee
@@ -751,6 +751,20 @@ class PayrollExcelExporter:
day_info['location'] = sorted_records[0].qr_code.location or ''
day_info['building_address'] = sorted_records[0].qr_code.location_address or ''
# Initialize weekly totals tracking
weekly_regular_hours = 0
weekly_ot_hours = 0
weekly_total_hours = 0
# Track overall totals for grand total
grand_regular_hours = 0
grand_ot_hours = 0
grand_total_hours = 0
total_pairs_written = 0 # Track total pairs across all days
# Track week boundaries (assuming payroll period starts on Monday)
current_week_start = None
# Write data rows using the calculated daily hours from emp_data
for date_str in sorted(emp_data['daily_hours'].keys()):
date_obj = datetime.strptime(date_str, '%Y-%m-%d')
@@ -760,90 +774,286 @@ class PayrollExcelExporter:
if day_hours_data.get('is_miss_punch', False) or day_hours_data.get('total_hours', 0) <= 0:
continue
# Calculate week boundaries
week_start = date_obj - timedelta(days=date_obj.weekday()) # Monday of the week
# Check if we've moved to a new week and need to write weekly total
if current_week_start is not None and week_start != current_week_start:
# Write weekly total row for previous week
current_row = self._write_weekly_total_row(worksheet, current_row,
weekly_regular_hours, weekly_ot_hours, weekly_total_hours)
# Reset weekly counters
weekly_regular_hours = 0
weekly_ot_hours = 0
weekly_total_hours = 0
current_week_start = week_start
total_hours = day_hours_data.get('total_hours', 0)
regular_hours = min(total_hours, 8.0) # Max 8 regular hours per day
ot_hours = max(0, total_hours - 8.0)
# Add to weekly and grand totals
weekly_regular_hours += regular_hours
weekly_ot_hours += ot_hours
weekly_total_hours += total_hours
grand_regular_hours += regular_hours
grand_ot_hours += ot_hours
grand_total_hours += total_hours
# Get location info for this date
location_info = daily_location_data.get(date_str, {})
location = location_info.get('location', '')
building_address = location_info.get('building_address', '')
# Get check-in/out times from records
check_in_time = ''
check_out_time = ''
# Get all records for this day, sorted by time
day_records = []
if date_str in daily_location_data:
records = daily_location_data[date_str]['records']
sorted_records = sorted(records, key=lambda x: x.check_in_time)
if sorted_records:
check_in_time = sorted_records[0].check_in_time.strftime('%I:%M:%S %p')
day_records = sorted(daily_location_data[date_str]['records'], key=lambda x: x.check_in_time)
# For single check-in system, determine check-out time
if len(sorted_records) > 1:
check_out_time = sorted_records[-1].check_in_time.strftime('%I:%M:%S %p')
# Write multiple pairs for the day (like template shows FRIDAY appearing twice)
pairs_written = 0
if len(day_records) >= 2:
# Create pairs from consecutive records
for i in range(0, len(day_records) - 1, 2):
if i + 1 < len(day_records):
start_record = day_records[i]
end_record = day_records[i + 1]
current_row = self._write_record_pair_row(
worksheet, current_row, date_obj, start_record, end_record,
location, building_address, total_hours, regular_hours, ot_hours,
pairs_written, total_hours # Pass total_hours for daily total on last pair
)
pairs_written += 1
total_pairs_written += 1 # Track total pairs
else:
# Single check-in - estimate check-out based on total hours
check_in_datetime = datetime.combine(sorted_records[0].check_in_date, sorted_records[0].check_in_time)
estimated_checkout = check_in_datetime + timedelta(hours=total_hours)
check_out_time = estimated_checkout.strftime('%I:%M:%S %p')
# Single record or no records - write one row
start_record = day_records[0] if day_records else None
current_row = self._write_single_record_row(
worksheet, current_row, date_obj, start_record,
location, building_address, total_hours, regular_hours, ot_hours
)
# Write final weekly total if we have data
if weekly_total_hours > 0:
current_row = self._write_weekly_total_row(worksheet, current_row,
weekly_regular_hours, weekly_ot_hours, weekly_total_hours)
# Write grand total row
current_row = self._write_grand_total_row(worksheet, current_row,
grand_regular_hours, grand_ot_hours)
# Add space between employees
current_row += 2
# Log the export action (use print for now since logger_handler import is complex)
print(f"✅ Template format export: Employee {employee_id} ({employee_name}) data written with {total_pairs_written} record pairs, weekly totals, and grand total")
return current_row
def _write_record_pair_row(self, worksheet, current_row: int, date_obj: datetime,
start_record, end_record, location: str, building_address: str,
day_total_hours: float, regular_hours: float, ot_hours: float,
pair_index: int, daily_total_hours: float) -> int:
"""Write a single record pair row (in/out times)"""
day_name = date_obj.strftime('%A').upper()
date_str = date_obj.strftime('%m/%d/%Y')
# Calculate hours for this specific pair
if start_record and end_record:
start_time = start_record.check_in_time
end_time = end_record.check_in_time
start_datetime = datetime.combine(start_record.check_in_date, start_time)
end_datetime = datetime.combine(end_record.check_in_date, end_time)
pair_duration = (end_datetime - start_datetime).total_seconds() / 3600
pair_hours = round(pair_duration, 2)
else:
start_time = start_record.check_in_time if start_record else None
end_time = end_record.check_in_time if end_record else None
pair_hours = 0
# Format times
check_in_time = start_time.strftime('%I:%M:%S %p') if start_time else ''
check_out_time = end_time.strftime('%I:%M:%S %p') if end_time else ''
# Get location accuracy info
location_accuracy = None
recorded_location = ''
distance_value = ''
possible_violation = ''
possible_violation = 'No'
# Find attendance record for this date to get location accuracy
if date_str in daily_location_data:
records = daily_location_data[date_str]['records']
if records:
# Get location accuracy from the first record of the day
location_accuracy = getattr(records[0], 'location_accuracy', None)
# Determine recorded location based on accuracy (same logic as attendance report)
if start_record and hasattr(start_record, 'location_accuracy'):
location_accuracy = getattr(start_record, 'location_accuracy', None)
if location_accuracy is not None:
try:
accuracy_value = float(location_accuracy)
distance_value = f"{accuracy_value:.3f}"
if accuracy_value < 0.3:
# High accuracy - use QR code address
recorded_location = building_address # This is already the QR location_address
recorded_location = building_address
possible_violation = "No"
else:
# Lower accuracy - use actual check-in address
recorded_location = getattr(records[0], 'address', '') or ''
possible_violation = "Yes"
recorded_location = f"GPS Location (Accuracy: {distance_value} miles)"
possible_violation = "Yes" if accuracy_value > 0.5 else "Possible"
except (ValueError, TypeError):
# If accuracy can't be converted, use actual address
recorded_location = getattr(records[0], 'address', '') or ''
distance_value = 'N/A'
possible_violation = 'Unknown'
else:
# No location accuracy data
recorded_location = getattr(records[0], 'address', '') or ''
distance_value = 'N/A'
possible_violation = 'Unknown'
distance_value = "Unknown"
recorded_location = "GPS Location (Unknown Accuracy)"
possible_violation = "Possible"
# Create building address hyperlink if available
building_address_display = building_address
if building_address and "," in building_address:
# Create Google Maps hyperlink
maps_url = f"https://www.google.com/maps/place/{building_address.replace(' ', '+')}"
building_address_display = f'=HYPERLINK("{maps_url}","{building_address}")'
# Create recorded location hyperlink if it's a GPS location
recorded_location_display = recorded_location
if recorded_location and "GPS Location" not in recorded_location and "," in recorded_location:
maps_url = f"https://www.google.com/maps/place/{recorded_location.replace(' ', '+')}"
recorded_location_display = f'=HYPERLINK("{maps_url}","{recorded_location}")'
# Determine if this is the last pair of the day (for daily total)
is_last_pair = pair_index > 0 # Show daily total on second+ pairs
daily_total_display = daily_total_hours if is_last_pair else ""
row_data = [
date_obj.strftime('%A').upper(), # Day name
date_obj.strftime('%m/%d/%Y'), # Date
check_in_time, # Check in
check_out_time, # Check out
location, # Location
'', # Zone
round(total_hours, 2), # Hours/Building
round(total_hours, 2), # Daily Total
round(regular_hours, 2) if regular_hours > 0 else '', # Regular Hours
round(ot_hours, 2) if ot_hours > 0 else '', # OT Hours
building_address, # Building Address
recorded_location, # Recorded Location
distance_value, # Distance
possible_violation # Possible Violation
day_name, # A - Day
date_str, # B - Date
check_in_time, # C - In
check_out_time, # D - Out
location, # E - Location
"", # F - Zone (empty)
pair_hours, # G - Hours/Building
daily_total_display, # H - Daily Total (only on last pair)
regular_hours if is_last_pair else "", # I - Regular Hours (only on last pair)
ot_hours if is_last_pair else "", # J - OT Hours (only on last pair)
building_address_display, # K - Building Address
recorded_location_display, # L - Recorded Location
distance_value, # M - Distance
possible_violation # N - Possible Violation
]
# Write the row
for col, value in enumerate(row_data, 1):
cell = worksheet.cell(row=current_row, column=col, value=value)
self._apply_style(cell, self.template_data_style)
current_row += 1
return current_row
return current_row + 1
def _write_single_record_row(self, worksheet, current_row: int, date_obj: datetime,
record, location: str, building_address: str,
total_hours: float, regular_hours: float, ot_hours: float) -> int:
"""Write a single record row when there's only one check-in"""
day_name = date_obj.strftime('%A').upper()
date_str = date_obj.strftime('%m/%d/%Y')
# Single record - estimate check-out time
if record:
check_in_time = record.check_in_time.strftime('%I:%M:%S %p')
# Estimate check-out based on total hours
check_in_datetime = datetime.combine(record.check_in_date, record.check_in_time)
estimated_checkout = check_in_datetime + timedelta(hours=total_hours)
check_out_time = estimated_checkout.strftime('%I:%M:%S %p')
else:
check_in_time = ''
check_out_time = ''
# Get location accuracy info (same logic as pair row)
location_accuracy = None
recorded_location = ''
distance_value = ''
possible_violation = 'No'
if record and hasattr(record, 'location_accuracy'):
location_accuracy = getattr(record, 'location_accuracy', None)
if location_accuracy is not None:
try:
accuracy_value = float(location_accuracy)
distance_value = f"{accuracy_value:.3f}"
if accuracy_value < 0.3:
recorded_location = building_address
possible_violation = "No"
else:
recorded_location = f"GPS Location (Accuracy: {distance_value} miles)"
possible_violation = "Yes" if accuracy_value > 0.5 else "Possible"
except (ValueError, TypeError):
distance_value = "Unknown"
recorded_location = "GPS Location (Unknown Accuracy)"
possible_violation = "Possible"
row_data = [
day_name, # A - Day
date_str, # B - Date
check_in_time, # C - In
check_out_time, # D - Out
location, # E - Location
"", # F - Zone
total_hours, # G - Hours/Building
total_hours, # H - Daily Total
regular_hours, # I - Regular Hours
ot_hours, # J - OT Hours
building_address, # K - Building Address
recorded_location, # L - Recorded Location
distance_value, # M - Distance
possible_violation # N - Possible Violation
]
# Write the row
for col, value in enumerate(row_data, 1):
cell = worksheet.cell(row=current_row, column=col, value=value)
self._apply_style(cell, self.template_data_style)
return current_row + 1
def _write_weekly_total_row(self, worksheet, current_row: int,
weekly_regular: float, weekly_ot: float, weekly_total: float) -> int:
"""Write weekly total row matching template format"""
# Weekly Total row (columns G-J)
cell = worksheet.cell(row=current_row, column=7, value="Weekly Total: ") # G
self._apply_style(cell, self.template_data_style)
cell.font = Font(name="Arial", size=10, bold=True)
cell = worksheet.cell(row=current_row, column=8, value=round(weekly_total, 2)) # H - Daily Total
self._apply_style(cell, self.template_data_style)
cell.font = Font(name="Arial", size=10, bold=True)
cell = worksheet.cell(row=current_row, column=9, value=round(weekly_regular, 2)) # I - Regular Hours
self._apply_style(cell, self.template_data_style)
cell.font = Font(name="Arial", size=10, bold=True)
cell = worksheet.cell(row=current_row, column=10, value=round(weekly_ot, 2)) # J - OT Hours
self._apply_style(cell, self.template_data_style)
cell.font = Font(name="Arial", size=10, bold=True)
# Log weekly total
print(f"✅ Template export: Weekly total written - Regular: {weekly_regular:.2f}, OT: {weekly_ot:.2f}, Total: {weekly_total:.2f}")
return current_row + 1
def _write_grand_total_row(self, worksheet, current_row: int,
grand_regular: float, grand_ot: float) -> int:
"""Write grand total row matching template format"""
# Grand Total row (columns G, I, J)
cell = worksheet.cell(row=current_row, column=7, value="GRAND TOTAL: ") # G
self._apply_style(cell, self.template_data_style)
cell.font = Font(name="Arial", size=10, bold=True)
cell = worksheet.cell(row=current_row, column=9, value=round(grand_regular, 2)) # I - Regular Hours
self._apply_style(cell, self.template_data_style)
cell.font = Font(name="Arial", size=10, bold=True)
cell = worksheet.cell(row=current_row, column=10, value=round(grand_ot, 2)) # J - OT Hours
self._apply_style(cell, self.template_data_style)
cell.font = Font(name="Arial", size=10, bold=True)
# Log grand total
print(f"✅ Template export: Grand total written - Regular: {grand_regular:.2f}, OT: {grand_ot:.2f}")
return current_row + 1