Update payroll export

This commit is contained in:
Nguyen Ngo
2025-08-22 16:57:30 -04:00
parent dd74c010d9
commit 49b222ab1f
3 changed files with 375 additions and 9 deletions
+29 -3
View File
@@ -4454,8 +4454,8 @@ def export_payroll_excel():
flash('Invalid date format. Please use YYYY-MM-DD format.', 'error') flash('Invalid date format. Please use YYYY-MM-DD format.', 'error')
return redirect(url_for('payroll_dashboard')) return redirect(url_for('payroll_dashboard'))
# Get attendance records with project filter # Get attendance records with project filter and QR code data
query = db.session.query(AttendanceData).join(QRCode, AttendanceData.qr_code_id == QRCode.id) query = db.session.query(AttendanceData, QRCode).join(QRCode, AttendanceData.qr_code_id == QRCode.id)
# Apply date filter # Apply date filter
query = query.filter( query = query.filter(
@@ -4470,7 +4470,16 @@ def export_payroll_excel():
query = query.order_by(AttendanceData.employee_id, AttendanceData.check_in_date, AttendanceData.check_in_time) query = query.order_by(AttendanceData.employee_id, AttendanceData.check_in_date, AttendanceData.check_in_time)
attendance_records = query.all() # Get the results and attach QR code data to attendance records
query_results = query.all()
attendance_records = []
for attendance_data, qr_code in query_results:
# Attach the QR code object to the attendance record
attendance_data.qr_code = qr_code
attendance_records.append(attendance_data)
print(f"📊 Export: Found {len(attendance_records)} records with QR data")
if not attendance_records: if not attendance_records:
flash('No attendance records found for the selected date range and project.', 'warning') flash('No attendance records found for the selected date range and project.', 'warning')
@@ -4518,6 +4527,21 @@ def export_payroll_excel():
start_date, end_date, attendance_records, employee_names, include_travel_time start_date, end_date, attendance_records, employee_names, include_travel_time
) )
filename_prefix = 'detailed_hours_report' filename_prefix = 'detailed_hours_report'
elif report_type == 'template':
# Get project name for the template
project_name = None
if project_filter:
try:
project = Project.query.get(int(project_filter))
if project:
project_name = project.name
except Exception as e:
print(f"⚠️ Error getting project name for template: {e}")
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'
else: else:
excel_file = exporter.create_payroll_report( excel_file = exporter.create_payroll_report(
start_date, end_date, attendance_records, employee_names, include_travel_time start_date, end_date, attendance_records, employee_names, include_travel_time
@@ -4544,6 +4568,8 @@ def export_payroll_excel():
# Log successful export # Log successful export
logger_handler.logger.info(f"Payroll Excel export generated by user {session.get('username', 'unknown')}: {filename}") logger_handler.logger.info(f"Payroll Excel export generated by user {session.get('username', 'unknown')}: {filename}")
if report_type == 'template':
logger_handler.logger.info(f"Template format hours export generated by user {session.get('username', 'unknown')}: {filename}")
return send_file( return send_file(
excel_file, excel_file,
+329 -4
View File
@@ -225,26 +225,26 @@ class PayrollExcelExporter:
# Company name # Company name
cell = worksheet.cell(row=current_row, column=1, value=self.company_name) cell = worksheet.cell(row=current_row, column=1, value=self.company_name)
self._apply_style(cell, self.header1_style) self._apply_style(cell, self.header1_style)
worksheet.merge_cells(f'A{current_row}:M{current_row}') worksheet.merge_cells(f'A{current_row}:Q{current_row}')
current_row += 1 current_row += 1
# Report title # Report title
cell = worksheet.cell(row=current_row, column=1, value="Payroll Report - Working Hours Summary") cell = worksheet.cell(row=current_row, column=1, value="Payroll Report - Working Hours Summary")
self._apply_style(cell, self.header2_style) self._apply_style(cell, self.header2_style)
worksheet.merge_cells(f'A{current_row}:M{current_row}') worksheet.merge_cells(f'A{current_row}:Q{current_row}')
current_row += 1 current_row += 1
# Contract name # Contract name
cell = worksheet.cell(row=current_row, column=1, value=self.contract_name) cell = worksheet.cell(row=current_row, column=1, value=self.contract_name)
self._apply_style(cell, self.header3_style) self._apply_style(cell, self.header3_style)
worksheet.merge_cells(f'A{current_row}:M{current_row}') worksheet.merge_cells(f'A{current_row}:Q{current_row}')
current_row += 1 current_row += 1
# Date range # Date range
date_range = f"Date range: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}" 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) cell = worksheet.cell(row=current_row, column=1, value=date_range)
self._apply_style(cell, self.header3_style) self._apply_style(cell, self.header3_style)
worksheet.merge_cells(f'A{current_row}:M{current_row}') worksheet.merge_cells(f'A{current_row}:Q{current_row}')
current_row += 2 # Add extra space current_row += 2 # Add extra space
return current_row return current_row
@@ -522,3 +522,328 @@ class PayrollExcelExporter:
except Exception as e: except Exception as e:
print(f"❌ Error creating detailed hours report: {e}") print(f"❌ Error creating detailed hours report: {e}")
raise 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="center", vertical="center")
}
# Report title style
self.template_title_style = {
'font': Font(name="Arial", size=12, bold=True),
'alignment': Alignment(horizontal="center", vertical="center")
}
# Project name style
self.template_project_style = {
'font': Font(name="Arial", size=11, bold=True),
'alignment': Alignment(horizontal="center", vertical="center")
}
# Date range style
self.template_date_style = {
'font': Font(name="Arial", size=11),
'alignment': Alignment(horizontal="center", 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
+15
View File
@@ -444,6 +444,7 @@
<form method="POST" action="{{ url_for('export_payroll_excel') }}" style="display: inline;"> <form method="POST" action="{{ url_for('export_payroll_excel') }}" style="display: inline;">
<input type="hidden" name="date_from" value="{{ date_from }}"> <input type="hidden" name="date_from" value="{{ date_from }}">
<input type="hidden" name="date_to" value="{{ date_to }}"> <input type="hidden" name="date_to" value="{{ date_to }}">
<input type="hidden" name="project_filter" value="{{ project_filter }}">
<input type="hidden" name="include_travel_time" value="{{ include_travel_time|lower }}"> <input type="hidden" name="include_travel_time" value="{{ include_travel_time|lower }}">
<input type="hidden" name="report_type" value="payroll"> <input type="hidden" name="report_type" value="payroll">
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
@@ -455,6 +456,7 @@
<form method="POST" action="{{ url_for('export_payroll_excel') }}" style="display: inline;"> <form method="POST" action="{{ url_for('export_payroll_excel') }}" style="display: inline;">
<input type="hidden" name="date_from" value="{{ date_from }}"> <input type="hidden" name="date_from" value="{{ date_from }}">
<input type="hidden" name="date_to" value="{{ date_to }}"> <input type="hidden" name="date_to" value="{{ date_to }}">
<input type="hidden" name="project_filter" value="{{ project_filter }}">
<input type="hidden" name="include_travel_time" value="{{ include_travel_time|lower }}"> <input type="hidden" name="include_travel_time" value="{{ include_travel_time|lower }}">
<input type="hidden" name="report_type" value="detailed"> <input type="hidden" name="report_type" value="detailed">
<button type="submit" class="btn btn-secondary"> <button type="submit" class="btn btn-secondary">
@@ -462,6 +464,19 @@
Export Detailed Hours Export Detailed Hours
</button> </button>
</form> </form>
<form method="POST" action="{{ url_for('export_payroll_excel') }}" style="display: inline;">
<input type="hidden" name="date_from" value="{{ date_from }}">
<input type="hidden" name="date_to" value="{{ date_to }}">
<input type="hidden" name="project_filter" value="{{ project_filter }}">
<input type="hidden" name="project_filter" value="{{ request.form.get('project_filter', '') }}">
<input type="hidden" name="include_travel_time" value="{{ include_travel_time|lower }}">
<input type="hidden" name="report_type" value="template">
<button type="submit" class="btn btn-info">
<i class="fas fa-file-contract"></i>
Export Template Format
</button>
</form>
</div> </div>
<div style="margin-top: 1rem; padding: 1rem; background: #f7fafc; border-radius: 8px; color: #4a5568; font-size: 0.9rem;"> <div style="margin-top: 1rem; padding: 1rem; background: #f7fafc; border-radius: 8px; color: #4a5568; font-size: 0.9rem;">