fix(finance): scale projected points by employee norm

This commit is contained in:
lizerk 2026-07-20 17:17:52 +03:00
parent d55657f7c3
commit bc965c412e
5 changed files with 51 additions and 35 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -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(

View File

@ -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: "Отклонение",