2437 lines
124 KiB
Python
2437 lines
124 KiB
Python
"""
|
||
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 _pair_is_regular(check_in_record, check_out_record) -> bool:
|
||
"""
|
||
True when a completed pair counts toward the 40-hour overtime rule.
|
||
|
||
SP / PW / PT / C hours are paid but never build toward overtime (§13). The
|
||
OUT record's work type wins, mirroring effective_work_type elsewhere, so a
|
||
Regular IN paired with an SP OUT counts as SP.
|
||
"""
|
||
in_work_type = getattr(check_in_record, 'work_type', None)
|
||
out_work_type = getattr(check_out_record, 'work_type', None)
|
||
effective = out_work_type or in_work_type
|
||
return effective not in ('SP', 'PW', 'PT', 'C')
|
||
|
||
|
||
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
|
||
# Regular-only hours feeding the 40-hour overtime rule. SP/PW/PT/C hours
|
||
# are worked and paid, but they do NOT build toward overtime (§13), so
|
||
# they are excluded here while column H still shows every hour worked.
|
||
weekly_regular_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. Column H = every hour worked; Regular
|
||
# and OT are computed from regular hours only (SP/PW/PT/C excluded).
|
||
week_regular = min(weekly_regular_hours, 40.0)
|
||
week_overtime = max(0, weekly_regular_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
|
||
weekly_regular_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
|
||
# Same pairs, minus SP/PW/PT/C: the overtime base (§13)
|
||
_day_regular_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
|
||
if _pair_is_regular(_in_r, _out_r):
|
||
_day_regular_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
|
||
if _pair_is_regular(_cb_in, _cb_out):
|
||
_day_regular_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
|
||
weekly_regular_hours += _qtr(_day_regular_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 (Regular/OT from regular hours only)
|
||
if weekly_total_hours > 0:
|
||
week_regular = min(weekly_regular_hours, 40.0)
|
||
week_overtime = max(0, weekly_regular_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
|
||
|
||
# Row 5: how the totals in this sheet are calculated. Overtime here is per
|
||
# BUILDING (each block counts only that building's hours), so an employee
|
||
# split across two buildings shows no overtime even past 40 hours — the main
|
||
# Time Attendance export totals the week across all buildings.
|
||
ws.merge_cells(f'A{current_row}:N{current_row}')
|
||
ot_note_cell = ws.cell(
|
||
row=current_row, column=1,
|
||
value=('Note: Weekly Total / Regular / OT below are per building, and overtime excludes '
|
||
'SP / PW / PT / C hours. Use the main Time Attendance export for payroll totals.')
|
||
)
|
||
ot_note_cell.font = Font(name='Aptos Narrow', size=10, italic=True)
|
||
ot_note_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
|
||
# Regular-only hours for the 40-hour rule (SP/PW/PT/C excluded, §13).
|
||
# Note: per BUILDING in this sheet — see the note under the date range.
|
||
weekly_regular_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. Column H = every hour worked at this
|
||
# building; Regular and OT come from regular hours only.
|
||
week_regular = min(weekly_regular_hours, 40.0)
|
||
week_overtime = max(0, weekly_regular_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
|
||
weekly_regular_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
|
||
_bb_day_regular_hours = 0.0 # overtime base: excludes SP/PW/PT/C
|
||
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
|
||
_bb_day_regular_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
|
||
weekly_regular_hours += _qtr(_bb_day_regular_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 (Regular/OT from regular hours only)
|
||
if weekly_total_hours > 0:
|
||
week_regular = min(weekly_regular_hours, 40.0)
|
||
week_overtime = max(0, weekly_regular_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
|
||
) |