From bc965c412e33c56e8aa939a02d3930eae0b77ec4 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 20 Jul 2026 17:17:52 +0300 Subject: [PATCH] fix(finance): scale projected points by employee norm --- backend/finance/payroll/attendance.py | 4 +- backend/finance/payroll/reporting.py | 41 +++++++++++-------- backend/finance/payroll/work_norm.py | 16 ++++---- .../tests/finance/test_payroll_admin_api.py | 13 +++++- .../finance/admin/PayrollPeriodTable.tsx | 12 +++--- 5 files changed, 51 insertions(+), 35 deletions(-) diff --git a/backend/finance/payroll/attendance.py b/backend/finance/payroll/attendance.py index a4044ae9..66c3aadb 100644 --- a/backend/finance/payroll/attendance.py +++ b/backend/finance/payroll/attendance.py @@ -97,7 +97,7 @@ def _is_explicit_absence(record: AttendanceRecord) -> bool: def calculate_attendance_points_projection( records: list[AttendanceRecord], *, - daily_target_points: Decimal, + daily_point_value: Decimal, ) -> Decimal: """Calculate a read-only point total from the available attendance rows. @@ -132,7 +132,7 @@ def calculate_attendance_points_projection( continue if worked == 0 and not record.arrival_time and not _is_explicit_absence(record): continue - actual_points += daily_target_points * worked / expected + actual_points += daily_point_value * worked / expected return actual_points.quantize(QUANTUM) diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 2c146678..3857ccce 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -286,28 +286,12 @@ def build_payroll_period_table(period: PayrollPeriod): work = work_records.get(employee.pk) source_lines = input_lines.get(employee.pk, []) schedule, target_points_source = resolve_employee_schedule(employee) - automatic_target_points, _, _ = calculate_period_target_points( + automatic_target_points, scheduled_workdays, _ = calculate_period_target_points( period, employee=employee, daily_target_points=daily_target_points, schedule=schedule, ) - attendance_records_for_employee = attendance_by_employee.get(employee.pk, []) - attendance_points = ( - calculate_attendance_points_projection( - attendance_records_for_employee, - daily_target_points=daily_target_points, - ) - if attendance_records_for_employee - else None - ) - personnel_points, _ = calculate_period_personnel_points( - period, - employee=employee, - actions=employee.payroll_personnel_actions, - daily_target_points=daily_target_points, - schedule=schedule, - ) if statement is not None: snapshot = statement.input_snapshot or {} result_lines = list(statement.lines.all()) @@ -396,6 +380,29 @@ def build_payroll_period_table(period: PayrollPeriod): else: status = "ready" + projection_target_points = Decimal(str(target_points or "0")) + daily_point_value = ( + projection_target_points / Decimal(scheduled_workdays) + if scheduled_workdays + else Decimal("0") + ) + attendance_records_for_employee = attendance_by_employee.get(employee.pk, []) + attendance_points = ( + calculate_attendance_points_projection( + attendance_records_for_employee, + daily_point_value=daily_point_value, + ) + if attendance_records_for_employee + else None + ) + personnel_points, _ = calculate_period_personnel_points( + period, + employee=employee, + actions=employee.payroll_personnel_actions, + period_target_points=projection_target_points, + schedule=schedule, + ) + point_base_amount = _point_base_amount(rate_amount, component_amounts) in_norm_point_rate = _in_norm_point_rate( point_base_amount, diff --git a/backend/finance/payroll/work_norm.py b/backend/finance/payroll/work_norm.py index a9b5e977..ab2366f4 100644 --- a/backend/finance/payroll/work_norm.py +++ b/backend/finance/payroll/work_norm.py @@ -97,7 +97,7 @@ def calculate_period_personnel_points( *, employee, actions, - daily_target_points: Decimal | None = None, + period_target_points: Decimal, schedule: dict | None = None, ) -> tuple[Decimal, int]: """Project points from the work calendar and official personnel events. @@ -110,19 +110,19 @@ def calculate_period_personnel_points( if schedule is None: schedule, _ = resolve_employee_schedule(employee) - daily_target = ( - daily_target_points - if daily_target_points is not None - else PayrollWorkSettings.get_daily_target_points() - ) + workdates = period_workdates(period, schedule) attended_dates = [ workdate - for workdate in period_workdates(period, schedule) + for workdate in workdates if resolve_employee_personnel_state( employee, workdate, actions=actions, ).expects_attendance ] - points = (daily_target * len(attended_dates)).quantize(QUANTUM) + points = ( + period_target_points * Decimal(len(attended_dates)) / Decimal(len(workdates)) + if workdates + else Decimal("0") + ).quantize(QUANTUM) return points, len(attended_dates) diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 94bebac3..134a4b4e 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -302,6 +302,14 @@ def test_period_table_projects_attendance_and_official_personnel_points( expected_hours=9, effective_is_workday=True, ) + PayrollWorkRecord.objects.create( + period=period, + employee=employee, + target_points="220", + target_points_overridden=True, + actual_points="0", + created_by=manager, + ) AttendanceRecord.objects.create( analysis_run=attendance_run, employee=employee, @@ -338,8 +346,9 @@ def test_period_table_projects_attendance_and_official_personnel_points( for item in response.json()["rows"] if item["employee"]["id"] == employee.pk ) - assert row["attendance_points"] == "7.5000" - assert row["personnel_points"] == "105.0000" + assert row["target_points"] == "220.0000" + assert row["attendance_points"] == "15.0000" + assert row["personnel_points"] == "210.0000" def test_component_cell_can_be_cleared_and_voids_all_active_lines( diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index eef3ced1..98c4cdbf 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -287,15 +287,15 @@ function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"][" }, attendancePoints: { title: "Баллы по посещаемости", - logic: "Вычисляются на ходу по доступным корректным записям посещаемости и не сохраняются в зарплате. Полная смена даёт дневную норму, неполная смена и переработка учитываются пропорционально. Если данных посещаемости нет, показывается прочерк.", - formula: "Баллы дня = Дневная норма × Отработанные часы ÷ Плановые часы\nБаллы по посещаемости = Σ баллов доступных рабочих дней", - manual: "Нет. Для изменения нужно исправить исходные данные посещаемости.", + logic: "Вычисляются на ходу от нормы баллов сотрудника за период и доступных корректных записей посещаемости. Полная смена даёт дневную долю нормы, неполная смена и переработка учитываются пропорционально. Если данных посещаемости нет, показывается прочерк.", + formula: "Дневная доля нормы = Норма баллов ÷ Плановые рабочие дни\nБаллы дня = Дневная доля нормы × Отработанные часы ÷ Плановые часы\nБаллы по посещаемости = Σ баллов доступных рабочих дней", + manual: "Нет. Значение меняется через норму баллов или исходные данные посещаемости.", }, personnelPoints: { title: "Баллы по кадровым событиям", - logic: "Вычисляются на ходу по рабочему графику и официальным кадровым событиям. Каждый плановый рабочий день считается отработанным полностью, пока одобренное заявление или кадровое событие не подтверждает отсутствие. Отпуск, больничный, отгул, декрет и увольнение дают 0 баллов за затронутые рабочие дни; удалённая работа считается присутствием.", - formula: "Баллы по кадровым событиям = Дневная норма × Рабочие дни без подтверждённого отсутствия", - manual: "Нет. Для изменения нужно исправить заявление, его статус, кадровое событие или рабочий график.", + logic: "Вычисляются на ходу от нормы баллов сотрудника, рабочего графика и официальных кадровых событий. Каждый плановый рабочий день считается отработанным полностью, пока одобренное заявление или кадровое событие не подтверждает отсутствие. Отпуск, больничный, отгул, декрет и увольнение дают 0 баллов за затронутые рабочие дни; удалённая работа считается присутствием.", + formula: "Баллы по кадровым событиям = Норма баллов × Рабочие дни без подтверждённого отсутствия ÷ Плановые рабочие дни", + manual: "Нет. Значение меняется через норму баллов, заявление, кадровое событие или рабочий график.", }, pointDelta: { title: "Отклонение",