fix(finance): count verified weekend attendance

This commit is contained in:
lizerk 2026-07-21 21:14:31 +03:00
parent 4ab4a9eb87
commit 8f59f2d1ed
3 changed files with 84 additions and 27 deletions

View File

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

View File

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

View File

@ -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({
</th>
{data.dates.map((day) => {
const parts = dateParts(day.date);
const hasActivity = hasAttendanceActivity(day) || Boolean(day.work_entry);
return (
<th
key={day.date}
className={`app-surface-muted border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center text-[10px] font-semibold ${day.is_workday ? "text-[var(--muted-foreground)]" : "opacity-55 text-[var(--muted-foreground)]"}`}
title={day.is_workday ? "Плановый рабочий день" : "Нерабочий день"}
className={`app-surface-muted border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center text-[10px] font-semibold ${day.is_workday || hasActivity ? "text-[var(--muted-foreground)]" : "opacity-55 text-[var(--muted-foreground)]"}`}
title={day.is_workday ? "Плановый рабочий день" : hasActivity ? "Нерабочий день с фактической активностью" : "Нерабочий день"}
>
<span className="block tabular-nums">{parts.date}</span>
<span className="mt-0.5 block font-normal uppercase">{parts.weekday}</span>
@ -286,10 +298,15 @@ export function PayrollPointBreakdownModal({
: row.id === "personnel_points"
? day.personnel_points != null || day.personnel_detail.actions.length > 0 || day.personnel_detail.requests.length > 0
: Boolean(day.work_entry);
const hasSourceActivity = row.id === "attendance_points"
? hasAttendanceActivity(day)
: row.id === "work_points"
? Boolean(day.work_entry)
: day.personnel_points != null || day.personnel_detail.actions.length > 0 || day.personnel_detail.requests.length > 0;
return (
<td
key={day.date}
className={`border-b border-r border-[var(--border-subtle)] p-0 text-center text-[11px] tabular-nums ${difference ? differenceClasses[difference.color] : day.is_workday ? "text-[var(--foreground)]" : "bg-[var(--surface-secondary)] text-[var(--muted-foreground)]"}`}
className={`border-b border-r border-[var(--border-subtle)] p-0 text-center text-[11px] tabular-nums ${difference ? differenceClasses[difference.color] : day.is_workday || day[row.id] != null || hasSourceActivity ? "text-[var(--foreground)]" : "bg-[var(--surface-secondary)] text-[var(--muted-foreground)]"}`}
title={differenceTitle(row, day, difference)}
>
{canOpen ? (