diff --git a/backend/main.py b/backend/main.py index 5fa41f3..32096ae 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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,