Feb26 2026: updated export to Excel calculation, detect midnight shift

This commit is contained in:
2026-02-26 15:52:09 -05:00
parent 83d0791ac7
commit dc0537023a
3 changed files with 352 additions and 62 deletions
+218 -53
View File
@@ -15,7 +15,7 @@ from dotenv import load_dotenv
from logger_handler import AppLogger, log_user_activity, log_database_operations
from single_checkin_calculator import SingleCheckInCalculator
from working_hours_calculator import WorkingHoursCalculator
from working_hours_calculator import WorkingHoursCalculator, round_time_to_quarter_hour, convert_minutes_to_base100, round_base100_hours
from payroll_excel_exporter import PayrollExcelExporter
from enhanced_payroll_excel_exporter import EnhancedPayrollExcelExporter
from time_attendance_import_service import TimeAttendanceImportService
@@ -9319,6 +9319,42 @@ def calculate_possible_violation(distance_value):
except (ValueError, TypeError):
return 'No'
def _overnight_aware_sort_key(record):
"""
Sort key for attendance records within a single calendar-date bucket.
Problem: when an overnight shift spans midnight, the check-out record's
check_in_time (e.g. 00:01 AM) sorts numerically BEFORE the check-in time
(e.g. 20:00 PM), producing an orphaned OUT followed by an orphaned IN.
Fix: if a record is a check-out AND its time is in the early-morning window
(<= 06:00), treat it as belonging to the *next* logical day by adding 24 h
worth of minutes so it sorts after any same-day evening check-ins.
"""
from datetime import time as _time
t = record.check_in_time
minutes = t.hour * 60 + t.minute if isinstance(t, _time) else 0
action = (record.action_description or '').lower()
is_out = 'out' in action or 'checkout' in action
# Push early-morning check-outs past midnight to end of day order
if is_out and t.hour <= 6:
minutes += 24 * 60
return minutes
def _qtr(decimal_hours: float) -> float:
"""
Round a decimal-hours value to the nearest quarter hour (.00/.25/.50/.75).
Pipeline: decimal hours minutes quarter-hour rounding base-100 quarter rounding.
Examples: 4.03 4.0, 4.08 4.25, 3.87 4.0, 4.16 4.25
Returns 0.0 for negative or zero input.
"""
if decimal_hours <= 0:
return 0.0
minutes = decimal_hours * 60.0
rounded_minutes = round_time_to_quarter_hour(minutes)
base100 = convert_minutes_to_base100(rounded_minutes)
return round_base100_hours(base100)
def export_time_attendance_excel(records, project_name_for_filename, date_range_str, filter_str, start_date_filter=None, end_date_filter=None):
"""Generate Excel export with template format matching the provided template"""
from openpyxl import Workbook
@@ -9590,6 +9626,80 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
daily_location_data[date_key][location_key]['records'].append(record)
# -------------------------------------------------------------------
# OVERNIGHT SHIFT DETECTION
# The midnight check-out record is stored in the DB with the next
# calendar day's date (e.g. checkout at 12:01 AM on Wednesday is
# stored as check_in_date = 2026-02-11). We need to move it into
# Tuesday's bucket so it pairs with the 8 PM check-in.
#
# Condition to move an early-morning checkout from Day N+1 → Day N:
# Day N: more check-ins than check-outs (unmatched late IN)
# Day N+1: more check-outs than check-ins (orphaned early OUT)
# -------------------------------------------------------------------
def _is_out(r):
a = (r.action_description or '').lower()
return 'out' in a or 'checkout' in a
sorted_dk = sorted(daily_location_data.keys())
for _di, _dk in enumerate(sorted_dk):
if _di + 1 >= len(sorted_dk):
continue
_ndk = sorted_dk[_di + 1]
# Must be consecutive calendar days
_dn = datetime.strptime(_dk, '%Y-%m-%d').date()
_dn1 = datetime.strptime(_ndk, '%Y-%m-%d').date()
if (_dn1 - _dn).days != 1:
continue
# Flatten all records for Day N and Day N+1 across locations
_day_recs = [r for loc in daily_location_data[_dk].values() for r in loc['records']]
_next_recs = [r for loc in daily_location_data[_ndk].values() for r in loc['records']]
_day_ins = [r for r in _day_recs if not _is_out(r)]
_day_outs = [r for r in _day_recs if _is_out(r)]
_nxt_ins = [r for r in _next_recs if not _is_out(r)]
_nxt_outs = [r for r in _next_recs if _is_out(r)]
# Day N must have an unmatched late check-in
if len(_day_ins) <= len(_day_outs):
continue
_late_ins = [r for r in _day_ins if r.check_in_time.hour >= 18]
if not _late_ins:
continue
# Day N+1 must have an orphaned early check-out
if len(_nxt_outs) <= len(_nxt_ins):
continue
_early_outs = [r for r in _nxt_outs if r.check_in_time.hour <= 6]
if not _early_outs:
continue
# Move up to as many early OUTs as there are unmatched late INs
_to_move = _early_outs[:len(_late_ins)]
for _co in _to_move:
_co_loc = _co.location_name or 'Unknown Location'
# Add to Day N bucket
if _co_loc not in daily_location_data[_dk]:
daily_location_data[_dk][_co_loc] = {'records': [], 'location_name': _co_loc}
daily_location_data[_dk][_co_loc]['records'].append(_co)
# Remove from Day N+1 bucket
if _ndk in daily_location_data and _co_loc in daily_location_data[_ndk]:
try:
daily_location_data[_ndk][_co_loc]['records'].remove(_co)
except ValueError:
pass
if not daily_location_data[_ndk][_co_loc]['records']:
del daily_location_data[_ndk][_co_loc]
if _ndk in daily_location_data and not daily_location_data[_ndk]:
del daily_location_data[_ndk]
print(f"🌙 [TA Export] Overnight: moved checkout {_co.check_in_time} "
f"from {_ndk}{_dk} for employee {employee_id}")
# -------------------------------------------------------------------
# END OVERNIGHT SHIFT DETECTION
# -------------------------------------------------------------------
# Track weekly hours for overtime calculation
weekly_total_hours = 0
current_week_start = None
@@ -9615,9 +9725,9 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
week_overtime = max(0, weekly_total_hours - 40.0)
ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font
ws.cell(row=current_row, column=8, value=round(weekly_total_hours, 2)).font = bold_font
ws.cell(row=current_row, column=9, value=round(week_regular, 2)).font = bold_font
ws.cell(row=current_row, column=10, value=round(week_overtime, 2)).font = bold_font
ws.cell(row=current_row, column=8, value=_qtr(weekly_total_hours)).font = bold_font
ws.cell(row=current_row, column=9, value=_qtr(week_regular)).font = bold_font
ws.cell(row=current_row, column=10, value=_qtr(week_overtime)).font = bold_font
grand_regular_hours += week_regular
grand_ot_hours += week_overtime
@@ -9634,55 +9744,84 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
total_hours = day_data['total_hours']
is_miss_punch = day_data.get('is_miss_punch', False)
# Calculate total hours for the day (even if miss punch)
# Re-evaluate is_miss_punch from actual records in daily_location_data.
# The overnight detection may have moved a checkout into this day's bucket
# AFTER working_hours_calculator ran, so emp_data may still say
# is_miss_punch=True even though the records now form a valid IN/OUT pair.
if is_miss_punch and total_locations > 0:
_all_recs_check = [r for loc in date_locations.values() for r in loc['records']]
_ins_c = sum(1 for r in _all_recs_check if not _is_out(r))
_outs_c = sum(1 for r in _all_recs_check if _is_out(r))
if _ins_c > 0 and _outs_c > 0 and _ins_c == _outs_c:
# Balanced pairs — overnight fix resolved the miss punch
is_miss_punch = False
total_hours = 0.0 # will be recalculated below
# Calculate total hours for the day
if not is_miss_punch and total_hours > 0:
weekly_total_hours += total_hours
elif not is_miss_punch and total_hours == 0 and total_locations > 0:
# is_miss_punch cleared above but total_hours not yet computed;
# recalculate from the (now-corrected) records in daily_location_data.
_all_recs = []
for loc_data in date_locations.values():
_all_recs.extend(loc_data['records'])
_sorted = sorted(_all_recs, key=_overnight_aware_sort_key)
_ins = [r for r in _sorted if not _is_out(r)]
_outs = [r for r in _sorted if _is_out(r)]
if _ins and _outs:
_day_total = 0.0
for _ir, _or in zip(_ins, _outs):
_dt_in = datetime.combine(date_obj, _ir.check_in_time)
_dt_out = datetime.combine(date_obj, _or.check_in_time)
if _dt_out < _dt_in:
_dt_out += timedelta(days=1)
_day_total += (_dt_out - _dt_in).total_seconds() / 3600.0
total_hours = _qtr(_day_total)
weekly_total_hours += total_hours
elif is_miss_punch and total_locations > 0:
# Calculate hours even for miss punch (different locations)
# Genuine miss punch day — sum only matched IN/OUT pairs,
# ignoring orphaned records (which correctly contribute 0 hours).
all_records_for_day = []
for loc_data in date_locations.values():
all_records_for_day.extend(loc_data['records'])
if len(all_records_for_day) >= 2:
sorted_all_records = sorted(all_records_for_day, key=lambda x: x.check_in_time)
sorted_all_records = sorted(all_records_for_day, key=_overnight_aware_sort_key)
# NEW: Check if all records are IN or all OUT
record_types = []
for record in sorted_all_records:
action_desc = record.action_description.lower() if record.action_description else ''
if 'out' in action_desc or 'checkout' in action_desc:
record_types.append('OUT')
else:
record_types.append('IN')
# Walk through records pairing each IN with the next available OUT
_used = [False] * len(sorted_all_records)
_day_total = 0.0
for _ii, _ri in enumerate(sorted_all_records):
if _used[_ii] or _is_out(_ri):
continue
# Find the next unused OUT
for _oi, _ro in enumerate(sorted_all_records):
if _oi <= _ii or _used[_oi] or not _is_out(_ro):
continue
_dt_in = datetime.combine(date_obj, _ri.check_in_time)
_dt_out = datetime.combine(date_obj, _ro.check_in_time)
if _dt_out < _dt_in:
_dt_out += timedelta(days=1)
_day_total += (_dt_out - _dt_in).total_seconds() / 3600.0
_used[_ii] = True
_used[_oi] = True
break
# If all same type (all IN or all OUT), hours = 0
if len(set(record_types)) == 1:
print(f"⚠️ {date_str}: All {len(sorted_all_records)} records are {record_types[0]} - 0 working hours")
calculated_hours = 0.0
total_hours = 0.0
else:
# Mixed IN/OUT - calculate from first to last
first_time = sorted_all_records[0].check_in_time
last_time = sorted_all_records[-1].check_in_time
first_datetime = datetime.combine(date_obj, first_time)
last_datetime = datetime.combine(date_obj, last_time)
calculated_hours = (last_datetime - first_datetime).total_seconds() / 3600.0
calculated_hours = round(calculated_hours, 2)
weekly_total_hours += calculated_hours
total_hours = calculated_hours
calculated_hours = _qtr(_day_total)
weekly_total_hours += calculated_hours
total_hours = calculated_hours
# Daily total display (only shown on last location's last row)
daily_total_display = round(total_hours, 2) if total_hours > 0 else ''
daily_total_display = _qtr(total_hours) if total_hours > 0 else ''
# FIXED: Get all records for the day and sort by time FIRST, then group by location
all_day_records = []
for loc_data in date_locations.values():
all_day_records.extend(loc_data['records'])
# Sort all records by time chronologically
all_day_records_sorted = sorted(all_day_records, key=lambda x: x.check_in_time)
# Sort all records by time chronologically, overnight-aware
all_day_records_sorted = sorted(all_day_records, key=_overnight_aware_sort_key)
# Group consecutive records by location while maintaining time order
location_groups = []
@@ -9824,10 +9963,15 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
if record_info[j]['used']:
continue
if record_info[j]['is_out']: # Found an OUT
# Missed punch only if an unused OUT exists between i and j
intervening_out = any(
record_info[k]['is_out'] and not record_info[k]['used']
for k in range(i + 1, j)
)
pairs_to_write.append({
'check_in': record_info[i]['record'],
'check_out': record_info[j]['record'],
'is_miss_punch': (j > i + 1) # Missed punch if not consecutive
'is_miss_punch': intervening_out
})
record_info[i]['used'] = True
record_info[j]['used'] = True
@@ -9865,25 +10009,46 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
# Calculate hours if complete pair
if check_in_record and check_out_record and not is_miss_punch:
pair_datetime_in = datetime.combine(date_obj, check_in_record.check_in_time)
pair_datetime_out = datetime.combine(date_obj, check_out_record.check_in_time)
pair_datetime_in = datetime.combine(check_in_record.check_in_date, check_in_record.check_in_time)
pair_datetime_out = datetime.combine(check_out_record.check_in_date, check_out_record.check_in_time)
# If check-out time is before check-in time (overnight shift, both on same date),
# add one day to the check-out datetime so the duration is positive and correct.
if pair_datetime_out < pair_datetime_in:
pair_datetime_out += timedelta(days=1)
pair_hours = (pair_datetime_out - pair_datetime_in).total_seconds() / 3600.0
pair_hours = round(pair_hours, 2)
else:
pair_hours = 'Missed Punch'
# Determine whether this is an overnight pair:
# check-in is late evening (>= 18:00) AND check-out is early morning (<= 06:00)
# Both records share the same check_in_date in the DB for this scenario.
_is_overnight_pair = (
check_in_record and check_out_record and
check_in_record.check_in_time.hour >= 18 and
check_out_record.check_in_time.hour <= 6
)
# Show daily total on last pair of last location
is_last_pair = (pair_idx == len(pairs_to_write) - 1) and is_last_location
current_daily_total = daily_total_display if is_last_pair else ''
# Build Out-time string (plain time only)
_out_time_str = check_out_record.check_in_time.strftime('%I:%M:%S %p') if check_out_record else ''
# Build Location string; append label for overnight pairs
_location_str = check_in_record.location_name if check_in_record else ''
if _is_overnight_pair:
_location_str = f"{_location_str} (midnight shift)"
# Build row data
if check_in_record and check_out_record:
row_data = [
day_display,
date_display,
check_in_record.check_in_time.strftime('%I:%M:%S %p'), # In
check_out_record.check_in_time.strftime('%I:%M:%S %p'), # Out
check_in_record.location_name,
_out_time_str, # Out
_location_str, # Location (includes "(midnight shift)" label if overnight)
'',
pair_hours,
current_daily_total,
@@ -9945,9 +10110,9 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
week_overtime = max(0, weekly_total_hours - 40.0)
ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font
ws.cell(row=current_row, column=8, value=round(weekly_total_hours, 2)).font = bold_font
ws.cell(row=current_row, column=9, value=round(week_regular, 2)).font = bold_font
ws.cell(row=current_row, column=10, value=round(week_overtime, 2)).font = bold_font
ws.cell(row=current_row, column=8, value=_qtr(weekly_total_hours)).font = bold_font
ws.cell(row=current_row, column=9, value=_qtr(week_regular)).font = bold_font
ws.cell(row=current_row, column=10, value=_qtr(week_overtime)).font = bold_font
grand_regular_hours += week_regular
grand_ot_hours += week_overtime
@@ -9986,8 +10151,8 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
# Write GRAND TOTAL row
ws.cell(row=current_row, column=7, value='GRAND TOTAL: ').font = Font(name='Aptos Narrow', size=11, bold=True)
ws.cell(row=current_row, column=9, value=round(grand_regular_hours, 2)).font = Font(name='Aptos Narrow', size=11, bold=True)
ws.cell(row=current_row, column=10, value=round(grand_ot_hours, 2)).font = Font(name='Aptos Narrow', size=11, bold=True)
ws.cell(row=current_row, column=9, value=_qtr(grand_regular_hours)).font = Font(name='Aptos Narrow', size=11, bold=True)
ws.cell(row=current_row, column=10, value=_qtr(grand_ot_hours)).font = Font(name='Aptos Narrow', size=11, bold=True)
current_row += 1
# Empty row after each employee
@@ -10417,9 +10582,9 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
week_overtime = max(0, weekly_total_hours - 40.0)
ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font
ws.cell(row=current_row, column=8, value=round(weekly_total_hours, 2)).font = bold_font
ws.cell(row=current_row, column=9, value=round(week_regular, 2)).font = bold_font
ws.cell(row=current_row, column=10, value=round(week_overtime, 2)).font = bold_font
ws.cell(row=current_row, column=8, value=_qtr(weekly_total_hours)).font = bold_font
ws.cell(row=current_row, column=9, value=_qtr(week_regular)).font = bold_font
ws.cell(row=current_row, column=10, value=_qtr(week_overtime)).font = bold_font
grand_regular_hours += week_regular
grand_ot_hours += week_overtime
@@ -10562,9 +10727,9 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
week_overtime = max(0, weekly_total_hours - 40.0)
ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font
ws.cell(row=current_row, column=8, value=round(weekly_total_hours, 2)).font = bold_font
ws.cell(row=current_row, column=9, value=round(week_regular, 2)).font = bold_font
ws.cell(row=current_row, column=10, value=round(week_overtime, 2)).font = bold_font
ws.cell(row=current_row, column=8, value=_qtr(weekly_total_hours)).font = bold_font
ws.cell(row=current_row, column=9, value=_qtr(week_regular)).font = bold_font
ws.cell(row=current_row, column=10, value=_qtr(week_overtime)).font = bold_font
grand_regular_hours += week_regular
grand_ot_hours += week_overtime
@@ -10605,8 +10770,8 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
# Write GRAND TOTAL row
ws.cell(row=current_row, column=7, value='GRAND TOTAL: ').font = Font(name='Aptos Narrow', size=11, bold=True)
ws.cell(row=current_row, column=9, value=round(grand_regular_hours, 2)).font = Font(name='Aptos Narrow', size=11, bold=True)
ws.cell(row=current_row, column=10, value=round(grand_ot_hours, 2)).font = Font(name='Aptos Narrow', size=11, bold=True)
ws.cell(row=current_row, column=9, value=_qtr(grand_regular_hours)).font = Font(name='Aptos Narrow', size=11, bold=True)
ws.cell(row=current_row, column=10, value=_qtr(grand_ot_hours)).font = Font(name='Aptos Narrow', size=11, bold=True)
current_row += 1
# Empty row after each employee
+54
View File
@@ -737,6 +737,60 @@ class PayrollExcelExporter:
}
daily_location_data[date_key]['records'].append(record)
# ---------------------------------------------------------------
# OVERNIGHT SHIFT DETECTION (display layer)
# Mirror the same logic used in working_hours_calculator so that
# the In/Out times rendered in the Excel rows are consistent with
# the computed hours: late check-in (>= 18:00) on Day N paired with
# early check-out (<= 06:00) on Day N+1 → move check-out to Day N.
# ---------------------------------------------------------------
from datetime import time as time_type
OVERNIGHT_CHECKIN_HOUR = 18
OVERNIGHT_CHECKOUT_HOUR = 6
sorted_dates = sorted(daily_location_data.keys())
for idx, date_key in enumerate(sorted_dates):
day_records = daily_location_data[date_key]['records']
check_ins = [r for r in day_records if getattr(r, 'record_type', 'check_in') == 'check_in']
check_outs = [r for r in day_records if getattr(r, 'record_type', 'check_in') == 'check_out']
unmatched_late_ins = []
for ci in check_ins:
ci_hour = ci.check_in_time.hour if isinstance(ci.check_in_time, time_type) else 0
if ci_hour >= OVERNIGHT_CHECKIN_HOUR and len(check_outs) < len(check_ins):
unmatched_late_ins.append(ci)
if not unmatched_late_ins or idx + 1 >= len(sorted_dates):
continue
next_date_key = sorted_dates[idx + 1]
day_n = datetime.strptime(date_key, '%Y-%m-%d').date()
day_n1 = datetime.strptime(next_date_key, '%Y-%m-%d').date()
if (day_n1 - day_n).days != 1:
continue
next_day_records = daily_location_data[next_date_key]['records']
next_check_ins = [r for r in next_day_records if getattr(r, 'record_type', 'check_in') == 'check_in']
next_check_outs = [r for r in next_day_records if getattr(r, 'record_type', 'check_in') == 'check_out']
orphaned_early_outs = []
for co in next_check_outs:
co_hour = co.check_in_time.hour if isinstance(co.check_in_time, time_type) else 0
if co_hour <= OVERNIGHT_CHECKOUT_HOUR and len(next_check_ins) < len(next_check_outs):
orphaned_early_outs.append(co)
for co in orphaned_early_outs[:len(unmatched_late_ins)]:
print(f"🌙 [Exporter] Overnight shift: moving check-out {co.check_in_time} "
f"from {next_date_key}{date_key} for employee {employee_id}")
daily_location_data[date_key]['records'].append(co)
daily_location_data[next_date_key]['records'].remove(co)
if not daily_location_data[next_date_key]['records']:
del daily_location_data[next_date_key]
# ---------------------------------------------------------------
# END OVERNIGHT SHIFT DETECTION (display layer)
# ---------------------------------------------------------------
# Get location info for each day
for date_str, day_info in daily_location_data.items():
sorted_records = sorted(day_info['records'], key=lambda x: x.check_in_time)
+71
View File
@@ -476,6 +476,77 @@ class WorkingHoursCalculator:
daily_records_by_type[work_type][date_key] = []
daily_records_by_type[work_type][date_key].append(record)
# ---------------------------------------------------------------
# OVERNIGHT SHIFT DETECTION
# If a late-evening check-in (>= 18:00) on Day N has no matching
# check-out on the same day, AND there is an early-morning check-out
# (<= 06:00) on Day N+1 that is itself unpaired, re-assign that
# check-out record to Day N so the pair resolves correctly.
# Hours are attributed to the earlier day (Day N).
# ---------------------------------------------------------------
OVERNIGHT_CHECKIN_HOUR = 18 # Check-in must be at or after 6 PM
OVERNIGHT_CHECKOUT_HOUR = 6 # Check-out must be at or before 6 AM
for work_type in ['regular', 'SP', 'PW', 'PT']:
all_dates = sorted(daily_records_by_type[work_type].keys())
for i, date_key in enumerate(all_dates):
day_records = daily_records_by_type[work_type][date_key]
# Count unpaired check-ins (late evening)
check_ins = [r for r in day_records if r.record_type == 'check_in']
check_outs = [r for r in day_records if r.record_type == 'check_out']
# Any unmatched check-ins that started late in the evening?
unmatched_late_ins = []
for ci in check_ins:
ci_hour = ci.check_in_time.hour if isinstance(ci.check_in_time, time) else ci.timestamp.hour
if ci_hour >= OVERNIGHT_CHECKIN_HOUR:
# Check that there is no check-out already on the same day for this in
if len(check_outs) < len(check_ins):
unmatched_late_ins.append(ci)
if not unmatched_late_ins:
continue
# Look at the next calendar day
if i + 1 >= len(all_dates):
continue
next_date_key = all_dates[i + 1]
# Verify it is truly the next day
from datetime import date as date_type
day_n = datetime.strptime(date_key, '%Y-%m-%d').date()
day_n1 = datetime.strptime(next_date_key, '%Y-%m-%d').date()
if (day_n1 - day_n).days != 1:
continue
next_day_records = daily_records_by_type[work_type][next_date_key]
next_check_outs = [r for r in next_day_records if r.record_type == 'check_out']
next_check_ins = [r for r in next_day_records if r.record_type == 'check_in']
# Identify early-morning check-outs on Day N+1 that are orphaned
orphaned_early_outs = []
for co in next_check_outs:
co_hour = co.check_in_time.hour if isinstance(co.check_in_time, time) else co.timestamp.hour
if co_hour <= OVERNIGHT_CHECKOUT_HOUR:
# Considered orphaned if there are fewer or equal check-ins to cover it
if len(next_check_ins) < len(next_check_outs):
orphaned_early_outs.append(co)
# Move orphaned early check-outs from Day N+1 → Day N
for co in orphaned_early_outs[:len(unmatched_late_ins)]:
print(f"🌙 Overnight shift detected for {work_type} on {date_key}: "
f"moving check-out {co.check_in_time} from {next_date_key}{date_key}")
daily_records_by_type[work_type][date_key].append(co)
daily_records_by_type[work_type][next_date_key].remove(co)
# Clean up empty buckets on Day N+1
if not daily_records_by_type[work_type][next_date_key]:
del daily_records_by_type[work_type][next_date_key]
# ---------------------------------------------------------------
# END OVERNIGHT SHIFT DETECTION
# ---------------------------------------------------------------
# Calculate daily hours for each work type
daily_hours = {}
weekly_hours = []