From eacbd96eecd18030d30016b3c1647bba528d702c Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 27 Apr 2026 11:04:58 +0300 Subject: [PATCH] Prioritize attendance absence display --- backend/attendance/services.py | 48 ++++++++++++-- .../v1/attendance/test_attendance_storage.py | 62 ++++++++++++++++++- frontend/src/app/attendance/page.tsx | 29 +++++++-- 3 files changed, 126 insertions(+), 13 deletions(-) diff --git a/backend/attendance/services.py b/backend/attendance/services.py index d0ce38f9..eaceabaf 100644 --- a/backend/attendance/services.py +++ b/backend/attendance/services.py @@ -75,7 +75,7 @@ MATRIX_STATUS_SHORT_LABELS = { "underwork": "НД", "late": "ОП", "overtime": "ПР", - "absent": "Н", + "absent": "Отс.", "non_working": "Нер", "normal": "OK", } @@ -127,6 +127,8 @@ SUPPRESSED_EMPLOYEE_ISSUE_MARKERS = ( "недоработ", ) +ABSENCE_ISSUE_MARKERS = ("absence", "absent", "отсутств") + DEFAULT_WORK_SCHEDULE_PAYLOAD = { "start_time": "08:00", "end_time": "17:00", @@ -604,7 +606,7 @@ def _append_attendance_matrix_legend_sheet(workbook: Workbook) -> None: ("ОП", "Опоздание"), ("НД", "Недоработка"), ("ПР", "Переработка"), - ("Н", "Отсутствие"), + ("Отс.", "Отсутствие"), ("Вых", "Выходной по графику/календарю"), ("Тех", "Технический сбой"), ("ОТП", "Отпуск"), @@ -745,6 +747,18 @@ def build_attendance_record_defaults( personnel_state=personnel_state, ) + if ( + effective_is_workday + and not has_work + and not is_absent + and _has_absence_issue([*statuses, *employee_issues]) + ): + is_absent = True + + if is_absent and not has_work: + is_underwork = False + underwork_hours = None + if not effective_is_workday: is_late = False late_minutes = None @@ -873,6 +887,14 @@ def _normalize_issues_after_manual_payload( ] +def _has_absence_issue(values: list[Any]) -> bool: + return any( + marker in str(value).lower() + for value in values + for marker in ABSENCE_ISSUE_MARKERS + ) + + def _parse_record_date(value: Any) -> date | None: if isinstance(value, date): return value @@ -1155,7 +1177,7 @@ def _monthly_matrix_status(record: AttendanceRecord) -> str: return "non_working" if record.technical_issues: return "technical" - if record.is_absent and not record.arrival_time and not record.departure_time: + if _record_has_absence(record): return "absent" if record.is_underwork: return "underwork" @@ -1163,11 +1185,21 @@ def _monthly_matrix_status(record: AttendanceRecord) -> str: return "late" if record.is_overtime: return "overtime" - if record.is_absent: - return "absent" return "normal" +def _record_has_absence(record: AttendanceRecord) -> bool: + return bool( + record.is_absent + or _has_absence_issue( + [ + *record.statuses, + *record.employee_issues, + ] + ) + ) + + def _non_working_reason( *, is_workday: bool, @@ -1231,7 +1263,11 @@ def _summary_overtime_days(records: list[AttendanceRecord]) -> int: def _summary_absent_days(records: list[AttendanceRecord]) -> int: - return sum(1 for record in records if _is_valid_workday_record(record) and record.is_absent) + return sum( + 1 + for record in records + if _is_valid_workday_record(record) and _record_has_absence(record) + ) def _employee_display_name(employee: Employee) -> str: diff --git a/backend/tests/api/v1/attendance/test_attendance_storage.py b/backend/tests/api/v1/attendance/test_attendance_storage.py index 9a0f30ee..8b210908 100644 --- a/backend/tests/api/v1/attendance/test_attendance_storage.py +++ b/backend/tests/api/v1/attendance/test_attendance_storage.py @@ -1029,10 +1029,68 @@ def test_monthly_matrix_prioritizes_absence_when_no_times_and_no_personnel_reaso ) matrix_cell = matrix_row["cells"][str(employee.id)] assert matrix_cell["status"] == "absent" - assert matrix_cell["display_text"] == "Н" + assert matrix_cell["display_text"] == "Отс." assert matrix_cell["primary_label"] == "Отсутствие" +def test_monthly_matrix_infers_absence_from_issue_when_logstorm_omits_flag( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + client = auth_client_factory(staff) + result = { + "records": [ + { + "date": "2026-04-20", + "employee_id": str(employee.id), + "display_name": "Absent User", + "arrival_time": None, + "departure_time": None, + "work_hours": 0, + "expected_hours": 9, + "is_workday": True, + "is_underwork": True, + "underwork_hours": 9, + "employee_issues": ["Отсутствие"], + "technical_issues": [], + } + ] + } + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=result, + ): + response = client.post( + _analyze_url(), + _payload(employee.id, period_start="2026-04-20", period_end="2026-04-20"), + format="json", + ) + assert response.status_code == 200 + + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + assert record.is_absent is True + assert record.is_underwork is False + + matrix_response = client.get( + _monthly_matrix_url(), + { + "employee_ids": str(employee.id), + "month": "2026-04", + }, + ) + matrix_row = next( + row for row in matrix_response.json()["rows"] if row["date"] == "2026-04-20" + ) + matrix_cell = matrix_row["cells"][str(employee.id)] + assert matrix_cell["status"] == "absent" + assert matrix_cell["display_text"] == "Отс." + assert matrix_cell["primary_label"] == "Отсутствие" + assert "Недоработка" not in " ".join(matrix_cell["detail_lines"]) + + def test_non_staff_cannot_export_monthly_matrix_xlsx( auth_client_factory, user_factory, @@ -1127,7 +1185,7 @@ def test_monthly_matrix_xlsx_export_keeps_full_month_but_limits_records_to_perio assert april["A21"].value == "2026-04-20" assert april["A22"].value == "2026-04-21" assert april["C21"].value is None - assert april["C22"].value == "Н" + assert april["C22"].value == "Отс." assert april["C33"].value == 1 assert april["C35"].value == 0 diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx index 8d74cf08..de6fffab 100644 --- a/frontend/src/app/attendance/page.tsx +++ b/frontend/src/app/attendance/page.tsx @@ -228,6 +228,25 @@ function formatAttendanceIssueLabel(label: string) { return attendanceIssueLabelMap[normalized] || label; } +function hasAbsenceIssue(values: Array | undefined) { + return Boolean(values?.some((value) => { + const normalized = String(value).trim().toLowerCase(); + return ( + normalized.includes("absence") + || normalized.includes("absent") + || normalized.includes("отсутств") + ); + })); +} + +function attendanceRecordHasAbsence(record: AttendanceRecord) { + return Boolean( + record.is_absent + || hasAbsenceIssue(record.employee_issues) + || hasAbsenceIssue(record.statuses), + ); +} + function matrixCellIssueLabels(cell: MonthlyAttendanceMatrixCell) { if (cell.status === "absent") return ["absence"]; const result = [...cell.issues.map(String)]; @@ -246,10 +265,10 @@ function attendanceRecordIssueLabels(record: AttendanceRecord) { ...(record.employee_issues || []), ...(record.technical_issues || []), ].map(String); - if (record.is_absent) result.push("absence"); + if (attendanceRecordHasAbsence(record)) result.push("absence"); if (record.is_late) result.push("late"); if (record.is_early_leave) result.push("early_leave"); - if (record.is_underwork) result.push("underwork"); + if (record.is_underwork && !attendanceRecordHasAbsence(record)) result.push("underwork"); if (record.is_overtime) result.push("overtime"); return Array.from(new Set(result)).map(formatAttendanceIssueLabel); } @@ -264,7 +283,7 @@ function matrixCellDisplay(cell: MonthlyAttendanceMatrixCell) { function attendanceRecordStatusLabel(record: AttendanceRecord) { if (record.technical_issues?.length) return "Техсбой"; if (record.personnel_status_label) return record.personnel_status_label; - if (record.is_absent) return "Отсутствие"; + if (attendanceRecordHasAbsence(record)) return "Отсутствие"; if (record.is_underwork) return "Недоработка"; if (record.is_late) return "Опоздание"; if (record.is_overtime) return "Переработка"; @@ -278,7 +297,7 @@ function attendanceRecordDisplay(record: AttendanceRecord) { const arrival = formatTime(record.arrival_time); const departure = formatTime(record.departure_time); if (arrival === "-" && departure === "-") { - if (record.is_absent) return "Отсутствие"; + if (attendanceRecordHasAbsence(record)) return "Отсутствие"; return record.non_working_reason || "Нет проходов"; } return `${arrival}/${departure} · ${formatHours(record.work_hours)}ч`; @@ -295,7 +314,7 @@ function attendanceRecordDetails(record: AttendanceRecord) { if (record.early_leave_minutes) { lines.push(`Ранний уход: ${record.early_leave_minutes} мин.`); } - if (record.underwork_hours) { + if (record.underwork_hours && !attendanceRecordHasAbsence(record)) { lines.push(`Недоработка: ${formatHours(record.underwork_hours)} ч.`); } if (record.overtime_hours) {