""" routes/time_attendance_export.py ================================= Excel export logic for Time Attendance — helper functions called by routes in time_attendance.py. Contains: - calculate_possible_violation() - _overnight_aware_sort_key() - _qtr() - export_time_attendance_excel() (single-employee / all-employees) - export_time_attendance_by_building_excel() """ from flask import send_file, g, current_app from datetime import datetime, date, timedelta, time import io, os, json, re import time as _time from extensions import db, logger_handler from models.employee import Employee from models.project import Project from models.qrcode import QRCode from models.time_attendance import TimeAttendance from sqlalchemy import text from working_hours_calculator import WorkingHoursCalculator, round_time_to_quarter_hour, convert_minutes_to_base100, round_base100_hours from utils.excel_safety import excel_hyperlink, neutralize_unexpected_formulas import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side, numbers from openpyxl.utils import get_column_letter import openpyxl.cell.cell # Export helpers — no Blueprint needed, these are plain functions # called from routes in time_attendance.py def calculate_possible_violation(distance_value): """ Calculate possible violation status based on distance Args: distance_value: Distance in miles (float or None) Returns: 'Yes' if distance > 0.3, 'No' otherwise """ if distance_value is None: return 'No' try: distance_float = float(distance_value) return 'Yes' if distance_float > 0.3 else 'No' except (ValueError, TypeError): return 'No' def _overnight_aware_sort_key(record): """ Sort key for attendance records within a single calendar-date bucket. 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 (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. Problem 2: two records in the same minute (e.g. IN 06:22:04, OUT 06:22:52) had identical sort keys because seconds were not included, leaving the 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 t = record.check_in_time 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() is_out = 'out' in action or 'checkout' in action # 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: seconds_total += 24 * 3600 return seconds_total 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) # --------------------------------------------------------------------------- # Private export helpers — shared by both export functions below. # --------------------------------------------------------------------------- def _resolve_date_range(start_date_filter, end_date_filter, records, export_label='TA Excel export', unlimited=False): """ Resolve and validate the export date range. Returns (start_date, end_date, filtered_records) where: - start_date / end_date are date objects - filtered_records is the input list capped to end_date + 1 day (overnight buffer) - the window is capped to a maximum 14 days unless unlimited=True: * a start-date filter was supplied -> keep it, trim end_date forward * no start-date filter (start derived from the records) -> keep end_date and move start_date back, so the export covers the MOST RECENT 14 days rather than the oldest 14 days in the database Returns None when there are no records and no date filters. """ MAX_EXPORT_DAYS = 14 def _as_date(value): if not value: return None if isinstance(value, str): return datetime.strptime(value, '%Y-%m-%d').date() return value filter_start = _as_date(start_date_filter) filter_end = _as_date(end_date_filter) if not records and not (filter_start and filter_end): return None record_min = min(r.attendance_date for r in records) if records else None record_max = max(r.attendance_date for r in records) if records else None start_date = filter_start or record_min end_date = filter_end or record_max if start_date is None or end_date is None: return None # Which end of the window is fixed when the 14-day cap has to trim it: # a user-supplied start date is honoured (trim the end); otherwise the range # was derived from the data and we keep the most recent day (move start back). anchor_start = filter_start is not None if not unlimited and (end_date - start_date).days >= MAX_EXPORT_DAYS: if anchor_start: # The user supplied a start date: keep it and trim the end. capped_end_date = start_date + timedelta(days=MAX_EXPORT_DAYS - 1) logger_handler.logger.info( f"{export_label}: date range [{start_date} - {end_date}] exceeds " f"{MAX_EXPORT_DAYS} days; capping end_date to {capped_end_date}." ) end_date = capped_end_date else: # No start-date filter: start_date came from the oldest record in the # whole dataset. Capping forward from there exports the OLDEST 14 days # of history - typically only the few employees active back then. # Anchor to the most recent day instead, so an unfiltered export # covers the latest 14 days of data (and every employee in them). capped_start_date = end_date - timedelta(days=MAX_EXPORT_DAYS - 1) logger_handler.logger.info( f"{export_label}: derived date range [{start_date} - {end_date}] exceeds " f"{MAX_EXPORT_DAYS} days; anchoring to the most recent data, " f"start_date set to {capped_start_date}." ) start_date = capped_start_date # Apply both bounds: # - Lower bound: exclude records before start_date (prevents historical data leaking in). # - Upper bound (+1 day buffer): early-morning check-out records stored on Day N+1 # must remain available for overnight pairing detection; the display range is # still controlled by each export's sorted_dates (capped to end_date). filtered_records = [ r for r in records if start_date <= r.attendance_date <= end_date + timedelta(days=1) ] return start_date, end_date, filtered_records def _convert_ta_records(records): """ Convert TimeAttendance ORM records to the lightweight anonymous-class format expected by WorkingHoursCalculator and the Excel rendering loops. Returns a list of converted record objects. """ from working_hours_calculator import parse_employee_id_for_work_type converted = [] for record in records: distance_value = getattr(record, 'distance', None) record_type = 'check_in' if hasattr(record, 'action_description') and record.action_description: action_lower = record.action_description.lower() if 'out' in action_lower or 'checkout' in action_lower: record_type = 'check_out' _, work_type = parse_employee_id_for_work_type(str(record.employee_id)) base_location_name = record.location_name if work_type and work_type in ('PT', 'SP', 'PW', 'C'): display_location_name = f"{base_location_name} ({work_type})" else: display_location_name = base_location_name converted_record = type('Record', (), { 'id': record.id, 'employee_id': str(record.employee_id), 'employee_name': getattr(record, 'employee_name', ''), 'check_in_date': record.attendance_date, 'check_in_time': record.attendance_time, 'location_name': display_location_name, 'original_location_name': base_location_name, 'work_type': work_type, 'latitude': None, 'longitude': None, 'distance': distance_value, 'record_type': record_type, 'action_description': record.action_description, 'event_description': record.event_description or '', 'recorded_address': record.recorded_address or '', 'qr_code': type('QRCode', (), { 'location': base_location_name, 'location_address': record.recorded_address or '', 'project': None })() })() converted.append(converted_record) return converted def _build_employee_name_map(records): """ Build a {base_employee_id: "Lastname, Firstname"} map for export headers. Looks up the Employee table by numeric base ID so work-type suffixes (e.g. '3937SP') in the stored employee_name column do not pollute labels. Falls back to the stored employee_name on lookup failure. """ from working_hours_calculator import parse_employee_id_for_work_type employee_names = {} for record in records: base_id, _ = parse_employee_id_for_work_type(str(record.employee_id)) if base_id not in employee_names: try: emp = Employee.query.filter_by(id=int(base_id)).first() if emp: employee_names[base_id] = f"{emp.lastName}, {emp.firstName}" else: employee_names[base_id] = getattr(record, 'employee_name', f'Employee {base_id}') logger_handler.logger.warning( f"Employee ID {base_id} not found in employee table during export; " f"using stored name." ) except Exception as e: employee_names[base_id] = getattr(record, 'employee_name', f'Employee {base_id}') logger_handler.logger.warning( f"Could not lookup employee name for ID {base_id} during export: {e}" ) return employee_names def _make_export_styles(): """ Return a dict of openpyxl style objects shared by both export functions. Keys: header_font, header_fill, data_font, bold_font, italic_bold_font, border, missed_punch_fill, border_day_middle, border_day_last, border_day_single, border_day_first, amber_fill """ header_font = Font(name='Aptos Narrow', size=11, bold=True, color='FFFFFF') header_fill = PatternFill(start_color='000000', end_color='000000', fill_type='solid') data_font = Font(name='Aptos Narrow', size=11) bold_font = Font(name='Aptos Narrow', size=11, bold=True) italic_bold_font = Font(name='Aptos Narrow', size=11, bold=True, italic=True) border = Border( left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin') ) missed_punch_fill = PatternFill(start_color='FFC000', end_color='FFC000', fill_type='solid') amber_fill = PatternFill(start_color='FFC000', end_color='FFC000', fill_type='solid') border_day_middle = Border() border_day_last = Border(bottom=Side(style='thin')) border_day_single = Border(bottom=Side(style='thin')) border_day_first = Border() return { 'header_font': header_font, 'header_fill': header_fill, 'data_font': data_font, 'bold_font': bold_font, 'italic_bold_font': italic_bold_font, 'border': border, 'missed_punch_fill': missed_punch_fill, 'amber_fill': amber_fill, 'border_day_middle': border_day_middle, 'border_day_last': border_day_last, 'border_day_single': border_day_single, 'border_day_first': border_day_first, } def export_time_attendance_excel(records, project_name_for_filename, date_range_str, filter_str, start_date_filter=None, end_date_filter=None, unlimited=False): """Generate Excel export with template format matching the provided template""" from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Border, Side, Alignment from openpyxl.utils import get_column_letter import io # Create workbook wb = Workbook() ws = wb.active ws.title = "Sheet0" # Resolve date range; skip 14-day cap when unlimited=True result = _resolve_date_range(start_date_filter, end_date_filter, records, 'TA Excel export', unlimited=unlimited) if result is None: return None start_date, end_date, records = result # Import parse function at the beginning for work type detection from working_hours_calculator import parse_employee_id_for_work_type # Convert TimeAttendance records to format expected by calculator converted_records = _convert_ta_records(records) # Calculate working hours using WorkingHoursCalculator calculator = WorkingHoursCalculator() hours_data = calculator.calculate_all_employees_hours( datetime.combine(start_date, datetime.min.time()), datetime.combine(end_date, datetime.max.time()), converted_records ) # Build employee name map (Lastname, Firstname keyed by base employee ID) employee_names = _build_employee_name_map(records) # Setup styles (shared objects) _styles = _make_export_styles() header_font = _styles['header_font'] header_fill = _styles['header_fill'] data_font = _styles['data_font'] bold_font = _styles['bold_font'] italic_bold_font = _styles['italic_bold_font'] border = _styles['border'] missed_punch_fill = _styles['missed_punch_fill'] amber_fill = _styles['amber_fill'] border_day_middle = _styles['border_day_middle'] border_day_last = _styles['border_day_last'] border_day_single = _styles['border_day_single'] border_day_first = _styles['border_day_first'] def get_day_border(row_position, total_rows): """ Get appropriate border style based on row position within a day. Matches sample.xlsx: only the LAST row of each day group has a bottom border. Args: row_position: Current row number (0-indexed) within the day total_rows: Total number of rows for this day Returns: Border object """ if total_rows == 1: return border_day_single elif row_position == total_rows - 1: return border_day_last else: return border_day_middle # Orange background for Missed Punch missed_punch_fill = PatternFill(start_color='FFC000', end_color='FFC000', fill_type='solid') # Write main headers current_row = 1 # Row 1: Company name ws.merge_cells(f'A{current_row}:N{current_row}') title_cell = ws.cell(row=current_row, column=1, value=current_app.config.get('COMPANY_NAME', 'QR Code Management System')) title_cell.font = Font(name='Aptos Narrow', size=14, bold=True) title_cell.alignment = Alignment(horizontal='left') current_row += 1 # Row 2: Summary title ws.merge_cells(f'A{current_row}:N{current_row}') summary_cell = ws.cell(row=current_row, column=1, value='Summary report of Hours worked') summary_cell.font = Font(name='Aptos Narrow', size=12, bold=True) summary_cell.alignment = Alignment(horizontal='left') current_row += 1 # Row 3: Project name project_display = project_name_for_filename.replace('_', ' ').strip() if project_name_for_filename else "[Project Name]" project_cell = ws.cell(row=current_row, column=1, value=project_display) project_cell.font = Font(name='Aptos Narrow', size=11, bold=True) project_cell.alignment = Alignment(horizontal='left') current_row += 1 # Row 4: Date range date_range_text = f"Date range: {start_date.strftime('%m/%d/%Y')} to {end_date.strftime('%m/%d/%Y')}" ws.merge_cells(f'A{current_row}:N{current_row}') date_cell = ws.cell(row=current_row, column=1, value=date_range_text) date_cell.font = Font(name='Aptos Narrow', size=11) date_cell.alignment = Alignment(horizontal='left') current_row += 1 # Row 5: Empty row current_row += 1 # Empty row before first employee current_row += 1 # Sort employees by name for organized output sorted_employees = sorted( hours_data['employees'].items(), key=lambda x: employee_names.get(x[0], f'Employee {x[0]}').lower() ) # Write data for each employee (sorted by name) for employee_id, emp_data in sorted_employees: employee_name = employee_names.get(employee_id, f'Employee {employee_id}') # Employee header row (merged A to O) ws.merge_cells(f'A{current_row}:O{current_row}') emp_header = ws.cell(row=current_row, column=1, value=f'Employee ID {employee_id}: {employee_name}') emp_header.font = Font(name='Aptos Narrow', size=11, bold=True) emp_header.alignment = Alignment(horizontal='left') current_row += 1 # Column headers headers = ['Day', 'Date', 'In', 'Out', 'Location', 'Zone', 'Hours/Building', 'Daily Total', 'Regular Hours', 'OT Hours', 'Building Address', 'Recorded Location', 'Distance (Mile)', 'Possible Violation'] for col, header in enumerate(headers, 1): cell = ws.cell(row=current_row, column=col, value=header) # White bold text on black background; no border (matching sample.xlsx) cell.font = header_font cell.fill = header_fill cell.alignment = Alignment(horizontal='center', vertical='center') current_row += 1 # Group records by date AND location for separate rows per location daily_location_data = {} # Import parse function to match base employee ID with all variants (SP, PW, PT, C) from working_hours_calculator import parse_employee_id_for_work_type # Filter records where the BASE employee ID matches (includes 1234, 1234 SP, 1234 PW, 1234 PT, 1234 C) employee_records = [] for r in converted_records: record_base_id, _ = parse_employee_id_for_work_type(str(r.employee_id)) if record_base_id == employee_id: employee_records.append(r) for record in employee_records: date_key = record.check_in_date.strftime('%Y-%m-%d') location_key = record.location_name or 'Unknown Location' # Create nested structure: date -> location -> records if date_key not in daily_location_data: daily_location_data[date_key] = {} if location_key not in daily_location_data[date_key]: daily_location_data[date_key][location_key] = { 'records': [], 'location_name': location_key } 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:18 AM on Thursday is # stored as check_in_date = 2026-02-26). We need to move it into # Wednesday's bucket so it pairs with the 8:18 PM check-in. # # Condition to move an early-morning checkout from Day N+1 -> Day N: # Day N: has an unmatched late check-in (>= 18:00) # Day N+1: has an early check-out (<= 06:00) that belongs to Day N, # detected by the absence of a non-evening IN on Day N+1 # that could own the early OUT (or raw count imbalance). # ------------------------------------------------------------------- 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 # Guard: _dk or _ndk may have been deleted by a prior iteration # when all its records were moved to the previous day's bucket. # Without this check, iterating the stale sorted_dk snapshot raises KeyError. if _dk not in daily_location_data: continue _ndk = sorted_dk[_di + 1] if _ndk not in daily_location_data: continue # 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)] # Early-morning OUTs on Day N (hour <= 3) are overnight orphans from # Day N-1. Counting them as regular Day N outs inflates the out-count # and makes the day appear balanced, which suppresses detection of an # unmatched late IN that needs a next-day OUT. Exclude them. _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, # with at least one IN at or after 12:00 PM) if len(_day_ins) <= len(_day_outs_non_early): continue _late_ins = [r for r in _day_ins if r.check_in_time.hour >= 12] if not _late_ins: continue # Find early-morning OUTs (<=03:00) on Day N+1 _early_outs = [r for r in _nxt_outs if r.check_in_time.hour <= 3] if not _early_outs: continue # Determine whether the early OUT belongs to Day N or Day N+1. # 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. # This handles both cases: # 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 # 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). # 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). _nxt_non_evening_ins = [ r for r in _nxt_ins if r.check_in_time.hour < 12 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): # Day N+1 has a morning IN that can own the early OUT, and counts # are balanced -> do NOT move continue # Move up to as many early OUTs as there are unmatched late INs on Day N _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] logger_handler.logger.info( f"TA Export overnight shift: moved checkout {_co.check_in_time} " f"from {_ndk} to {_dk} for employee {employee_id}" ) # ------------------------------------------------------------------- # END OVERNIGHT SHIFT DETECTION # ------------------------------------------------------------------- # Track weekly hours for overtime calculation weekly_total_hours = 0 current_week_start = None grand_regular_hours = 0 grand_ot_hours = 0 # Accumulate SP/PW/PT/C hours from cross-type pairs (where the calculator # could not detect them because it processes each work-type stream independently). cross_type_sp_hours = 0.0 cross_type_pw_hours = 0.0 cross_type_pt_hours = 0.0 cross_type_c_hours = 0.0 # Accumulate raw (uncapped) hours from regular (non-SP/PW/PT) pairs only. # Used to populate the "Regular" summary row when an employee also has # special work-type hours (SP/PW/PT/C). regular_only_hours = 0.0 # Get all dates that have records (not all weekdays) dates_with_records = sorted([ date_str for date_str, day_data in emp_data['daily_hours'].items() if day_data.get('records_count', 0) > 0 ]) # Write daily data (ONLY DAYS WITH RECORDS) for date_str in dates_with_records: date_obj = datetime.strptime(date_str, '%Y-%m-%d') day_data = emp_data['daily_hours'][date_str] # Check for week boundary anchored to the resolved report start date # (not calendar Monday, and never the current row's own date — that # would restart the week on every single day). _report_start = start_date week_start = (_report_start + timedelta(days=((date_obj.date() - _report_start).days // 7) * 7)) if current_week_start is not None and week_start != current_week_start: # Write weekly total row week_regular = min(weekly_total_hours, 40.0) 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=_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 current_row += 1 weekly_total_hours = 0 current_week_start = week_start # Get all locations for this date date_locations = daily_location_data.get(date_str, {}) total_locations = len(date_locations) total_hours = day_data['total_hours'] is_miss_punch = day_data.get('is_miss_punch', False) # 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 by mirroring the display pairing logic: # group records by base location, apply the OUT-after-IN guard within each # group, and sum only complete pairs. This ensures the daily total in # column H matches exactly the pairs rendered in the export rows. _day_total_hours = 0.0 # Track which records are consumed by same-building pairing so the # cross-building pass only considers true orphans. _same_building_used_ids = set() for _loc_data in date_locations.values(): _loc_recs = sorted(_loc_data['records'], key=_overnight_aware_sort_key) _loc_ins = [r for r in _loc_recs if not _is_out(r)] _loc_outs = [r for r in _loc_recs if _is_out(r)] _out_used = [False] * len(_loc_outs) for _in_r in _loc_ins: for _oi2, _out_r in enumerate(_loc_outs): if _out_used[_oi2]: continue # Time-only pairing guard (mirrors Step 1/2 pairing logic). _in_t_d = _in_r.check_in_time _out_t_d = _out_r.check_in_time if _out_t_d.hour <= 3: if _in_t_d.hour < 12: continue # Orphan guard: an early-morning OUT whose check_in_date # matches the current day is an orphan from the PREVIOUS # overnight shift — it must NOT steal an evening IN. # Only OUTs moved in by overnight detection (check_in_date # is later than the current day) are valid partners. _out_orig_date = _out_r.check_in_date if hasattr(_out_orig_date, 'date'): _out_orig_date = _out_orig_date.date() if _out_orig_date <= date_obj.date(): continue elif _out_t_d <= _in_t_d: continue _in_ts = datetime.combine(_in_r.check_in_date, _in_r.check_in_time) _out_ts = datetime.combine(_out_r.check_in_date, _out_r.check_in_time) if _out_ts < _in_ts: _out_ts += timedelta(days=1) _duration = (_out_ts - _in_ts).total_seconds() / 3600.0 if _duration > 24: continue _day_total_hours += _duration _out_used[_oi2] = True _same_building_used_ids.add(id(_in_r)) _same_building_used_ids.add(id(_out_r)) break # ------------------------------------------------------------------- # CROSS-BUILDING PAIRING # After same-building pairing, collect all orphaned INs and OUTs # across every location group for this day. Pair them chronologically # (earliest available OUT that is strictly after the IN). This handles # employees who check in at one building and check out at another. # ------------------------------------------------------------------- _all_day_recs_flat = [] for _loc_data in date_locations.values(): _all_day_recs_flat.extend(_loc_data['records']) _orphan_ins = sorted( [r for r in _all_day_recs_flat if not _is_out(r) and id(r) not in _same_building_used_ids], key=_overnight_aware_sort_key ) _orphan_outs = sorted( [r for r in _all_day_recs_flat if _is_out(r) and id(r) not in _same_building_used_ids], key=_overnight_aware_sort_key ) # Pre-compute cross-building pairs for this day (used both for totals # and for row writing after the location_groups loop). cross_building_pairs = [] # list of {'check_in': r, 'check_out': r, 'hours': float} _cb_out_used = [False] * len(_orphan_outs) for _cb_in in _orphan_ins: for _cb_oi, _cb_out in enumerate(_orphan_outs): if _cb_out_used[_cb_oi]: continue # Same time-only guard as Steps 1–3 _cb_in_t = _cb_in.check_in_time _cb_out_t = _cb_out.check_in_time if _cb_out_t.hour <= 3: if _cb_in_t.hour < 12: continue # Orphan guard: same-day early-morning OUT is from previous # overnight shift — skip it. Only moved OUTs (check_in_date # later than current day) are valid overnight partners. _cb_out_orig = _cb_out.check_in_date if hasattr(_cb_out_orig, 'date'): _cb_out_orig = _cb_out_orig.date() if _cb_out_orig <= date_obj.date(): continue elif _cb_out_t <= _cb_in_t: continue _cb_in_ts = datetime.combine(_cb_in.check_in_date, _cb_in.check_in_time) _cb_out_ts = datetime.combine(_cb_out.check_in_date, _cb_out.check_in_time) if _cb_out_ts < _cb_in_ts: _cb_out_ts += timedelta(days=1) _cb_dur = (_cb_out_ts - _cb_in_ts).total_seconds() / 3600.0 if _cb_dur > 24: continue _cb_out_used[_cb_oi] = True cross_building_pairs.append({ 'check_in': _cb_in, 'check_out': _cb_out, 'hours': _cb_dur, }) _day_total_hours += _cb_dur logger_handler.logger.info( f"[TA Export] Cross-building pair for employee {employee_id} on {date_str}: " f"IN {_cb_in.location_name} @ {_cb_in.check_in_time} → " f"OUT {_cb_out.location_name} @ {_cb_out.check_in_time} " f"({_cb_dur:.2f} h)" ) break # Build a set of record ids that are part of a cross-building pair so # the single-record group path can suppress its Missed Punch row. _cross_building_record_ids = set() for _cbp in cross_building_pairs: _cross_building_record_ids.add(id(_cbp['check_in'])) _cross_building_record_ids.add(id(_cbp['check_out'])) # ------------------------------------------------------------------- # END CROSS-BUILDING PAIRING PRE-COMPUTATION # ------------------------------------------------------------------- total_hours = _qtr(_day_total_hours) weekly_total_hours += total_hours # Daily total display (only shown on last location's last row) daily_total_display = _qtr(total_hours) if total_hours > 0 else '' # Get all records for the day and sort by time FIRST, then group by BASE location. # Grouping by base location (original_location_name) ensures that records from the # same building but different work types (e.g. regular IN + SP OUT) land in the # same group so the cross-type pairing rule can resolve them. all_day_records = [] for loc_data in date_locations.values(): all_day_records.extend(loc_data['records']) # 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 BASE location (original_location_name without work-type # suffix) while maintaining time order. def _base_loc(r): return getattr(r, 'original_location_name', None) or r.location_name or 'Unknown Location' location_groups = [] current_base_location = None current_group = [] for record in all_day_records_sorted: bloc = _base_loc(record) if current_base_location is None or bloc == current_base_location: current_base_location = bloc current_group.append(record) else: if current_group: location_groups.append({ 'location': current_base_location, 'records': current_group }) current_base_location = bloc current_group = [record] # Add the last group if current_group: location_groups.append({ 'location': current_base_location, 'records': current_group }) # Process each location group in chronological order total_groups = len(location_groups) for group_index, group_data in enumerate(location_groups): location_count = group_index + 1 is_last_location = (location_count == total_groups) location_name = group_data['location'] sorted_records = group_data['records'] if len(sorted_records) == 1: # Single record for this location single_record = sorted_records[0] # If this record has been resolved by cross-building pairing, # suppress the Missed Punch row here — it will be written after # all location groups have been processed (Touch Point 3). if id(single_record) in _cross_building_record_ids: continue # Get the original TimeAttendance record to check action_description original_record = None for rec in records: if (rec.employee_id == single_record.employee_id and rec.attendance_date == single_record.check_in_date and rec.attendance_time == single_record.check_in_time): original_record = rec break # Determine if this is a check-in or check-out is_check_out = False if original_record and original_record.action_description: action_lower = original_record.action_description.lower() is_check_out = 'out' in action_lower or 'checkout' in action_lower # Show day name and date only for first group's first record day_display = date_obj.strftime('%A').upper() if location_count == 1 else '' date_display = date_obj.strftime('%m/%d/%Y') if location_count == 1 else '' # Show daily total only if this is the last group current_daily_total = daily_total_display if is_last_location else '' if is_check_out: # Orphaned check-out row_data = [ day_display, date_display, '', # No check-in time single_record.check_in_time.strftime('%I:%M:%S %p'), # Out single_record.location_name, '', 'Missed Punch', current_daily_total, '', '', single_record.event_description or '', single_record.recorded_address or '', getattr(single_record, 'distance', None) or '', calculate_possible_violation(getattr(single_record, 'distance', None)) ] else: # Orphaned check-in row_data = [ day_display, date_display, single_record.check_in_time.strftime('%I:%M:%S %p'), # In '', # No check-out time single_record.location_name, '', 'Missed Punch', current_daily_total, '', '', single_record.event_description or '', single_record.recorded_address or '', getattr(single_record, 'distance', None) or '', calculate_possible_violation(getattr(single_record, 'distance', None)) ] day_border = border_day_last if is_last_location else border_day_middle for col, value in enumerate(row_data, 1): cell = ws.cell(row=current_row, column=col, value=value) cell.font = data_font cell.border = day_border # Apply orange background to Missed Punch cell (column G) if col == 7: cell.fill = missed_punch_fill current_row += 1 else: # Multiple records for this location group. # Build record_info with work_type included. record_info = [] for record in sorted_records: action_desc = record.action_description.lower() if record.action_description else '' is_out = 'out' in action_desc or 'checkout' in action_desc wt = getattr(record, 'work_type', None) # None = regular record_info.append({ 'record': record, 'is_out': is_out, 'work_type': wt, # None means regular 'used': False }) logger_handler.logger.debug(f"TA Export record: time={record.check_in_time}, action='{record.action_description}', is_out={is_out}, work_type={wt}") ins = [ri for ri in record_info if not ri['is_out']] outs = [ri for ri in record_info if ri['is_out']] pairs_to_write = [] # ── STEP 1: same-type pairing ────────────────────────────────────── # Pair each IN with an OUT of the same work type first. # Sort INs chronologically and OUTs with overnight-aware key so that # an early-morning OUT (e.g. 00:30 moved in by overnight detection) # sorts AFTER same-day evening OUTs and does not steal a daytime IN. ins_sorted = sorted(ins, key=lambda ri: _overnight_aware_sort_key(ri['record'])) outs_sorted = sorted(outs, key=lambda ri: _overnight_aware_sort_key(ri['record'])) for in_ri in ins_sorted: if in_ri['used']: continue for out_ri in outs_sorted: if out_ri['used']: continue # Guard: time-only pairing rule. # 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. # Using time-only (not datetime) avoids false positives from moved overnight # OUT records whose check_in_date is still a later date. _in_t = in_ri['record'].check_in_time _out_t = out_ri['record'].check_in_time if _out_t.hour <= 3: if _in_t.hour < 12: continue # early-morning OUT cannot pair with morning IN # Orphan guard: same-day early-morning OUT is from a # previous overnight shift — not a valid partner for # this evening IN. Only moved OUTs (check_in_date # later than current day) should pair. _out_orig_d = out_ri['record'].check_in_date if hasattr(_out_orig_d, 'date'): _out_orig_d = _out_orig_d.date() if _out_orig_d <= date_obj.date(): continue elif _out_t <= _in_t: continue # same-day OUT must be strictly after IN if out_ri['work_type'] == in_ri['work_type']: # Matched same work type — standard pair in_ri['used'] = True out_ri['used'] = True pairs_to_write.append({ 'check_in': in_ri['record'], 'check_out': out_ri['record'], 'is_miss_punch': False, 'effective_work_type': in_ri['work_type'] }) break # ── STEP 2: cross-type pairing (forgot the work code) ────────────── # If any INs or OUTs remain unmatched after same-type pairing, # attempt to pair an unmatched IN with an unmatched OUT of a # *different* work type. Hours count as the special type's hours # (if either side is special, the pair is treated as special; # if both are different special types, use the OUT's type as # the authoritative code — it's the scan that carries the code). unmatched_ins = [ri for ri in ins_sorted if not ri['used']] unmatched_outs = [ri for ri in outs_sorted if not ri['used']] for in_ri in unmatched_ins: if in_ri['used']: continue for out_ri in unmatched_outs: if out_ri['used']: continue # Guard: same time-only rule as Step 1. _in_t2 = in_ri['record'].check_in_time _out_t2 = out_ri['record'].check_in_time if _out_t2.hour <= 3: if _in_t2.hour < 12: continue # Orphan guard: same-day early-morning OUT is from a # previous overnight shift — not a valid partner for # this evening IN. Only moved OUTs (check_in_date # later than current day) should pair. _out_orig_d2 = out_ri['record'].check_in_date if hasattr(_out_orig_d2, 'date'): _out_orig_d2 = _out_orig_d2.date() if _out_orig_d2 <= date_obj.date(): continue elif _out_t2 <= _in_t2: continue # Cross-type pair: one side is regular, other is special # (or both special but different codes — treat OUT's type as definitive) effective_wt = out_ri['work_type'] if out_ri['work_type'] else in_ri['work_type'] in_ri['used'] = True out_ri['used'] = True pairs_to_write.append({ 'check_in': in_ri['record'], 'check_out': out_ri['record'], 'is_miss_punch': False, 'effective_work_type': effective_wt, 'is_cross_type': True }) break # ── STEP 3: remaining unmatched records → Missed Punch ───────────── for ri in record_info: if not ri['used']: ri['used'] = True if ri['is_out']: pairs_to_write.append({ 'check_in': None, 'check_out': ri['record'], 'is_miss_punch': True, 'effective_work_type': ri['work_type'] }) else: pairs_to_write.append({ 'check_in': ri['record'], 'check_out': None, 'is_miss_punch': True, 'effective_work_type': ri['work_type'] }) logger_handler.logger.debug(f"TA Export: created {len(pairs_to_write)} pairs for export") # Sort pairs chronologically by the anchor record's time so that # orphaned records (assembled last in Steps 2-3) appear in the # correct time-order position relative to complete pairs. def _pair_sort_key(pd): anchor = pd['check_in'] or pd['check_out'] return _overnight_aware_sort_key(anchor) if anchor else 0 pairs_to_write.sort(key=_pair_sort_key) # Write all pairs for pair_idx, pair_data in enumerate(pairs_to_write): check_in_record = pair_data['check_in'] check_out_record = pair_data['check_out'] is_miss_punch = pair_data['is_miss_punch'] # Show day name and date only for first pair of first location day_display = date_obj.strftime('%A').upper() if (location_count == 1 and pair_idx == 0) else '' date_display = date_obj.strftime('%m/%d/%Y') if (location_count == 1 and pair_idx == 0) else '' # Calculate hours if complete pair if check_in_record and check_out_record and not is_miss_punch: 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), # 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' # Accumulate SP/PW/PT/C hours for CROSS-TYPE pairs only. # Same-type SP/PW/PT pairs are already captured in grand_totals # by WorkingHoursCalculator; adding them again here would double-count. if not is_miss_punch and isinstance(pair_hours, (int, float)) and pair_data.get('is_cross_type', False): _ewt = pair_data.get('effective_work_type') if _ewt == 'SP': cross_type_sp_hours += pair_hours elif _ewt == 'PW': cross_type_pw_hours += pair_hours elif _ewt == 'PT': cross_type_pt_hours += pair_hours elif _ewt == 'C': cross_type_c_hours += pair_hours # Accumulate raw regular-only hours (non-SP/PW/PT completed pairs). # This feeds the "Regular" summary row shown when an employee also # has SP/PW/PT/C hours. Cross-type pairs are excluded here because # their effective_work_type is SP/PW/PT, not regular. if not is_miss_punch and isinstance(pair_hours, (int, float)): _pair_ewt = pair_data.get('effective_work_type') if _pair_ewt not in ('SP', 'PW', 'PT', 'C'): regular_only_hours += pair_hours # Determine whether this is an overnight pair: # check-in is late evening (>= 20:00) AND check-out is early morning (<= 03: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 >= 20 and check_out_record.check_in_time.hour <= 3 ) # 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. # For a complete pair, derive the display name from effective_work_type: # - regular pair → base location name (no suffix) # - SP/PW/PT pair → base location name + " (SP/PW/PT/C)" # 'regular' is treated identically to None — no suffix is shown. # For orphaned records keep their own location_name. _effective_wt = pair_data.get('effective_work_type') _is_special_wt = _effective_wt in ('SP', 'PW', 'PT', 'C') _ref_record = check_in_record or check_out_record if check_in_record and check_out_record: _base = _base_loc(check_in_record) if _is_special_wt: _location_str = f"{_base} ({_effective_wt})" else: _location_str = _base else: _location_str = _ref_record.location_name if _ref_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 _out_time_str, # Out _location_str, # Location (effective work type + optional midnight label) '', pair_hours, current_daily_total, '', '', check_in_record.event_description or '', check_in_record.recorded_address or '', getattr(check_in_record, 'distance', None) or '', calculate_possible_violation(getattr(check_in_record, 'distance', None)) ] elif check_in_record: # IN without OUT row_data = [ day_display, date_display, check_in_record.check_in_time.strftime('%I:%M:%S %p'), # In '', # No OUT _location_str, '', 'Missed Punch', current_daily_total, '', '', check_in_record.event_description or '', check_in_record.recorded_address or '', getattr(check_in_record, 'distance', None) or '', calculate_possible_violation(getattr(check_in_record, 'distance', None)) ] else: # OUT without IN row_data = [ day_display, date_display, '', # No IN check_out_record.check_in_time.strftime('%I:%M:%S %p'), # Out _location_str, '', 'Missed Punch', current_daily_total, '', '', check_out_record.event_description or '', check_out_record.recorded_address or '', getattr(check_out_record, 'distance', None) or '', calculate_possible_violation(getattr(check_out_record, 'distance', None)) ] day_border = border_day_last if is_last_pair else border_day_middle for col, value in enumerate(row_data, 1): cell = ws.cell(row=current_row, column=col, value=value) cell.font = data_font cell.border = day_border # Apply orange background to Missed Punch cell if col == 7 and value == 'Missed Punch': cell.fill = missed_punch_fill current_row += 1 # ------------------------------------------------------------------- # CROSS-BUILDING PAIR ROW WRITING (Touch Point 3) # Write one row per cross-building pair identified during pre-computation. # The day-name and date columns are only shown for the very first row # of this day that is actually rendered; we track that with a flag. # ------------------------------------------------------------------- if cross_building_pairs: # Determine whether any non-cross-building rows were already written # for this day. We look at how many rows were consumed since the # start of this date's block. The simplest proxy: check whether # the first location group had at least one real (non-skipped) record. # We use a dedicated flag instead to keep this clean. _cb_first_row_of_day = not any( id(r) not in _cross_building_record_ids for loc_data in date_locations.values() for r in loc_data['records'] ) for _cb_idx, _cbp in enumerate(cross_building_pairs): _cb_in_rec = _cbp['check_in'] _cb_out_rec = _cbp['check_out'] _cb_hours = _cbp['hours'] _cb_pair_hours = round(_cb_hours, 2) _is_last_cb = (_cb_idx == len(cross_building_pairs) - 1) # Show day/date only on the very first row written for this date # (either this is the first row overall, or prior groups had records) if _cb_idx == 0 and _cb_first_row_of_day: _cb_day_display = date_obj.strftime('%A').upper() _cb_date_display = date_obj.strftime('%m/%d/%Y') else: _cb_day_display = '' _cb_date_display = '' # Show daily total on the last cross-building row if it is # also the last row written for this day. _cb_daily_total = daily_total_display if _is_last_cb else '' # Location label: clearly identifies both buildings _cb_in_loc = _base_loc(_cb_in_rec) _cb_out_loc = _base_loc(_cb_out_rec) _cb_loc_str = f"IN: {_cb_in_loc} → OUT: {_cb_out_loc}" row_data = [ _cb_day_display, _cb_date_display, _cb_in_rec.check_in_time.strftime('%I:%M:%S %p'), # In _cb_out_rec.check_in_time.strftime('%I:%M:%S %p'), # Out _cb_loc_str, '', _cb_pair_hours, _cb_daily_total, '', '', _cb_in_rec.event_description or '', _cb_in_rec.recorded_address or '', getattr(_cb_in_rec, 'distance', None) or '', calculate_possible_violation(getattr(_cb_in_rec, 'distance', None)) ] _cb_border = border_day_last if _is_last_cb else border_day_middle for col, value in enumerate(row_data, 1): cell = ws.cell(row=current_row, column=col, value=value) cell.font = data_font cell.border = _cb_border current_row += 1 # ------------------------------------------------------------------- # END CROSS-BUILDING PAIR ROW WRITING # ------------------------------------------------------------------- # Write final weekly total for this employee if weekly_total_hours > 0: week_regular = min(weekly_total_hours, 40.0) 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=_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 current_row += 1 # Write extra working hours rows (SP/PW/PT/C) if employee has any. # Get extra hours from emp_data grand_totals, then add any cross-type hours # accumulated during rendering (pairs the calculator could not detect). grand_totals = emp_data.get('grand_totals', {}) sp_hours = grand_totals.get('sp_hours', 0.0) + cross_type_sp_hours pw_hours = grand_totals.get('pw_hours', 0.0) + cross_type_pw_hours pt_hours = grand_totals.get('pt_hours', 0.0) + cross_type_pt_hours c_hours = grand_totals.get('c_hours', 0.0) + cross_type_c_hours _has_special_hours = sp_hours > 0 or pw_hours > 0 or pt_hours > 0 or c_hours > 0 _summary_font = Font(name='Aptos Narrow', size=11, bold=True, italic=True) # Write SP row if hours > 0 (abbreviated label in col 8, hours in col 9) if sp_hours > 0: ws.cell(row=current_row, column=8, value='SP').font = _summary_font ws.cell(row=current_row, column=9, value=_qtr(sp_hours)).font = _summary_font logger_handler.logger.info(f"Export: Employee {employee_id} SP hours: {sp_hours:.2f}") current_row += 1 # Write PW row if hours > 0 if pw_hours > 0: ws.cell(row=current_row, column=8, value='PW').font = _summary_font ws.cell(row=current_row, column=9, value=_qtr(pw_hours)).font = _summary_font logger_handler.logger.info(f"Export: Employee {employee_id} PW hours: {pw_hours:.2f}") current_row += 1 # Write PT row if hours > 0 if pt_hours > 0: ws.cell(row=current_row, column=8, value='PT').font = _summary_font ws.cell(row=current_row, column=9, value=_qtr(pt_hours)).font = _summary_font logger_handler.logger.info(f"Export: Employee {employee_id} PT hours: {pt_hours:.2f}") current_row += 1 # Write C (Covering) row if hours > 0 if c_hours > 0: ws.cell(row=current_row, column=8, value='C').font = _summary_font ws.cell(row=current_row, column=9, value=_qtr(c_hours)).font = _summary_font logger_handler.logger.info(f"Export: Employee {employee_id} C hours: {c_hours:.2f}") current_row += 1 # Write Regular row — only when the employee has at least one special # work-type (SP/PW/PT/C). Shows raw accumulated hours from non-special pairs. if _has_special_hours: ws.cell(row=current_row, column=8, value='Regular').font = _summary_font ws.cell(row=current_row, column=9, value=_qtr(regular_only_hours)).font = _summary_font logger_handler.logger.info(f"Export: Employee {employee_id} Regular hours: {regular_only_hours:.2f}") current_row += 1 # 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=_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 current_row += 1 # Auto-size columns - handle merged cells properly for col_idx in range(1, 15): column_letter = get_column_letter(col_idx) # Set fixed width for Day column (column A) if col_idx == 1: ws.column_dimensions[column_letter].width = 18 continue max_length = 0 for row in ws.iter_rows(min_col=col_idx, max_col=col_idx): for cell in row: if isinstance(cell, openpyxl.cell.cell.MergedCell): continue try: if cell.value and len(str(cell.value)) > max_length: max_length = len(str(cell.value)) except Exception: pass # Non-string cell value — skip width measurement adjusted_width = min(max_length + 2, 50) ws.column_dimensions[column_letter].width = adjusted_width # Save to BytesIO output = io.BytesIO() neutralize_unexpected_formulas(wb) # formula-injection guard (utils/excel_safety.py) wb.save(output) output.seek(0) # Filename if date_range_str: filename = f'{project_name_for_filename}time_attendance_{date_range_str}.xlsx' else: filename = f'{project_name_for_filename}time_attendance.xlsx' return send_file( output, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', as_attachment=True, download_name=filename ) # --------------------------------------------------------------------------- # Export by Building — extra sheets ("Filtered Report" + "Weekly Hours by # Location"). Both are built from the row bookkeeping collected while Sheet0 # is written; Sheet0 itself is never modified. # --------------------------------------------------------------------------- def _normalize_base_id(employee_id): """'04921' and '4921' are the same employee for exclusion matching.""" s = str(employee_id or '').strip().upper() return s.lstrip('0') or s def _format_id_list(ids): """['4921', '4944', '4816'] -> '4921, 4944, and 4816'.""" ids = [str(i) for i in ids] if len(ids) <= 2: return ' and '.join(ids) return ', '.join(ids[:-1]) + f', and {ids[-1]}' def _kept_building_employees(block, excluded): """Employee blocks of one building whose base ID is not excluded.""" return [e for e in block['employees'] if _normalize_base_id(e['employee_id']) not in excluded] def _build_filtered_building_sheet(wb, src_ws, blocks, sp_summary_rows, excluded_ids): """ Add a "Filtered Report" sheet — a copy of Sheet0 that: - removes every employee block whose base ID is in excluded_ids, and the building header too when no employee is left (buildings renumbered); - removes the SP summary rows ('SP' in the Daily Total column, its hours in the Regular Hours column); - keeps everything else, including punch rows whose location carries '(SP)' and every GRAND TOTAL row. """ from copy import copy excluded = {_normalize_base_id(e) for e in excluded_ids} dst_ws = wb.create_sheet('Filtered Report') # Output plan: (source_row, building-header text override or None) plan = [] first_block_row = blocks[0]['header_row'] if blocks else src_ws.max_row + 1 plan.extend((r, None) for r in range(1, first_block_row)) new_index = 0 for block in blocks: kept = _kept_building_employees(block, excluded) if not kept: continue new_index += 1 plan.append((block['header_row'], f"{new_index}) {block['name']} - Zone {block['zone']}")) for emp in kept: plan.extend((r, None) for r in range(emp['start_row'], emp['end_row'] + 1) if r not in sp_summary_rows) # Trailing blank row(s) after the building plan.extend((r, None) for r in range(block['employees'][-1]['end_row'] + 1, block['end_row'] + 1)) merged_by_row = {} for rng in src_ws.merged_cells.ranges: if rng.min_row == rng.max_row: merged_by_row.setdefault(rng.min_row, []).append((rng.min_col, rng.max_col)) max_col = src_ws.max_column for new_r, (src_r, override) in enumerate(plan, 1): for c in range(1, max_col + 1): src_cell = src_ws.cell(row=src_r, column=c) if isinstance(src_cell, openpyxl.cell.cell.MergedCell): continue if src_cell.value is None and not src_cell.has_style: continue dst_cell = dst_ws.cell(row=new_r, column=c, value=src_cell.value) if src_cell.has_style: dst_cell.font = copy(src_cell.font) dst_cell.fill = copy(src_cell.fill) dst_cell.border = copy(src_cell.border) dst_cell.alignment = copy(src_cell.alignment) dst_cell.number_format = src_cell.number_format if override is not None: dst_ws.cell(row=new_r, column=1).value = override for c1, c2 in merged_by_row.get(src_r, []): dst_ws.merge_cells(start_row=new_r, start_column=c1, end_row=new_r, end_column=c2) for key, dim in src_ws.column_dimensions.items(): dst_ws.column_dimensions[key].width = dim.width # Row 5 is blank in Sheet0 (between the date range and the first building) if first_block_row > 6: removed = [f"employee IDs {_format_id_list(excluded_ids)}"] if excluded_ids else [] removed.append("SP summary rows") note = dst_ws.cell(row=5, column=1, value=f"Filtered - removed: {'; '.join(removed)}.") note.font = Font(name='Aptos Narrow', size=11, italic=True, color='9C5700') logger_handler.logger.info( f"Export by Building: Filtered Report built — {new_index} buildings, " f"{len(plan)} rows (excluded IDs: {', '.join(excluded_ids) or 'none'})" ) def _build_weekly_hours_by_location_sheet(wb, blocks, excluded_ids, start_date, end_date, project_display): """ Add a "Weekly Hours by Location" sheet: one row per employee per building with non-SP hours per report week, plus a Location Totals table and the project's total hours worked. Weeks are anchored to the report start date, exactly like the Weekly Total rows in Sheet0. Excluded employee IDs are left out; totals are Excel formulas so they follow manual edits. """ excluded = {_normalize_base_id(e) for e in excluded_ids} ws = wb.create_sheet('Weekly Hours by Location') ws.sheet_view.showGridLines = False n_weeks = max(1, (end_date - start_date).days // 7 + 1) week_ranges = [] for w in range(n_weeks): w_start = start_date + timedelta(days=7 * w) w_end = min(w_start + timedelta(days=6), end_date) week_ranges.append(f"{w_start.strftime('%m/%d')}-{w_end.strftime('%m/%d')}") rows, locations = [], [] for block in blocks: kept = _kept_building_employees(block, excluded) if not kept: continue locations.append(block['name']) for emp in kept: weeks = [round(emp['weeks'].get(w, 0.0), 2) for w in range(n_weeks)] rows.append((block['name'], emp['employee_id'], emp['name'], weeks)) # Column layout: detail table, one gap column, location totals table first_week_col = 4 total_col = first_week_col + n_weeks loc_col = total_col + 2 loc_total_col = loc_col + n_weeks + 1 L = get_column_letter title_fill = PatternFill(start_color='1F4E78', end_color='1F4E78', fill_type='solid') header_fill = PatternFill(start_color='203864', end_color='203864', fill_type='solid') total_fill = PatternFill(start_color='548235', end_color='548235', fill_type='solid') band_fills = (PatternFill(start_color='FFFFFF', end_color='FFFFFF', fill_type='solid'), PatternFill(start_color='F7FAFC', end_color='F7FAFC', fill_type='solid')) white_bold = Font(name='Cambria', size=11, bold=True, color='FFFFFF') note_font = Font(name='Cambria', size=11, color='9C5700') data_font = Font(name='Calibri', size=11) header_align = Alignment(horizontal='center', vertical='center', wrap_text=True) # Title, report period, total hours, notes ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=total_col) c = ws.cell(row=1, column=1, value=f"{project_display} - Weekly Hours Review") c.font = Font(name='Cambria', size=16, bold=True, color='FFFFFF') c.fill = title_fill c.alignment = Alignment(horizontal='center', vertical='center') ws.row_dimensions[1].height = 24.75 ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=total_col) ws.cell(row=2, column=1, value=f"Report period: {start_date.strftime('%m/%d/%Y')} to {end_date.strftime('%m/%d/%Y')}" ).font = Font(name='Cambria', size=11, color='404040') first_data_row = 7 last_data_row = first_data_row + len(rows) - 1 c = ws.cell(row=3, column=1, value='Total Hours Worked') c.font, c.fill = white_bold, total_fill c = ws.cell(row=3, column=2, value=f"=SUM({L(total_col)}{first_data_row}:{L(total_col)}{last_data_row})" if rows else 0) c.font = Font(name='Cambria', size=14, bold=True, color='FFFFFF') c.fill, c.number_format = total_fill, '0.00' ws.row_dimensions[3].height = 17.25 excl_text = 'Special Project (SP) hours.' if excluded_ids: excl_text = f"Employee IDs {_format_id_list(excluded_ids)}; Special Project (SP) hours." ws.cell(row=4, column=1, value='Exclusions:').font = Font(name='Cambria', size=11, bold=True, color='9C5700') ws.merge_cells(start_row=4, start_column=2, end_row=4, end_column=total_col) ws.cell(row=4, column=2, value=excl_text).font = note_font zero_rows = sum(1 for r in rows if not any(r[3])) if zero_rows: ws.cell(row=5, column=1, value='Note:').font = Font(name='Cambria', size=11, bold=True, color='9C5700') ws.merge_cells(start_row=5, start_column=2, end_row=5, end_column=total_col) ws.cell(row=5, column=2, value=f"{zero_rows} employee-location records have 0.00 weekly hours " f"(only SP or missed-punch time at that location).").font = note_font # Detail table header headers = ['Location', 'Employee ID', 'Employee Name'] headers += [f"Week {w + 1} Hours\n{week_ranges[w]}" for w in range(n_weeks)] headers.append('Total Hours') for col, text in enumerate(headers, 1): c = ws.cell(row=6, column=col, value=text) c.font, c.fill, c.alignment = white_bold, header_fill, header_align # Location totals header ws.merge_cells(start_row=1, start_column=loc_col, end_row=1, end_column=loc_total_col) c = ws.cell(row=1, column=loc_col, value='Location Totals') c.font = Font(name='Cambria', size=14, bold=True, color='FFFFFF') c.fill = title_fill c.alignment = Alignment(horizontal='center', vertical='center') loc_headers = ['Location'] + [f"Week {w + 1}" for w in range(n_weeks)] + ['Total'] for offset, text in enumerate(loc_headers): c = ws.cell(row=6, column=loc_col + offset, value=text) c.font, c.fill, c.alignment = white_bold, header_fill, header_align ws.row_dimensions[6].height = 33.75 if not rows: ws.cell(row=first_data_row, column=1, value='No records remain after exclusions.').font = data_font else: # Detail rows for i, (location, emp_id, emp_name, weeks) in enumerate(rows): r = first_data_row + i fill = band_fills[i % 2] values = [location, int(emp_id) if str(emp_id).isdigit() else emp_id, emp_name] + weeks values.append(f"=SUM({L(first_week_col)}{r}:{L(total_col - 1)}{r})") for col, value in enumerate(values, 1): c = ws.cell(row=r, column=col, value=value) c.font, c.fill = data_font, fill if col >= first_week_col: c.number_format = '0.00' # Location totals rows (SUMIF over the detail table) loc_range = f"$A${first_data_row}:$A${last_data_row}" for i, location in enumerate(locations): r = first_data_row + i ws.cell(row=r, column=loc_col, value=location).font = data_font for w in range(n_weeks): wl = L(first_week_col + w) c = ws.cell(row=r, column=loc_col + 1 + w, value=f"=SUMIF({loc_range},{L(loc_col)}{r},${wl}${first_data_row}:${wl}${last_data_row})") c.font, c.number_format = data_font, '0.00' c = ws.cell(row=r, column=loc_total_col, value=f"=SUM({L(loc_col + 1)}{r}:{L(loc_total_col - 1)}{r})") c.font, c.number_format = data_font, '0.00' # Project total row under the location totals loc_last = first_data_row + len(locations) - 1 pr = loc_last + 2 c = ws.cell(row=pr, column=loc_col, value='Project Total') c.font, c.fill = white_bold, total_fill for col in range(loc_col + 1, loc_total_col + 1): cl = L(col) c = ws.cell(row=pr, column=col, value=f"=SUM({cl}{first_data_row}:{cl}{loc_last})") c.font, c.fill, c.number_format = white_bold, total_fill, '0.00' ws.auto_filter.ref = f"A6:{L(total_col)}{last_data_row}" # Column widths (mirrors the reference layout) ws.column_dimensions['A'].width = 32 ws.column_dimensions['B'].width = 13 ws.column_dimensions['C'].width = 30 for w in range(n_weeks): ws.column_dimensions[L(first_week_col + w)].width = 17 ws.column_dimensions[L(loc_col + 1 + w)].width = 12 ws.column_dimensions[L(total_col)].width = 14 ws.column_dimensions[L(total_col + 1)].width = 3 ws.column_dimensions[L(loc_col)].width = 32 ws.column_dimensions[L(loc_total_col)].width = 12 ws.freeze_panes = f"A{first_data_row}" logger_handler.logger.info( f"Export by Building: Weekly Hours by Location built — {len(rows)} employee-location rows, " f"{len(locations)} locations, {n_weeks} week(s)" ) def export_time_attendance_by_building_excel(records, project_name_for_filename, date_range_str, start_date_filter=None, end_date_filter=None, unlimited=False): """Generate Excel export grouped by building/location with template format""" from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Border, Side, Alignment from openpyxl.utils import get_column_letter import io # Create workbook wb = Workbook() ws = wb.active ws.title = "Sheet0" # Resolve date range; skip 14-day cap when unlimited=True result = _resolve_date_range(start_date_filter, end_date_filter, records, 'TA by-building Excel export', unlimited=unlimited) if result is None: return None start_date, end_date, records = result from working_hours_calculator import parse_employee_id_for_work_type # Convert TimeAttendance records to format expected by calculator converted_records = _convert_ta_records(records) # Group records by location (building) location_groups = {} for record in converted_records: loc_name = record.original_location_name or 'Unknown Location' if loc_name not in location_groups: location_groups[loc_name] = [] location_groups[loc_name].append(record) # Sort locations alphabetically sorted_locations = sorted(location_groups.keys()) # Log grouping info logger_handler.logger.info( f"Export by Building: Grouped {len(converted_records)} records into {len(sorted_locations)} locations" ) # Calculate working hours using WorkingHoursCalculator for SP/PT/PW hours calculator = WorkingHoursCalculator() hours_data = calculator.calculate_all_employees_hours( datetime.combine(start_date, datetime.min.time()), datetime.combine(end_date, datetime.max.time()), converted_records ) # Build employee name map (Lastname, Firstname keyed by base employee ID) employee_names = _build_employee_name_map(records) # Setup styles (shared objects) _styles = _make_export_styles() header_font = _styles['header_font'] header_fill = _styles['header_fill'] data_font = _styles['data_font'] bold_font = _styles['bold_font'] italic_bold_font = _styles['italic_bold_font'] border = _styles['border'] missed_punch_fill = _styles['missed_punch_fill'] amber_fill = _styles['amber_fill'] border_day_middle = _styles['border_day_middle'] border_day_last = _styles['border_day_last'] # Write main headers current_row = 1 # Row 1: Company name ws.merge_cells(f'A{current_row}:N{current_row}') title_cell = ws.cell(row=current_row, column=1, value=current_app.config.get('COMPANY_NAME', 'QR Code Management System')) title_cell.font = Font(name='Aptos Narrow', size=14, bold=True) title_cell.alignment = Alignment(horizontal='left') current_row += 1 # Row 2: Summary title ws.merge_cells(f'A{current_row}:N{current_row}') summary_cell = ws.cell(row=current_row, column=1, value='Summary report of Hours worked') summary_cell.font = Font(name='Aptos Narrow', size=12, bold=True) summary_cell.alignment = Alignment(horizontal='left') current_row += 1 # Row 3: Project name project_display = project_name_for_filename.replace('_', ' ').strip() if project_name_for_filename else "[Project Name]" project_cell = ws.cell(row=current_row, column=1, value=project_display) project_cell.font = Font(name='Aptos Narrow', size=11, bold=True) project_cell.alignment = Alignment(horizontal='left') current_row += 1 # Row 4: Date range date_range_text = f"Date range: {start_date.strftime('%m/%d/%Y')} to {end_date.strftime('%m/%d/%Y')}" ws.merge_cells(f'A{current_row}:N{current_row}') date_cell = ws.cell(row=current_row, column=1, value=date_range_text) date_cell.font = Font(name='Aptos Narrow', size=11) date_cell.alignment = Alignment(horizontal='left') current_row += 1 # Empty rows before first building current_row += 2 # Row bookkeeping for the extra "Filtered Report" and "Weekly Hours by # Location" sheets — records where each building/employee block lands in # Sheet0, which rows are SP summary rows, and each employee's weekly # non-SP hours. Sheet0 itself is written exactly as before. _bb_blocks = [] _bb_sp_summary_rows = set() # Process each building/location for location_index, location_name in enumerate(sorted_locations, 1): location_records = location_groups[location_name] # Get zone info from QR code if available zone_info = '' try: qr_code = QRCode.query.filter_by(location=location_name).first() if qr_code: zone_info = getattr(qr_code, 'zone', '') or '' except Exception as e: logger_handler.logger.debug(f"Could not retrieve zone info for location '{location_name}': {e}") # Building header row building_header = f"{location_index}) {location_name} - Zone {zone_info}" ws.merge_cells(f'A{current_row}:O{current_row}') building_cell = ws.cell(row=current_row, column=1, value=building_header) building_cell.font = Font(name='Aptos Narrow', size=11, bold=True) building_cell.alignment = Alignment(horizontal='left') _bb_block = {'name': location_name, 'zone': zone_info, 'header_row': current_row, 'employees': []} current_row += 1 # Get unique employees for this location employees_at_location = {} for record in location_records: base_id, _ = parse_employee_id_for_work_type(record.employee_id) if base_id not in employees_at_location: employees_at_location[base_id] = [] employees_at_location[base_id].append(record) # Sort employees by name sorted_employee_ids = sorted( employees_at_location.keys(), key=lambda emp_id: employee_names.get(emp_id, f'Employee {emp_id}').lower() ) # Process each employee at this location for employee_id in sorted_employee_ids: emp_records = employees_at_location[employee_id] emp_name = employee_names.get(employee_id, f'Employee {employee_id}') # Compute SP/PW/PT/C hours from the records already scoped to this # building and employee (emp_records). Using the calculator's # grand_totals here would be incorrect: those totals are GLOBAL # (across all buildings), so an employee with SP hours at Building A # would incorrectly show an SP row at Building B where they have none. # # 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') c_hours = _building_special_hours(emp_records, 'C') _bb_emp_meta = {'employee_id': employee_id, 'name': emp_name, 'start_row': current_row, 'weeks': {}} # Employee header row ws.merge_cells(f'A{current_row}:O{current_row}') emp_header = ws.cell(row=current_row, column=1, value=f'Employee ID {employee_id}: {emp_name}') emp_header.font = Font(name='Aptos Narrow', size=11, bold=True) emp_header.alignment = Alignment(horizontal='left') current_row += 1 # Column headers headers = ['Day', 'Date', 'In', 'Out', 'Location', 'Zone', 'Hours/Building', 'Daily Total', 'Regular Hours', 'OT Hours', 'Building Address', 'Recorded Location', 'Distance (Mile)', 'Possible Violation'] for col, header in enumerate(headers, 1): cell = ws.cell(row=current_row, column=col, value=header) cell.font = header_font cell.fill = header_fill cell.border = border cell.alignment = Alignment(horizontal='center', vertical='center') current_row += 1 # Group employee records by date daily_records = {} for record in emp_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) # ----------------------------------------------------------- # 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 (>= 12:00 PM) 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 >= 12] 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-morning INs guard: do NOT move if Day N+1 has a morning # IN (< 12:00) 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 < 12 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 weekly_total_hours = 0 current_week_start = None grand_regular_hours = 0 grand_ot_hours = 0 # Accumulate raw regular-only (non-SP/PW/PT) pair hours. # Used for the "Regular" summary row when the employee also has # special work-type hours. regular_only_hours = 0.0 # Sort dates (re-sort after overnight detection may have removed buckets). # 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: date_obj = datetime.strptime(date_str, '%Y-%m-%d') # Sort records overnight-aware: early-morning OUTs (<=03:00) sort after # evening records so they pair with the correct evening check-in. day_records = sorted(daily_records[date_str], key=_overnight_aware_sort_key) # Check for week boundary anchored to the resolved report start date # (not calendar Monday, and never the current row's own date — that # would restart the week on every single day). _report_start = start_date week_start = (_report_start + timedelta(days=((date_obj.date() - _report_start).days // 7) * 7)) if current_week_start is not None and week_start != current_week_start: # Write weekly total row week_regular = min(weekly_total_hours, 40.0) 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=_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 current_row += 1 weekly_total_hours = 0 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 record_info = [] for record in day_records: action_desc = record.action_description.lower() if record.action_description else '' is_out = 'out' in action_desc or 'checkout' in action_desc record_info.append({ 'record': record, 'is_out': is_out, 'used': False }) # Create pairs pairs = [] ins = [ri for ri in record_info if not ri['is_out']] outs = [ri for ri in record_info if ri['is_out']] if len(ins) > len(outs) and len(outs) > 0: # Odd-IN rule: discard all but the LATEST IN; pair it with the earliest OUT. # Use overnight-aware sort so early-morning OUTs sort after evening OUTs. ins_sorted = sorted(ins, key=lambda ri: _overnight_aware_sort_key(ri['record'])) outs_sorted = sorted(outs, key=lambda ri: _overnight_aware_sort_key(ri['record'])) latest_in = ins_sorted[-1] excess_ins = ins_sorted[:-1] # Orphan guard: when the latest IN is afternoon/evening (>=12h), skip # early-morning OUTs (<=3h) whose check_in_date matches the # current day — they are orphans from a previous overnight shift. _oi_in_hour = latest_in['record'].check_in_time.hour earliest_out = None _oi_skip = [] for _oi_ri in outs_sorted: if (earliest_out is None and _oi_in_hour >= 12 and _oi_ri['record'].check_in_time.hour <= 3): _oi_out_d = _oi_ri['record'].check_in_date if hasattr(_oi_out_d, 'date'): _oi_out_d = _oi_out_d.date() if _oi_out_d <= date_obj.date(): _oi_skip.append(_oi_ri) continue if earliest_out is None: earliest_out = _oi_ri break for ri in excess_ins: ri['used'] = True pairs.append({'check_in': ri['record'], 'check_out': None, 'is_miss_punch': True}) if earliest_out is not None: latest_in['used'] = True earliest_out['used'] = True pairs.append({'check_in': latest_in['record'], 'check_out': earliest_out['record'], 'is_miss_punch': False}) else: latest_in['used'] = True pairs.append({'check_in': latest_in['record'], 'check_out': None, 'is_miss_punch': True}) for ri in outs_sorted: if not ri['used'] and ri not in _oi_skip: ri['used'] = True pairs.append({'check_in': None, 'check_out': ri['record'], 'is_miss_punch': True}) # Orphan OUTs that were skipped for ri in _oi_skip: ri['used'] = True pairs.append({'check_in': None, 'check_out': ri['record'], 'is_miss_punch': True}) else: # Standard pairing i = 0 while i < len(record_info): if record_info[i]['used']: i += 1 continue if not record_info[i]['is_out']: # IN out_found = False for j in range(i + 1, len(record_info)): if record_info[j]['used']: continue if record_info[j]['is_out']: # Orphan guard: when this IN is an afternoon/evening # check-in (>=12h) and the candidate OUT is # early-morning (<=3h), the OUT is only a # valid partner if it was moved in by overnight # detection (check_in_date > current day). # Same-day early-morning OUTs are orphans from # a previous overnight shift. _in_rec = record_info[i]['record'] _out_rec = record_info[j]['record'] if (_in_rec.check_in_time.hour >= 12 and _out_rec.check_in_time.hour <= 3): _out_bb_date = _out_rec.check_in_date if hasattr(_out_bb_date, 'date'): _out_bb_date = _out_bb_date.date() if _out_bb_date <= date_obj.date(): continue # orphan — skip pairs.append({ 'check_in': record_info[i]['record'], 'check_out': record_info[j]['record'], 'is_miss_punch': False }) record_info[i]['used'] = True record_info[j]['used'] = True out_found = True break if not out_found: pairs.append({ 'check_in': record_info[i]['record'], 'check_out': None, 'is_miss_punch': True }) record_info[i]['used'] = True else: # Orphaned OUT pairs.append({ 'check_in': None, 'check_out': record_info[i]['record'], 'is_miss_punch': True }) record_info[i]['used'] = True i += 1 # Calculate daily hours daily_hours = 0 _bb_day_non_sp_hours = 0.0 # weekly summary sheet excludes SP time for pair in pairs: 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_out = datetime.combine(date_obj, pair['check_out'].check_in_time) # 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 # Accumulate regular-only hours: pairs where neither record # carries a special work type (SP/PW/PT/C). _bb_in_wt = getattr(pair['check_in'], 'work_type', None) _bb_out_wt = getattr(pair['check_out'], 'work_type', None) _bb_eff_wt = _bb_out_wt or _bb_in_wt # prefer OUT's type (mirrors main export) if _bb_eff_wt not in ('SP', 'PW', 'PT', 'C'): regular_only_hours += _bb_dur if _bb_eff_wt != 'SP': _bb_day_non_sp_hours += _bb_dur daily_hours = _qtr(daily_hours) weekly_total_hours += daily_hours # Same week anchoring as the Weekly Total rows (report start date) _bb_week_idx = (date_obj.date() - start_date).days // 7 _bb_emp_meta['weeks'][_bb_week_idx] = ( _bb_emp_meta['weeks'].get(_bb_week_idx, 0.0) + _qtr(_bb_day_non_sp_hours) ) # Write pairs for pair_idx, pair in enumerate(pairs): check_in = pair['check_in'] check_out = pair['check_out'] is_miss_punch = pair['is_miss_punch'] # Day/date only on first row day_display = date_obj.strftime('%A').upper() if pair_idx == 0 else '' date_display = date_obj.strftime('%m/%d/%Y') if pair_idx == 0 else '' # Calculate hours for this pair if check_in and check_out and not is_miss_punch: _pair_in_dt = datetime.combine(date_obj, check_in.check_in_time) _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: pair_hours = 'Missed Punch' # Daily total only on last row of day daily_total_display = daily_hours if pair_idx == len(pairs) - 1 else '' # Get record for address/distance info ref_record = check_in or check_out # Build row data row_data = [ day_display, date_display, check_in.check_in_time.strftime('%I:%M:%S %p') if check_in else '', check_out.check_in_time.strftime('%I:%M:%S %p') if check_out else '', ref_record.location_name if ref_record else '', zone_info, pair_hours, daily_total_display if daily_total_display else '', '', # Regular Hours '', # OT Hours '', # Building Address (will be HYPERLINK) '', # Recorded Location (will be HYPERLINK) getattr(ref_record, 'distance', None) or '' 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): cell = ws.cell(row=current_row, column=col, value=value) cell.font = data_font cell.border = _bb_row_border if col == 7 and value == 'Missed Punch': cell.fill = missed_punch_fill # Add HYPERLINK formulas for addresses if ref_record: building_address = ref_record.event_description or '' if building_address: encoded_addr = building_address.replace(' ', '+').replace(',', '%2C') hyperlink_formula = excel_hyperlink(f"https://www.google.com/maps/place/{encoded_addr}", building_address) ws.cell(row=current_row, column=11, value=hyperlink_formula) recorded_addr = ref_record.recorded_address or '' if recorded_addr: encoded_recorded = recorded_addr.replace(' ', '+').replace(',', '%2C') recorded_hyperlink = excel_hyperlink(f"https://www.google.com/maps/place/{encoded_recorded}", recorded_addr) ws.cell(row=current_row, column=12, value=recorded_hyperlink) current_row += 1 # Write final weekly total if weekly_total_hours > 0: week_regular = min(weekly_total_hours, 40.0) 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=_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 current_row += 1 # ================================================================ # Write extra working hours rows (SP/PW/PT/C) if employee has any. # Abbreviated label in col 8, hours in col 9. # A "Regular" row follows whenever at least one special type is present. # ================================================================ _has_special_hours = sp_hours > 0 or pw_hours > 0 or pt_hours > 0 or c_hours > 0 # Write SP row if hours > 0 if sp_hours > 0: ws.cell(row=current_row, column=8, value='SP').font = italic_bold_font ws.cell(row=current_row, column=9, value=_qtr(sp_hours)).font = italic_bold_font logger_handler.logger.info(f"Export by Building: Employee {employee_id} SP hours: {sp_hours:.2f}") _bb_sp_summary_rows.add(current_row) current_row += 1 # Write PW row if hours > 0 if pw_hours > 0: ws.cell(row=current_row, column=8, value='PW').font = italic_bold_font ws.cell(row=current_row, column=9, value=_qtr(pw_hours)).font = italic_bold_font logger_handler.logger.info(f"Export by Building: Employee {employee_id} PW hours: {pw_hours:.2f}") current_row += 1 # Write PT row if hours > 0 if pt_hours > 0: ws.cell(row=current_row, column=8, value='PT').font = italic_bold_font ws.cell(row=current_row, column=9, value=_qtr(pt_hours)).font = italic_bold_font logger_handler.logger.info(f"Export by Building: Employee {employee_id} PT hours: {pt_hours:.2f}") current_row += 1 # Write C (Covering) row if hours > 0 if c_hours > 0: ws.cell(row=current_row, column=8, value='C').font = italic_bold_font ws.cell(row=current_row, column=9, value=_qtr(c_hours)).font = italic_bold_font logger_handler.logger.info(f"Export by Building: Employee {employee_id} C hours: {c_hours:.2f}") current_row += 1 # Write Regular row — only when the employee has special work-type hours. if _has_special_hours: ws.cell(row=current_row, column=8, value='Regular').font = italic_bold_font ws.cell(row=current_row, column=9, value=_qtr(regular_only_hours)).font = italic_bold_font logger_handler.logger.info(f"Export by Building: Employee {employee_id} Regular hours: {regular_only_hours:.2f}") current_row += 1 # ================================================================ # End of extra working hours section # ================================================================ # 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=_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 current_row += 1 _bb_emp_meta['end_row'] = current_row - 1 _bb_block['employees'].append(_bb_emp_meta) # Empty row after each building current_row += 1 _bb_block['end_row'] = current_row - 1 _bb_blocks.append(_bb_block) # Auto-size columns for col_idx in range(1, 15): column_letter = get_column_letter(col_idx) if col_idx == 1: ws.column_dimensions[column_letter].width = 18 continue max_length = 0 for row in ws.iter_rows(min_col=col_idx, max_col=col_idx): for cell in row: if isinstance(cell, openpyxl.cell.cell.MergedCell): continue try: if cell.value and len(str(cell.value)) > max_length: max_length = len(str(cell.value)) except Exception: pass # Non-string cell value — skip width measurement adjusted_width = min(max_length + 2, 50) ws.column_dimensions[column_letter].width = adjusted_width # Extra sheets: filtered copy of Sheet0 + weekly hours by location. # A failure here must never block the Sheet0 export itself. excluded_ids = current_app.config.get('BUILDING_SUMMARY_EXCLUDED_EMPLOYEE_IDS', []) try: _build_filtered_building_sheet(wb, ws, _bb_blocks, _bb_sp_summary_rows, excluded_ids) _build_weekly_hours_by_location_sheet(wb, _bb_blocks, excluded_ids, start_date, end_date, project_display) except Exception as e: logger_handler.logger.error(f"Export by Building: could not build summary sheets: {e}", exc_info=True) for _extra in ('Filtered Report', 'Weekly Hours by Location'): if _extra in wb.sheetnames: wb.remove(wb[_extra]) # Save to BytesIO output = io.BytesIO() neutralize_unexpected_formulas(wb) # formula-injection guard (utils/excel_safety.py) wb.save(output) output.seek(0) # Filename if date_range_str: filename = f'{project_name_for_filename}time_attendance_by_building_{date_range_str}.xlsx' else: filename = f'{project_name_for_filename}time_attendance_by_building.xlsx' # Log successful export logger_handler.logger.info( f"Export by Building completed: {filename} with {len(sorted_locations)} buildings" ) return send_file( output, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', as_attachment=True, download_name=filename )