Attendance filter: enrich-then-filter so badge matches result set

The previous filter applied status_code conditions on raw DB rows, then
enriched the page of results. That meant a missing record with a leave
request would surface under '缺勤' (raw code='missing') but render a
'請假'/'公假' badge (enriched status_code).

Move status_text filter to AFTER enrichment so it matches the badge.
status_text sort_by='status_code' also moves to Python sort since
post-enrich codes aren't in the DB. Log a warning if the in-memory
filter scans >5000 rows so we know when to revisit.
This commit is contained in:
IT狗
2026-07-21 22:18:55 +08:00
parent 4bd6d0e7c8
commit 3ba6617009
+58 -42
View File
@@ -1248,7 +1248,14 @@ async def attendance_records(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Paginated records from attendance_records (SQL)."""
"""Paginated records from attendance_records (SQL).
status_text filter runs AFTER enrichment so it matches the badge rendered
from post-enrich status_code. Otherwise missing-day-with-leave records
would surface under '缺勤' but render as '請假'/'公假' badges (mismatch).
Trade-off: status_text/sort_by filter runs in Python after enrich instead
of SQL — fine while dataset stays small (~80 records), log if it grows.
"""
from datetime import datetime as _dt
from sqlalchemy import or_, and_
@@ -1267,39 +1274,6 @@ async def attendance_records(
q = q.filter(AttendanceRecord.employee_name == staff)
if status:
q = q.filter(AttendanceRecord.status_code == status)
if status_text:
# Map status_text filter to status_code conditions so the result set
# stays aligned with the badge rendered from (post-enrich) status_code.
# Previously used late/early/OT thresholds which mismatched because
# the import path can leave late_minutes=0 while status_code='abnormal'
# (e.g. act_in=18:00 vs exp_in=11:00), and missing records also have
# all three == 0 despite status_code='missing'.
# Accept both English status_code values AND the Chinese basic-status
# labels returned by /api/attendance/status_texts dropdown.
abnormal_codes = ('abnormal', 'late', 'early', 'ot',
'late_early', 'late_ot', 'early_ot')
if status_text in ('abnormal', '異常'):
q = q.filter(AttendanceRecord.status_code.in_(abnormal_codes))
elif status_text == 'late':
q = q.filter(AttendanceRecord.status_code.in_(('late', 'late_early', 'late_ot')))
elif status_text == 'early':
q = q.filter(AttendanceRecord.status_code.in_(('early', 'late_early', 'early_ot')))
elif status_text == 'ot':
q = q.filter(AttendanceRecord.status_code.in_(('ot', 'late_ot', 'early_ot')))
elif status_text in ('missing', '缺勤'):
q = q.filter(AttendanceRecord.status_code == 'missing')
elif status_text in ('holiday', '公假'):
q = q.filter(AttendanceRecord.status_code == 'holiday')
elif status_text in ('al', 'sl', 'cl', 'mixed_leave'):
q = q.filter(AttendanceRecord.status_code == status_text)
elif status_text in ('leave', '請假'):
# Any leave type — group filter
q = q.filter(AttendanceRecord.status_code.in_(['al', 'sl', 'cl', 'mixed_leave']))
elif status_text in ('normal', '正常'):
q = q.filter(AttendanceRecord.status_code == 'normal')
else:
# Fallback to text search
q = q.filter(AttendanceRecord.status_text.like(f"%{status_text}%"))
if search:
like = f"%{search}%"
q = q.filter(or_(
@@ -1308,20 +1282,62 @@ async def attendance_records(
AttendanceRecord.shift_code.like(like),
))
# sort
sortable = {
# Pre-sort by raw date so enrich-then-filter ordering is deterministic.
sortable_raw = {
"date": AttendanceRecord.date,
"employee_name": AttendanceRecord.employee_name,
"weekday": AttendanceRecord.weekday,
"shift_code": AttendanceRecord.shift_code,
"status_code": AttendanceRecord.status_code,
}
col = sortable.get(sort_by, AttendanceRecord.date)
col = sortable_raw.get(sort_by, AttendanceRecord.date)
q = q.order_by(col.desc() if sort_order == "desc" else col.asc())
total = q.count()
rows = q.offset((page - 1) * per_page).limit(per_page).all()
enrich_attendance_with_leave(rows, db)
all_rows = q.all()
if len(all_rows) > 5000:
logger.warning(
"attendance_records: in-memory filter over %d rows — consider prefiltering via date range",
len(all_rows),
)
# Enrich ALL rows so we can filter by post-enrich status_code.
enrich_attendance_with_leave(all_rows, db)
# Apply status_text filter on enriched status_code (post-leave/holiday override)
if status_text:
abnormal_codes = ('abnormal', 'late', 'early', 'ot',
'late_early', 'late_ot', 'early_ot')
leave_codes = ('al', 'sl', 'cl', 'mixed_leave')
if status_text in ('abnormal', '異常'):
rows = [r for r in all_rows if (r.status_code or '').lower() in abnormal_codes]
elif status_text == 'late':
rows = [r for r in all_rows if (r.status_code or '').lower() in ('late', 'late_early', 'late_ot')]
elif status_text == 'early':
rows = [r for r in all_rows if (r.status_code or '').lower() in ('early', 'late_early', 'early_ot')]
elif status_text == 'ot':
rows = [r for r in all_rows if (r.status_code or '').lower() in ('ot', 'late_ot', 'early_ot')]
elif status_text in ('missing', '缺勤'):
rows = [r for r in all_rows if (r.status_code or '').lower() == 'missing']
elif status_text in ('holiday', '公假'):
rows = [r for r in all_rows if (r.status_code or '').lower() == 'holiday']
elif status_text in leave_codes:
rows = [r for r in all_rows if (r.status_code or '').lower() == status_text]
elif status_text in ('leave', '請假'):
rows = [r for r in all_rows if (r.status_code or '').lower() in leave_codes]
elif status_text in ('normal', '正常'):
rows = [r for r in all_rows if (r.status_code or '').lower() == 'normal']
else:
# Fallback to raw status_text substring search
rows = [r for r in all_rows
if r.status_text and status_text in r.status_text]
else:
rows = all_rows
# Python sort by status_code when requested (raw DB only handled the basic columns)
if sort_by == 'status_code':
rows.sort(key=lambda r: (r.status_code or ''), reverse=(sort_order == 'desc'))
total = len(rows)
page_rows = rows[(page - 1) * per_page: (page - 1) * per_page + per_page]
def to_dict(r):
return {
@@ -1347,7 +1363,7 @@ async def attendance_records(
}
return {
"records": [to_dict(r) for r in rows],
"records": [to_dict(r) for r in page_rows],
"total": total,
"page": page,
"per_page": per_page,