diff --git a/backend/finance/payroll/attendance.py b/backend/finance/payroll/attendance.py index 5419cbed..b5f86183 100644 --- a/backend/finance/payroll/attendance.py +++ b/backend/finance/payroll/attendance.py @@ -106,19 +106,20 @@ def _calculate_attendance_day_points( # 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: + has_verified_shift = bool( + worked is not None + and worked > 0 + and record.arrival_time + and record.departure_time + ) + if (not record.is_workday and not has_verified_shift) 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 {}) ) - or ( - worked is not None - and worked > 0 - and record.arrival_time - and record.departure_time - ) + or has_verified_shift ): return None if bool(record.arrival_time) != bool(record.departure_time): @@ -274,6 +275,7 @@ def _analyze_attendance( manual_days = 0 overtime_days = 0 personnel_conflict_days = 0 + outside_schedule_days = 0 missing_schedule = False for record in records: @@ -289,18 +291,16 @@ 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.is_workday: - blockers.append( - _issue( - "OUTSIDE_SCHEDULE_WORK_REQUIRES_REVIEW", - "Есть работа вне рабочего графика; её нужно проверить вручную.", - ) - ) - continue - if not record.is_workday: + has_verified_shift = bool( + worked is not None + and worked > 0 + and record.arrival_time + and record.departure_time + ) + if not record.is_workday and not has_verified_shift: continue - effective_workdays += 1 + effective_workdays += int(record.is_workday) if record.technical_issues: technical_issue_days += 1 continue @@ -309,12 +309,7 @@ def _analyze_attendance( 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 - ) + or has_verified_shift ): blockers.append( _issue( @@ -358,12 +353,14 @@ def _analyze_attendance( expected_hours += expected worked_hours += worked - target_points += daily_target_points + if record.is_workday: + target_points += daily_target_points actual_points += daily_target_points * worked / expected 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) + outside_schedule_days += int(not record.is_workday and worked > 0) if technical_issue_days: blockers.append( @@ -415,6 +412,14 @@ def _analyze_attendance( f"{personnel_conflict_days} дн.", ) ) + if outside_schedule_days: + warnings.append( + _issue( + "ATTENDANCE_OUTSIDE_SCHEDULE_INCLUDED", + "Фактическая работа в выходные учтена сверх нормы периода: " + f"{outside_schedule_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 c95b48d6..cec6e053 100644 --- a/backend/tests/finance/test_payroll_attendance_import.py +++ b/backend/tests/finance/test_payroll_attendance_import.py @@ -229,6 +229,41 @@ def test_personnel_non_working_day_keeps_verified_attendance_points( } +def test_weekend_with_verified_shift_adds_points_without_raising_period_target( + user_factory, + auth_client_factory, +): + manager = user_factory(email="attendance.weekend.manager@example.test") + employee = user_factory(email="attendance.weekend.employee@example.test") + grant(manager, "manage_payroll_inputs") + period = make_period(manager, suffix="weekend-attendance") + records = make_attendance(employee) + weekend_record = records[2] + weekend_record.work_hours = 4 + weekend_record.arrival_time = "10:00" + weekend_record.departure_time = "14:00" + weekend_record.is_overtime = True + weekend_record.overtime_hours = 4 + weekend_record.statuses = ["work_outside_personnel_schedule"] + weekend_record.save() + + assert calculate_attendance_day_points( + weekend_record, + daily_point_value=Decimal("5"), + ) == Decimal("2.5000") + + 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"] == "7.5000" + assert item["blockers"] == [] + assert "ATTENDANCE_OUTSIDE_SCHEDULE_INCLUDED" in { + warning["code"] for warning in item["warnings"] + } + + def test_replace_mode_revises_approved_and_preserves_excel_controls( user_factory, auth_client_factory, diff --git a/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx b/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx index 77049d68..d9396b0e 100644 --- a/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx +++ b/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx @@ -68,6 +68,17 @@ function formatDate(value: string): string { return new Date(`${value}T00:00:00`).toLocaleDateString("ru-RU"); } +function hasAttendanceActivity(day: PayrollPointBreakdownDay): boolean { + const record = day.attendance_record; + if (!record) return false; + const worked = Number(record.work_hours); + return Boolean( + record.arrival_time + || record.departure_time + || (Number.isFinite(worked) && worked > 0), + ); +} + function differenceTitle( row: PointRow, day: PayrollPointBreakdownDay, @@ -249,11 +260,12 @@ export function PayrollPointBreakdownModal({ {data.dates.map((day) => { const parts = dateParts(day.date); + const hasActivity = hasAttendanceActivity(day) || Boolean(day.work_entry); return (