From 4ab4a9eb8785bab9185625a0510317e4e3cf8a66 Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 21 Jul 2026 18:39:26 +0300 Subject: [PATCH] fix(finance): separate attendance from personnel points --- backend/finance/payroll/attendance.py | 47 +++++++++++++++---- .../finance/test_payroll_attendance_import.py | 41 ++++++++++++++-- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/backend/finance/payroll/attendance.py b/backend/finance/payroll/attendance.py index ca3caf29..5419cbed 100644 --- a/backend/finance/payroll/attendance.py +++ b/backend/finance/payroll/attendance.py @@ -101,13 +101,24 @@ def _calculate_attendance_day_points( ) -> Decimal | None: worked = _finite_decimal(record.work_hours) expected = _finite_decimal(record.expected_hours) - if worked is not None and worked > 0 and not record.effective_is_workday: - return None - if not record.effective_is_workday or record.technical_issues: + # Attendance and personnel projections are deliberately independent. + # A vacation/dismissal makes ``effective_is_workday`` false, but verified + # entrance/exit events on an underlying scheduled day still earn + # attendance points. The personnel column represents the conflicting + # personnel state separately. + if not record.is_workday or record.technical_issues: return None if record.personnel_status == ACTION_REMOTE and not ( - record.is_manually_edited - and "work_hours" in (record.manual_edit_payload or {}) + ( + record.is_manually_edited + and "work_hours" in (record.manual_edit_payload or {}) + ) + or ( + worked is not None + and worked > 0 + and record.arrival_time + and record.departure_time + ) ): return None if bool(record.arrival_time) != bool(record.departure_time): @@ -262,6 +273,7 @@ def _analyze_attendance( absence_days = 0 manual_days = 0 overtime_days = 0 + personnel_conflict_days = 0 missing_schedule = False for record in records: @@ -277,7 +289,7 @@ def _analyze_attendance( worked = _finite_decimal(record.work_hours) expected = _finite_decimal(record.expected_hours) - if worked is not None and worked > 0 and not record.effective_is_workday: + if worked is not None and worked > 0 and not record.is_workday: blockers.append( _issue( "OUTSIDE_SCHEDULE_WORK_REQUIRES_REVIEW", @@ -285,7 +297,7 @@ def _analyze_attendance( ) ) continue - if not record.effective_is_workday: + if not record.is_workday: continue effective_workdays += 1 @@ -293,8 +305,16 @@ def _analyze_attendance( technical_issue_days += 1 continue if record.personnel_status == ACTION_REMOTE and not ( - record.is_manually_edited - and "work_hours" in (record.manual_edit_payload or {}) + ( + record.is_manually_edited + and "work_hours" in (record.manual_edit_payload or {}) + ) + or ( + worked is not None + and worked > 0 + and record.arrival_time + and record.departure_time + ) ): blockers.append( _issue( @@ -343,6 +363,7 @@ def _analyze_attendance( absence_days += int(explicit_absence) manual_days += int(record.is_manually_edited) overtime_days += int(record.is_overtime) + personnel_conflict_days += int(not record.effective_is_workday and worked > 0) if technical_issue_days: blockers.append( @@ -386,6 +407,14 @@ def _analyze_attendance( f"Факт включает переработку в рабочие дни: {overtime_days} дн.", ) ) + if personnel_conflict_days: + warnings.append( + _issue( + "ATTENDANCE_PERSONNEL_CONFLICT_INCLUDED", + "Фактическая работа в кадрово нерабочие дни учтена в баллах: " + f"{personnel_conflict_days} дн.", + ) + ) return { "target_points": str(target_points.quantize(QUANTUM)), diff --git a/backend/tests/finance/test_payroll_attendance_import.py b/backend/tests/finance/test_payroll_attendance_import.py index a215cab7..c95b48d6 100644 --- a/backend/tests/finance/test_payroll_attendance_import.py +++ b/backend/tests/finance/test_payroll_attendance_import.py @@ -9,6 +9,7 @@ from django.utils import timezone from attendance.models import AttendanceAnalysisRun, AttendanceRecord from finance.enums import ApprovalStatus, InputSource from finance.models import PayrollAuditEvent, PayrollPeriod, PayrollWorkRecord +from finance.payroll.attendance import calculate_attendance_day_points pytestmark = pytest.mark.django_db @@ -123,9 +124,7 @@ def test_attendance_preview_and_missing_only_create_audited_draft( "Неполный день и переработка рассчитываются пропорционально " "плановым часам." ), - "formula": ( - "Баллы дня = дневная норма × отработанные часы ÷ плановые часы" - ), + "formula": ("Баллы дня = дневная норма × отработанные часы ÷ плановые часы"), "daily_target_points": "5.0000", } assert preview["summary"] == { @@ -194,6 +193,42 @@ def test_attendance_preview_and_missing_only_create_audited_draft( ).exists() +def test_personnel_non_working_day_keeps_verified_attendance_points( + user_factory, + auth_client_factory, +): + manager = user_factory(email="attendance.personnel.manager@example.test") + employee = user_factory(email="attendance.personnel.employee@example.test") + grant(manager, "manage_payroll_inputs") + period = make_period(manager, suffix="personnel-separation") + records = make_attendance(employee) + worked_record = records[0] + worked_record.effective_is_workday = False + worked_record.personnel_status = "on_leave" + worked_record.personnel_status_label = "В отпуске" + worked_record.statuses = ["work_outside_personnel_schedule"] + worked_record.is_overtime = True + worked_record.overtime_hours = worked_record.work_hours + worked_record.save() + + assert calculate_attendance_day_points( + worked_record, + daily_point_value=Decimal("5"), + ) == Decimal("5.0000") + + preview_response = auth_client_factory(manager).get(endpoint(period)) + + assert preview_response.status_code == 200 + item = preview_response.json()["items"][0] + assert item["target_points"] == "10.0000" + assert item["actual_points"] == "5.0000" + assert item["blockers"] == [] + assert {warning["code"] for warning in item["warnings"]} >= { + "ATTENDANCE_OVERTIME_INCLUDED", + "ATTENDANCE_PERSONNEL_CONFLICT_INCLUDED", + } + + def test_replace_mode_revises_approved_and_preserves_excel_controls( user_factory, auth_client_factory,