04/08 Recalculation of overnight from 12 PM - 3 AM

This commit is contained in:
2026-04-08 17:39:17 -04:00
parent 6e1c9bbc7d
commit 8ad5c4b047
5 changed files with 38 additions and 46 deletions
+2 -1
View File
@@ -7,6 +7,7 @@ This model interfaces with the existing employee table structure.
""" """
from datetime import datetime from datetime import datetime
from sqlalchemy import cast, String
from . import base from . import base
class Employee(base.db.Model): class Employee(base.db.Model):
@@ -54,7 +55,7 @@ class Employee(base.db.Model):
cls.firstName.like(search_pattern), cls.firstName.like(search_pattern),
cls.lastName.like(search_pattern), cls.lastName.like(search_pattern),
cls.title.like(search_pattern), cls.title.like(search_pattern),
cls.id.like(search_pattern) cast(cls.id, String).like(search_pattern)
) )
).all() ).all()
+4
View File
@@ -70,6 +70,7 @@ def dashboard():
search_status=search_status) search_status=search_status)
except Exception as e: except Exception as e:
db.session.rollback()
logger_handler.log_database_error('dashboard_load', e) logger_handler.log_database_error('dashboard_load', e)
flash('Error loading dashboard. Please try again.', 'error') flash('Error loading dashboard. Please try again.', 'error')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@@ -125,6 +126,7 @@ def project_qr_codes(project_id):
search_status=search_status) search_status=search_status)
except Exception as e: except Exception as e:
db.session.rollback()
logger_handler.log_database_error('project_qr_codes_view', e) logger_handler.log_database_error('project_qr_codes_view', e)
flash('Error loading project QR codes. Please try again.', 'error') flash('Error loading project QR codes. Please try again.', 'error')
return redirect(url_for('dashboard.dashboard')) return redirect(url_for('dashboard.dashboard'))
@@ -197,6 +199,7 @@ def dashboard_stats_api():
}) })
except Exception as e: except Exception as e:
db.session.rollback()
logger_handler.log_database_error('dashboard_stats_api', e) logger_handler.log_database_error('dashboard_stats_api', e)
return jsonify({ return jsonify({
'success': False, 'success': False,
@@ -235,6 +238,7 @@ def dashboard_realtime_api():
}) })
except Exception as e: except Exception as e:
db.session.rollback()
logger_handler.log_database_error('dashboard_realtime_api', e) logger_handler.log_database_error('dashboard_realtime_api', e)
return jsonify({ return jsonify({
'success': False, 'success': False,
+5 -3
View File
@@ -163,6 +163,7 @@ def payroll_dashboard():
user_role=user_role) user_role=user_role)
except Exception as e: except Exception as e:
db.session.rollback()
logger_handler.logger.error(f"Error loading payroll dashboard: {e}", exc_info=True) logger_handler.logger.error(f"Error loading payroll dashboard: {e}", exc_info=True)
logger_handler.log_flask_error( logger_handler.log_flask_error(
@@ -400,6 +401,7 @@ def export_payroll_excel():
return redirect(url_for('payroll.payroll_dashboard')) return redirect(url_for('payroll.payroll_dashboard'))
except Exception as e: except Exception as e:
db.session.rollback()
logger_handler.logger.error(f"Error in export_payroll_excel route: {e}", exc_info=True) logger_handler.logger.error(f"Error in export_payroll_excel route: {e}", exc_info=True)
logger_handler.log_flask_error( logger_handler.log_flask_error(
@@ -476,6 +478,7 @@ def calculate_working_hours_api():
}) })
except Exception as e: except Exception as e:
db.session.rollback()
logger_handler.logger.error(f"Error in calculate_working_hours_api: {e}", exc_info=True) logger_handler.logger.error(f"Error in calculate_working_hours_api: {e}", exc_info=True)
logger_handler.log_flask_error( logger_handler.log_flask_error(
'working_hours_api_error', 'working_hours_api_error',
@@ -637,6 +640,7 @@ def get_miss_punch_details(employee_id):
}) })
except Exception as e: except Exception as e:
db.session.rollback()
logger_handler.logger.error(f"Error in get_miss_punch_details: {e}", exc_info=True) logger_handler.logger.error(f"Error in get_miss_punch_details: {e}", exc_info=True)
logger_handler.log_flask_error( logger_handler.log_flask_error(
@@ -648,6 +652,4 @@ def get_miss_punch_details(employee_id):
return jsonify({ return jsonify({
'success': False, 'success': False,
'message': 'Internal server error. Please check the server logs.' 'message': 'Internal server error. Please check the server logs.'
}), 500 }), 500
+2 -6
View File
@@ -200,6 +200,7 @@ def qr_statistics():
today_date=datetime.now().strftime('%Y-%m-%d')) today_date=datetime.now().strftime('%Y-%m-%d'))
except Exception as e: except Exception as e:
db.session.rollback()
# Log the error using the correct method # Log the error using the correct method
logger_handler.log_database_error('statistics_page_error', e) logger_handler.log_database_error('statistics_page_error', e)
flash('Error loading statistics. Please try again.', 'error') flash('Error loading statistics. Please try again.', 'error')
@@ -293,13 +294,8 @@ def export_statistics():
return response return response
except Exception as e: except Exception as e:
db.session.rollback()
logger_handler.log_database_error('statistics_export_error', e) logger_handler.log_database_error('statistics_export_error', e)
return jsonify({'error': 'Export failed'}), 500 return jsonify({'error': 'Export failed'}), 500
except Exception as e:
# Log the error
logger_handler.log_database_error('statistics_page_error', e)
flash('Error loading statistics. Please try again.', 'error')
return redirect(url_for('dashboard.dashboard'))
# EMPLOYEE MANAGEMENT ROUTES # EMPLOYEE MANAGEMENT ROUTES
+25 -36
View File
@@ -11,9 +11,9 @@ Contains:
- export_time_attendance_excel() (single-employee / all-employees) - export_time_attendance_excel() (single-employee / all-employees)
- export_time_attendance_by_building_excel() - export_time_attendance_by_building_excel()
""" """
from flask import Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, Response, g, current_app, url_for from flask import send_file, g, current_app
from datetime import datetime, date, timedelta, time from datetime import datetime, date, timedelta, time
import io, os, json, re, uuid, traceback import io, os, json, re
import time as _time import time as _time
from extensions import db, logger_handler from extensions import db, logger_handler
@@ -21,19 +21,8 @@ from models.employee import Employee
from models.project import Project from models.project import Project
from models.qrcode import QRCode from models.qrcode import QRCode
from models.time_attendance import TimeAttendance from models.time_attendance import TimeAttendance
from models.user import User
from sqlalchemy import text from sqlalchemy import text
from werkzeug.utils import secure_filename
from logger_handler import log_user_activity, log_database_operations
from utils.helpers import (
admin_required,
has_admin_privileges,
has_staff_level_access,
login_required,
staff_or_admin_required)
from utils.geocoding import calculate_location_accuracy_enhanced
from working_hours_calculator import WorkingHoursCalculator, round_time_to_quarter_hour, convert_minutes_to_base100, round_base100_hours from working_hours_calculator import WorkingHoursCalculator, round_time_to_quarter_hour, convert_minutes_to_base100, round_base100_hours
from time_attendance_import_service import TimeAttendanceImportService
import openpyxl import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side, numbers from openpyxl.styles import Font, PatternFill, Alignment, Border, Side, numbers
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
@@ -506,10 +495,10 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
_day_outs_non_early = [r for r in _day_outs if r.check_in_time.hour > 3] _day_outs_non_early = [r for r in _day_outs if r.check_in_time.hour > 3]
# Day N must have an unmatched late check-in (more INs than non-early OUTs, # Day N must have an unmatched late check-in (more INs than non-early OUTs,
# with at least one IN at or after 20:00) # with at least one IN at or after 12:00 PM)
if len(_day_ins) <= len(_day_outs_non_early): if len(_day_ins) <= len(_day_outs_non_early):
continue continue
_late_ins = [r for r in _day_ins if r.check_in_time.hour >= 19] _late_ins = [r for r in _day_ins if r.check_in_time.hour >= 12]
if not _late_ins: if not _late_ins:
continue continue
@@ -519,22 +508,22 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
continue continue
# Determine whether the early OUT belongs to Day N or Day N+1. # Determine whether the early OUT belongs to Day N or Day N+1.
# It belongs to Day N when Day N+1 has no non-evening (< 18:00) check-in # It belongs to Day N when Day N+1 has no morning (< 12:00) check-in
# that could own it, OR when OUTs outnumber INs on Day N+1. # that could own it, OR when OUTs outnumber INs on Day N+1.
# This handles both cases: # This handles both cases:
# Case A: Day N+1 has only evening INs (all >= 18:00) -> early OUT is Day N's # Case A: Day N+1 has only afternoon/evening INs (all >= 12:00) -> early OUT is Day N's
# Case B: Day N+1 has more OUTs than INs overall -> early OUT is unmatched # Case B: Day N+1 has more OUTs than INs overall -> early OUT is unmatched
# A non-evening IN on Day N+1 can only own an early OUT when that IN # A morning IN on Day N+1 can only own an early OUT when that IN
# occurs STRICTLY BEFORE the early OUT's time (IN → OUT is time-ordered). # occurs STRICTLY BEFORE the early OUT's time (IN → OUT is time-ordered).
# An IN that starts AFTER the early OUT cannot own it and must NOT block # An IN that starts AFTER the early OUT cannot own it and must NOT block
# the overnight move (e.g. 01:55 AM IN cannot own a 01:00 AM OUT). # the overnight move (e.g. 01:55 AM IN cannot own a 01:00 AM OUT).
_nxt_non_evening_ins = [ _nxt_non_evening_ins = [
r for r in _nxt_ins r for r in _nxt_ins
if r.check_in_time.hour < 18 if r.check_in_time.hour < 12
and any(r.check_in_time < eo.check_in_time for eo in _early_outs) and any(r.check_in_time < eo.check_in_time for eo in _early_outs)
] ]
if _nxt_non_evening_ins and len(_nxt_outs) <= len(_nxt_ins): if _nxt_non_evening_ins and len(_nxt_outs) <= len(_nxt_ins):
# Day N+1 has a non-evening IN that can own the early OUT, and counts # Day N+1 has a morning IN that can own the early OUT, and counts
# are balanced -> do NOT move # are balanced -> do NOT move
continue continue
@@ -649,7 +638,7 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
_in_t_d = _in_r.check_in_time _in_t_d = _in_r.check_in_time
_out_t_d = _out_r.check_in_time _out_t_d = _out_r.check_in_time
if _out_t_d.hour <= 3: if _out_t_d.hour <= 3:
if _in_t_d.hour < 18: if _in_t_d.hour < 12:
continue continue
# Orphan guard: an early-morning OUT whose check_in_date # Orphan guard: an early-morning OUT whose check_in_date
# matches the current day is an orphan from the PREVIOUS # matches the current day is an orphan from the PREVIOUS
@@ -709,7 +698,7 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
_cb_in_t = _cb_in.check_in_time _cb_in_t = _cb_in.check_in_time
_cb_out_t = _cb_out.check_in_time _cb_out_t = _cb_out.check_in_time
if _cb_out_t.hour <= 3: if _cb_out_t.hour <= 3:
if _cb_in_t.hour < 18: if _cb_in_t.hour < 12:
continue continue
# Orphan guard: same-day early-morning OUT is from previous # Orphan guard: same-day early-morning OUT is from previous
# overnight shift — skip it. Only moved OUTs (check_in_date # overnight shift — skip it. Only moved OUTs (check_in_date
@@ -924,15 +913,15 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
if out_ri['used']: if out_ri['used']:
continue continue
# Guard: time-only pairing rule. # Guard: time-only pairing rule.
# An early-morning OUT (hour<=3) is only valid for an evening IN (hour>=18). # An early-morning OUT (hour<=3) is only valid for an afternoon/evening IN (hour>=12).
# For all other OUTs, the OUT time must be strictly after the IN time. # For all other OUTs, the OUT time must be strictly after the IN time.
# Using time-only (not datetime) avoids false positives from moved overnight # Using time-only (not datetime) avoids false positives from moved overnight
# OUT records whose check_in_date is still a later date. # OUT records whose check_in_date is still a later date.
_in_t = in_ri['record'].check_in_time _in_t = in_ri['record'].check_in_time
_out_t = out_ri['record'].check_in_time _out_t = out_ri['record'].check_in_time
if _out_t.hour <= 3: if _out_t.hour <= 3:
if _in_t.hour < 18: if _in_t.hour < 12:
continue # early-morning OUT cannot pair with non-evening IN continue # early-morning OUT cannot pair with morning IN
# Orphan guard: same-day early-morning OUT is from a # Orphan guard: same-day early-morning OUT is from a
# previous overnight shift — not a valid partner for # previous overnight shift — not a valid partner for
# this evening IN. Only moved OUTs (check_in_date # this evening IN. Only moved OUTs (check_in_date
@@ -976,7 +965,7 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
_in_t2 = in_ri['record'].check_in_time _in_t2 = in_ri['record'].check_in_time
_out_t2 = out_ri['record'].check_in_time _out_t2 = out_ri['record'].check_in_time
if _out_t2.hour <= 3: if _out_t2.hour <= 3:
if _in_t2.hour < 18: if _in_t2.hour < 12:
continue continue
# Orphan guard: same-day early-morning OUT is from a # Orphan guard: same-day early-morning OUT is from a
# previous overnight shift — not a valid partner for # previous overnight shift — not a valid partner for
@@ -1585,22 +1574,22 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
# Exclude early-morning OUTs on Day N from the balance check: # Exclude early-morning OUTs on Day N from the balance check:
# they are overnight orphans from Day N-1, not Day N regulars. # they are overnight orphans from Day N-1, not Day N regulars.
_bb_day_outs_non_early = [r for r in _bb_day_outs if r.check_in_time.hour > 3] _bb_day_outs_non_early = [r for r in _bb_day_outs if r.check_in_time.hour > 3]
# Day N must have an unmatched late check-in (>= 19:00) # Day N must have an unmatched late check-in (>= 12:00 PM)
if len(_bb_day_ins) <= len(_bb_day_outs_non_early): if len(_bb_day_ins) <= len(_bb_day_outs_non_early):
continue continue
_bb_late_ins = [r for r in _bb_day_ins if r.check_in_time.hour >= 19] _bb_late_ins = [r for r in _bb_day_ins if r.check_in_time.hour >= 12]
if not _bb_late_ins: if not _bb_late_ins:
continue continue
# Find early-morning OUTs (<= 03:00) on Day N+1 # Find early-morning OUTs (<= 03:00) on Day N+1
_bb_early_outs = [r for r in _bb_nxt_outs if r.check_in_time.hour <= 3] _bb_early_outs = [r for r in _bb_nxt_outs if r.check_in_time.hour <= 3]
if not _bb_early_outs: if not _bb_early_outs:
continue continue
# Non-evening INs guard: do NOT move if Day N+1 has a non-evening # Non-morning INs guard: do NOT move if Day N+1 has a morning
# IN that precedes the early OUT (i.e. it can own the early OUT) # IN (< 12:00) that precedes the early OUT (i.e. it can own the early OUT)
# and the counts are balanced. # and the counts are balanced.
_bb_nxt_non_evening_ins = [ _bb_nxt_non_evening_ins = [
r for r in _bb_nxt_ins r for r in _bb_nxt_ins
if r.check_in_time.hour < 18 if r.check_in_time.hour < 12
and any(r.check_in_time < eo.check_in_time for eo in _bb_early_outs) and any(r.check_in_time < eo.check_in_time for eo in _bb_early_outs)
] ]
if _bb_nxt_non_evening_ins and len(_bb_nxt_outs) <= len(_bb_nxt_ins): if _bb_nxt_non_evening_ins and len(_bb_nxt_outs) <= len(_bb_nxt_ins):
@@ -1697,7 +1686,7 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
latest_in = ins_sorted[-1] latest_in = ins_sorted[-1]
excess_ins = ins_sorted[:-1] excess_ins = ins_sorted[:-1]
# Orphan guard: when the latest IN is evening (>=18h), skip # Orphan guard: when the latest IN is afternoon/evening (>=12h), skip
# early-morning OUTs (<=3h) whose check_in_date matches the # early-morning OUTs (<=3h) whose check_in_date matches the
# current day — they are orphans from a previous overnight shift. # current day — they are orphans from a previous overnight shift.
_oi_in_hour = latest_in['record'].check_in_time.hour _oi_in_hour = latest_in['record'].check_in_time.hour
@@ -1705,7 +1694,7 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
_oi_skip = [] _oi_skip = []
for _oi_ri in outs_sorted: for _oi_ri in outs_sorted:
if (earliest_out is None if (earliest_out is None
and _oi_in_hour >= 18 and _oi_in_hour >= 12
and _oi_ri['record'].check_in_time.hour <= 3): and _oi_ri['record'].check_in_time.hour <= 3):
_oi_out_d = _oi_ri['record'].check_in_date _oi_out_d = _oi_ri['record'].check_in_date
if hasattr(_oi_out_d, 'date'): if hasattr(_oi_out_d, 'date'):
@@ -1752,8 +1741,8 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
if record_info[j]['used']: if record_info[j]['used']:
continue continue
if record_info[j]['is_out']: if record_info[j]['is_out']:
# Orphan guard: when this IN is an evening # Orphan guard: when this IN is an afternoon/evening
# check-in (>=18h) and the candidate OUT is # check-in (>=12h) and the candidate OUT is
# early-morning (<=3h), the OUT is only a # early-morning (<=3h), the OUT is only a
# valid partner if it was moved in by overnight # valid partner if it was moved in by overnight
# detection (check_in_date > current day). # detection (check_in_date > current day).
@@ -1761,7 +1750,7 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
# a previous overnight shift. # a previous overnight shift.
_in_rec = record_info[i]['record'] _in_rec = record_info[i]['record']
_out_rec = record_info[j]['record'] _out_rec = record_info[j]['record']
if (_in_rec.check_in_time.hour >= 18 if (_in_rec.check_in_time.hour >= 12
and _out_rec.check_in_time.hour <= 3): and _out_rec.check_in_time.hour <= 3):
_out_bb_date = _out_rec.check_in_date _out_bb_date = _out_rec.check_in_date
if hasattr(_out_bb_date, 'date'): if hasattr(_out_bb_date, 'date'):