849 lines
37 KiB
Python
849 lines
37 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Payroll Excel Exporter
|
|
=====================
|
|
|
|
Enhanced Excel exporter for payroll reports with working hours calculations.
|
|
Based on the Java PayrollReport.java implementation.
|
|
|
|
Features:
|
|
- Employee working hours with daily/weekly breakdown
|
|
- Regular and overtime hour calculations
|
|
- Travel time inclusion options
|
|
- Professional Excel formatting
|
|
- Multiple report formats
|
|
"""
|
|
|
|
import io
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Dict, Any, Optional
|
|
from openpyxl import Workbook
|
|
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
|
from openpyxl.utils import get_column_letter
|
|
from single_checkin_calculator import SingleCheckInCalculator
|
|
from logger_handler import log_database_operations
|
|
|
|
|
|
class PayrollExcelExporter:
|
|
"""Excel exporter for payroll reports with working hours"""
|
|
|
|
def __init__(self, company_name: str = "Company Name", contract_name: str = "Default Contract"):
|
|
self.company_name = company_name
|
|
self.contract_name = contract_name
|
|
|
|
# Excel styling
|
|
self.header1_style = None
|
|
self.header2_style = None
|
|
self.header3_style = None
|
|
self.table_header_style = None
|
|
self.data_style = None
|
|
self.total_style = None
|
|
|
|
def _setup_styles(self, workbook: Workbook):
|
|
"""Setup Excel cell styles"""
|
|
|
|
# Header 1 style (Company name)
|
|
self.header1_style = {
|
|
'font': Font(name="Arial", size=16, bold=True, color="FFFFFF"),
|
|
'fill': PatternFill(start_color="2F4F4F", end_color="2F4F4F", fill_type="solid"),
|
|
'alignment': Alignment(horizontal="center", vertical="center")
|
|
}
|
|
|
|
# Header 2 style (Report title)
|
|
self.header2_style = {
|
|
'font': Font(name="Arial", size=14, bold=True, color="FFFFFF"),
|
|
'fill': PatternFill(start_color="4682B4", end_color="4682B4", fill_type="solid"),
|
|
'alignment': Alignment(horizontal="center", vertical="center")
|
|
}
|
|
|
|
# Header 3 style (Contract and date range)
|
|
self.header3_style = {
|
|
'font': Font(name="Arial", size=12, bold=True),
|
|
'alignment': Alignment(horizontal="center", vertical="center")
|
|
}
|
|
|
|
# Table header style
|
|
self.table_header_style = {
|
|
'font': Font(name="Arial", size=11, bold=True, color="FFFFFF"),
|
|
'fill': PatternFill(start_color="366092", end_color="366092", fill_type="solid"),
|
|
'alignment': Alignment(horizontal="center", vertical="center"),
|
|
'border': Border(
|
|
left=Side(style="thin"),
|
|
right=Side(style="thin"),
|
|
top=Side(style="thin"),
|
|
bottom=Side(style="thin")
|
|
)
|
|
}
|
|
|
|
# Data style
|
|
self.data_style = {
|
|
'font': Font(name="Arial", size=10),
|
|
'alignment': Alignment(horizontal="center", vertical="center"),
|
|
'border': Border(
|
|
left=Side(style="thin"),
|
|
right=Side(style="thin"),
|
|
top=Side(style="thin"),
|
|
bottom=Side(style="thin")
|
|
)
|
|
}
|
|
|
|
# Total style
|
|
self.total_style = {
|
|
'font': Font(name="Arial", size=11, bold=True),
|
|
'fill': PatternFill(start_color="E6E6FA", end_color="E6E6FA", fill_type="solid"),
|
|
'alignment': Alignment(horizontal="center", vertical="center"),
|
|
'border': Border(
|
|
left=Side(style="thick"),
|
|
right=Side(style="thick"),
|
|
top=Side(style="thick"),
|
|
bottom=Side(style="thick")
|
|
)
|
|
}
|
|
|
|
def _apply_style(self, cell, style_dict):
|
|
"""Apply a style dictionary to a cell"""
|
|
for attr, value in style_dict.items():
|
|
setattr(cell, attr, value)
|
|
|
|
def _get_employee_names(self, attendance_records: List[Dict]) -> Dict[str, str]:
|
|
"""Get employee names - fallback method if names not provided"""
|
|
print("⚠️ Excel exporter falling back to internal name lookup - this should not normally happen")
|
|
return {} # Return empty dict - names should be provided by the route
|
|
|
|
def _get_employee_names(self, attendance_records: List[Dict]) -> Dict[str, str]:
|
|
"""Get employee names using the same CAST method as attendance report"""
|
|
employee_names = {}
|
|
try:
|
|
from flask import current_app
|
|
from sqlalchemy import text
|
|
|
|
# Get unique employee IDs from attendance records
|
|
employee_ids = []
|
|
for record in attendance_records:
|
|
if hasattr(record, '__dict__'):
|
|
emp_id = str(record.employee_id)
|
|
else:
|
|
emp_id = str(record['employee_id'])
|
|
|
|
if emp_id not in employee_ids:
|
|
employee_ids.append(emp_id)
|
|
|
|
if employee_ids:
|
|
# Use the same SQL approach as attendance report - JOIN with CAST
|
|
placeholders = ','.join([f"'{emp_id}'" for emp_id in employee_ids])
|
|
|
|
# Import db from current app context
|
|
from app import db
|
|
|
|
employee_query = db.session.execute(text(f"""
|
|
SELECT
|
|
ad.employee_id,
|
|
CONCAT(e.firstName, ' ', e.lastName) as full_name
|
|
FROM attendance_data ad
|
|
LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id
|
|
WHERE ad.employee_id IN ({placeholders})
|
|
GROUP BY ad.employee_id, e.firstName, e.lastName
|
|
"""))
|
|
|
|
for row in employee_query:
|
|
if row[1]: # Only add if we got a name
|
|
employee_names[str(row[0])] = row[1]
|
|
|
|
print(f"📊 Excel exporter retrieved names for {len(employee_names)} employees using CAST method")
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ Excel exporter could not load employee names: {e}")
|
|
import traceback
|
|
print(f"⚠️ Traceback: {traceback.format_exc()}")
|
|
|
|
return employee_names
|
|
|
|
@log_database_operations('payroll_excel_export')
|
|
def create_payroll_report(self, start_date: datetime, end_date: datetime,
|
|
attendance_records: List[Dict], employee_names: Dict[str, str] = None,
|
|
include_travel_time: bool = True) -> io.BytesIO:
|
|
"""
|
|
Create a comprehensive payroll report with working hours
|
|
|
|
Args:
|
|
start_date: Report start date
|
|
end_date: Report end date
|
|
attendance_records: List of attendance records
|
|
employee_names: Dictionary mapping employee_id to full name
|
|
include_travel_time: Whether to include travel time in calculations
|
|
|
|
Returns:
|
|
BytesIO buffer containing the Excel file
|
|
"""
|
|
try:
|
|
print(f"📊 Creating payroll report from {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
|
|
|
|
# Get employee names using the same method as payroll routes
|
|
if employee_names is None:
|
|
employee_names = self._get_employee_names(attendance_records)
|
|
|
|
# Calculate working hours
|
|
calculator = SingleCheckInCalculator()
|
|
hours_data = calculator.calculate_all_employees_hours(start_date, end_date, attendance_records)
|
|
|
|
# Create workbook
|
|
workbook = Workbook()
|
|
worksheet = workbook.active
|
|
worksheet.title = "Payroll Report"
|
|
|
|
# Setup styles
|
|
self._setup_styles(workbook)
|
|
|
|
# Write report headers
|
|
current_row = self._write_report_headers(worksheet, start_date, end_date)
|
|
|
|
# Write employee data
|
|
current_row = self._write_employee_payroll_data(worksheet, hours_data, employee_names, current_row)
|
|
|
|
# Write summary totals
|
|
self._write_summary_totals(worksheet, hours_data, current_row)
|
|
|
|
# Auto-adjust column widths
|
|
self._auto_adjust_columns(worksheet)
|
|
|
|
# Save to BytesIO
|
|
excel_buffer = io.BytesIO()
|
|
workbook.save(excel_buffer)
|
|
excel_buffer.seek(0)
|
|
|
|
print("✅ Payroll report Excel file created successfully")
|
|
return excel_buffer
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error creating payroll report: {e}")
|
|
raise e
|
|
|
|
def _write_report_headers(self, worksheet, start_date: datetime, end_date: datetime) -> int:
|
|
"""Write report headers and return next row number"""
|
|
current_row = 1
|
|
|
|
# Company name
|
|
cell = worksheet.cell(row=current_row, column=1, value=self.company_name)
|
|
self._apply_style(cell, self.header1_style)
|
|
worksheet.merge_cells(f'A{current_row}:Q{current_row}')
|
|
current_row += 1
|
|
|
|
# Report title
|
|
cell = worksheet.cell(row=current_row, column=1, value="Payroll Report - Working Hours Summary")
|
|
self._apply_style(cell, self.header2_style)
|
|
worksheet.merge_cells(f'A{current_row}:Q{current_row}')
|
|
current_row += 1
|
|
|
|
# Contract name
|
|
cell = worksheet.cell(row=current_row, column=1, value=self.contract_name)
|
|
self._apply_style(cell, self.header3_style)
|
|
worksheet.merge_cells(f'A{current_row}:Q{current_row}')
|
|
current_row += 1
|
|
|
|
# Date range
|
|
date_range = f"Date range: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}"
|
|
cell = worksheet.cell(row=current_row, column=1, value=date_range)
|
|
self._apply_style(cell, self.header3_style)
|
|
worksheet.merge_cells(f'A{current_row}:Q{current_row}')
|
|
current_row += 2 # Add extra space
|
|
|
|
return current_row
|
|
|
|
def _write_employee_payroll_data(self, worksheet, hours_data: Dict, employee_names: Dict[str, str], start_row: int) -> int:
|
|
"""Write employee payroll data and return next row number"""
|
|
current_row = start_row
|
|
|
|
# Table headers
|
|
headers = [
|
|
"#", "Employee ID", "Employee Name",
|
|
"Week 1 Mon", "Week 1 Tue", "Week 1 Wed", "Week 1 Thu", "Week 1 Fri", "Week 1 Sat", "Week 1 Sun",
|
|
"Week 1 Regular", "Week 1 OT",
|
|
"Week 2 Mon", "Week 2 Tue", "Week 2 Wed", "Week 2 Thu", "Week 2 Fri", "Week 2 Sat", "Week 2 Sun",
|
|
"Week 2 Regular", "Week 2 OT",
|
|
"Total Regular", "Total OT", "Grand Total"
|
|
]
|
|
|
|
for col, header in enumerate(headers, 1):
|
|
cell = worksheet.cell(row=current_row, column=col, value=header)
|
|
self._apply_style(cell, self.table_header_style)
|
|
current_row += 1
|
|
|
|
# Employee data
|
|
counter = 1
|
|
for employee_id, emp_data in hours_data['employees'].items():
|
|
# Employee basic info
|
|
employee_name = employee_names.get(employee_id, f"Employee {employee_id}") if employee_names else f"Employee {employee_id}"
|
|
|
|
row_data = [
|
|
counter,
|
|
employee_id,
|
|
employee_name
|
|
]
|
|
|
|
# Calculate daily hours for two weeks (14 days)
|
|
daily_hours = self._calculate_daily_hours_for_payroll(emp_data, hours_data['period_start'])
|
|
|
|
# Week 1 daily hours (7 days)
|
|
week1_total = 0
|
|
for day_idx in range(7):
|
|
hours = daily_hours.get(day_idx, 0)
|
|
row_data.append(round(hours, 2))
|
|
week1_total += hours
|
|
|
|
# Week 1 regular and OT
|
|
week1_regular = min(week1_total, 40.0)
|
|
week1_ot = max(0, week1_total - 40.0)
|
|
row_data.extend([round(week1_regular, 2), round(week1_ot, 2)])
|
|
|
|
# Week 2 daily hours (7 days)
|
|
week2_total = 0
|
|
for day_idx in range(7, 14):
|
|
hours = daily_hours.get(day_idx, 0)
|
|
row_data.append(round(hours, 2))
|
|
week2_total += hours
|
|
|
|
# Week 2 regular and OT
|
|
week2_regular = min(week2_total, 40.0)
|
|
week2_ot = max(0, week2_total - 40.0)
|
|
row_data.extend([round(week2_regular, 2), round(week2_ot, 2)])
|
|
|
|
# Totals
|
|
total_regular = week1_regular + week2_regular
|
|
total_ot = week1_ot + week2_ot
|
|
grand_total = total_regular + total_ot
|
|
row_data.extend([round(total_regular, 2), round(total_ot, 2), round(grand_total, 2)])
|
|
|
|
# Write row data
|
|
for col, value in enumerate(row_data, 1):
|
|
cell = worksheet.cell(row=current_row, column=col, value=value)
|
|
self._apply_style(cell, self.data_style)
|
|
|
|
current_row += 1
|
|
counter += 1
|
|
|
|
return current_row + 1 # Add space before summary
|
|
|
|
def _calculate_daily_hours_for_payroll(self, emp_data: Dict, start_date_str: str) -> Dict[int, float]:
|
|
"""Calculate daily hours for payroll format (14 days)"""
|
|
daily_hours = {}
|
|
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
|
|
|
|
for day_idx in range(14):
|
|
current_date = start_date + timedelta(days=day_idx)
|
|
date_key = current_date.strftime('%Y-%m-%d')
|
|
|
|
if date_key in emp_data['daily_hours']:
|
|
day_data = emp_data['daily_hours'][date_key]
|
|
if not day_data['is_miss_punch']:
|
|
daily_hours[day_idx] = day_data['total_hours']
|
|
else:
|
|
daily_hours[day_idx] = 0 # Miss punch = 0 hours
|
|
else:
|
|
daily_hours[day_idx] = 0 # No records = 0 hours
|
|
|
|
return daily_hours
|
|
|
|
def _write_summary_totals(self, worksheet, hours_data: Dict, start_row: int):
|
|
"""Write summary totals section"""
|
|
current_row = start_row + 1
|
|
|
|
# Summary header
|
|
cell = worksheet.cell(row=current_row, column=1, value="PAYROLL SUMMARY")
|
|
self._apply_style(cell, self.header2_style)
|
|
worksheet.merge_cells(f'A{current_row}:F{current_row}')
|
|
current_row += 2
|
|
|
|
# Calculate totals across all employees
|
|
total_employees = len(hours_data['employees'])
|
|
total_regular_hours = 0
|
|
total_overtime_hours = 0
|
|
total_hours = 0
|
|
|
|
for emp_data in hours_data['employees'].values():
|
|
total_regular_hours += emp_data['grand_totals']['regular_hours']
|
|
total_overtime_hours += emp_data['grand_totals']['overtime_hours']
|
|
total_hours += emp_data['grand_totals']['total_hours']
|
|
|
|
# Summary data
|
|
summary_data = [
|
|
("Total Employees:", total_employees),
|
|
("Total Regular Hours:", round(total_regular_hours, 2)),
|
|
("Total Overtime Hours:", round(total_overtime_hours, 2)),
|
|
("Grand Total Hours:", round(total_hours, 2)),
|
|
("Travel Time Included:", "Yes" if hours_data['include_travel_time'] else "No"),
|
|
("Report Generated:", datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
|
|
]
|
|
|
|
for label, value in summary_data:
|
|
cell1 = worksheet.cell(row=current_row, column=1, value=label)
|
|
self._apply_style(cell1, self.total_style)
|
|
cell2 = worksheet.cell(row=current_row, column=2, value=value)
|
|
self._apply_style(cell2, self.total_style)
|
|
current_row += 1
|
|
|
|
def _auto_adjust_columns(self, worksheet):
|
|
"""Auto-adjust column widths"""
|
|
for column in worksheet.columns:
|
|
max_length = 0
|
|
column_letter = get_column_letter(column[0].column)
|
|
|
|
for cell in column:
|
|
try:
|
|
if len(str(cell.value)) > max_length:
|
|
max_length = len(str(cell.value))
|
|
except:
|
|
pass
|
|
|
|
adjusted_width = min(max_length + 2, 20)
|
|
worksheet.column_dimensions[column_letter].width = adjusted_width
|
|
|
|
@log_database_operations('detailed_hours_export')
|
|
def create_detailed_hours_report(self, start_date: datetime, end_date: datetime,
|
|
attendance_records: List[Dict], employee_names: Dict[str, str] = None,
|
|
include_travel_time: bool = True) -> io.BytesIO:
|
|
"""
|
|
Create a detailed daily hours report for all employees
|
|
|
|
Args:
|
|
start_date: Report start date
|
|
end_date: Report end date
|
|
attendance_records: List of attendance records
|
|
employee_names: Dictionary mapping employee_id to full name
|
|
include_travel_time: Whether to include travel time in calculations
|
|
|
|
Returns:
|
|
BytesIO buffer containing the Excel file
|
|
"""
|
|
try:
|
|
print(f"📊 Creating detailed hours report from {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
|
|
|
|
# Get employee names using the same method as payroll routes
|
|
if employee_names is None:
|
|
employee_names = self._get_employee_names(attendance_records)
|
|
|
|
# Calculate working hours
|
|
calculator = SingleCheckInCalculator()
|
|
hours_data = calculator.calculate_all_employees_hours(start_date, end_date, attendance_records)
|
|
|
|
# Create workbook
|
|
workbook = Workbook()
|
|
worksheet = workbook.active
|
|
worksheet.title = "Detailed Hours Report"
|
|
|
|
# Setup styles
|
|
self._setup_styles(workbook)
|
|
|
|
# Write headers
|
|
current_row = 1
|
|
cell = worksheet.cell(row=current_row, column=1, value=self.company_name)
|
|
self._apply_style(cell, self.header1_style)
|
|
worksheet.merge_cells(f'A{current_row}:J{current_row}')
|
|
current_row += 1
|
|
|
|
cell = worksheet.cell(row=current_row, column=1, value="Detailed Daily Hours Report")
|
|
self._apply_style(cell, self.header2_style)
|
|
worksheet.merge_cells(f'A{current_row}:J{current_row}')
|
|
current_row += 2
|
|
|
|
# Table headers
|
|
headers = ["Employee ID", "Employee Name", "Date", "Day of Week", "Total Hours",
|
|
"Status", "Records Count", "Regular Hours", "Overtime Hours", "Notes"]
|
|
|
|
for col, header in enumerate(headers, 1):
|
|
cell = worksheet.cell(row=current_row, column=col, value=header)
|
|
self._apply_style(cell, self.table_header_style)
|
|
current_row += 1
|
|
|
|
# Write detailed data
|
|
for employee_id, emp_data in hours_data['employees'].items():
|
|
employee_name = employee_names.get(employee_id, f"Employee {employee_id}") if employee_names else f"Employee {employee_id}"
|
|
|
|
# Sort daily hours by date
|
|
daily_items = sorted(emp_data['daily_hours'].items())
|
|
|
|
for date_str, day_data in daily_items:
|
|
date_obj = datetime.strptime(date_str, '%Y-%m-%d')
|
|
day_name = date_obj.strftime('%A')
|
|
|
|
# Determine status and notes
|
|
if day_data['is_miss_punch']:
|
|
status = "Miss Punch"
|
|
notes = "Missing check-in or check-out"
|
|
hours = 0
|
|
elif day_data['records_count'] == 0:
|
|
status = "No Records"
|
|
notes = "No attendance records"
|
|
hours = 0
|
|
else:
|
|
status = "Complete"
|
|
notes = f"{day_data['records_count']} record(s)"
|
|
hours = day_data['total_hours']
|
|
|
|
# Calculate regular/overtime for this day
|
|
regular_hours = min(hours, 8.0) # Daily limit
|
|
overtime_hours = max(0, hours - 8.0)
|
|
|
|
row_data = [
|
|
employee_id,
|
|
employee_name,
|
|
date_str,
|
|
day_name,
|
|
round(hours, 2),
|
|
status,
|
|
day_data['records_count'],
|
|
round(regular_hours, 2),
|
|
round(overtime_hours, 2),
|
|
notes
|
|
]
|
|
|
|
for col, value in enumerate(row_data, 1):
|
|
cell = worksheet.cell(row=current_row, column=col, value=value)
|
|
self._apply_style(cell, self.data_style)
|
|
|
|
# Color code miss punches
|
|
if status == "Miss Punch":
|
|
cell.fill = PatternFill(start_color="FFE6E6", end_color="FFE6E6", fill_type="solid")
|
|
elif status == "No Records":
|
|
cell.fill = PatternFill(start_color="F0F0F0", end_color="F0F0F0", fill_type="solid")
|
|
|
|
current_row += 1
|
|
|
|
# Auto-adjust columns
|
|
self._auto_adjust_columns(worksheet)
|
|
|
|
# Save to BytesIO
|
|
excel_buffer = io.BytesIO()
|
|
workbook.save(excel_buffer)
|
|
excel_buffer.seek(0)
|
|
|
|
print("✅ Detailed hours report Excel file created successfully")
|
|
return excel_buffer
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error creating detailed hours report: {e}")
|
|
raise e
|
|
|
|
@log_database_operations('template_hours_export')
|
|
def create_template_format_report(self, start_date: datetime, end_date: datetime,
|
|
attendance_records: List[Dict], employee_names: Dict[str, str] = None,
|
|
include_travel_time: bool = True, project_name: str = None) -> io.BytesIO:
|
|
"""
|
|
Create a template-format report matching the provided Excel template.
|
|
This creates a single sheet with all employees' detailed reports.
|
|
|
|
Args:
|
|
start_date: Report start date
|
|
end_date: Report end date
|
|
attendance_records: List of attendance records
|
|
employee_names: Dictionary mapping employee_id to full name
|
|
include_travel_time: Whether to include travel time in calculations
|
|
project_name: Project name for the report header
|
|
|
|
Returns:
|
|
BytesIO buffer containing the Excel file
|
|
"""
|
|
try:
|
|
print(f"📊 Creating single-sheet template format report from {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
|
|
|
|
# Get employee names using the same method as payroll routes
|
|
if employee_names is None:
|
|
employee_names = self._get_employee_names(attendance_records)
|
|
|
|
# Calculate working hours
|
|
calculator = SingleCheckInCalculator()
|
|
hours_data = calculator.calculate_all_employees_hours(start_date, end_date, attendance_records)
|
|
|
|
# Create workbook with single sheet
|
|
workbook = Workbook()
|
|
worksheet = workbook.active
|
|
worksheet.title = "Hours Report"
|
|
|
|
# Setup styles
|
|
self._setup_template_styles(workbook)
|
|
|
|
# Write main report headers once at the top
|
|
current_row = self._write_main_report_headers(worksheet, start_date, end_date, project_name)
|
|
|
|
# Write all employees data in one sheet
|
|
for employee_id, emp_data in hours_data['employees'].items():
|
|
employee_name = employee_names.get(employee_id, f"Employee {employee_id}") if employee_names else f"Employee {employee_id}"
|
|
|
|
# Write employee section
|
|
current_row = self._write_employee_section(worksheet, employee_id, employee_name, emp_data,
|
|
start_date, end_date, attendance_records, current_row)
|
|
|
|
# Add spacing between employees
|
|
current_row += 2
|
|
|
|
# Auto-adjust column widths to match template
|
|
self._adjust_template_columns(worksheet)
|
|
|
|
# Save to BytesIO
|
|
excel_buffer = io.BytesIO()
|
|
workbook.save(excel_buffer)
|
|
excel_buffer.seek(0)
|
|
|
|
print("✅ Single-sheet template format report Excel file created successfully")
|
|
return excel_buffer
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error creating template format report: {e}")
|
|
import traceback
|
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
|
raise e
|
|
|
|
def _setup_template_styles(self, workbook: Workbook):
|
|
"""Setup Excel cell styles for template format"""
|
|
|
|
# Company name style (matches template)
|
|
self.template_company_style = {
|
|
'font': Font(name="Arial", size=14, bold=True),
|
|
'alignment': Alignment(horizontal="left", vertical="center")
|
|
}
|
|
|
|
# Report title style
|
|
self.template_title_style = {
|
|
'font': Font(name="Arial", size=12, bold=True),
|
|
'alignment': Alignment(horizontal="left", vertical="center")
|
|
}
|
|
|
|
# Project name style
|
|
self.template_project_style = {
|
|
'font': Font(name="Arial", size=11, bold=True),
|
|
'alignment': Alignment(horizontal="left", vertical="center")
|
|
}
|
|
|
|
# Date range style
|
|
self.template_date_style = {
|
|
'font': Font(name="Arial", size=11),
|
|
'alignment': Alignment(horizontal="left", vertical="center")
|
|
}
|
|
|
|
# Employee info style
|
|
self.template_employee_style = {
|
|
'font': Font(name="Arial", size=11, bold=True),
|
|
'alignment': Alignment(horizontal="left", vertical="center")
|
|
}
|
|
|
|
# Column header style
|
|
self.template_header_style = {
|
|
'font': Font(name="Arial", size=10, bold=True),
|
|
'alignment': Alignment(horizontal="center", vertical="center"),
|
|
'border': Border(
|
|
left=Side(style="thin"),
|
|
right=Side(style="thin"),
|
|
top=Side(style="thin"),
|
|
bottom=Side(style="thin")
|
|
)
|
|
}
|
|
|
|
# Data cell style
|
|
self.template_data_style = {
|
|
'font': Font(name="Arial", size=10),
|
|
'alignment': Alignment(horizontal="center", vertical="center"),
|
|
'border': Border(
|
|
left=Side(style="thin"),
|
|
right=Side(style="thin"),
|
|
top=Side(style="thin"),
|
|
bottom=Side(style="thin")
|
|
)
|
|
}
|
|
|
|
def _adjust_template_columns(self, worksheet):
|
|
"""Adjust column widths to match template format"""
|
|
# Column widths based on template analysis
|
|
column_widths = {
|
|
'A': 12, # Day
|
|
'B': 10.6, # Date
|
|
'C': 12.6, # In
|
|
'D': 12.6, # Out
|
|
'E': 34, # Location
|
|
'F': 5.6, # Zone
|
|
'G': 15.4, # Hours/Building
|
|
'H': 10.9, # Daily Total
|
|
'I': 14.0, # Regular Hours
|
|
'J': 9.6, # OT Hours
|
|
'K': 35, # Building Address
|
|
'L': 35, # Recorded Location
|
|
'M': 10.0, # Distance
|
|
'N': 15.0, # Possible Violation
|
|
}
|
|
|
|
for col_letter, width in column_widths.items():
|
|
worksheet.column_dimensions[col_letter].width = width
|
|
|
|
def _write_main_report_headers(self, worksheet, start_date: datetime, end_date: datetime, project_name: str) -> int:
|
|
"""Write main report headers at the top of the sheet"""
|
|
current_row = 1
|
|
|
|
# 1. Company Name (merged across columns A-N)
|
|
cell = worksheet.cell(row=current_row, column=1, value=self.company_name)
|
|
self._apply_style(cell, self.template_company_style)
|
|
worksheet.merge_cells(f'A{current_row}:N{current_row}')
|
|
current_row += 1
|
|
|
|
# 2. Report title (merged across columns A-N)
|
|
cell = worksheet.cell(row=current_row, column=1, value="Summary report of Hours worked")
|
|
self._apply_style(cell, self.template_title_style)
|
|
worksheet.merge_cells(f'A{current_row}:N{current_row}')
|
|
current_row += 1
|
|
|
|
# 3. Project name (merged across columns A-N)
|
|
project_display = project_name if project_name else "[Project Name]"
|
|
cell = worksheet.cell(row=current_row, column=1, value=project_display)
|
|
self._apply_style(cell, self.template_project_style)
|
|
worksheet.merge_cells(f'A{current_row}:N{current_row}')
|
|
current_row += 1
|
|
|
|
# 4. Date range (merged across columns A-N)
|
|
date_range = f"Date range: {start_date.strftime('%m/%d/%Y')} to {end_date.strftime('%m/%d/%Y')}"
|
|
cell = worksheet.cell(row=current_row, column=1, value=date_range)
|
|
self._apply_style(cell, self.template_date_style)
|
|
worksheet.merge_cells(f'A{current_row}:N{current_row}')
|
|
current_row += 2 # Add extra space
|
|
|
|
return current_row
|
|
|
|
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"""
|
|
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}')
|
|
current_row += 1
|
|
|
|
# Column headers for this employee
|
|
headers = ["Day", "Date", "In", "Out", "Location", "Zone", "Hours/Building",
|
|
"Daily Total", "Regular Hours", "OT Hours", "Building Address",
|
|
"Recorded Location", "Distance", "Possible Violation"]
|
|
|
|
for col, header in enumerate(headers, 1):
|
|
cell = worksheet.cell(row=current_row, column=col, value=header)
|
|
self._apply_style(cell, self.template_header_style)
|
|
current_row += 1
|
|
|
|
# Get attendance records for this employee for location info
|
|
employee_records = [record for record in attendance_records if str(record.employee_id) == employee_id]
|
|
|
|
# Group attendance records by date for location info
|
|
daily_location_data = {}
|
|
for record in employee_records:
|
|
date_key = record.check_in_date.strftime('%Y-%m-%d')
|
|
if date_key not in daily_location_data:
|
|
daily_location_data[date_key] = {
|
|
'records': [],
|
|
'location': '',
|
|
'building_address': ''
|
|
}
|
|
daily_location_data[date_key]['records'].append(record)
|
|
|
|
# Get location info for each day
|
|
for date_str, day_info in daily_location_data.items():
|
|
sorted_records = sorted(day_info['records'], key=lambda x: x.check_in_time)
|
|
if sorted_records:
|
|
# Get location info from QR code
|
|
if hasattr(sorted_records[0], 'qr_code') and sorted_records[0].qr_code:
|
|
day_info['location'] = sorted_records[0].qr_code.location or ''
|
|
day_info['building_address'] = sorted_records[0].qr_code.location_address or ''
|
|
|
|
# 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')
|
|
day_hours_data = emp_data['daily_hours'][date_str]
|
|
|
|
# Skip days with no hours or miss punch
|
|
if day_hours_data.get('is_miss_punch', False) or day_hours_data.get('total_hours', 0) <= 0:
|
|
continue
|
|
|
|
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)
|
|
|
|
# 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 = ''
|
|
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')
|
|
|
|
# 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')
|
|
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')
|
|
|
|
location_accuracy = None
|
|
recorded_location = ''
|
|
distance_value = ''
|
|
possible_violation = ''
|
|
|
|
# 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 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
|
|
possible_violation = "No"
|
|
else:
|
|
# Lower accuracy - use actual check-in address
|
|
recorded_location = getattr(records[0], 'address', '') or ''
|
|
possible_violation = "Yes"
|
|
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'
|
|
|
|
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
|
|
]
|
|
|
|
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 |