March 16 2026: fix export by building issues

This commit is contained in:
2026-03-16 15:49:10 -04:00
parent 8a2710d2fa
commit d129d46b08
+181 -21
View File
@@ -9342,23 +9342,35 @@ def _overnight_aware_sort_key(record):
""" """
Sort key for attendance records within a single calendar-date bucket. Sort key for attendance records within a single calendar-date bucket.
Problem: when an overnight shift spans midnight, the check-out record's Problem 1: 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 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. (e.g. 20:00 PM), producing an orphaned OUT followed by an orphaned IN.
Fix: push early-morning check-outs (hour <= 3) past midnight by adding
24 h worth of seconds so they sort after same-day evening check-ins.
Fix: if a record is a check-out AND its time is in the early-morning window Problem 2: two records in the same minute (e.g. IN 06:22:04, OUT 06:22:52)
(<= 06:00), treat it as belonging to the *next* logical day by adding 24 h had identical sort keys because seconds were not included, leaving the
worth of minutes so it sorts after any same-day evening check-ins. database-delivery order intact (DESC OUT first). The pairing loop then
encountered the OUT before the IN, emitting an orphaned-OUT row followed
by an orphaned-IN row reversed from chronological order.
Fix: include seconds in the key so true chronological order is preserved.
""" """
from datetime import time as _time from datetime import time as _time
t = record.check_in_time t = record.check_in_time
minutes = t.hour * 60 + t.minute if isinstance(t, _time) else 0 if isinstance(t, _time):
# Use fractional minutes (hours*60 + minutes + seconds/60) so that
# records sharing the same HH:MM still sort by their seconds component.
seconds_total = t.hour * 3600 + t.minute * 60 + t.second
else:
seconds_total = 0
action = (record.action_description or '').lower() action = (record.action_description or '').lower()
is_out = 'out' in action or 'checkout' in action is_out = 'out' in action or 'checkout' in action
# Push early-morning check-outs past midnight to end of day order # Push early-morning check-outs past midnight to end of day order.
# Use seconds-based offset (24 h = 86400 s) to remain consistent with
# the seconds-granularity key above.
if is_out and t.hour <= 3: if is_out and t.hour <= 3:
minutes += 24 * 60 seconds_total += 24 * 3600
return minutes return seconds_total
def _qtr(decimal_hours: float) -> float: def _qtr(decimal_hours: float) -> float:
""" """
@@ -10786,6 +10798,10 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
bottom=Side(style='thin') bottom=Side(style='thin')
) )
missed_punch_fill = PatternFill(start_color='FFC000', end_color='FFC000', fill_type='solid') missed_punch_fill = PatternFill(start_color='FFC000', end_color='FFC000', fill_type='solid')
# Bottom-only border on the last row of each day group (matches normal TA export).
# Intermediate rows within a day have no borders.
border_day_middle = Border() # No borders on intermediate rows
border_day_last = Border(bottom=Side(style='thin')) # Bottom border on last row of day
# Write main headers # Write main headers
current_row = 1 current_row = 1
@@ -10862,12 +10878,50 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
emp_records = employees_at_location[employee_id] emp_records = employees_at_location[employee_id]
emp_name = employee_names.get(employee_id, f'Employee {employee_id}') emp_name = employee_names.get(employee_id, f'Employee {employee_id}')
# Get SP/PT/PW hours from the calculator's hours_data for this employee # Compute SP/PW/PT hours from the records already scoped to this
emp_hours_data = hours_data.get('employees', {}).get(employee_id, {}) # building and employee (emp_records). Using the calculator's
grand_totals = emp_hours_data.get('grand_totals', {}) # grand_totals here would be incorrect: those totals are GLOBAL
sp_hours = grand_totals.get('sp_hours', 0.0) # (across all buildings), so an employee with SP hours at Building A
pw_hours = grand_totals.get('pw_hours', 0.0) # would incorrectly show an SP row at Building B where they have none.
pt_hours = grand_totals.get('pt_hours', 0.0) #
# Strategy: pair same-building SP/PW/PT records the same way the
# main loop pairs regular records, and sum the durations.
def _building_special_hours(emp_recs, work_type_code):
"""Sum paired hours for a given work-type code at this building."""
from datetime import datetime as _dt, timedelta as _td
wt_recs = [r for r in emp_recs if getattr(r, 'work_type', None) == work_type_code]
if not wt_recs:
return 0.0
# Group by date
by_date = {}
for r in wt_recs:
dk = r.check_in_date.strftime('%Y-%m-%d') if hasattr(r.check_in_date, 'strftime') else str(r.check_in_date)
by_date.setdefault(dk, []).append(r)
total = 0.0
for dk, day_recs in by_date.items():
day_recs_s = sorted(day_recs, key=_overnight_aware_sort_key)
ins_r = [r for r in day_recs_s if not ('out' in (r.action_description or '').lower() or 'checkout' in (r.action_description or '').lower())]
outs_r = [r for r in day_recs_s if ('out' in (r.action_description or '').lower() or 'checkout' in (r.action_description or '').lower())]
used = [False] * len(outs_r)
d_obj = _dt.strptime(dk, '%Y-%m-%d')
for in_r in ins_r:
for oi, out_r in enumerate(outs_r):
if used[oi]:
continue
in_dt = _dt.combine(d_obj, in_r.check_in_time)
out_dt = _dt.combine(d_obj, out_r.check_in_time)
if out_dt < in_dt:
out_dt += _td(days=1)
dur = (out_dt - in_dt).total_seconds() / 3600.0
if 0 < dur < 24:
total += dur
used[oi] = True
break
return total
sp_hours = _building_special_hours(emp_records, 'SP')
pw_hours = _building_special_hours(emp_records, 'PW')
pt_hours = _building_special_hours(emp_records, 'PT')
# Employee header row # Employee header row
ws.merge_cells(f'A{current_row}:O{current_row}') ws.merge_cells(f'A{current_row}:O{current_row}')
@@ -10898,14 +10952,94 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
daily_records[date_key] = [] daily_records[date_key] = []
daily_records[date_key].append(record) daily_records[date_key].append(record)
# -----------------------------------------------------------
# OVERNIGHT SHIFT DETECTION (by-building export)
# The midnight check-out record is stored in the DB on the
# next calendar day's date (e.g. checkout at 01:00 AM on
# Thursday is stored as check_in_date = Thursday). Move it
# into Wednesday's bucket so it pairs with the 8 PM check-in.
#
# Mirrors the identical logic in export_time_attendance_excel.
# -----------------------------------------------------------
def _bb_is_out(r):
a = (r.action_description or '').lower()
return 'out' in a or 'checkout' in a
_bb_sorted_dk = sorted(daily_records.keys())
for _bb_di, _bb_dk in enumerate(_bb_sorted_dk):
if _bb_di + 1 >= len(_bb_sorted_dk):
continue
# Guard: bucket may have been emptied by a prior iteration
if _bb_dk not in daily_records:
continue
_bb_ndk = _bb_sorted_dk[_bb_di + 1]
if _bb_ndk not in daily_records:
continue
# Must be consecutive calendar days
_bb_dn = datetime.strptime(_bb_dk, '%Y-%m-%d').date()
_bb_dn1 = datetime.strptime(_bb_ndk, '%Y-%m-%d').date()
if (_bb_dn1 - _bb_dn).days != 1:
continue
# Collect INs/OUTs for Day N and Day N+1
_bb_day_recs = daily_records[_bb_dk]
_bb_next_recs = daily_records[_bb_ndk]
_bb_day_ins = [r for r in _bb_day_recs if not _bb_is_out(r)]
_bb_day_outs = [r for r in _bb_day_recs if _bb_is_out(r)]
_bb_nxt_ins = [r for r in _bb_next_recs if not _bb_is_out(r)]
_bb_nxt_outs = [r for r in _bb_next_recs if _bb_is_out(r)]
# Exclude early-morning OUTs on Day N from the balance check:
# 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]
# Day N must have an unmatched late check-in (>= 19:00)
if len(_bb_day_ins) <= len(_bb_day_outs_non_early):
continue
_bb_late_ins = [r for r in _bb_day_ins if r.check_in_time.hour >= 19]
if not _bb_late_ins:
continue
# 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]
if not _bb_early_outs:
continue
# Non-evening INs guard: do NOT move if Day N+1 has a non-evening
# IN that precedes the early OUT (i.e. it can own the early OUT)
# and the counts are balanced.
_bb_nxt_non_evening_ins = [
r for r in _bb_nxt_ins
if r.check_in_time.hour < 18
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):
continue
# Move up to as many early OUTs as there are unmatched late INs
_bb_to_move = _bb_early_outs[:len(_bb_late_ins)]
for _bb_co in _bb_to_move:
daily_records[_bb_dk].append(_bb_co)
daily_records[_bb_ndk].remove(_bb_co)
if not daily_records[_bb_ndk]:
del daily_records[_bb_ndk]
logger_handler.logger.info(
f"[TA by-building Export] Overnight: moved checkout "
f"{_bb_co.check_in_time} from {_bb_ndk} to {_bb_dk} "
f"for employee {employee_id} at {location_name}"
)
# -----------------------------------------------------------
# END OVERNIGHT SHIFT DETECTION
# -----------------------------------------------------------
# Track weekly hours for overtime calculation # Track weekly hours for overtime calculation
weekly_total_hours = 0 weekly_total_hours = 0
current_week_start = None current_week_start = None
grand_regular_hours = 0 grand_regular_hours = 0
grand_ot_hours = 0 grand_ot_hours = 0
# Sort dates # Sort dates (re-sort after overnight detection may have removed buckets).
sorted_dates = sorted(daily_records.keys()) # CRITICAL: cap to end_date — daily_records may contain the +1 buffer day
# (fetched so overnight checkout records are available for pairing) but
# that extra day must never be rendered, or it creates a spurious 3rd week.
sorted_dates = sorted(
dk for dk in daily_records.keys()
if datetime.strptime(dk, '%Y-%m-%d').date() <= end_date
)
for date_str in sorted_dates: for date_str in sorted_dates:
date_obj = datetime.strptime(date_str, '%Y-%m-%d') date_obj = datetime.strptime(date_str, '%Y-%m-%d')
@@ -10934,6 +11068,15 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
current_week_start = week_start current_week_start = week_start
# Re-evaluate miss-punch status after overnight detection may
# have moved a next-day checkout into this day's bucket.
# If INs and OUTs are now balanced, this day is no longer a
# miss punch (mirrors logic in export_time_attendance_excel).
_bb_all_day = day_records
_bb_ins_count = sum(1 for r in _bb_all_day if not _bb_is_out(r))
_bb_outs_count = sum(1 for r in _bb_all_day if _bb_is_out(r))
_bb_day_is_miss_punch = (_bb_ins_count != _bb_outs_count)
# Process day records - create IN/OUT pairs # Process day records - create IN/OUT pairs
record_info = [] record_info = []
for record in day_records: for record in day_records:
@@ -11017,9 +11160,17 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
daily_hours = 0 daily_hours = 0
for pair in pairs: for pair in pairs:
if pair['check_in'] and pair['check_out'] and not pair['is_miss_punch']: if pair['check_in'] and pair['check_out'] and not pair['is_miss_punch']:
pair_in = datetime.combine(date_obj, pair['check_in'].check_in_time) pair_in = datetime.combine(date_obj, pair['check_in'].check_in_time)
pair_out = datetime.combine(date_obj, pair['check_out'].check_in_time) pair_out = datetime.combine(date_obj, pair['check_out'].check_in_time)
daily_hours += (pair_out - pair_in).total_seconds() / 3600.0 # Overnight shift correction: if OUT is before IN on the same
# calendar date, the employee worked past midnight — advance
# pair_out by one day so the duration is always positive.
if pair_out < pair_in:
pair_out += timedelta(days=1)
_bb_dur = (pair_out - pair_in).total_seconds() / 3600.0
# 24h guard: reject implausible durations (data errors)
if _bb_dur <= 24:
daily_hours += _bb_dur
daily_hours = round(daily_hours, 2) daily_hours = round(daily_hours, 2)
weekly_total_hours += daily_hours weekly_total_hours += daily_hours
@@ -11036,8 +11187,13 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
# Calculate hours for this pair # Calculate hours for this pair
if check_in and check_out and not is_miss_punch: if check_in and check_out and not is_miss_punch:
pair_hours = round((datetime.combine(date_obj, check_out.check_in_time) - _pair_in_dt = datetime.combine(date_obj, check_in.check_in_time)
datetime.combine(date_obj, check_in.check_in_time)).total_seconds() / 3600.0, 2) _pair_out_dt = datetime.combine(date_obj, check_out.check_in_time)
# Overnight shift correction: advance OUT by one day when it
# falls before IN (employee crossed midnight).
if _pair_out_dt < _pair_in_dt:
_pair_out_dt += timedelta(days=1)
pair_hours = round((_pair_out_dt - _pair_in_dt).total_seconds() / 3600.0, 2)
else: else:
pair_hours = 'Missed Punch' pair_hours = 'Missed Punch'
@@ -11065,10 +11221,14 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
calculate_possible_violation(getattr(ref_record, 'distance', None)) if ref_record else '' calculate_possible_violation(getattr(ref_record, 'distance', None)) if ref_record else ''
] ]
# Use bottom-only border on the last pair row of the day;
# no borders on intermediate rows (matches normal TA export).
_bb_is_last_pair = (pair_idx == len(pairs) - 1)
_bb_row_border = border_day_last if _bb_is_last_pair else border_day_middle
for col, value in enumerate(row_data, 1): for col, value in enumerate(row_data, 1):
cell = ws.cell(row=current_row, column=col, value=value) cell = ws.cell(row=current_row, column=col, value=value)
cell.font = data_font cell.font = data_font
cell.border = border cell.border = _bb_row_border
if col == 7 and value == 'Missed Punch': if col == 7 and value == 'Missed Punch':
cell.fill = missed_punch_fill cell.fill = missed_punch_fill