March 12 2026: changed regular & special work mixing calculation issues 2

This commit is contained in:
2026-03-12 16:10:32 -04:00
parent b394a2559c
commit f4884a1aa0
2 changed files with 270 additions and 173 deletions
+265 -168
View File
@@ -9200,7 +9200,20 @@ def export_time_attendance():
# Apply filters # Apply filters
if employee_filter: if employee_filter:
query = query.filter(TimeAttendance.employee_id == employee_filter) # Include all SP/PW/PT work-type variants of the base employee ID so that
# cross-type pairing (e.g. regular IN + SP OUT) works correctly in the export.
from working_hours_calculator import parse_employee_id_for_work_type as _parse_wt
_base_emp_id, _ = _parse_wt(str(employee_filter))
_emp_id_variants = [
_base_emp_id,
f"{_base_emp_id} SP", f"{_base_emp_id}SP",
f"SP {_base_emp_id}", f"SP{_base_emp_id}",
f"{_base_emp_id} PW", f"{_base_emp_id}PW",
f"PW {_base_emp_id}", f"PW{_base_emp_id}",
f"{_base_emp_id} PT", f"{_base_emp_id}PT",
f"PT {_base_emp_id}", f"PT{_base_emp_id}",
]
query = query.filter(TimeAttendance.employee_id.in_(_emp_id_variants))
if location_filter: if location_filter:
query = query.filter(TimeAttendance.location_name == location_filter) query = query.filter(TimeAttendance.location_name == location_filter)
@@ -9216,7 +9229,13 @@ def export_time_attendance():
if end_date: if end_date:
try: try:
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
query = query.filter(TimeAttendance.attendance_date <= end_date_obj) # Fetch one extra calendar day beyond the requested end date so that
# early-morning check-out records stored on Day N+1 (overnight shifts
# ending after midnight on the last report day) are available for the
# overnight pairing detection inside export_time_attendance_excel.
# The displayed date range is controlled by start_date_filter /
# end_date_filter inside that function and is not affected.
query = query.filter(TimeAttendance.attendance_date <= end_date_obj + timedelta(days=1))
except ValueError: except ValueError:
flash('Invalid end date format.', 'error') flash('Invalid end date format.', 'error')
return redirect(url_for('time_attendance_records')) return redirect(url_for('time_attendance_records'))
@@ -9397,7 +9416,11 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
) )
end_date = capped_end_date end_date = capped_end_date
# Drop records that fall outside the capped window # Drop records that fall outside the capped window
records = [r for r in records if r.attendance_date <= end_date] # Preserve one extra calendar day so early-morning check-out records
# stored on Day N+1 (overnight shifts ending after midnight on the
# last report day) remain available for overnight pairing detection.
# The display range is still controlled by dates_with_records (capped to end_date).
records = [r for r in records if r.attendance_date <= end_date + timedelta(days=1)]
# Import parse function at the beginning for work type detection # Import parse function at the beginning for work type detection
from working_hours_calculator import parse_employee_id_for_work_type from working_hours_calculator import parse_employee_id_for_work_type
@@ -9696,7 +9719,7 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
# with at least one IN at or after 20:00) # with at least one IN at or after 20:00)
if len(_day_ins) <= len(_day_outs_non_early): if len(_day_ins) <= len(_day_outs_non_early):
continue continue
_late_ins = [r for r in _day_ins if r.check_in_time.hour >= 20] _late_ins = [r for r in _day_ins if r.check_in_time.hour >= 19]
if not _late_ins: if not _late_ins:
continue continue
@@ -9711,7 +9734,15 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
# This handles both cases: # This handles both cases:
# Case A: Day N+1 has only evening INs (all >= 18:00) -> early OUT is Day N's # Case A: Day N+1 has only evening INs (all >= 18:00) -> early OUT is Day N's
# Case B: Day N+1 has more OUTs than INs overall -> early OUT is unmatched # Case B: Day N+1 has more OUTs than INs overall -> early OUT is unmatched
_nxt_non_evening_ins = [r for r in _nxt_ins if r.check_in_time.hour < 18] # A non-evening 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 < 18
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): if _nxt_non_evening_ins and len(_nxt_outs) <= len(_nxt_ins):
# Day N+1 has a non-evening IN that can own the early OUT, and counts # Day N+1 has a non-evening IN that can own the early OUT, and counts
# are balanced -> do NOT move # are balanced -> do NOT move
@@ -9747,6 +9778,11 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
current_week_start = None current_week_start = None
grand_regular_hours = 0 grand_regular_hours = 0
grand_ot_hours = 0 grand_ot_hours = 0
# Accumulate SP/PW/PT 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
# Get all dates that have records (not all weekdays) # Get all dates that have records (not all weekdays)
dates_with_records = sorted([ dates_with_records = sorted([
@@ -9800,65 +9836,49 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
is_miss_punch = False is_miss_punch = False
total_hours = 0.0 # will be recalculated below total_hours = 0.0 # will be recalculated below
# Calculate total hours for the day # Calculate total hours for the day by mirroring the display pairing logic:
if not is_miss_punch and total_hours > 0: # group records by base location, apply the OUT-after-IN guard within each
weekly_total_hours += total_hours # group, and sum only complete pairs. This ensures the daily total in
elif not is_miss_punch and total_hours == 0 and total_locations > 0: # column H matches exactly the pairs rendered in the export rows.
# is_miss_punch cleared above but total_hours not yet computed; _day_total_hours = 0.0
# recalculate from the (now-corrected) records in daily_location_data. for _loc_data in date_locations.values():
_all_recs = [] _loc_recs = sorted(_loc_data['records'], key=_overnight_aware_sort_key)
for loc_data in date_locations.values(): _loc_ins = [r for r in _loc_recs if not _is_out(r)]
_all_recs.extend(loc_data['records']) _loc_outs = [r for r in _loc_recs if _is_out(r)]
_sorted = sorted(_all_recs, key=_overnight_aware_sort_key) _out_used = [False] * len(_loc_outs)
_ins = [r for r in _sorted if not _is_out(r)] for _in_r in _loc_ins:
_outs = [r for r in _sorted if _is_out(r)] for _oi2, _out_r in enumerate(_loc_outs):
if _ins and _outs: if _out_used[_oi2]:
_day_total = 0.0
for _ir, _or in zip(_ins, _outs):
_dt_in = datetime.combine(date_obj, _ir.check_in_time)
_dt_out = datetime.combine(date_obj, _or.check_in_time)
if _dt_out < _dt_in:
_dt_out += timedelta(days=1)
_day_total += (_dt_out - _dt_in).total_seconds() / 3600.0
total_hours = _qtr(_day_total)
weekly_total_hours += total_hours
elif is_miss_punch and total_locations > 0:
# Genuine miss punch day — sum only matched IN/OUT pairs,
# ignoring orphaned records (which correctly contribute 0 hours).
all_records_for_day = []
for loc_data in date_locations.values():
all_records_for_day.extend(loc_data['records'])
if len(all_records_for_day) >= 2:
sorted_all_records = sorted(all_records_for_day, key=_overnight_aware_sort_key)
# Walk through records pairing each IN with the next available OUT
_used = [False] * len(sorted_all_records)
_day_total = 0.0
for _ii, _ri in enumerate(sorted_all_records):
if _used[_ii] or _is_out(_ri):
continue continue
# Find the next unused OUT # Time-only pairing guard (mirrors Step 1/2 pairing logic):
for _oi, _ro in enumerate(sorted_all_records): # an early-morning OUT (hour<=3) is only valid for an evening IN (hour>=18).
if _oi <= _ii or _used[_oi] or not _is_out(_ro): _in_t_d = _in_r.check_in_time
continue _out_t_d = _out_r.check_in_time
_dt_in = datetime.combine(date_obj, _ri.check_in_time) if _out_t_d.hour <= 3:
_dt_out = datetime.combine(date_obj, _ro.check_in_time) if _in_t_d.hour < 18:
if _dt_out < _dt_in: continue # early-morning OUT cannot pair with non-evening IN
_dt_out += timedelta(days=1) elif _out_t_d <= _in_t_d:
_day_total += (_dt_out - _dt_in).total_seconds() / 3600.0 continue # same-day OUT must be strictly after IN
_used[_ii] = True _in_ts = datetime.combine(_in_r.check_in_date, _in_r.check_in_time)
_used[_oi] = True _out_ts = datetime.combine(_out_r.check_in_date, _out_r.check_in_time)
break if _out_ts < _in_ts:
_out_ts += timedelta(days=1) # overnight: normalise to positive duration
calculated_hours = _qtr(_day_total) _duration = (_out_ts - _in_ts).total_seconds() / 3600.0
weekly_total_hours += calculated_hours if _duration > 24:
total_hours = calculated_hours continue # Sanity guard: skip implausible durations
_day_total_hours += _duration
_out_used[_oi2] = True
break
total_hours = _qtr(_day_total_hours)
weekly_total_hours += total_hours
# Daily total display (only shown on last location's last row) # Daily total display (only shown on last location's last row)
daily_total_display = _qtr(total_hours) if total_hours > 0 else '' daily_total_display = _qtr(total_hours) if total_hours > 0 else ''
# FIXED: Get all records for the day and sort by time FIRST, then group by location # 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 = [] all_day_records = []
for loc_data in date_locations.values(): for loc_data in date_locations.values():
all_day_records.extend(loc_data['records']) all_day_records.extend(loc_data['records'])
@@ -9866,30 +9886,33 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
# Sort all records by time chronologically, overnight-aware # Sort all records by time chronologically, overnight-aware
all_day_records_sorted = sorted(all_day_records, key=_overnight_aware_sort_key) all_day_records_sorted = sorted(all_day_records, key=_overnight_aware_sort_key)
# Group consecutive records by location while maintaining time order # 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 = [] location_groups = []
current_location = None current_base_location = None
current_group = [] current_group = []
for record in all_day_records_sorted: for record in all_day_records_sorted:
if current_location is None or record.location_name == current_location: bloc = _base_loc(record)
# Same location or first record if current_base_location is None or bloc == current_base_location:
current_location = record.location_name current_base_location = bloc
current_group.append(record) current_group.append(record)
else: else:
# Different location - save current group and start new one
if current_group: if current_group:
location_groups.append({ location_groups.append({
'location': current_location, 'location': current_base_location,
'records': current_group 'records': current_group
}) })
current_location = record.location_name current_base_location = bloc
current_group = [record] current_group = [record]
# Add the last group # Add the last group
if current_group: if current_group:
location_groups.append({ location_groups.append({
'location': current_location, 'location': current_base_location,
'records': current_group 'records': current_group
}) })
@@ -9976,113 +9999,133 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
current_row += 1 current_row += 1
else: else:
# Multiple records for this location - use action_description for pairing # Multiple records for this location group.
# Determine record types based on action_description # Build record_info with work_type included.
record_info = [] record_info = []
for record in sorted_records: for record in sorted_records:
action_desc = record.action_description.lower() if record.action_description else '' action_desc = record.action_description.lower() if record.action_description else ''
is_out = 'out' in action_desc or 'checkout' in action_desc is_out = 'out' in action_desc or 'checkout' in action_desc
wt = getattr(record, 'work_type', None) # None = regular
record_info.append({ record_info.append({
'record': record, 'record': record,
'is_out': is_out, 'is_out': is_out,
'work_type': wt, # None means regular
'used': False 'used': False
}) })
print(f" Record at {record.check_in_time}: action='{record.action_description}', is_out={is_out}") print(f" Record at {record.check_in_time}: action='{record.action_description}', is_out={is_out}, work_type={wt}")
# Odd-IN rule: if there are more INs than OUTs in this building group,
# discard all but the LATEST IN — those earlier INs become Missed Punch rows.
# The latest IN is then paired with the earliest OUT.
ins = [ri for ri in record_info if not ri['is_out']] ins = [ri for ri in record_info if not ri['is_out']]
outs = [ri for ri in record_info if ri['is_out']] outs = [ri for ri in record_info if ri['is_out']]
pairs_to_write = [] pairs_to_write = []
if len(ins) > len(outs) and len(outs) > 0: # ── STEP 1: same-type pairing ──────────────────────────────────────
# Sort INs by time ascending; keep only the last one for pairing # Pair each IN with an OUT of the same work type first.
ins_sorted = sorted(ins, key=lambda ri: ri['record'].check_in_time) # Sort INs chronologically and OUTs with overnight-aware key so that
latest_in = ins_sorted[-1] # an early-morning OUT (e.g. 00:30 moved in by overnight detection)
excess_ins = ins_sorted[:-1] # all earlier INs become Missed Punch # 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']))
# Mark excess INs as used and emit Missed Punch rows for in_ri in ins_sorted:
for ri in excess_ins: if in_ri['used']:
ri['used'] = True continue
pairs_to_write.append({ for out_ri in outs_sorted:
'check_in': ri['record'], if out_ri['used']:
'check_out': None,
'is_miss_punch': True
})
# Pair latest IN with earliest OUT
outs_sorted = sorted(outs, key=lambda ri: ri['record'].check_in_time)
earliest_out = outs_sorted[0]
latest_in['used'] = True
earliest_out['used'] = True
pairs_to_write.append({
'check_in': latest_in['record'],
'check_out': earliest_out['record'],
'is_miss_punch': False
})
# Any remaining unused OUTs after the first become Missed Punch
for ri in outs_sorted[1:]:
ri['used'] = True
pairs_to_write.append({
'check_in': None,
'check_out': ri['record'],
'is_miss_punch': True
})
else:
# Standard pairing: each IN finds the next available OUT
i = 0
while i < len(record_info):
if record_info[i]['used']:
i += 1
continue continue
# Guard: validate the IN→OUT pairing using time-only logic.
current_is_out = record_info[i]['is_out'] # After overnight detection, a moved OUT record retains its
# original check_in_date (a later date), so datetime-based
if not current_is_out: # This is an IN # comparison would incorrectly accept any IN as valid.
# Look for next OUT # Rule: an early-morning OUT (hour<=3) is only valid for an
out_found = False # evening IN (hour>=18); for all other INs, reject it.
for j in range(i + 1, len(record_info)): # For non-early-morning OUTs, the OUT time must be after the IN time.
if record_info[j]['used']: _in_t = in_ri['record'].check_in_time
continue _out_t = out_ri['record'].check_in_time
if record_info[j]['is_out']: # Found an OUT if _out_t.hour <= 3:
# Missed punch only if an unused OUT exists between i and j if _in_t.hour < 18:
intervening_out = any( continue # early-morning OUT cannot pair with non-evening IN
record_info[k]['is_out'] and not record_info[k]['used'] elif _out_t <= _in_t:
for k in range(i + 1, j) continue # same-day OUT must be strictly after IN
) if out_ri['work_type'] == in_ri['work_type']:
pairs_to_write.append({ # Matched same work type — standard pair
'check_in': record_info[i]['record'], in_ri['used'] = True
'check_out': record_info[j]['record'], out_ri['used'] = True
'is_miss_punch': intervening_out
})
record_info[i]['used'] = True
record_info[j]['used'] = True
out_found = True
break
if not out_found: # No OUT found for this IN
pairs_to_write.append({
'check_in': record_info[i]['record'],
'check_out': None,
'is_miss_punch': True
})
record_info[i]['used'] = True
else: # This is an orphaned OUT
pairs_to_write.append({ pairs_to_write.append({
'check_in': None, 'check_in': in_ri['record'],
'check_out': record_info[i]['record'], 'check_out': out_ri['record'],
'is_miss_punch': True 'is_miss_punch': False,
'effective_work_type': in_ri['work_type']
}) })
record_info[i]['used'] = True break
i += 1 # ── 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.
# An early-morning OUT (hour<=3) only pairs with an evening IN (hour>=18).
_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 < 18:
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
})
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']
})
print(f" Created {len(pairs_to_write)} pairs") print(f" Created {len(pairs_to_write)} pairs")
# 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 # Write all pairs
for pair_idx, pair_data in enumerate(pairs_to_write): for pair_idx, pair_data in enumerate(pairs_to_write):
check_in_record = pair_data['check_in'] check_in_record = pair_data['check_in']
@@ -10106,6 +10149,19 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
else: else:
pair_hours = 'Missed Punch' pair_hours = 'Missed Punch'
# Accumulate SP/PW/PT hours for cross-type pairs.
# The calculator only saw orphaned records on each work-type stream,
# so grand_totals misses these hours. Track them here so the
# summary rows below the weekly total are correct.
if not is_miss_punch and isinstance(pair_hours, (int, float)):
_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
# Determine whether this is an overnight pair: # Determine whether this is an overnight pair:
# check-in is late evening (>= 20:00) AND check-out is early morning (<= 03:00) # 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. # Both records share the same check_in_date in the DB for this scenario.
@@ -10122,8 +10178,24 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
# Build Out-time string (plain time only) # 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 '' _out_time_str = check_out_record.check_in_time.strftime('%I:%M:%S %p') if check_out_record else ''
# Build Location string; append label for overnight pairs # Build Location string.
_location_str = check_in_record.location_name if check_in_record else '' # 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)"
# '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')
_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: if _is_overnight_pair:
_location_str = f"{_location_str} (midnight shift)" _location_str = f"{_location_str} (midnight shift)"
@@ -10134,7 +10206,7 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
date_display, date_display,
check_in_record.check_in_time.strftime('%I:%M:%S %p'), # In check_in_record.check_in_time.strftime('%I:%M:%S %p'), # In
_out_time_str, # Out _out_time_str, # Out
_location_str, # Location (includes "(midnight shift)" label if overnight) _location_str, # Location (effective work type + optional midnight label)
'', '',
pair_hours, pair_hours,
current_daily_total, current_daily_total,
@@ -10151,7 +10223,7 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
date_display, date_display,
check_in_record.check_in_time.strftime('%I:%M:%S %p'), # In check_in_record.check_in_time.strftime('%I:%M:%S %p'), # In
'', # No OUT '', # No OUT
check_in_record.location_name, _location_str,
'', '',
'Missed Punch', 'Missed Punch',
current_daily_total, current_daily_total,
@@ -10168,7 +10240,7 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
date_display, date_display,
'', # No IN '', # No IN
check_out_record.check_in_time.strftime('%I:%M:%S %p'), # Out check_out_record.check_in_time.strftime('%I:%M:%S %p'), # Out
check_out_record.location_name, _location_str,
'', '',
'Missed Punch', 'Missed Punch',
current_daily_total, current_daily_total,
@@ -10205,11 +10277,12 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
current_row += 1 current_row += 1
# Write extra working hours rows (SP/PW/PT) if employee has any # Write extra working hours rows (SP/PW/PT) if employee has any
# Get extra hours from emp_data grand_totals # 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', {}) grand_totals = emp_data.get('grand_totals', {})
sp_hours = grand_totals.get('sp_hours', 0.0) sp_hours = grand_totals.get('sp_hours', 0.0) + cross_type_sp_hours
pw_hours = grand_totals.get('pw_hours', 0.0) pw_hours = grand_totals.get('pw_hours', 0.0) + cross_type_pw_hours
pt_hours = grand_totals.get('pt_hours', 0.0) pt_hours = grand_totals.get('pt_hours', 0.0) + cross_type_pt_hours
# Write SP row if hours > 0 # Write SP row if hours > 0
if sp_hours > 0: if sp_hours > 0:
@@ -10313,7 +10386,19 @@ def export_time_attendance_by_building():
# Apply filters # Apply filters
if employee_filter: if employee_filter:
query = query.filter(TimeAttendance.employee_id == employee_filter) # Include all SP/PW/PT work-type variants of the base employee ID.
from working_hours_calculator import parse_employee_id_for_work_type as _parse_wt
_base_emp_id, _ = _parse_wt(str(employee_filter))
_emp_id_variants = [
_base_emp_id,
f"{_base_emp_id} SP", f"{_base_emp_id}SP",
f"SP {_base_emp_id}", f"SP{_base_emp_id}",
f"{_base_emp_id} PW", f"{_base_emp_id}PW",
f"PW {_base_emp_id}", f"PW{_base_emp_id}",
f"{_base_emp_id} PT", f"{_base_emp_id}PT",
f"PT {_base_emp_id}", f"PT{_base_emp_id}",
]
query = query.filter(TimeAttendance.employee_id.in_(_emp_id_variants))
if location_filter: if location_filter:
query = query.filter(TimeAttendance.location_name == location_filter) query = query.filter(TimeAttendance.location_name == location_filter)
@@ -10329,7 +10414,12 @@ def export_time_attendance_by_building():
if end_date: if end_date:
try: try:
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
query = query.filter(TimeAttendance.attendance_date <= end_date_obj) # Fetch one extra calendar day so that early-morning check-out records
# stored on Day N+1 (overnight shifts ending after midnight on the last
# report day) are included for overnight pairing detection.
# The display range remains controlled by start_date_filter/end_date_filter
# inside export_time_attendance_by_building_excel and is not affected.
query = query.filter(TimeAttendance.attendance_date <= end_date_obj + timedelta(days=1))
except ValueError: except ValueError:
flash('Invalid end date format.', 'error') flash('Invalid end date format.', 'error')
return redirect(url_for('time_attendance_records')) return redirect(url_for('time_attendance_records'))
@@ -10441,7 +10531,11 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
f"capping end_date to {capped_end_date}." f"capping end_date to {capped_end_date}."
) )
end_date = capped_end_date end_date = capped_end_date
records = [r for r in records if r.attendance_date <= end_date] # Preserve one extra calendar day so early-morning check-out records
# stored on Day N+1 (overnight shifts ending after midnight on the
# last report day) remain available for overnight pairing detection.
# The display range is still controlled by dates_with_records (capped to end_date).
records = [r for r in records if r.attendance_date <= end_date + timedelta(days=1)]
# Import parse function for work type detection # Import parse function for work type detection
from working_hours_calculator import parse_employee_id_for_work_type from working_hours_calculator import parse_employee_id_for_work_type
@@ -10669,7 +10763,9 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
for date_str in sorted_dates: for date_str in sorted_dates:
date_obj = datetime.strptime(date_str, '%Y-%m-%d') date_obj = datetime.strptime(date_str, '%Y-%m-%d')
day_records = sorted(daily_records[date_str], key=lambda x: x.check_in_time) # 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 start_date_filter (not calendar Monday) # Check for week boundary anchored to start_date_filter (not calendar Monday)
_report_start = start_date if start_date_filter else date_obj.date() _report_start = start_date if start_date_filter else date_obj.date()
@@ -10709,9 +10805,10 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
outs = [ri for ri in record_info if ri['is_out']] outs = [ri for ri in record_info if ri['is_out']]
if len(ins) > len(outs) and len(outs) > 0: if len(ins) > len(outs) and len(outs) > 0:
# Odd-IN rule: discard all but the LATEST IN; pair it with the earliest OUT # Odd-IN rule: discard all but the LATEST IN; pair it with the earliest OUT.
ins_sorted = sorted(ins, key=lambda ri: ri['record'].check_in_time) # Use overnight-aware sort so early-morning OUTs sort after evening OUTs.
outs_sorted = sorted(outs, key=lambda ri: ri['record'].check_in_time) 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] latest_in = ins_sorted[-1]
excess_ins = ins_sorted[:-1] excess_ins = ins_sorted[:-1]
+1 -1
View File
@@ -484,7 +484,7 @@ class WorkingHoursCalculator:
# check-out record to Day N so the pair resolves correctly. # check-out record to Day N so the pair resolves correctly.
# Hours are attributed to the earlier day (Day N). # Hours are attributed to the earlier day (Day N).
# --------------------------------------------------------------- # ---------------------------------------------------------------
OVERNIGHT_CHECKIN_HOUR = 20 # Check-in must be at or after 8 PM OVERNIGHT_CHECKIN_HOUR = 19 # Check-in must be at or after 7 PM
OVERNIGHT_CHECKOUT_HOUR = 3 # Check-out must be at or before 3 AM OVERNIGHT_CHECKOUT_HOUR = 3 # Check-out must be at or before 3 AM
for work_type in ['regular', 'SP', 'PW', 'PT']: for work_type in ['regular', 'SP', 'PW', 'PT']: