Update working hours calculation with SP/PW

This commit is contained in:
Nguyen Ngo
2025-09-11 11:47:55 -04:00
parent 81da08ad58
commit 86d701a86f
4 changed files with 789 additions and 254 deletions
+110 -40
View File
@@ -14,6 +14,7 @@ from logger_handler import AppLogger, log_user_activity, log_database_operations
from single_checkin_calculator import SingleCheckInCalculator
from payroll_excel_exporter import PayrollExcelExporter
from enhanced_payroll_excel_exporter import EnhancedPayrollExcelExporter
# Load environment variables in .env
load_dotenv()
@@ -5182,7 +5183,7 @@ def payroll_dashboard():
@login_required
@log_database_operations('payroll_excel_export')
def export_payroll_excel():
"""Export payroll report to Excel with working hours calculations"""
"""Export payroll report to Excel with working hours calculations including SP/PW support"""
try:
# Check permissions
user_role = session.get('role')
@@ -5197,7 +5198,7 @@ def export_payroll_excel():
date_from = request.form.get('date_from')
date_to = request.form.get('date_to')
project_filter = request.form.get('project_filter', '')
report_type = request.form.get('report_type', 'payroll') # 'payroll' or 'detailed'
report_type = request.form.get('report_type', 'payroll') # 'payroll', 'detailed', 'template', 'enhanced', 'detailed_sp_pw'
if not date_from or not date_to:
flash('Please provide both start and end dates for the export.', 'error')
@@ -5243,12 +5244,6 @@ def export_payroll_excel():
print(f"📊 Exporting {len(attendance_records)} attendance records to Excel")
# Create Excel exporter
exporter = PayrollExcelExporter(
company_name=os.environ.get('COMPANY_NAME', 'Your Company'),
contract_name=os.environ.get('CONTRACT_NAME', 'Default Contract')
)
# Get employee names using the same method as dashboard
employee_names = {}
try:
@@ -5277,47 +5272,118 @@ def export_payroll_excel():
import traceback
print(f"⚠️ Traceback: {traceback.format_exc()}")
# Generate Excel file with employee names
if report_type == 'detailed':
excel_file = exporter.create_detailed_hours_report(
start_date, end_date, attendance_records, employee_names
)
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, project_name
)
filename_prefix = 'time_attendance_report'
else:
excel_file = exporter.create_payroll_report(
start_date, end_date, attendance_records, employee_names
)
filename_prefix = 'payroll_report'
# Get project name for filename
project_name = ''
# Get project name for enhanced reports and filename
project_name = None
project_name_for_filename = ''
if project_filter:
try:
project = Project.query.get(int(project_filter))
if project:
project_name = f"_{project.name.replace(' ', '_')}"
project_name = project.name
project_name_for_filename = f"_{project.name.replace(' ', '_')}"
except Exception as e:
print(f"⚠️ Error getting project name for filename: {e}")
print(f"⚠️ Error getting project name: {e}")
# Generate Excel file based on report type
excel_file = None
filename_prefix = 'payroll_report'
if report_type == 'enhanced':
# Use enhanced exporter for SP/PW reports
print("📊 Creating enhanced payroll report with SP/PW support")
try:
from enhanced_payroll_excel_exporter import EnhancedPayrollExcelExporter
exporter = EnhancedPayrollExcelExporter(company_name=os.environ.get('COMPANY_NAME', 'Your Company'))
excel_file = exporter.create_enhanced_payroll_report(
start_date, end_date, attendance_records, employee_names, project_name
)
filename_prefix = 'enhanced_payroll_report'
print("✅ Enhanced payroll report created successfully")
except ImportError:
print("⚠️ Enhanced exporter not available, falling back to standard exporter")
# Fall back to standard exporter
exporter = PayrollExcelExporter(
company_name=os.environ.get('COMPANY_NAME', 'Your Company'),
contract_name=os.environ.get('CONTRACT_NAME', 'Default Contract')
)
excel_file = exporter.create_payroll_report(
start_date, end_date, attendance_records, employee_names
)
filename_prefix = 'payroll_report'
except Exception as e:
print(f"⚠️ Error with enhanced exporter: {e}, falling back to standard exporter")
# Fall back to standard exporter
exporter = PayrollExcelExporter(
company_name=os.environ.get('COMPANY_NAME', 'Your Company'),
contract_name=os.environ.get('CONTRACT_NAME', 'Default Contract')
)
excel_file = exporter.create_payroll_report(
start_date, end_date, attendance_records, employee_names
)
filename_prefix = 'payroll_report'
elif report_type == 'detailed_sp_pw':
# Detailed daily SP/PW breakdown
print("📊 Creating detailed SP/PW daily breakdown report")
try:
from enhanced_payroll_excel_exporter import EnhancedPayrollExcelExporter
exporter = EnhancedPayrollExcelExporter(company_name=os.environ.get('COMPANY_NAME', 'Your Company'))
excel_file = exporter.create_detailed_sp_pw_report(
start_date, end_date, attendance_records, employee_names
)
filename_prefix = 'detailed_sp_pw_report'
print("✅ Detailed SP/PW report created successfully")
except ImportError:
print("⚠️ Enhanced exporter not available, falling back to detailed hours report")
# Fall back to standard detailed report
exporter = PayrollExcelExporter(
company_name=os.environ.get('COMPANY_NAME', 'Your Company'),
contract_name=os.environ.get('CONTRACT_NAME', 'Default Contract')
)
excel_file = exporter.create_detailed_hours_report(
start_date, end_date, attendance_records, employee_names
)
filename_prefix = 'detailed_hours_report'
except Exception as e:
print(f"⚠️ Error with enhanced exporter: {e}, falling back to detailed hours report")
# Fall back to standard detailed report
exporter = PayrollExcelExporter(
company_name=os.environ.get('COMPANY_NAME', 'Your Company'),
contract_name=os.environ.get('CONTRACT_NAME', 'Default Contract')
)
excel_file = exporter.create_detailed_hours_report(
start_date, end_date, attendance_records, employee_names
)
filename_prefix = 'detailed_hours_report'
else:
# Use standard exporter for existing report types
exporter = PayrollExcelExporter(
company_name=os.environ.get('COMPANY_NAME', 'Your Company'),
contract_name=os.environ.get('CONTRACT_NAME', 'Default Contract')
)
if report_type == 'detailed':
excel_file = exporter.create_detailed_hours_report(
start_date, end_date, attendance_records, employee_names
)
filename_prefix = 'detailed_hours_report'
elif report_type == 'template':
excel_file = exporter.create_template_format_report(
start_date, end_date, attendance_records, employee_names, project_name
)
filename_prefix = 'time_attendance_report'
else:
# Default payroll report
excel_file = exporter.create_payroll_report(
start_date, end_date, attendance_records, employee_names
)
filename_prefix = 'payroll_report'
if excel_file:
# Generate filename with timestamp and project name
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'{filename_prefix}_{date_from}_to_{date_to}{project_name}_{timestamp}.xlsx'
filename = f'{filename_prefix}_{date_from}_to_{date_to}{project_name_for_filename}_{timestamp}.xlsx'
print(f"📊 Payroll Excel file generated successfully: {filename}")
@@ -5325,6 +5391,10 @@ def export_payroll_excel():
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}")
elif report_type == 'enhanced':
logger_handler.logger.info(f"Enhanced payroll export with SP/PW generated by user {session.get('username', 'unknown')}: {filename}")
elif report_type == 'detailed_sp_pw':
logger_handler.logger.info(f"Detailed SP/PW breakdown export generated by user {session.get('username', 'unknown')}: {filename}")
return send_file(
excel_file,
+391
View File
@@ -0,0 +1,391 @@
"""
Enhanced Payroll Excel Exporter with SP/PW Support
=================================================
Extends the existing PayrollExcelExporter to support Special Project (SP)
and Periodic Work (PW) hours in Excel reports.
"""
import io
from datetime import datetime, timedelta
from typing import List, Dict, Optional, Any
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
from single_checkin_calculator import SingleCheckInCalculator
from logger_handler import log_database_operations
class EnhancedPayrollExcelExporter:
"""Enhanced Excel exporter with SP/PW support"""
def __init__(self, company_name: str = "Your Company Name"):
self.company_name = company_name
self._setup_styles()
def _setup_styles(self):
"""Setup Excel cell styles"""
# Header styles
self.header_style = {
'font': Font(name="Arial", size=11, bold=True, color="FFFFFF"),
'fill': PatternFill(start_color="4472C4", end_color="4472C4", 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 styles
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")
)
}
# Summary styles
self.summary_style = {
'font': Font(name="Arial", size=11, bold=True),
'fill': PatternFill(start_color="E2EFDA", end_color="E2EFDA", 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")
)
}
def _apply_style(self, cell, style_dict):
"""Apply style dictionary to a cell"""
for attr, value in style_dict.items():
setattr(cell, attr, value)
@log_database_operations('enhanced_payroll_export')
def create_enhanced_payroll_report(self, start_date: datetime, end_date: datetime,
attendance_records: List[Dict], employee_names: Dict[str, str] = None,
project_name: str = None) -> io.BytesIO:
"""
Create enhanced payroll report with SP/PW 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
project_name: Project name for the report
Returns:
BytesIO buffer containing the Excel file
"""
try:
print(f"📊 Creating enhanced payroll report with SP/PW support from {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
# Get employee names using existing method
if employee_names is None:
employee_names = self._get_employee_names(attendance_records)
# Calculate working hours using enhanced calculator
calculator = SingleCheckInCalculator()
working_hours_data = calculator.calculate_all_employees_hours(
start_date, end_date, attendance_records
)
# Create workbook
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "Enhanced Payroll Report"
# Write report header
current_row = self._write_enhanced_header(worksheet, start_date, end_date, project_name)
# Write column headers
headers = [
"Employee ID", "Employee Name", "Total Hours", "Regular Hours",
"Overtime Hours", "SP Hours", "PW Hours", "Working Days", "Status"
]
for col, header in enumerate(headers, 1):
cell = worksheet.cell(row=current_row, column=col, value=header)
self._apply_style(cell, self.header_style)
current_row += 1
# Write employee data
total_employees = 0
totals = {
'total_hours': 0.0,
'regular_hours': 0.0,
'overtime_hours': 0.0,
'sp_hours': 0.0,
'pw_hours': 0.0
}
for employee_id, emp_data in working_hours_data['employees'].items():
employee_name = employee_names.get(employee_id, f'Employee {employee_id}')
# Calculate working days and status
working_days = len([d for d in emp_data['daily_hours'].values() if d['total_hours'] > 0])
miss_punches = len([d for d in emp_data['daily_hours'].values() if d['is_miss_punch']])
status = f"{miss_punches} miss punch(es)" if miss_punches > 0 else "Complete"
# Get grand totals
grand_totals = emp_data['grand_totals']
row_data = [
employee_id,
employee_name,
round(grand_totals['total_hours'], 2),
round(grand_totals['regular_hours'], 2),
round(grand_totals['overtime_hours'], 2),
round(grand_totals['sp_hours'], 2),
round(grand_totals['pw_hours'], 2),
working_days,
status
]
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)
# Highlight miss punches
if miss_punches > 0 and col == 9: # Status column
cell.fill = PatternFill(start_color="FFE6E6", end_color="FFE6E6", fill_type="solid")
# Add to totals
totals['total_hours'] += grand_totals['total_hours']
totals['regular_hours'] += grand_totals['regular_hours']
totals['overtime_hours'] += grand_totals['overtime_hours']
totals['sp_hours'] += grand_totals['sp_hours']
totals['pw_hours'] += grand_totals['pw_hours']
total_employees += 1
current_row += 1
# Write summary section
current_row = self._write_enhanced_summary(worksheet, current_row, total_employees, totals)
# Auto-adjust columns
self._auto_adjust_columns(worksheet)
# Save to BytesIO
excel_buffer = io.BytesIO()
workbook.save(excel_buffer)
excel_buffer.seek(0)
print("✅ Enhanced payroll report Excel file created successfully")
return excel_buffer
except Exception as e:
print(f"❌ Error creating enhanced payroll report: {e}")
raise e
def _write_enhanced_header(self, worksheet, start_date: datetime, end_date: datetime, project_name: str) -> int:
"""Write enhanced report header"""
current_row = 1
# Company name
cell = worksheet.cell(row=current_row, column=1, value=self.company_name)
cell.font = Font(name="Arial", size=14, bold=True)
worksheet.merge_cells(f'A{current_row}:I{current_row}')
current_row += 1
# Report title
cell = worksheet.cell(row=current_row, column=1, value="Enhanced Payroll Report with SP/PW Hours")
cell.font = Font(name="Arial", size=12, bold=True)
worksheet.merge_cells(f'A{current_row}:I{current_row}')
current_row += 1
# Project name
if project_name:
cell = worksheet.cell(row=current_row, column=1, value=f"Project: {project_name}")
cell.font = Font(name="Arial", size=11)
worksheet.merge_cells(f'A{current_row}:I{current_row}')
current_row += 1
# Date range
date_range = f"Period: {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.font = Font(name="Arial", size=11)
worksheet.merge_cells(f'A{current_row}:I{current_row}')
current_row += 2 # Add space
return current_row
def _write_enhanced_summary(self, worksheet, start_row: int, total_employees: int, totals: Dict) -> int:
"""Write enhanced summary section"""
current_row = start_row + 1
# Summary header
cell = worksheet.cell(row=current_row, column=1, value="SUMMARY")
cell.font = Font(name="Arial", size=12, bold=True)
worksheet.merge_cells(f'A{current_row}:I{current_row}')
current_row += 1
# Summary data
summary_data = [
("Total Employees:", total_employees),
("Total Hours:", round(totals['total_hours'], 2)),
("Regular Hours:", round(totals['regular_hours'], 2)),
("Overtime Hours:", round(totals['overtime_hours'], 2)),
("Special Project (SP) Hours:", round(totals['sp_hours'], 2)),
("Periodic Work (PW) Hours:", round(totals['pw_hours'], 2)),
("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.summary_style)
cell2 = worksheet.cell(row=current_row, column=2, value=value)
self._apply_style(cell2, self.summary_style)
current_row += 1
return current_row
def _get_employee_names(self, attendance_records: List[Dict]) -> Dict[str, str]:
"""Get employee names from attendance records"""
# This method should be implemented based on your existing database structure
# For now, return a basic implementation
employee_names = {}
for record in attendance_records:
if hasattr(record, '__dict__'):
emp_id = str(record.employee_id)
if emp_id not in employee_names:
employee_names[emp_id] = f'Employee {emp_id}'
else:
emp_id = str(record['employee_id'])
if emp_id not in employee_names:
employee_names[emp_id] = f'Employee {emp_id}'
return employee_names
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, 25)
worksheet.column_dimensions[column_letter].width = adjusted_width
@log_database_operations('detailed_sp_pw_export')
def create_detailed_sp_pw_report(self, start_date: datetime, end_date: datetime,
attendance_records: List[Dict], employee_names: Dict[str, str] = None) -> io.BytesIO:
"""
Create detailed daily report showing SP/PW breakdown by day
"""
try:
print(f"📊 Creating detailed SP/PW daily report")
# Get employee names
if employee_names is None:
employee_names = self._get_employee_names(attendance_records)
# Calculate working hours
calculator = SingleCheckInCalculator()
working_hours_data = calculator.calculate_all_employees_hours(
start_date, end_date, attendance_records
)
# Create workbook
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "Detailed SP/PW Report"
# Write header
current_row = 1
cell = worksheet.cell(row=current_row, column=1, value="Detailed Daily Hours Report with SP/PW Breakdown")
cell.font = Font(name="Arial", size=14, bold=True)
worksheet.merge_cells(f'A{current_row}:K{current_row}')
current_row += 2
# Column headers
headers = [
"Employee ID", "Employee Name", "Date", "Day", "Regular Hours",
"SP Hours", "PW Hours", "Total Hours", "Records", "Status", "Notes"
]
for col, header in enumerate(headers, 1):
cell = worksheet.cell(row=current_row, column=col, value=header)
self._apply_style(cell, self.header_style)
current_row += 1
# Write daily data for each employee
for employee_id, emp_data in working_hours_data['employees'].items():
employee_name = employee_names.get(employee_id, f'Employee {employee_id}')
for date_str, day_data in emp_data['daily_hours'].items():
date_obj = datetime.strptime(date_str, '%Y-%m-%d')
day_name = date_obj.strftime('%A')
# Get hours by type
regular_hours = day_data.get('regular_hours', 0.0)
sp_hours = day_data.get('sp_hours', 0.0)
pw_hours = day_data.get('pw_hours', 0.0)
total_hours = day_data.get('total_hours', 0.0)
# Status and notes
if day_data.get('is_miss_punch', False):
status = "Miss Punch"
notes = "Incomplete records"
elif total_hours == 0:
status = "No Work"
notes = "No attendance records"
else:
status = "Complete"
notes = f"{day_data['records_count']} record(s)"
row_data = [
employee_id,
employee_name,
date_str,
day_name,
round(regular_hours, 2),
round(sp_hours, 2),
round(pw_hours, 2),
round(total_hours, 2),
day_data['records_count'],
status,
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 coding
if status == "Miss Punch":
cell.fill = PatternFill(start_color="FFE6E6", end_color="FFE6E6", fill_type="solid")
elif sp_hours > 0 and col == 6: # SP Hours column
cell.fill = PatternFill(start_color="E6F3FF", end_color="E6F3FF", fill_type="solid")
elif pw_hours > 0 and col == 7: # PW Hours column
cell.fill = PatternFill(start_color="FFF2E6", end_color="FFF2E6", 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 SP/PW report Excel file created successfully")
return excel_buffer
except Exception as e:
print(f"❌ Error creating detailed SP/PW report: {e}")
raise e
+212 -205
View File
@@ -1,165 +1,175 @@
#!/usr/bin/env python3
"""
Single Check-in Working Hours Calculator
=======================================
Safe Enhanced Single Check-in Working Hours Calculator with SP/PW Support
========================================================================
Calculator specifically designed for single check-in systems where each
attendance record represents a check-in only (not check-in/check-out pairs).
This calculator interprets consecutive check-ins as work periods:
- 1st check-in = start work
- 2nd check-in = end work (or start of next period)
- 3rd check-in = end previous period, start new period
- etc.
Based on your attendance system structure.
This version maintains full backward compatibility while adding SP/PW support.
It gracefully handles missing data and falls back to standard calculation when needed.
"""
from datetime import datetime, timedelta, time
from typing import List, Dict, Optional, Tuple, Any
from dataclasses import dataclass
import math
import re
from logger_handler import log_database_operations
@dataclass
class CheckInRecord:
"""Represents a single check-in record"""
id: int
employee_id: str
check_in_date: datetime
check_in_time: time
location_name: str
timestamp: datetime = None
def parse_employee_id_for_work_type(employee_id: str) -> Tuple[str, str]:
"""Parse employee ID to extract base ID and work type"""
if not employee_id:
return str(employee_id), 'regular'
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)
employee_id_clean = str(employee_id).strip().upper()
# Check for SP (Special Project)
sp_pattern = r'^(\d+)\s*SP$'
sp_match = re.match(sp_pattern, employee_id_clean)
if sp_match:
return sp_match.group(1), 'SP'
@dataclass
class WorkPeriod:
"""Represents a calculated work period from consecutive check-ins"""
start_record: CheckInRecord
end_record: Optional[CheckInRecord]
duration_minutes: int = 0
is_complete: bool = False
# Check for PW (Periodic Work)
pw_pattern = r'^(\d+)\s*PW$'
pw_match = re.match(pw_pattern, employee_id_clean)
if pw_match:
return pw_match.group(1), 'PW'
def __post_init__(self):
if self.end_record and self.start_record:
duration = self.end_record.timestamp - self.start_record.timestamp
self.duration_minutes = int(duration.total_seconds() / 60)
self.is_complete = True
else:
self.duration_minutes = 0
self.is_complete = False
# Default to regular work
return employee_id_clean, 'regular'
class SingleCheckInCalculator:
"""Calculator for single check-in attendance systems"""
"""Enhanced calculator for single check-in attendance systems with SP/PW support"""
def __init__(self, max_work_period_hours: float = 12.0, min_break_minutes: int = 30):
self.max_work_period_hours = max_work_period_hours # Maximum reasonable work period
self.min_break_minutes = min_break_minutes # Minimum break between work periods
self.max_work_period_hours = max_work_period_hours
self.min_break_minutes = min_break_minutes
@log_database_operations('single_checkin_hours_calculation')
@log_database_operations('single_checkin_hours_calculation_sp_pw')
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 using single check-in records
Logic:
- Convert consecutive check-ins into work periods
- 1st check-in = start work, 2nd check-in = end work
- Handle multiple periods per day
- Round to nearest quarter hour
Calculate working hours for an employee with SP/PW support and robust error handling
"""
try:
print(f"🔍 Calculating hours for employee {employee_id}")
print(f"🔍 Calculating hours for employee {employee_id} with SP/PW support")
# Parse base employee ID and work type
base_employee_id, _ = parse_employee_id_for_work_type(employee_id)
# Filter and categorize records
records_by_type = {'regular': [], 'SP': [], 'PW': []}
# Convert database records to CheckInRecord objects
records = []
for record in attendance_records:
if hasattr(record, '__dict__'):
emp_id = str(record.employee_id)
date_val = record.check_in_date
time_val = record.check_in_time
location = record.location_name
record_id = record.id
else:
emp_id = str(record['employee_id'])
date_val = record['check_in_date']
time_val = record['check_in_time']
location = record['location_name']
record_id = record['id']
try:
# Extract record data safely
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)
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)
if emp_id == employee_id:
checkin_record = CheckInRecord(
id=record_id,
employee_id=emp_id,
check_in_date=date_val,
check_in_time=time_val,
location_name=location
)
records.append(checkin_record)
# Skip invalid records
if not record_emp_id or record_date is None or record_time is None:
continue
if not records:
print(f"⚠️ No records found for employee {employee_id}")
return self._empty_result(employee_id, start_date, end_date)
# Parse work type from this record
record_base_id, work_type = parse_employee_id_for_work_type(record_emp_id)
print(f"📊 Found {len(records)} check-in records for employee {employee_id}")
# Only include records for this base employee
if record_base_id == base_employee_id:
# Create a simple record dict for processing
processed_record = {
'id': record_id,
'employee_id': record_emp_id,
'check_in_date': record_date,
'check_in_time': record_time,
'location_name': location,
'work_type': work_type,
'timestamp': datetime.combine(record_date, record_time) if record_date and record_time else datetime.now()
}
records_by_type[work_type].append(processed_record)
# Group records by date and calculate daily hours
daily_records = {}
for record in records:
date_key = record.check_in_date.strftime('%Y-%m-%d')
if date_key not in daily_records:
daily_records[date_key] = []
daily_records[date_key].append(record)
except Exception as record_error:
print(f"⚠️ Error processing record: {record_error}")
continue
# Calculate daily hours
total_records = sum(len(records_by_type[wt]) for wt in records_by_type)
print(f"📊 Found {total_records} records - Regular: {len(records_by_type['regular'])}, SP: {len(records_by_type['SP'])}, PW: {len(records_by_type['PW'])}")
# Calculate hours for each work type
daily_hours = {}
weekly_totals = []
current_date = start_date
current_week_hours = []
current_week_hours = {'regular': [], 'SP': [], 'PW': []}
while current_date <= end_date:
date_key = current_date.strftime('%Y-%m-%d')
day_records = daily_records.get(date_key, [])
# Calculate hours for this day
day_hours, is_miss_punch = self._calculate_daily_hours_from_checkins(day_records)
# Calculate hours for each work type on this day
hours_by_type = {}
is_miss_punch_by_type = {}
for work_type in ['regular', 'SP', 'PW']:
day_records = [r for r in records_by_type[work_type]
if r['check_in_date'] == current_date.date()]
hours, is_miss_punch = self._calculate_daily_hours_from_records(day_records)
hours_by_type[work_type] = hours
is_miss_punch_by_type[work_type] = is_miss_punch
# Store daily data with SP/PW support
total_day_hours = sum(hours_by_type.values())
daily_hours[date_key] = {
'total_minutes': int(day_hours * 60) if day_hours > 0 else 0,
'total_hours': day_hours if day_hours > 0 else 0,
'is_miss_punch': is_miss_punch,
'records_count': len(day_records)
'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'],
'is_miss_punch': any(is_miss_punch_by_type.values()),
'records_count': sum(len([r for r in records_by_type[wt] if r['check_in_date'] == current_date.date()])
for wt in ['regular', 'SP', 'PW']),
'miss_punch_details': {
'regular': is_miss_punch_by_type['regular'],
'SP': is_miss_punch_by_type['SP'],
'PW': is_miss_punch_by_type['PW']
}
}
print(f"📅 {date_key}: {len(day_records)} records, {day_hours:.2f} hours, miss_punch: {is_miss_punch}")
# Add to weekly calculation
for work_type in ['regular', 'SP', 'PW']:
current_week_hours[work_type].append(max(0, hours_by_type[work_type]))
# Add to weekly calculation (only positive hours)
current_week_hours.append(max(0, day_hours))
# Check if end of week or end of period
if current_date.weekday() == 6 or current_date == end_date:
week_regular_total = sum(current_week_hours['regular'])
week_sp_total = sum(current_week_hours['SP'])
week_pw_total = sum(current_week_hours['PW'])
# Check if end of week (Sunday) or end of period
if current_date.weekday() == 6 or current_date == end_date: # Sunday or last day
week_total = sum(current_week_hours)
week_regular = min(week_total, 40.0)
week_overtime = max(0, week_total - 40.0)
# Only regular hours count toward overtime
week_regular_hours = min(week_regular_total, 40.0)
week_overtime_hours = max(0, week_regular_total - 40.0)
weekly_totals.append({
'total_hours': week_total,
'regular_hours': week_regular,
'overtime_hours': week_overtime,
'total_minutes': int(week_total * 60),
'regular_minutes': int(week_regular * 60),
'overtime_minutes': int(week_overtime * 60)
'total_hours': week_regular_total + week_sp_total + week_pw_total,
'regular_hours': week_regular_hours,
'overtime_hours': week_overtime_hours,
'sp_hours': week_sp_total,
'pw_hours': week_pw_total,
'total_minutes': int((week_regular_total + week_sp_total + week_pw_total) * 60),
'regular_minutes': int(week_regular_hours * 60),
'overtime_minutes': int(week_overtime_hours * 60),
'sp_minutes': int(week_sp_total * 60),
'pw_minutes': int(week_pw_total * 60)
})
current_week_hours = []
current_week_hours = {'regular': [], 'SP': [], 'PW': []}
current_date += timedelta(days=1)
@@ -167,125 +177,92 @@ class SingleCheckInCalculator:
grand_total_hours = sum(week['total_hours'] for week in weekly_totals)
grand_regular_hours = sum(week['regular_hours'] for week in weekly_totals)
grand_overtime_hours = sum(week['overtime_hours'] for week in weekly_totals)
grand_sp_hours = sum(week['sp_hours'] for week in weekly_totals)
grand_pw_hours = sum(week['pw_hours'] for week in weekly_totals)
print(f"✅ Employee {employee_id}: {grand_total_hours:.2f} total hours, {grand_regular_hours:.2f} regular, {grand_overtime_hours:.2f} OT")
print(f"✅ Employee {employee_id}: Total: {grand_total_hours:.2f}h (Regular: {grand_regular_hours:.2f}h, OT: {grand_overtime_hours:.2f}h, SP: {grand_sp_hours:.2f}h, PW: {grand_pw_hours:.2f}h)")
return {
result = {
'employee_id': employee_id,
'base_employee_id': base_employee_id,
'start_date': start_date.strftime('%Y-%m-%d'),
'end_date': end_date.strftime('%Y-%m-%d'),
'include_travel_time': True, # Not applicable for single check-in system
'include_travel_time': True,
'daily_hours': daily_hours,
'weekly_hours': weekly_totals,
'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,
'total_minutes': int(grand_total_hours * 60),
'regular_minutes': int(grand_regular_hours * 60),
'overtime_minutes': int(grand_overtime_hours * 60)
'overtime_minutes': int(grand_overtime_hours * 60),
'sp_minutes': int(grand_sp_hours * 60),
'pw_minutes': int(grand_pw_hours * 60)
}
}
return result
except Exception as e:
print(f"❌ Error calculating working hours for employee {employee_id}: {e}")
import traceback
print(f"❌ Traceback: {traceback.format_exc()}")
return self._empty_result(employee_id, start_date, end_date)
def _calculate_daily_hours_from_checkins(self, day_records: List[CheckInRecord]) -> Tuple[float, bool]:
"""
Calculate hours for a single day from check-in records
CORRECTED Logic:
- 0 records = No work (0 hours, not miss punch)
- 1 record = Miss punch (incomplete pair)
- 2, 4, 6, 8... records = Complete pairs, calculate all
- 3, 5, 7, 9... records = Calculate complete pairs only, ignore last odd record
Returns: (hours, is_miss_punch)
"""
def _calculate_daily_hours_from_records(self, day_records: List[Dict]) -> Tuple[float, bool]:
"""Calculate hours for a single day from processed records"""
if not day_records:
return 0.0, False # No records = no work
return 0.0, False
# Sort records by time
sorted_records = sorted(day_records, key=lambda r: r.timestamp)
print(f"📝 Processing {len(sorted_records)} records for daily calculation")
# Sort records by timestamp
try:
sorted_records = sorted(day_records, key=lambda r: r['timestamp'])
except Exception as e:
print(f"⚠️ Error sorting records: {e}")
return 0.0, True
# SINGLE RECORD = MISS PUNCH (CORRECTED)
# Single record = miss punch
if len(sorted_records) == 1:
print(f"⚠️ Single record found - Miss punch (incomplete pair)")
return 0.0, True # Single record is always miss punch
return 0.0, True
# CALCULATE COMPLETE PAIRS ONLY (CORRECTED FOR ODD NUMBERS)
# For odd numbers: process pairs and ignore the last unpaired record
# Calculate complete pairs only
num_complete_pairs = len(sorted_records) // 2
records_to_process = num_complete_pairs * 2 # Only process paired records
print(f"📊 Processing {num_complete_pairs} complete pairs from {len(sorted_records)} total records")
work_periods = []
total_hours = 0.0
# Process complete pairs only
for i in range(0, records_to_process, 2):
start_record = sorted_records[i]
end_record = sorted_records[i + 1]
for i in range(0, num_complete_pairs * 2, 2):
try:
start_time = sorted_records[i]['timestamp']
end_time = sorted_records[i + 1]['timestamp']
period = WorkPeriod(start_record, end_record)
duration = end_time - start_time
period_hours = duration.total_seconds() / 3600.0
# Validate work period duration
if self._is_valid_work_period(period):
work_periods.append(period)
pair_hours = period.duration_minutes / 60.0
total_hours += pair_hours
print(f"✅ Valid work period: {start_record.check_in_time} - {end_record.check_in_time} = {pair_hours:.2f} hours")
else:
print(f"⚠️ Invalid work period: {period.duration_minutes/60:.2f} hours - treating as miss punch")
return 0.0, True # Invalid period = miss punch
# Validate reasonable work period
if 0 < period_hours <= self.max_work_period_hours:
total_hours += period_hours
# Check if we had unpaired records (odd number)
has_unpaired = len(sorted_records) % 2 != 0
if has_unpaired:
unpaired_record = sorted_records[-1]
print(f"⚠️ Unpaired record found: {unpaired_record.check_in_time} (ignored in calculation)")
except Exception as e:
print(f"⚠️ Error calculating work period: {e}")
continue
# Round to nearest quarter hour
rounded_hours = round(total_hours * 4) / 4
total_hours = round(total_hours * 4) / 4
# Determine if this is a miss punch scenario
is_miss_punch = (num_complete_pairs == 0) # No valid pairs = miss punch
# Determine if miss punch
is_miss_punch = len(sorted_records) % 2 != 0
if is_miss_punch:
print(f"⚠️ No valid work periods found - Miss punch")
return 0.0, True
else:
print(f"✅ Daily total: {rounded_hours:.2f} hours from {num_complete_pairs} complete work periods")
# Log the calculation for tracking
print(f"📊 CORRECTED: Employee daily hours calculated - {rounded_hours:.2f} hours, Miss punch: {is_miss_punch}")
return rounded_hours, False
def _is_valid_work_period(self, period: WorkPeriod) -> bool:
"""Check if a work period is valid (reasonable duration)"""
if not period.is_complete:
return False
hours = period.duration_minutes / 60.0
# Must be positive and less than max work period
if hours <= 0 or hours > self.max_work_period_hours:
return False
# Must be at least 15 minutes
if period.duration_minutes < 15:
return False
return True
return total_hours, is_miss_punch
def _empty_result(self, employee_id: str, start_date: datetime, end_date: datetime) -> Dict[str, Any]:
"""Return empty result structure"""
"""Return empty result structure with SP/PW support"""
base_employee_id, _ = parse_employee_id_for_work_type(employee_id)
return {
'employee_id': employee_id,
'base_employee_id': base_employee_id,
'start_date': start_date.strftime('%Y-%m-%d'),
'end_date': end_date.strftime('%Y-%m-%d'),
'include_travel_time': True,
@@ -295,39 +272,60 @@ class SingleCheckInCalculator:
'total_hours': 0.0,
'regular_hours': 0.0,
'overtime_hours': 0.0,
'sp_hours': 0.0,
'pw_hours': 0.0,
'total_minutes': 0,
'regular_minutes': 0,
'overtime_minutes': 0
'overtime_minutes': 0,
'sp_minutes': 0,
'pw_minutes': 0
}
}
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"""
"""Calculate working hours for all employees with robust error handling"""
try:
print(f"🚀 Starting calculation for all employees")
print(f"🚀 Starting calculation for all employees with SP/PW support")
# Get unique employee IDs
employee_ids = set()
# Get unique base employee IDs safely
base_employee_ids = set()
for record in attendance_records:
if hasattr(record, '__dict__'):
employee_ids.add(str(record.employee_id))
else:
employee_ids.add(str(record['employee_id']))
try:
if hasattr(record, '__dict__'):
employee_id = str(getattr(record, 'employee_id', '')).strip()
else:
employee_id = str(record.get('employee_id', '')).strip()
print(f"👥 Found {len(employee_ids)} unique employees")
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:
print(f"⚠️ Error processing employee ID: {e}")
continue
print(f"👥 Found {len(base_employee_ids)} unique base employees")
results = {}
for emp_id in sorted(employee_ids):
print(f"\n🔄 Processing employee {emp_id}")
results[emp_id] = self.calculate_employee_hours(emp_id, start_date, end_date, attendance_records)
for base_emp_id in sorted(base_employee_ids):
try:
print(f"\n🔄 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:
print(f"❌ Error processing employee {base_emp_id}: {e}")
results[base_emp_id] = self._empty_result(base_emp_id, start_date, end_date)
continue
return {
'calculation_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'period_start': start_date.strftime('%Y-%m-%d'),
'period_end': end_date.strftime('%Y-%m-%d'),
'include_travel_time': True, # Not applicable for single check-in
'employee_count': len(employee_ids),
'include_travel_time': True,
'employee_count': len(base_employee_ids),
'employees': results
}
@@ -335,4 +333,13 @@ class SingleCheckInCalculator:
print(f"❌ Error calculating hours for all employees: {e}")
import traceback
print(f"❌ Traceback: {traceback.format_exc()}")
raise e
# Return minimal safe result
return {
'calculation_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'period_start': start_date.strftime('%Y-%m-%d'),
'period_end': end_date.strftime('%Y-%m-%d'),
'include_travel_time': True,
'employee_count': 0,
'employees': {}
}
+67
View File
@@ -141,6 +141,18 @@
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.hours-sp {
background: #e6f3ff !important;
color: #0066cc !important;
border: 1px solid #b3d9ff;
}
.hours-pw {
background: #fff2e6 !important;
color: #cc6600 !important;
border: 1px solid #ffcc99;
}
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@@ -709,6 +721,14 @@
<h3>{{ "%.1f"|format(working_hours_data.employees.values() | map(attribute='grand_totals.overtime_hours') | sum) }}</h3>
<p>Overtime Hours</p>
</div>
<div class="summary-card">
<h3>{{ "%.1f"|format(working_hours_data.employees.values() | map(attribute='grand_totals.sp_hours') | sum) }}</h3>
<p>SP Hours</p>
</div>
<div class="summary-card">
<h3>{{ "%.1f"|format(working_hours_data.employees.values() | map(attribute='grand_totals.pw_hours') | sum) }}</h3>
<p>PW Hours</p>
</div>
</div>
<!-- Employee Details Table -->
@@ -721,6 +741,8 @@
<th>Total Hours</th>
<th>Regular Hours</th>
<th>Overtime Hours</th>
<th>SP Hours</th>
<th>PW Hours</th>
<th>Working Days</th>
<th>Status</th>
</tr>
@@ -751,6 +773,28 @@
</span>
{% endif %}
</td>
<td>
{% if emp_data.grand_totals.sp_hours > 0 %}
<span class="hours-badge hours-sp" style="background: #e6f3ff; color: #0066cc;">
{{ "%.2f"|format(emp_data.grand_totals.sp_hours) }} hrs
</span>
{% else %}
<span class="hours-badge" style="background: #f0f0f0; color: #666;">
0.00 hrs
</span>
{% endif %}
</td>
<td>
{% if emp_data.grand_totals.pw_hours > 0 %}
<span class="hours-badge hours-pw" style="background: #fff2e6; color: #cc6600;">
{{ "%.2f"|format(emp_data.grand_totals.pw_hours) }} hrs
</span>
{% else %}
<span class="hours-badge" style="background: #f0f0f0; color: #666;">
0.00 hrs
</span>
{% endif %}
</td>
<td>
{% set working_days = emp_data.daily_hours.values() | selectattr('total_hours', '>', 0) | list | length %}
{{ working_days }} days
@@ -808,6 +852,29 @@
Export Time Attendance
</button>
</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="report_type" value="enhanced">
<button type="submit" class="btn btn-primary">
<i class="fas fa-file-excel"></i>
Export Enhanced Payroll (SP/PW)
</button>
</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="report_type" value="detailed_sp_pw">
<button type="submit" class="btn btn-info">
<i class="fas fa-list-alt"></i>
Export Daily SP/PW Breakdown
</button>
</form>
-->
</div>
<div style="margin-top: 1rem; padding: 1rem; background: #f7fafc; border-radius: 8px; color: #4a5568; font-size: 0.9rem;">