Merge branch 'develop'

This commit is contained in:
lizerk 2026-07-21 18:40:40 +03:00
commit 0016c70d1a
2 changed files with 76 additions and 12 deletions

View File

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

View File

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