Sep 16 - Optimize code, part 2

This commit is contained in:
2026-09-16 14:31:41 -04:00
parent 2c5627354e
commit 13b56fb1d1
9 changed files with 632 additions and 140 deletions
+98 -38
View File
@@ -24,6 +24,7 @@ from sqlalchemy import text, or_, and_
from logger_handler import log_user_activity, log_database_operations
from utils.helpers import (
admin_required,
load_project_manager_scope,
expand_employee_id_filter,
get_base_employee_id,
get_client_ip,
@@ -837,27 +838,38 @@ def time_attendance_locations_api():
Used by the time attendance records page to dynamically scope the location dropdown."""
try:
project_id = request.args.get('project_id', '').strip()
conditions = ["location_name IS NOT NULL"]
params = {}
if project_id:
try:
project_id_int = int(project_id)
params['project_id'] = int(project_id)
except (ValueError, TypeError):
return jsonify({'success': False, 'error': 'Invalid project_id'}), 400
conditions.append("project_id = :project_id")
result = db.session.execute(text("""
SELECT DISTINCT location_name
FROM time_attendance
WHERE project_id = :project_id
AND location_name IS NOT NULL
ORDER BY location_name
"""), {'project_id': project_id_int})
else:
result = db.session.execute(text("""
SELECT DISTINCT location_name
FROM time_attendance
WHERE location_name IS NOT NULL
ORDER BY location_name
"""))
# Project Managers see only their assigned projects / locations (§4)
is_pm, allowed_project_ids, allowed_location_names = load_project_manager_scope()
if is_pm:
if not (allowed_project_ids or allowed_location_names):
return jsonify({'success': True, 'locations': []})
scope = []
if allowed_project_ids:
placeholders = ', '.join(f':pm_project_{i}' for i in range(len(allowed_project_ids)))
scope.append(f"project_id IN ({placeholders})")
params.update({f'pm_project_{i}': pid for i, pid in enumerate(allowed_project_ids)})
if allowed_location_names:
placeholders = ', '.join(f':pm_location_{i}' for i in range(len(allowed_location_names)))
scope.append(f"location_name IN ({placeholders})")
params.update({f'pm_location_{i}': loc for i, loc in enumerate(allowed_location_names)})
conditions.append('(' + ' OR '.join(scope) + ')')
result = db.session.execute(text(f"""
SELECT DISTINCT location_name
FROM time_attendance
WHERE {' AND '.join(conditions)}
ORDER BY location_name
"""), params)
locations = [row[0] for row in result.fetchall()]
logger_handler.logger.info(
@@ -878,28 +890,39 @@ def attendance_locations_api():
Used by the attendance report page to dynamically scope the location dropdown when a project is selected."""
try:
project_id = request.args.get('project_id', '').strip()
conditions = ["ad.location_name IS NOT NULL"]
params = {}
if project_id:
try:
project_id_int = int(project_id)
params['project_id'] = int(project_id)
except (ValueError, TypeError):
return jsonify({'success': False, 'error': 'Invalid project_id'}), 400
conditions.append("qc.project_id = :project_id")
result = db.session.execute(text("""
SELECT DISTINCT ad.location_name
FROM attendance_data ad
INNER JOIN qr_codes qc ON ad.qr_code_id = qc.id
WHERE qc.project_id = :project_id
AND ad.location_name IS NOT NULL
ORDER BY ad.location_name
"""), {'project_id': project_id_int})
else:
result = db.session.execute(text("""
SELECT DISTINCT location_name
FROM attendance_data
WHERE location_name IS NOT NULL
ORDER BY location_name
"""))
# Project Managers see only their assigned projects / locations (§4)
is_pm, allowed_project_ids, allowed_location_names = load_project_manager_scope()
if is_pm:
if not (allowed_project_ids or allowed_location_names):
return jsonify({'success': True, 'locations': []})
scope = []
if allowed_project_ids:
placeholders = ', '.join(f':pm_project_{i}' for i in range(len(allowed_project_ids)))
scope.append(f"qc.project_id IN ({placeholders})")
params.update({f'pm_project_{i}': pid for i, pid in enumerate(allowed_project_ids)})
if allowed_location_names:
placeholders = ', '.join(f':pm_location_{i}' for i in range(len(allowed_location_names)))
scope.append(f"ad.location_name IN ({placeholders})")
params.update({f'pm_location_{i}': loc for i, loc in enumerate(allowed_location_names)})
conditions.append('(' + ' OR '.join(scope) + ')')
result = db.session.execute(text(f"""
SELECT DISTINCT ad.location_name
FROM attendance_data ad
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
WHERE {' AND '.join(conditions)}
ORDER BY ad.location_name
"""), params)
locations = [row[0] for row in result.fetchall()]
logger_handler.logger.info(
@@ -930,15 +953,35 @@ def search_employees_api():
search_pattern = f"%{search_query}%"
# Project Managers may only search within their own projects (§4). A PM
# scoped by locations is resolved to the projects those locations belong
# to, so their report filter still works without exposing other projects.
is_pm, allowed_project_ids, allowed_location_names = load_project_manager_scope()
pm_project_ids = []
if is_pm:
pm_project_ids = list(allowed_project_ids)
if allowed_location_names:
location_projects = db.session.query(QRCode.project_id).filter(
QRCode.location.in_(allowed_location_names),
QRCode.project_id.isnot(None)
).distinct().all()
pm_project_ids.extend(row[0] for row in location_projects)
pm_project_ids = sorted(set(pm_project_ids))
if not pm_project_ids:
return jsonify({'employees': []})
# 1. Registered employees — search by ID or name
employees = Employee.query.filter(
employee_query = Employee.query.filter(
db.or_(
Employee.id.like(search_pattern),
Employee.firstName.like(search_pattern),
Employee.lastName.like(search_pattern),
db.func.concat(Employee.firstName, ' ', Employee.lastName).like(search_pattern)
)
).limit(10).all()
)
if is_pm:
employee_query = employee_query.filter(Employee.contractId.in_(pm_project_ids))
employees = employee_query.limit(10).all()
employee_list = [{
'id': emp.id,
@@ -955,17 +998,25 @@ def search_employees_api():
if len(employee_list) < 10:
remaining_slots = 10 - len(employee_list)
try:
unregistered_conditions = ["e.id IS NULL", "ad.employee_id LIKE :pattern"]
unregistered_params = {'pattern': search_pattern, 'lim': remaining_slots}
if is_pm:
placeholders = ', '.join(f':pm_project_{i}' for i in range(len(pm_project_ids)))
unregistered_conditions.append(f"qc.project_id IN ({placeholders})")
unregistered_params.update(
{f'pm_project_{i}': pid for i, pid in enumerate(pm_project_ids)})
unregistered_rows = db.session.execute(
text("""
text(f"""
SELECT DISTINCT ad.employee_id
FROM attendance_data ad
LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id
WHERE e.id IS NULL
AND ad.employee_id LIKE :pattern
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
WHERE {' AND '.join(unregistered_conditions)}
ORDER BY ad.employee_id
LIMIT :lim
"""),
{'pattern': search_pattern, 'lim': remaining_slots}
unregistered_params
).fetchall()
for row in unregistered_rows:
@@ -999,7 +1050,16 @@ def get_project_locations_api():
if not project_id:
return jsonify({'success': False, 'locations': [], 'error': 'Project ID required'})
# Project Managers may only ask about their own projects (§4)
is_pm, allowed_project_ids, _ = load_project_manager_scope()
if is_pm and int(project_id) not in allowed_project_ids:
logger_handler.logger.warning(
f"Project Manager {session.get('username')} requested locations for "
f"project {project_id}, which is not assigned to them"
)
return jsonify({'success': True, 'locations': []})
# Get active QR codes for this project
qr_codes = QRCode.query.filter_by(
project_id=int(project_id),
+62 -12
View File
@@ -35,6 +35,38 @@ from openpyxl.utils import get_column_letter
from routes.attendance import bp # shared blueprint — do not redefine
# Employee IDs typed on the edit / manual-add forms follow the same rule as a
# check-in: 1 to 4 digits, optionally carrying a work-type code.
MANUAL_EMPLOYEE_ID_MAX_DIGITS = 4
def _normalize_manual_employee_id(raw_employee_id):
"""
(canonical_id, base_id, work_type) for an ID typed on a form, or
(None, None, None) when it is not a valid employee ID.
"1234" -> ("1234", "1234", "regular")
"1234sp" -> ("1234SP", "1234", "SP")
"1234 . PW"-> ("1234PW", "1234", "PW")
"12345", "abc", "" -> (None, None, None)
Manual entry used to do int(employee_id), so an extra-work record such as
"1234SP" could not be added by hand at all, and edit accepted any text,
which created pseudo-employees in the exports (§13).
"""
from working_hours_calculator import parse_employee_id_for_work_type
raw = str(raw_employee_id or '').strip()
if not raw:
return None, None, None
base_id, work_type = parse_employee_id_for_work_type(raw)
if not re.fullmatch(r'[0-9]+', base_id) or len(base_id) > MANUAL_EMPLOYEE_ID_MAX_DIGITS:
return None, None, None
canonical = base_id if work_type == 'regular' else f"{base_id}{work_type}"
return canonical, base_id, work_type
@bp.route('/attendance/<int:record_id>/edit', methods=['GET', 'POST'], endpoint='edit_attendance')
@login_required
@@ -74,7 +106,16 @@ def edit_attendance(record_id):
}
# Update attendance record fields
new_employee_id = request.form['employee_id'].strip().upper()
new_employee_id, _base_id, _work_type = _normalize_manual_employee_id(
request.form.get('employee_id', ''))
if not new_employee_id:
flash('Employee ID must be 1 to 4 digits, optionally with a work type '
'(for example 1234 or 1234SP).', 'error')
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
return render_template('edit_attendance.html',
attendance_record=attendance_record,
projects=projects,
qr_codes=QRCode.query.filter_by(active_status=True).all())
new_check_in_date = datetime.strptime(request.form['check_in_date'], '%Y-%m-%d').date()
new_check_in_time = datetime.strptime(request.form['check_in_time'], '%H:%M').time()
new_location_name = request.form['location_name'].strip()
@@ -120,10 +161,12 @@ def edit_attendance(record_id):
attendance_record.check_in_time = new_check_in_time
attendance_record.location_name = new_location_name
attendance_record.qr_code_id = int(new_qr_code_id)
attendance_record.updated_timestamp = datetime.utcnow()
# Local time throughout (§18): check-in dates and times are local, so
# a UTC audit stamp read hours apart from the record it describes.
attendance_record.updated_timestamp = datetime.now()
# Store the audit note with timestamp and user info
timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
username = session.get('username', 'Unknown')
role = session.get('role', 'unknown')
@@ -262,10 +305,17 @@ def save_manual_attendance():
flash('All fields are required.', 'error')
return redirect(url_for('attendance.add_manual_attendance'))
# Validate employee exists
employee = Employee.query.filter_by(id=int(employee_id)).first()
# Validate the ID, and keep any work-type code ("1234SP") on the stored value
canonical_employee_id, base_employee_id, work_type = _normalize_manual_employee_id(employee_id)
if not canonical_employee_id:
flash('Employee ID must be 1 to 4 digits, optionally with a work type '
'(for example 1234 or 1234SP).', 'error')
return redirect(url_for('attendance.add_manual_attendance'))
# Validate employee exists (by the numeric base ID)
employee = Employee.query.filter_by(id=int(base_employee_id)).first()
if not employee:
flash(f'Employee with ID {employee_id} not found.', 'error')
flash(f'Employee with ID {base_employee_id} not found.', 'error')
return redirect(url_for('attendance.add_manual_attendance'))
# Get QR code (location)
@@ -285,7 +335,7 @@ def save_manual_attendance():
# Check if record already exists for this employee, location, date, and time
existing_record = AttendanceData.query.filter_by(
employee_id=str(employee_id),
employee_id=canonical_employee_id,
qr_code_id=qr_code.id,
check_in_date=check_date_obj,
check_in_time=check_time_obj
@@ -300,7 +350,7 @@ def save_manual_attendance():
# Set fixed distance of 0.010 miles
new_attendance = AttendanceData(
qr_code_id=qr_code.id,
employee_id=str(employee_id),
employee_id=canonical_employee_id,
check_in_date=check_date_obj,
check_in_time=check_time_obj,
location_name=qr_code.location,
@@ -320,8 +370,8 @@ def save_manual_attendance():
status='present',
verification_required=False,
verification_status='approved',
created_timestamp=datetime.utcnow(),
updated_timestamp=datetime.utcnow()
created_timestamp=datetime.now(),
updated_timestamp=datetime.now()
)
db.session.add(new_attendance)
@@ -330,7 +380,7 @@ def save_manual_attendance():
# Log the manual entry
logger_handler.logger.info(
f"Manual attendance record created by {session.get('username')} ({user_role}): "
f"Employee {employee.firstName} {employee.lastName} (ID: {employee_id}), "
f"Employee {employee.firstName} {employee.lastName} (ID: {canonical_employee_id}), "
f"Location: {qr_code.location}, Event: {qr_code.location_event}, "
f"Date: {check_date}, Time: {check_time}"
)
+106 -24
View File
@@ -8,6 +8,7 @@ Routes: /dashboard, /project/<id>/qr-codes, /dashboard/search,
"""
from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, url_for
from datetime import datetime, timedelta, date, time
from sqlalchemy import or_
from extensions import db, logger_handler
from models.attendance import AttendanceData
@@ -15,7 +16,27 @@ from models.project import Project
from models.qrcode import QRCode
from models.user import User
from logger_handler import log_user_activity, log_database_operations
from utils.helpers import login_required
from utils.helpers import login_required, load_project_manager_scope
def _project_manager_qr_filter():
"""
(is_pm, qr_filter) for the session user the QR codes a Project Manager may
see (§4). qr_filter is None for everyone else, and a never-true condition for
a PM with no assignments.
"""
is_pm, allowed_project_ids, allowed_location_names = load_project_manager_scope()
if not is_pm:
return False, None
scope = []
if allowed_project_ids:
scope.append(QRCode.project_id.in_(allowed_project_ids))
if allowed_location_names:
scope.append(QRCode.location.in_(allowed_location_names))
if not scope:
return True, QRCode.id.is_(None) # assigned nothing → sees nothing
return True, or_(*scope)
bp = Blueprint('dashboard', __name__)
@@ -34,7 +55,12 @@ def dashboard():
# Build QR codes query with filters
qr_query = QRCode.query
# Project Managers only see QR codes in their assigned projects/locations
is_pm, pm_qr_filter = _project_manager_qr_filter()
if pm_qr_filter is not None:
qr_query = qr_query.filter(pm_qr_filter)
# Apply name filter if provided
if search_name:
qr_query = qr_query.filter(QRCode.name.ilike(f'%{search_name}%'))
@@ -47,7 +73,16 @@ def dashboard():
# Execute query
qr_codes = qr_query.order_by(QRCode.created_date.desc()).all()
projects = Project.query.order_by(Project.name.asc()).all()
project_query = Project.query
if is_pm:
# Only the projects the PM is assigned to, plus those behind their locations
visible_project_ids = {qr.project_id for qr in qr_codes if qr.project_id}
_, allowed_project_ids, _ = load_project_manager_scope()
visible_project_ids.update(allowed_project_ids)
project_query = (project_query.filter(Project.id.in_(sorted(visible_project_ids)))
if visible_project_ids else project_query.filter(Project.id.is_(None)))
projects = project_query.order_by(Project.name.asc()).all()
# Log dashboard access with filter info
filter_info = []
@@ -87,7 +122,16 @@ def project_qr_codes(project_id):
project = db.session.get(Project, project_id)
if project is None:
abort(404)
# Project Managers may only open their own projects (§4)
is_pm, allowed_project_ids, _ = load_project_manager_scope()
if is_pm and project_id not in allowed_project_ids:
logger_handler.logger.warning(
f"Project Manager {session.get('username')} tried to open project {project_id}"
)
flash('You do not have permission to view that project.', 'error')
return redirect(url_for('dashboard.dashboard'))
# Get search parameters from URL
search_name = request.args.get('search_name', '').strip()
search_status = request.args.get('search_status', '').strip()
@@ -154,36 +198,59 @@ def search_qr_codes():
def dashboard_stats_api():
"""API endpoint for dashboard statistics"""
try:
# Project Manager scope (§4): every count below is restricted to the QR
# codes / locations they are assigned to.
is_pm, allowed_project_ids, allowed_location_names = load_project_manager_scope()
_, pm_qr_filter = _project_manager_qr_filter()
qr_base_query = QRCode.query.filter_by(active_status=True)
attendance_base_query = AttendanceData.query
location_base_query = db.session.query(AttendanceData.location_name)
project_base_query = Project.query.filter_by(active_status=True)
if is_pm:
qr_base_query = qr_base_query.filter(pm_qr_filter)
attendance_scope = []
if allowed_project_ids:
attendance_scope.append(AttendanceData.qr_code_id.in_(
db.session.query(QRCode.id).filter(QRCode.project_id.in_(allowed_project_ids))
))
project_base_query = project_base_query.filter(Project.id.in_(allowed_project_ids))
else:
project_base_query = project_base_query.filter(Project.id.is_(None))
if allowed_location_names:
attendance_scope.append(AttendanceData.location_name.in_(allowed_location_names))
attendance_condition = or_(*attendance_scope) if attendance_scope else AttendanceData.id.is_(None)
attendance_base_query = attendance_base_query.filter(attendance_condition)
location_base_query = location_base_query.filter(attendance_condition)
# Get current stats
total_qr_codes = QRCode.query.filter_by(active_status=True).count()
# Today's check-ins
today = datetime.utcnow().date()
today_checkins = AttendanceData.query.filter(
total_qr_codes = qr_base_query.count()
# Today's check-ins — local date, matching how check-ins are stored
today = datetime.now().date()
today_checkins = attendance_base_query.filter(
AttendanceData.check_in_date == today
).count()
# Active projects
active_projects = Project.query.filter_by(active_status=True).count()
active_projects = project_base_query.count()
# Unique locations
unique_locations = db.session.query(
AttendanceData.location_name
).distinct().count()
unique_locations = location_base_query.distinct().count()
# Calculate trends (compared to last month)
last_month = datetime.utcnow() - timedelta(days=30)
last_month = datetime.now() - timedelta(days=30)
# QR codes trend
old_qr_count = QRCode.query.filter(
QRCode.created_date <= last_month,
QRCode.active_status == True
old_qr_count = qr_base_query.filter(
QRCode.created_date <= last_month
).count()
qr_change = ((total_qr_codes - old_qr_count) / max(old_qr_count, 1)) * 100
# Check-ins trend (yesterday)
yesterday = today - timedelta(days=1)
yesterday_checkins = AttendanceData.query.filter(
yesterday_checkins = attendance_base_query.filter(
AttendanceData.check_in_date == yesterday
).count()
checkin_change = ((today_checkins - yesterday_checkins) / max(yesterday_checkins, 1)) * 100
@@ -214,12 +281,27 @@ def dashboard_realtime_api():
"""API endpoint for real-time dashboard data"""
try:
# Get recent activity (last 10 check-ins)
recent_activity = db.session.query(
recent_query = db.session.query(
AttendanceData.employee_id,
AttendanceData.location_name,
AttendanceData.check_in_time,
AttendanceData.check_in_date
).order_by(
)
# Project Managers only see activity at their own projects/locations (§4)
is_pm, allowed_project_ids, allowed_location_names = load_project_manager_scope()
if is_pm:
recent_scope = []
if allowed_project_ids:
recent_scope.append(AttendanceData.qr_code_id.in_(
db.session.query(QRCode.id).filter(QRCode.project_id.in_(allowed_project_ids))
))
if allowed_location_names:
recent_scope.append(AttendanceData.location_name.in_(allowed_location_names))
recent_query = recent_query.filter(or_(*recent_scope) if recent_scope
else AttendanceData.id.is_(None))
recent_activity = recent_query.order_by(
AttendanceData.check_in_date.desc(),
AttendanceData.check_in_time.desc()
).limit(10).all()
+66 -17
View File
@@ -85,6 +85,20 @@ def _overnight_aware_sort_key(record):
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).
@@ -596,6 +610,10 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
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
@@ -624,20 +642,22 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
_report_start = start_date
week_start = (_report_start + timedelta(days=((date_obj.date() - _report_start).days // 7) * 7))
if current_week_start is not None and week_start != current_week_start:
# Write weekly total row
week_regular = min(weekly_total_hours, 40.0)
week_overtime = max(0, weekly_total_hours - 40.0)
# 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
@@ -666,6 +686,8 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
# 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()
@@ -704,6 +726,8 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
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))
@@ -768,6 +792,8 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
'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}"
@@ -788,6 +814,7 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
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 ''
@@ -1282,10 +1309,10 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
# END CROSS-BUILDING PAIR ROW WRITING
# -------------------------------------------------------------------
# Write final weekly total for this employee
# Write final weekly total for this employee (Regular/OT from regular hours only)
if weekly_total_hours > 0:
week_regular = min(weekly_total_hours, 40.0)
week_overtime = max(0, weekly_total_hours - 40.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
@@ -1757,7 +1784,21 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
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
@@ -1967,6 +2008,9 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
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.
@@ -1993,9 +2037,10 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
_report_start = start_date
week_start = (_report_start + timedelta(days=((date_obj.date() - _report_start).days // 7) * 7))
if current_week_start is not None and week_start != current_week_start:
# Write weekly total row
week_regular = min(weekly_total_hours, 40.0)
week_overtime = max(0, weekly_total_hours - 40.0)
# 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
@@ -2005,8 +2050,9 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
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
@@ -2145,6 +2191,7 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
# 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)
@@ -2165,11 +2212,13 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
_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
@@ -2250,10 +2299,10 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
current_row += 1
# Write final weekly total
# Write final weekly total (Regular/OT from regular hours only)
if weekly_total_hours > 0:
week_regular = min(weekly_total_hours, 40.0)
week_overtime = max(0, weekly_total_hours - 40.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