Add payroll functionalities
This commit is contained in:
@@ -5,13 +5,15 @@ from functools import wraps
|
|||||||
from datetime import datetime, date, time, timedelta
|
from datetime import datetime, date, time, timedelta
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from user_agents import parse
|
from user_agents import parse
|
||||||
import io, os, base64, re, uuid, requests, json, qrcode, math
|
import io, os, base64, re, uuid, requests, json, qrcode, math, traceback
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
from math import radians, sin, cos, asin, sqrt
|
from math import radians, sin, cos, asin, sqrt
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
# Import the logging handler
|
# Import the logging handler
|
||||||
from logger_handler import AppLogger, log_user_activity, log_database_operations
|
from logger_handler import AppLogger, log_user_activity, log_database_operations
|
||||||
|
|
||||||
|
from single_checkin_calculator import SingleCheckInCalculator
|
||||||
|
from payroll_excel_exporter import PayrollExcelExporter
|
||||||
|
|
||||||
# Load environment variables in .env
|
# Load environment variables in .env
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -465,7 +467,6 @@ def calculate_location_accuracy_enhanced(qr_address, checkin_address, checkin_la
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error calculating distance: {e}")
|
print(f"❌ Error calculating distance: {e}")
|
||||||
import traceback
|
|
||||||
print(f"❌ Distance calculation traceback: {traceback.format_exc()}")
|
print(f"❌ Distance calculation traceback: {traceback.format_exc()}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -2815,7 +2816,6 @@ def delete_qr_code(qr_id):
|
|||||||
logger_handler.log_database_error('qr_code_deletion', e)
|
logger_handler.log_database_error('qr_code_deletion', e)
|
||||||
print(f"❌ ERROR in delete route: {e}")
|
print(f"❌ ERROR in delete route: {e}")
|
||||||
print(f"❌ Exception type: {type(e)}")
|
print(f"❌ Exception type: {type(e)}")
|
||||||
import traceback
|
|
||||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
flash('Error deleting QR code. Please try again.', 'error')
|
flash('Error deleting QR code. Please try again.', 'error')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
@@ -3006,7 +3006,6 @@ def qr_checkin(qr_url):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error in location accuracy calculation: {e}")
|
print(f"❌ Error in location accuracy calculation: {e}")
|
||||||
import traceback
|
|
||||||
print(f"❌ Full traceback: {traceback.format_exc()}")
|
print(f"❌ Full traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
# ENHANCED DEBUG: Save to database with verification
|
# ENHANCED DEBUG: Save to database with verification
|
||||||
@@ -3050,7 +3049,6 @@ def qr_checkin(qr_url):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Database error: {e}")
|
print(f"❌ Database error: {e}")
|
||||||
import traceback
|
|
||||||
print(f"❌ Full traceback: {traceback.format_exc()}")
|
print(f"❌ Full traceback: {traceback.format_exc()}")
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger_handler.log_database_error('checkin_save', e)
|
logger_handler.log_database_error('checkin_save', e)
|
||||||
@@ -3094,7 +3092,6 @@ def qr_checkin(qr_url):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Unexpected error in check-in process: {e}")
|
print(f"❌ Unexpected error in check-in process: {e}")
|
||||||
import traceback
|
|
||||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -3505,7 +3502,6 @@ def attendance_report():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error loading attendance report: {e}")
|
print(f"❌ Error loading attendance report: {e}")
|
||||||
print(f"❌ Exception type: {type(e)}")
|
print(f"❌ Exception type: {type(e)}")
|
||||||
import traceback
|
|
||||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
# Log the error
|
# Log the error
|
||||||
@@ -3784,7 +3780,6 @@ def export_configuration():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error in export_configuration route: {e}")
|
print(f"❌ Error in export_configuration route: {e}")
|
||||||
import traceback
|
|
||||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
# Use your existing logger error method with correct parameters
|
# Use your existing logger error method with correct parameters
|
||||||
@@ -3902,7 +3897,6 @@ def generate_excel_export():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error in generate_excel_export route: {e}")
|
print(f"❌ Error in generate_excel_export route: {e}")
|
||||||
import traceback
|
|
||||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
# Use your existing logger error method with correct parameters
|
# Use your existing logger error method with correct parameters
|
||||||
@@ -4077,7 +4071,6 @@ def create_excel_export(selected_columns, column_names, filters):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error creating Excel export: {e}")
|
print(f"❌ Error creating Excel export: {e}")
|
||||||
import traceback
|
|
||||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -4276,10 +4269,384 @@ def create_excel_export_ordered(selected_columns, column_names, filters):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error creating Excel export: {e}")
|
print(f"❌ Error creating Excel export: {e}")
|
||||||
import traceback
|
|
||||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@app.route('/payroll')
|
||||||
|
@login_required
|
||||||
|
def payroll_dashboard():
|
||||||
|
"""Payroll dashboard for calculating and exporting working hours"""
|
||||||
|
try:
|
||||||
|
# Check if user has payroll access
|
||||||
|
user_role = session.get('role')
|
||||||
|
if user_role not in ['admin', 'payroll']:
|
||||||
|
logger_handler.logger.warning(f"User {session.get('username', 'unknown')} (role: {user_role}) attempted to access payroll dashboard without permissions")
|
||||||
|
flash('Access denied. Only administrators and payroll staff can access payroll features.', 'error')
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
print("📊 Loading payroll dashboard")
|
||||||
|
|
||||||
|
# Log payroll dashboard access
|
||||||
|
logger_handler.logger.info(f"User {session.get('username', 'unknown')} accessed payroll dashboard")
|
||||||
|
|
||||||
|
# Get filter parameters with defaults
|
||||||
|
date_from = request.args.get('date_from', '')
|
||||||
|
date_to = request.args.get('date_to', '')
|
||||||
|
project_filter = request.args.get('project_filter', '')
|
||||||
|
include_travel_time = request.args.get('include_travel_time', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# Set default date range if not provided (last 2 weeks)
|
||||||
|
if not date_from or not date_to:
|
||||||
|
end_date = datetime.now().date()
|
||||||
|
start_date = end_date - timedelta(days=13) # 2 weeks (14 days)
|
||||||
|
date_from = start_date.strftime('%Y-%m-%d')
|
||||||
|
date_to = end_date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# Get list of projects for dropdown
|
||||||
|
projects = []
|
||||||
|
try:
|
||||||
|
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||||
|
print(f"📊 Found {len(projects)} active projects for filter")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error loading projects: {e}")
|
||||||
|
|
||||||
|
# Get attendance records for the period
|
||||||
|
attendance_records = []
|
||||||
|
working_hours_data = None
|
||||||
|
|
||||||
|
if date_from and date_to:
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(date_from, '%Y-%m-%d')
|
||||||
|
end_date = datetime.strptime(date_to, '%Y-%m-%d')
|
||||||
|
|
||||||
|
# Query attendance records with optional project filter
|
||||||
|
query = db.session.query(AttendanceData).join(QRCode, AttendanceData.qr_code_id == QRCode.id)
|
||||||
|
|
||||||
|
# Apply date filter
|
||||||
|
query = query.filter(
|
||||||
|
AttendanceData.check_in_date >= start_date.date(),
|
||||||
|
AttendanceData.check_in_date <= end_date.date()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply project filter if selected
|
||||||
|
if project_filter and project_filter != '':
|
||||||
|
query = query.filter(QRCode.project_id == int(project_filter))
|
||||||
|
print(f"📊 Applied project filter: {project_filter}")
|
||||||
|
|
||||||
|
query = query.order_by(AttendanceData.employee_id, AttendanceData.check_in_date, AttendanceData.check_in_time)
|
||||||
|
|
||||||
|
attendance_records = query.all()
|
||||||
|
print(f"📊 Found {len(attendance_records)} attendance records for payroll calculation")
|
||||||
|
|
||||||
|
# Calculate working hours if we have records
|
||||||
|
if attendance_records:
|
||||||
|
calculator = SingleCheckInCalculator()
|
||||||
|
working_hours_data = calculator.calculate_all_employees_hours(
|
||||||
|
start_date, end_date, attendance_records
|
||||||
|
)
|
||||||
|
print(f"📊 Calculated hours for {working_hours_data['employee_count']} employees")
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"⚠️ Invalid date format: {e}")
|
||||||
|
flash('Invalid date format. Please use YYYY-MM-DD format.', 'error')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error calculating working hours: {e}")
|
||||||
|
logger_handler.log_database_error('payroll_calculation', e)
|
||||||
|
flash('Error calculating working hours. Please check the server logs.', 'error')
|
||||||
|
|
||||||
|
# Get employee names for display
|
||||||
|
employee_names = {}
|
||||||
|
if working_hours_data:
|
||||||
|
try:
|
||||||
|
# Try to get employee names from your employee table if it exists
|
||||||
|
employee_ids = list(working_hours_data['employees'].keys())
|
||||||
|
# This assumes you have an employee table - modify as needed
|
||||||
|
employee_query = db.session.execute(text("""
|
||||||
|
SELECT id, CONCAT(firstName, ' ', lastName) as full_name
|
||||||
|
FROM employee
|
||||||
|
WHERE id IN :employee_ids
|
||||||
|
"""), {'employee_ids': tuple(employee_ids)})
|
||||||
|
|
||||||
|
for row in employee_query:
|
||||||
|
employee_names[str(row[0])] = row[1]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Could not load employee names: {e}")
|
||||||
|
# Continue without names - will use employee IDs
|
||||||
|
|
||||||
|
# Get selected project name for display
|
||||||
|
selected_project_name = ''
|
||||||
|
if project_filter:
|
||||||
|
try:
|
||||||
|
selected_project = Project.query.get(int(project_filter))
|
||||||
|
if selected_project:
|
||||||
|
selected_project_name = selected_project.name
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error getting selected project name: {e}")
|
||||||
|
|
||||||
|
return render_template('payroll_dashboard.html',
|
||||||
|
working_hours_data=working_hours_data,
|
||||||
|
employee_names=employee_names,
|
||||||
|
projects=projects,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
project_filter=project_filter,
|
||||||
|
selected_project_name=selected_project_name,
|
||||||
|
include_travel_time=include_travel_time,
|
||||||
|
user_role=user_role)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error loading payroll dashboard: {e}")
|
||||||
|
import traceback
|
||||||
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
|
logger_handler.log_flask_error(
|
||||||
|
error_type="payroll_dashboard_error",
|
||||||
|
error_message=str(e),
|
||||||
|
stack_trace=traceback.format_exc()
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('Error loading payroll dashboard. Please check the server logs.', 'error')
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/payroll/export-excel', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@log_database_operations('payroll_excel_export')
|
||||||
|
def export_payroll_excel():
|
||||||
|
"""Export payroll report to Excel with working hours calculations"""
|
||||||
|
try:
|
||||||
|
# Check permissions
|
||||||
|
user_role = session.get('role')
|
||||||
|
if user_role not in ['admin', 'payroll']:
|
||||||
|
logger_handler.logger.warning(f"User {session.get('username', 'unknown')} (role: {user_role}) attempted unauthorized payroll Excel export")
|
||||||
|
flash('Access denied. Only administrators and payroll staff can export payroll data.', 'error')
|
||||||
|
return redirect(url_for('payroll_dashboard'))
|
||||||
|
|
||||||
|
print("📊 Payroll Excel export started")
|
||||||
|
|
||||||
|
# Get parameters from form
|
||||||
|
date_from = request.form.get('date_from')
|
||||||
|
date_to = request.form.get('date_to')
|
||||||
|
project_filter = request.form.get('project_filter', '')
|
||||||
|
include_travel_time = request.form.get('include_travel_time', 'false').lower() == 'true'
|
||||||
|
report_type = request.form.get('report_type', 'payroll') # 'payroll' or 'detailed'
|
||||||
|
|
||||||
|
if not date_from or not date_to:
|
||||||
|
flash('Please provide both start and end dates for the export.', 'error')
|
||||||
|
return redirect(url_for('payroll_dashboard'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(date_from, '%Y-%m-%d')
|
||||||
|
end_date = datetime.strptime(date_to, '%Y-%m-%d')
|
||||||
|
except ValueError:
|
||||||
|
flash('Invalid date format. Please use YYYY-MM-DD format.', 'error')
|
||||||
|
return redirect(url_for('payroll_dashboard'))
|
||||||
|
|
||||||
|
# Get attendance records with project filter
|
||||||
|
query = db.session.query(AttendanceData).join(QRCode, AttendanceData.qr_code_id == QRCode.id)
|
||||||
|
|
||||||
|
# Apply date filter
|
||||||
|
query = query.filter(
|
||||||
|
AttendanceData.check_in_date >= start_date.date(),
|
||||||
|
AttendanceData.check_in_date <= end_date.date()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply project filter if selected
|
||||||
|
if project_filter and project_filter != '':
|
||||||
|
query = query.filter(QRCode.project_id == int(project_filter))
|
||||||
|
print(f"📊 Applied project filter to export: {project_filter}")
|
||||||
|
|
||||||
|
query = query.order_by(AttendanceData.employee_id, AttendanceData.check_in_date, AttendanceData.check_in_time)
|
||||||
|
|
||||||
|
attendance_records = query.all()
|
||||||
|
|
||||||
|
if not attendance_records:
|
||||||
|
flash('No attendance records found for the selected date range and project.', 'warning')
|
||||||
|
return redirect(url_for('payroll_dashboard'))
|
||||||
|
|
||||||
|
print(f"📊 Exporting {len(attendance_records)} attendance records to Excel")
|
||||||
|
|
||||||
|
# Get employee names
|
||||||
|
employee_names = {}
|
||||||
|
try:
|
||||||
|
employee_ids = list(set(str(record.employee_id) for record in attendance_records))
|
||||||
|
employee_query = db.session.execute(text("""
|
||||||
|
SELECT id, CONCAT(firstName, ' ', lastName) as full_name
|
||||||
|
FROM employee
|
||||||
|
WHERE id IN :employee_ids
|
||||||
|
"""), {'employee_ids': tuple(employee_ids)})
|
||||||
|
|
||||||
|
for row in employee_query:
|
||||||
|
employee_names[str(row[0])] = row[1]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Could not load employee names for export: {e}")
|
||||||
|
|
||||||
|
# Get project name for filename
|
||||||
|
project_name = ''
|
||||||
|
if project_filter:
|
||||||
|
try:
|
||||||
|
project = Project.query.get(int(project_filter))
|
||||||
|
if project:
|
||||||
|
project_name = f"_{project.name.replace(' ', '_')}"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error getting project name for filename: {e}")
|
||||||
|
|
||||||
|
# Create Excel exporter
|
||||||
|
exporter = PayrollExcelExporter(
|
||||||
|
company_name=os.environ.get('COMPANY_NAME', 'Your Company'),
|
||||||
|
contract_name=os.environ.get('CONTRACT_NAME', 'Default Contract')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate Excel file
|
||||||
|
if report_type == 'detailed':
|
||||||
|
excel_file = exporter.create_detailed_hours_report(
|
||||||
|
start_date, end_date, attendance_records, employee_names, include_travel_time
|
||||||
|
)
|
||||||
|
filename_prefix = 'detailed_hours_report'
|
||||||
|
else:
|
||||||
|
excel_file = exporter.create_payroll_report(
|
||||||
|
start_date, end_date, attendance_records, employee_names, include_travel_time
|
||||||
|
)
|
||||||
|
filename_prefix = 'payroll_report'
|
||||||
|
|
||||||
|
if excel_file:
|
||||||
|
# Generate filename with timestamp and project name
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
travel_suffix = '_with_travel' if include_travel_time else '_no_travel'
|
||||||
|
filename = f'{filename_prefix}_{date_from}_to_{date_to}{project_name}{travel_suffix}_{timestamp}.xlsx'
|
||||||
|
|
||||||
|
print(f"📊 Payroll Excel file generated successfully: {filename}")
|
||||||
|
|
||||||
|
# Log successful export
|
||||||
|
logger_handler.logger.info(f"Payroll Excel export generated by user {session.get('username', 'unknown')}: {filename}")
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
excel_file,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=filename,
|
||||||
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash('Error generating payroll Excel file.', 'error')
|
||||||
|
return redirect(url_for('payroll_dashboard'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error in export_payroll_excel route: {e}")
|
||||||
|
import traceback
|
||||||
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
|
logger_handler.log_flask_error(
|
||||||
|
error_type="payroll_excel_export_error",
|
||||||
|
error_message=str(e),
|
||||||
|
stack_trace=traceback.format_exc()
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('Error generating payroll Excel export. Please check the server logs.', 'error')
|
||||||
|
return redirect(url_for('payroll_dashboard'))
|
||||||
|
|
||||||
|
@app.route('/api/working-hours/calculate', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@log_database_operations('working_hours_api_calculation')
|
||||||
|
def calculate_working_hours_api():
|
||||||
|
"""API endpoint for calculating working hours"""
|
||||||
|
try:
|
||||||
|
# Check permissions
|
||||||
|
user_role = session.get('role')
|
||||||
|
if user_role not in ['admin', 'payroll']:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Access denied. Insufficient permissions.'
|
||||||
|
}), 403
|
||||||
|
|
||||||
|
# Get parameters from JSON request
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'No data provided'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
employee_id = data.get('employee_id')
|
||||||
|
date_from = data.get('date_from')
|
||||||
|
date_to = data.get('date_to')
|
||||||
|
include_travel_time = data.get('include_travel_time', True)
|
||||||
|
|
||||||
|
if not all([employee_id, date_from, date_to]):
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Missing required parameters: employee_id, date_from, date_to'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(date_from, '%Y-%m-%d')
|
||||||
|
end_date = datetime.strptime(date_to, '%Y-%m-%d')
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Invalid date format. Use YYYY-MM-DD.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Get attendance records for the employee
|
||||||
|
query = db.session.query(AttendanceData).filter(
|
||||||
|
AttendanceData.employee_id == str(employee_id),
|
||||||
|
AttendanceData.check_in_date >= start_date.date(),
|
||||||
|
AttendanceData.check_in_date <= end_date.date()
|
||||||
|
).order_by(AttendanceData.check_in_date, AttendanceData.check_in_time)
|
||||||
|
|
||||||
|
attendance_records = query.all()
|
||||||
|
|
||||||
|
# Calculate working hours
|
||||||
|
calculator = SingleCheckInCalculator()
|
||||||
|
hours_data = calculator.calculate_employee_hours(
|
||||||
|
str(employee_id), start_date, end_date, attendance_records
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log API usage
|
||||||
|
logger_handler.logger.info(f"Working hours API used by {session.get('username', 'unknown')} for employee {employee_id}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': hours_data
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error in calculate_working_hours_api: {e}")
|
||||||
|
logger_handler.log_flask_error(
|
||||||
|
error_type="working_hours_api_error",
|
||||||
|
error_message=str(e),
|
||||||
|
stack_trace=traceback.format_exc()
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Internal server error. Please check the server logs.'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
def get_employee_name(employee_id):
|
||||||
|
"""Helper function to get employee full name by ID"""
|
||||||
|
try:
|
||||||
|
result = db.session.execute(text("""
|
||||||
|
SELECT CONCAT(firstName, ' ', lastName) as full_name
|
||||||
|
FROM employee
|
||||||
|
WHERE id = :employee_id
|
||||||
|
"""), {'employee_id': employee_id})
|
||||||
|
|
||||||
|
row = result.fetchone()
|
||||||
|
return row[0] if row else f"Employee {employee_id}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error getting employee name for ID {employee_id}: {e}")
|
||||||
|
return f"Employee {employee_id}"
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_payroll_utils():
|
||||||
|
"""Inject payroll utility functions into templates"""
|
||||||
|
return {
|
||||||
|
'get_employee_name': get_employee_name,
|
||||||
|
'format_hours': lambda hours: f"{hours:.2f}" if hours else "0.00"
|
||||||
|
}
|
||||||
|
|
||||||
# Jinja2 filters for better template functionality
|
# Jinja2 filters for better template functionality
|
||||||
@app.template_filter('days_since')
|
@app.template_filter('days_since')
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
#!/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)
|
||||||
|
|
||||||
|
@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')}")
|
||||||
|
|
||||||
|
# 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}:M{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}:M{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}:M{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}:M{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')}")
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Single Check-in Working Hours Calculator
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, time
|
||||||
|
from typing import List, Dict, Optional, Tuple, Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import math
|
||||||
|
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 __post_init__(self):
|
||||||
|
if self.timestamp is None:
|
||||||
|
# Combine date and time for timestamp
|
||||||
|
self.timestamp = datetime.combine(self.check_in_date, self.check_in_time)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class SingleCheckInCalculator:
|
||||||
|
"""Calculator for single check-in attendance systems"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@log_database_operations('single_checkin_hours_calculation')
|
||||||
|
def calculate_employee_hours(self, employee_id: str, start_date: datetime, end_date: datetime,
|
||||||
|
attendance_records: List[Dict]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Calculate working hours for an employee 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
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
print(f"🔍 Calculating hours for employee {employee_id}")
|
||||||
|
|
||||||
|
# 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']
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
print(f"⚠️ No records found for employee {employee_id}")
|
||||||
|
return self._empty_result(employee_id, start_date, end_date)
|
||||||
|
|
||||||
|
print(f"📊 Found {len(records)} check-in records for employee {employee_id}")
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# Calculate daily hours
|
||||||
|
daily_hours = {}
|
||||||
|
weekly_totals = []
|
||||||
|
|
||||||
|
current_date = start_date
|
||||||
|
current_week_hours = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"📅 {date_key}: {len(day_records)} records, {day_hours:.2f} hours, miss_punch: {is_miss_punch}")
|
||||||
|
|
||||||
|
# Add to weekly calculation (only positive hours)
|
||||||
|
current_week_hours.append(max(0, day_hours))
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
current_week_hours = []
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
# Calculate grand totals
|
||||||
|
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)
|
||||||
|
|
||||||
|
print(f"✅ Employee {employee_id}: {grand_total_hours:.2f} total hours, {grand_regular_hours:.2f} regular, {grand_overtime_hours:.2f} OT")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'employee_id': 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
|
||||||
|
'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,
|
||||||
|
'total_minutes': int(grand_total_hours * 60),
|
||||||
|
'regular_minutes': int(grand_regular_hours * 60),
|
||||||
|
'overtime_minutes': int(grand_overtime_hours * 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Logic:
|
||||||
|
- Pair consecutive check-ins as work periods
|
||||||
|
- 1st = start, 2nd = end, 3rd = start new period, 4th = end, etc.
|
||||||
|
- Single record = miss punch
|
||||||
|
- Validate reasonable work periods
|
||||||
|
|
||||||
|
Returns: (hours, is_miss_punch)
|
||||||
|
"""
|
||||||
|
if not day_records:
|
||||||
|
return 0.0, False
|
||||||
|
|
||||||
|
if len(day_records) == 1:
|
||||||
|
print(f"⚠️ Single check-in found - miss punch")
|
||||||
|
return 0.0, True # Single check-in = miss punch
|
||||||
|
|
||||||
|
# Sort records by time
|
||||||
|
sorted_records = sorted(day_records, key=lambda r: r.timestamp)
|
||||||
|
print(f"📝 Processing {len(sorted_records)} records for the day")
|
||||||
|
|
||||||
|
# Create work periods from consecutive check-ins
|
||||||
|
work_periods = []
|
||||||
|
for i in range(0, len(sorted_records) - 1, 2):
|
||||||
|
start_record = sorted_records[i]
|
||||||
|
end_record = sorted_records[i + 1] if i + 1 < len(sorted_records) else None
|
||||||
|
|
||||||
|
if end_record:
|
||||||
|
period = WorkPeriod(start_record, end_record)
|
||||||
|
|
||||||
|
# Validate work period duration
|
||||||
|
if self._is_valid_work_period(period):
|
||||||
|
work_periods.append(period)
|
||||||
|
print(f"✅ Valid work period: {start_record.check_in_time} - {end_record.check_in_time} = {period.duration_minutes/60:.2f} hours")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Invalid work period: {period.duration_minutes/60:.2f} hours (too long or negative)")
|
||||||
|
return 0.0, True # Invalid period = miss punch
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Unpaired check-in at {start_record.check_in_time}")
|
||||||
|
return 0.0, True # Unpaired record = miss punch
|
||||||
|
|
||||||
|
# Calculate total hours
|
||||||
|
total_minutes = sum(period.duration_minutes for period in work_periods)
|
||||||
|
total_hours = total_minutes / 60.0
|
||||||
|
|
||||||
|
# Round to nearest quarter hour
|
||||||
|
rounded_hours = round(total_hours * 4) / 4
|
||||||
|
|
||||||
|
print(f"📊 Daily total: {rounded_hours:.2f} hours from {len(work_periods)} work periods")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def _empty_result(self, employee_id: str, start_date: datetime, end_date: datetime) -> Dict[str, Any]:
|
||||||
|
"""Return empty result structure"""
|
||||||
|
return {
|
||||||
|
'employee_id': employee_id,
|
||||||
|
'start_date': start_date.strftime('%Y-%m-%d'),
|
||||||
|
'end_date': end_date.strftime('%Y-%m-%d'),
|
||||||
|
'include_travel_time': True,
|
||||||
|
'daily_hours': {},
|
||||||
|
'weekly_hours': [],
|
||||||
|
'grand_totals': {
|
||||||
|
'total_hours': 0.0,
|
||||||
|
'regular_hours': 0.0,
|
||||||
|
'overtime_hours': 0.0,
|
||||||
|
'total_minutes': 0,
|
||||||
|
'regular_minutes': 0,
|
||||||
|
'overtime_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"""
|
||||||
|
try:
|
||||||
|
print(f"🚀 Starting calculation for all employees")
|
||||||
|
|
||||||
|
# Get unique employee IDs
|
||||||
|
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']))
|
||||||
|
|
||||||
|
print(f"👥 Found {len(employee_ids)} unique 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)
|
||||||
|
|
||||||
|
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),
|
||||||
|
'employees': results
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error calculating hours for all employees: {e}")
|
||||||
|
import traceback
|
||||||
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
raise e
|
||||||
@@ -68,18 +68,30 @@
|
|||||||
<i class="fas fa-chart-line"></i>
|
<i class="fas fa-chart-line"></i>
|
||||||
<span class="menu-text">Reports</span>
|
<span class="menu-text">Reports</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('payroll_dashboard') }}" class="menu-item">
|
||||||
|
<i class="fas fa-calculator"></i>
|
||||||
|
<span class="menu-text">Payroll</span>
|
||||||
|
</a>
|
||||||
<a href="{{ url_for('admin_logs') }}"
|
<a href="{{ url_for('admin_logs') }}"
|
||||||
class="menu-item {% if request.endpoint == 'admin_logs' %}active{% endif %}">
|
class="menu-item {% if request.endpoint == 'admin_logs' %}active{% endif %}">
|
||||||
<i class="fas fa-clipboard-list"></i>
|
<i class="fas fa-clipboard-list"></i>
|
||||||
<span class="menu-text">System Logs</span>
|
<span class="menu-text">System Logs</span>
|
||||||
</a>
|
</a>
|
||||||
{% elif session.role in ['payroll', 'project_manager'] %}
|
{% elif session.role in ['payroll'] %}
|
||||||
|
<a href="{{ url_for('attendance_report') }}" class="menu-item">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
<span class="menu-text">Reports</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('payroll_dashboard') }}" class="menu-item">
|
||||||
|
<i class="fas fa-calculator"></i>
|
||||||
|
<span class="menu-text">Payroll</span>
|
||||||
|
</a>
|
||||||
|
{% elif session.role in ['project_manager'] %}
|
||||||
<a href="{{ url_for('attendance_report') }}" class="menu-item">
|
<a href="{{ url_for('attendance_report') }}" class="menu-item">
|
||||||
<i class="fas fa-chart-line"></i>
|
<i class="fas fa-chart-line"></i>
|
||||||
<span class="menu-text">Reports</span>
|
<span class="menu-text">Reports</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a href="{{ url_for('profile') }}" class="menu-item">
|
<a href="{{ url_for('profile') }}" class="menu-item">
|
||||||
<i class="fas fa-user"></i>
|
<i class="fas fa-user"></i>
|
||||||
<span class="menu-text">Profile</span>
|
<span class="menu-text">Profile</span>
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
{% extends "base_authenticated.html" %}
|
||||||
|
{% block title %}Payroll Dashboard - Working Hours{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.payroll-page {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payroll-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payroll-header {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payroll-header h1 {
|
||||||
|
color: #2d3748;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payroll-header h1 i {
|
||||||
|
color: #48bb78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-section {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a5568;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4299e1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #4299e1, #3182ce);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: linear-gradient(135deg, #48bb78, #38a169);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: linear-gradient(135deg, #a0aec0, #718096);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-section {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: linear-gradient(135deg, #f7fafc, #edf2f7);
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card:hover {
|
||||||
|
border-color: #4299e1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(66, 153, 225, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card h3 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d3748;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card p {
|
||||||
|
color: #718096;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
background: linear-gradient(135deg, #4299e1, #3182ce);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid #2b6cb0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background-color: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hours-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hours-regular {
|
||||||
|
background: #e6fffa;
|
||||||
|
color: #38a169;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hours-overtime {
|
||||||
|
background: #fff5f5;
|
||||||
|
color: #e53e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hours-total {
|
||||||
|
background: #ebf8ff;
|
||||||
|
color: #3182ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miss-punch {
|
||||||
|
background: #feb2b2;
|
||||||
|
color: #c53030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #a0aec0;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-nav:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.payroll-header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cards {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="payroll-page">
|
||||||
|
<div class="payroll-container">
|
||||||
|
<!-- Back Navigation -->
|
||||||
|
<a href="{{ url_for('dashboard') }}" class="back-nav">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
<span>Back to Dashboard</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="payroll-header">
|
||||||
|
<h1>
|
||||||
|
<i class="fas fa-calculator"></i>
|
||||||
|
Payroll Dashboard
|
||||||
|
</h1>
|
||||||
|
<p>Calculate employee working hours and generate payroll reports</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters Section -->
|
||||||
|
<div class="filters-section">
|
||||||
|
<h3 style="margin-bottom: 1.5rem; color: #2d3748;">
|
||||||
|
<i class="fas fa-filter" style="color: #4299e1; margin-right: 0.5rem;"></i>
|
||||||
|
Calculation Parameters
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form method="GET" action="{{ url_for('payroll_dashboard') }}" class="filters-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="date_from">Start Date</label>
|
||||||
|
<input type="date" id="date_from" name="date_from" value="{{ date_from }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="date_to">End Date</label>
|
||||||
|
<input type="date" id="date_to" name="date_to" value="{{ date_to }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="project_filter">Filter by Project</label>
|
||||||
|
<select id="project_filter" name="project_filter">
|
||||||
|
<option value="">All Projects</option>
|
||||||
|
{% for project in projects %}
|
||||||
|
<option value="{{ project.id }}" {% if project_filter == project.id|string %}selected{% endif %}>
|
||||||
|
{{ project.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-calculator"></i>
|
||||||
|
Calculate Hours
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<input type="checkbox" id="include_travel_time" name="include_travel_time"
|
||||||
|
value="true" {% if include_travel_time %}checked{% endif %}>
|
||||||
|
<label for="include_travel_time">Include travel time between locations</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Section -->
|
||||||
|
{% if working_hours_data %}
|
||||||
|
<div class="results-section">
|
||||||
|
<h3 style="margin-bottom: 1.5rem; color: #2d3748;">
|
||||||
|
<i class="fas fa-chart-bar" style="color: #48bb78; margin-right: 0.5rem;"></i>
|
||||||
|
Working Hours Summary
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="summary-cards">
|
||||||
|
<div class="summary-card">
|
||||||
|
<h3>{{ working_hours_data.employee_count }}</h3>
|
||||||
|
<p>Total Employees</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<h3>{{ "%.1f"|format(working_hours_data.employees.values() | map(attribute='grand_totals.total_hours') | sum) }}</h3>
|
||||||
|
<p>Total Hours</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<h3>{{ "%.1f"|format(working_hours_data.employees.values() | map(attribute='grand_totals.regular_hours') | sum) }}</h3>
|
||||||
|
<p>Regular Hours</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<h3>{{ "%.1f"|format(working_hours_data.employees.values() | map(attribute='grand_totals.overtime_hours') | sum) }}</h3>
|
||||||
|
<p>Overtime Hours</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Employee Details Table -->
|
||||||
|
<div class="employees-table">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Employee ID</th>
|
||||||
|
<th>Employee Name</th>
|
||||||
|
<th>Total Hours</th>
|
||||||
|
<th>Regular Hours</th>
|
||||||
|
<th>Overtime Hours</th>
|
||||||
|
<th>Working Days</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for employee_id, emp_data in working_hours_data.employees.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ employee_id }}</td>
|
||||||
|
<td>{{ employee_names.get(employee_id, 'Employee ' + employee_id) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="hours-badge hours-total">
|
||||||
|
{{ "%.2f"|format(emp_data.grand_totals.total_hours) }} hrs
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="hours-badge hours-regular">
|
||||||
|
{{ "%.2f"|format(emp_data.grand_totals.regular_hours) }} hrs
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if emp_data.grand_totals.overtime_hours > 0 %}
|
||||||
|
<span class="hours-badge hours-overtime">
|
||||||
|
{{ "%.2f"|format(emp_data.grand_totals.overtime_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
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set miss_punches = emp_data.daily_hours.values() | selectattr('is_miss_punch', 'equalto', true) | list | length %}
|
||||||
|
{% if miss_punches > 0 %}
|
||||||
|
<span class="hours-badge miss-punch">
|
||||||
|
{{ miss_punches }} miss punch(es)
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="hours-badge" style="background: #e6fffa; color: #38a169;">
|
||||||
|
Complete
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export Actions -->
|
||||||
|
<div class="export-actions">
|
||||||
|
<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="include_travel_time" value="{{ include_travel_time|lower }}">
|
||||||
|
<input type="hidden" name="report_type" value="payroll">
|
||||||
|
<button type="submit" class="btn btn-success">
|
||||||
|
<i class="fas fa-file-excel"></i>
|
||||||
|
Export Payroll Report
|
||||||
|
</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="include_travel_time" value="{{ include_travel_time|lower }}">
|
||||||
|
<input type="hidden" name="report_type" value="detailed">
|
||||||
|
<button type="submit" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-list-alt"></i>
|
||||||
|
Export Detailed Hours
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 1rem; padding: 1rem; background: #f7fafc; border-radius: 8px; color: #4a5568; font-size: 0.9rem;">
|
||||||
|
<strong>Report Details:</strong><br>
|
||||||
|
Period: {{ working_hours_data.period_start }} to {{ working_hours_data.period_end }}<br>
|
||||||
|
Travel Time: {{ "Included" if working_hours_data.include_travel_time else "Excluded" }}<br>
|
||||||
|
Generated: {{ working_hours_data.calculation_date }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="results-section">
|
||||||
|
<div class="no-data">
|
||||||
|
<i class="fas fa-clock" style="font-size: 3rem; margin-bottom: 1rem; opacity: 0.5;"></i><br>
|
||||||
|
{% if date_from and date_to %}
|
||||||
|
No attendance records found for the selected date range.<br>
|
||||||
|
Please try a different date range or check if attendance data has been recorded.
|
||||||
|
{% else %}
|
||||||
|
Please select a date range to calculate working hours.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Auto-submit form when travel time checkbox changes
|
||||||
|
const travelTimeCheckbox = document.getElementById('include_travel_time');
|
||||||
|
if (travelTimeCheckbox) {
|
||||||
|
travelTimeCheckbox.addEventListener('change', function() {
|
||||||
|
// Only auto-submit if we have dates
|
||||||
|
const dateFrom = document.getElementById('date_from').value;
|
||||||
|
const dateTo = document.getElementById('date_to').value;
|
||||||
|
|
||||||
|
if (dateFrom && dateTo) {
|
||||||
|
this.form.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set max date to today for date inputs
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
document.getElementById('date_from').setAttribute('max', today);
|
||||||
|
document.getElementById('date_to').setAttribute('max', today);
|
||||||
|
|
||||||
|
// Validate date range
|
||||||
|
const dateFromInput = document.getElementById('date_from');
|
||||||
|
const dateToInput = document.getElementById('date_to');
|
||||||
|
|
||||||
|
function validateDateRange() {
|
||||||
|
const dateFrom = new Date(dateFromInput.value);
|
||||||
|
const dateTo = new Date(dateToInput.value);
|
||||||
|
|
||||||
|
if (dateFrom && dateTo && dateFrom > dateTo) {
|
||||||
|
dateToInput.setCustomValidity('End date must be after start date');
|
||||||
|
} else {
|
||||||
|
dateToInput.setCustomValidity('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dateFromInput.addEventListener('change', validateDateRange);
|
||||||
|
dateToInput.addEventListener('change', validateDateRange);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Working Hours Calculator for Employee Payroll
|
||||||
|
============================================
|
||||||
|
|
||||||
|
This module implements the working hours calculation logic
|
||||||
|
based on the Java files provided. It handles:
|
||||||
|
- Daily time calculations with travel time options
|
||||||
|
- Weekly regular and overtime hours
|
||||||
|
- Record pairing (check-in/check-out)
|
||||||
|
- Missing punch detection
|
||||||
|
- Quarter-hour rounding
|
||||||
|
|
||||||
|
Based on the Java classes:
|
||||||
|
- DailyTimeCalculator.java
|
||||||
|
- WeeklyTimeCalculator.java
|
||||||
|
- PayrollReport.java
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, time
|
||||||
|
from typing import List, Dict, Optional, Tuple, Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import math
|
||||||
|
from logger_handler import log_database_operations
|
||||||
|
|
||||||
|
# Constants from Java implementation
|
||||||
|
TRAVEL_TIME_MAX_MINUTES = 60
|
||||||
|
RECORD_GROUPING_MAX_MINUTES = 60 * 6 # 6 hours
|
||||||
|
MAX_REGULAR_TIME_MINUTES = 60 * 40 # 40 hours per week
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AttendanceRecord:
|
||||||
|
"""Represents a single attendance record"""
|
||||||
|
id: int
|
||||||
|
employee_id: str
|
||||||
|
check_in_date: datetime
|
||||||
|
check_in_time: time
|
||||||
|
location_name: str
|
||||||
|
record_type: str = 'check_in' # 'check_in' or 'check_out'
|
||||||
|
timestamp: datetime = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.timestamp is None:
|
||||||
|
# Combine date and time for timestamp
|
||||||
|
self.timestamp = datetime.combine(self.check_in_date, self.check_in_time)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RecordPair:
|
||||||
|
"""Represents a paired check-in/check-out record"""
|
||||||
|
check_in: Optional[AttendanceRecord]
|
||||||
|
check_out: Optional[AttendanceRecord]
|
||||||
|
is_miss_punch: bool = False
|
||||||
|
date: datetime = None
|
||||||
|
location: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.check_in:
|
||||||
|
self.date = self.check_in.check_in_date
|
||||||
|
self.location = self.check_in.location_name
|
||||||
|
elif self.check_out:
|
||||||
|
self.date = self.check_out.check_in_date
|
||||||
|
self.location = self.check_out.location_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_minutes(self) -> int:
|
||||||
|
"""Calculate duration in minutes between check-in and check-out"""
|
||||||
|
if self.is_miss_punch or not self.check_in or not self.check_out:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
duration = self.check_out.timestamp - self.check_in.timestamp
|
||||||
|
return int(duration.total_seconds() / 60)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeCalculator:
|
||||||
|
"""Base time calculator with rounding functionality"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def round_time_to_nearest_quarter_hour(minutes: int) -> int:
|
||||||
|
"""Round time to nearest quarter hour (15 minutes)"""
|
||||||
|
if minutes < 0:
|
||||||
|
return minutes # Keep negative values for miss punches
|
||||||
|
|
||||||
|
# Round to nearest 15-minute interval
|
||||||
|
return round(minutes / 15) * 15
|
||||||
|
|
||||||
|
|
||||||
|
class DailyTimeCalculator(TimeCalculator):
|
||||||
|
"""Calculate daily working hours with travel time options"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.record_pairs: List[RecordPair] = []
|
||||||
|
|
||||||
|
def add_record_pair(self, pair: RecordPair):
|
||||||
|
"""Add a record pair to the daily calculation"""
|
||||||
|
self.record_pairs.append(pair)
|
||||||
|
|
||||||
|
def get_minutes_total_include_travel_time(self) -> int:
|
||||||
|
"""Calculate total minutes including travel time between locations"""
|
||||||
|
minute_total = 0
|
||||||
|
grouped_pairs = self._group_record_pairs(self.record_pairs)
|
||||||
|
|
||||||
|
for group in grouped_pairs:
|
||||||
|
group_minutes = self._get_minutes_per_record_pair_group(group)
|
||||||
|
if group_minutes < 0:
|
||||||
|
return -1 # Miss punch detected
|
||||||
|
minute_total += group_minutes
|
||||||
|
|
||||||
|
return self.round_time_to_nearest_quarter_hour(minute_total)
|
||||||
|
|
||||||
|
def get_minutes_total_exclude_travel_time(self) -> int:
|
||||||
|
"""Calculate total minutes excluding travel time"""
|
||||||
|
minute_total = 0
|
||||||
|
|
||||||
|
for pair in self.record_pairs:
|
||||||
|
if not pair.is_miss_punch:
|
||||||
|
minute_total += pair.duration_minutes
|
||||||
|
else:
|
||||||
|
return -1 # Miss punch detected
|
||||||
|
|
||||||
|
return self.round_time_to_nearest_quarter_hour(minute_total)
|
||||||
|
|
||||||
|
def _group_record_pairs(self, record_pairs: List[RecordPair]) -> List[List[RecordPair]]:
|
||||||
|
"""Group record pairs based on time gaps (similar to Java implementation)"""
|
||||||
|
if not record_pairs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Sort pairs by time
|
||||||
|
sorted_pairs = sorted(record_pairs, key=lambda p: p.check_in.timestamp if p.check_in else p.check_out.timestamp)
|
||||||
|
|
||||||
|
all_groups = []
|
||||||
|
current_group = [sorted_pairs[0]]
|
||||||
|
|
||||||
|
for i in range(1, len(sorted_pairs)):
|
||||||
|
prev_pair = sorted_pairs[i-1]
|
||||||
|
current_pair = sorted_pairs[i]
|
||||||
|
|
||||||
|
# Calculate time gap
|
||||||
|
if prev_pair.check_out and current_pair.check_in:
|
||||||
|
gap_minutes = (current_pair.check_in.timestamp - prev_pair.check_out.timestamp).total_seconds() / 60
|
||||||
|
|
||||||
|
if gap_minutes > TRAVEL_TIME_MAX_MINUTES:
|
||||||
|
# Start new group
|
||||||
|
all_groups.append(current_group)
|
||||||
|
current_group = [current_pair]
|
||||||
|
else:
|
||||||
|
current_group.append(current_pair)
|
||||||
|
else:
|
||||||
|
current_group.append(current_pair)
|
||||||
|
|
||||||
|
all_groups.append(current_group)
|
||||||
|
return all_groups
|
||||||
|
|
||||||
|
def _get_minutes_per_record_pair_group(self, group: List[RecordPair]) -> int:
|
||||||
|
"""Calculate minutes for a group of record pairs with travel time"""
|
||||||
|
if not group:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Check for miss punches
|
||||||
|
for pair in group:
|
||||||
|
if pair.is_miss_punch:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Find overall start and end times for the group
|
||||||
|
start_time = min(pair.check_in.timestamp for pair in group if pair.check_in)
|
||||||
|
end_time = max(pair.check_out.timestamp for pair in group if pair.check_out)
|
||||||
|
|
||||||
|
duration = end_time - start_time
|
||||||
|
return int(duration.total_seconds() / 60)
|
||||||
|
|
||||||
|
|
||||||
|
class WeeklyTimeCalculator(TimeCalculator):
|
||||||
|
"""Calculate weekly regular and overtime hours"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.daily_calculators: List[DailyTimeCalculator] = []
|
||||||
|
self.total_minutes = 0
|
||||||
|
self.regular_minutes = 0
|
||||||
|
self.overtime_minutes = 0
|
||||||
|
self.include_travel_time = True # Default setting
|
||||||
|
|
||||||
|
def add_daily_calculator(self, daily_calc: DailyTimeCalculator):
|
||||||
|
"""Add a daily time calculator to the weekly calculation"""
|
||||||
|
self.daily_calculators.append(daily_calc)
|
||||||
|
|
||||||
|
def set_include_travel_time(self, include: bool):
|
||||||
|
"""Set whether to include travel time in calculations"""
|
||||||
|
self.include_travel_time = include
|
||||||
|
|
||||||
|
def calculate_time(self):
|
||||||
|
"""Calculate weekly totals with regular and overtime split"""
|
||||||
|
self.total_minutes = 0
|
||||||
|
|
||||||
|
for daily_calc in self.daily_calculators:
|
||||||
|
if self.include_travel_time:
|
||||||
|
daily_minutes = daily_calc.get_minutes_total_include_travel_time()
|
||||||
|
else:
|
||||||
|
daily_minutes = daily_calc.get_minutes_total_exclude_travel_time()
|
||||||
|
|
||||||
|
if daily_minutes > 0:
|
||||||
|
self.total_minutes += daily_minutes
|
||||||
|
|
||||||
|
# Calculate regular and overtime
|
||||||
|
if self.total_minutes > MAX_REGULAR_TIME_MINUTES:
|
||||||
|
self.regular_minutes = MAX_REGULAR_TIME_MINUTES
|
||||||
|
else:
|
||||||
|
self.regular_minutes = self.total_minutes
|
||||||
|
|
||||||
|
self.overtime_minutes = self.total_minutes - self.regular_minutes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_hours(self) -> float:
|
||||||
|
"""Get total hours as decimal"""
|
||||||
|
return self.total_minutes / 60.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def regular_hours(self) -> float:
|
||||||
|
"""Get regular hours as decimal"""
|
||||||
|
return self.regular_minutes / 60.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def overtime_hours(self) -> float:
|
||||||
|
"""Get overtime hours as decimal"""
|
||||||
|
return self.overtime_minutes / 60.0
|
||||||
|
|
||||||
|
|
||||||
|
class RecordPairBuilder:
|
||||||
|
"""Build record pairs from attendance records"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_pairs_from_records(records: List[AttendanceRecord]) -> List[RecordPair]:
|
||||||
|
"""Build check-in/check-out pairs from attendance records"""
|
||||||
|
if not records:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Sort records by timestamp
|
||||||
|
sorted_records = sorted(records, key=lambda r: r.timestamp)
|
||||||
|
pairs = []
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(sorted_records):
|
||||||
|
current_record = sorted_records[i]
|
||||||
|
|
||||||
|
# Look for matching check-out record
|
||||||
|
check_out_record = None
|
||||||
|
if i + 1 < len(sorted_records):
|
||||||
|
next_record = sorted_records[i + 1]
|
||||||
|
# Simple pairing: assume alternating check-in/check-out
|
||||||
|
if current_record.record_type == 'check_in' and next_record.record_type == 'check_out':
|
||||||
|
check_out_record = next_record
|
||||||
|
i += 2 # Skip both records
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Create pair
|
||||||
|
if current_record.record_type == 'check_in':
|
||||||
|
pair = RecordPair(
|
||||||
|
check_in=current_record,
|
||||||
|
check_out=check_out_record,
|
||||||
|
is_miss_punch=(check_out_record is None)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Orphaned check-out
|
||||||
|
pair = RecordPair(
|
||||||
|
check_in=None,
|
||||||
|
check_out=current_record,
|
||||||
|
is_miss_punch=True
|
||||||
|
)
|
||||||
|
|
||||||
|
pairs.append(pair)
|
||||||
|
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
class WorkingHoursCalculator:
|
||||||
|
"""Main calculator for employee working hours"""
|
||||||
|
|
||||||
|
def __init__(self, include_travel_time: bool = True):
|
||||||
|
self.include_travel_time = include_travel_time
|
||||||
|
|
||||||
|
@log_database_operations('working_hours_calculation')
|
||||||
|
def calculate_employee_hours(self, employee_id: str, start_date: datetime, end_date: datetime,
|
||||||
|
attendance_records: List[Dict]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Calculate working hours for an employee over a date range
|
||||||
|
|
||||||
|
Args:
|
||||||
|
employee_id: Employee ID
|
||||||
|
start_date: Start date for calculation
|
||||||
|
end_date: End date for calculation
|
||||||
|
attendance_records: List of attendance records from database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing daily and weekly hour calculations
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Convert database records to AttendanceRecord objects
|
||||||
|
records = []
|
||||||
|
for record in attendance_records:
|
||||||
|
# Handle both dictionary and SQLAlchemy object formats
|
||||||
|
if hasattr(record, '__dict__'):
|
||||||
|
# SQLAlchemy object
|
||||||
|
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:
|
||||||
|
# Dictionary
|
||||||
|
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']
|
||||||
|
|
||||||
|
if emp_id == employee_id:
|
||||||
|
att_record = AttendanceRecord(
|
||||||
|
id=record_id,
|
||||||
|
employee_id=emp_id,
|
||||||
|
check_in_date=date_val,
|
||||||
|
check_in_time=time_val,
|
||||||
|
location_name=location,
|
||||||
|
record_type='check_in' # Default, could be enhanced
|
||||||
|
)
|
||||||
|
records.append(att_record)
|
||||||
|
|
||||||
|
# Group records by date
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Calculate daily hours
|
||||||
|
daily_hours = {}
|
||||||
|
weekly_calculators = []
|
||||||
|
|
||||||
|
current_date = start_date
|
||||||
|
while current_date <= end_date:
|
||||||
|
date_key = current_date.strftime('%Y-%m-%d')
|
||||||
|
day_records = daily_records.get(date_key, [])
|
||||||
|
|
||||||
|
daily_calc = DailyTimeCalculator()
|
||||||
|
|
||||||
|
if day_records:
|
||||||
|
# Build record pairs
|
||||||
|
pairs = RecordPairBuilder.build_pairs_from_records(day_records)
|
||||||
|
for pair in pairs:
|
||||||
|
daily_calc.add_record_pair(pair)
|
||||||
|
|
||||||
|
# Calculate daily totals
|
||||||
|
if self.include_travel_time:
|
||||||
|
total_minutes = daily_calc.get_minutes_total_include_travel_time()
|
||||||
|
else:
|
||||||
|
total_minutes = daily_calc.get_minutes_total_exclude_travel_time()
|
||||||
|
|
||||||
|
daily_hours[date_key] = {
|
||||||
|
'total_minutes': total_minutes,
|
||||||
|
'total_hours': total_minutes / 60.0 if total_minutes > 0 else 0,
|
||||||
|
'is_miss_punch': total_minutes < 0,
|
||||||
|
'records_count': len(day_records)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add to weekly calculator (group by week)
|
||||||
|
if current_date.weekday() == 0: # Monday - start new week
|
||||||
|
weekly_calc = WeeklyTimeCalculator()
|
||||||
|
weekly_calc.set_include_travel_time(self.include_travel_time)
|
||||||
|
weekly_calculators.append(weekly_calc)
|
||||||
|
|
||||||
|
if weekly_calculators:
|
||||||
|
weekly_calculators[-1].add_daily_calculator(daily_calc)
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
# Calculate weekly totals
|
||||||
|
weekly_hours = []
|
||||||
|
for week_calc in weekly_calculators:
|
||||||
|
week_calc.calculate_time()
|
||||||
|
weekly_hours.append({
|
||||||
|
'total_hours': week_calc.total_hours,
|
||||||
|
'regular_hours': week_calc.regular_hours,
|
||||||
|
'overtime_hours': week_calc.overtime_hours,
|
||||||
|
'total_minutes': week_calc.total_minutes,
|
||||||
|
'regular_minutes': week_calc.regular_minutes,
|
||||||
|
'overtime_minutes': week_calc.overtime_minutes
|
||||||
|
})
|
||||||
|
|
||||||
|
# Calculate grand totals
|
||||||
|
grand_total_hours = sum(week['total_hours'] for week in weekly_hours)
|
||||||
|
grand_regular_hours = sum(week['regular_hours'] for week in weekly_hours)
|
||||||
|
grand_overtime_hours = sum(week['overtime_hours'] for week in weekly_hours)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'employee_id': employee_id,
|
||||||
|
'start_date': start_date.strftime('%Y-%m-%d'),
|
||||||
|
'end_date': end_date.strftime('%Y-%m-%d'),
|
||||||
|
'include_travel_time': self.include_travel_time,
|
||||||
|
'daily_hours': daily_hours,
|
||||||
|
'weekly_hours': weekly_hours,
|
||||||
|
'grand_totals': {
|
||||||
|
'total_hours': grand_total_hours,
|
||||||
|
'regular_hours': grand_regular_hours,
|
||||||
|
'overtime_hours': grand_overtime_hours,
|
||||||
|
'total_minutes': int(grand_total_hours * 60),
|
||||||
|
'regular_minutes': int(grand_regular_hours * 60),
|
||||||
|
'overtime_minutes': int(grand_overtime_hours * 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error calculating working hours for employee {employee_id}: {e}")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def calculate_all_employees_hours(self, start_date: datetime, end_date: datetime,
|
||||||
|
attendance_records: List[Dict]) -> Dict[str, Any]:
|
||||||
|
"""Calculate working hours for all employees in the given period"""
|
||||||
|
try:
|
||||||
|
# Get unique employee IDs
|
||||||
|
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']))
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for emp_id in employee_ids:
|
||||||
|
results[emp_id] = self.calculate_employee_hours(emp_id, start_date, end_date, attendance_records)
|
||||||
|
|
||||||
|
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': self.include_travel_time,
|
||||||
|
'employee_count': len(employee_ids),
|
||||||
|
'employees': results
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error calculating hours for all employees: {e}")
|
||||||
|
raise e
|
||||||
Reference in New Issue
Block a user