868 lines
31 KiB
Python
868 lines
31 KiB
Python
"""Host adapter that turns verified attendance hours into payroll work drafts.
|
||
|
||
The deterministic payroll core remains unaware of the portal's attendance
|
||
models. This adapter is deliberately conservative: it only imports complete,
|
||
unambiguous attendance and never approves a payroll record automatically.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import math
|
||
from collections import defaultdict
|
||
from datetime import timedelta
|
||
from decimal import Decimal, InvalidOperation
|
||
|
||
from django.contrib.auth import get_user_model
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import IntegrityError, transaction
|
||
from django.utils import timezone
|
||
|
||
from attendance.models import AttendanceRecord
|
||
from attendance.services import ABSENCE_ISSUE_MARKERS
|
||
from employees.constants import ACTION_REMOTE
|
||
from payroll_core import PointPolicy
|
||
|
||
from finance.enums import (
|
||
ApprovalStatus,
|
||
InputSource,
|
||
PayrollPeriodStatus,
|
||
PayrollRunStatus,
|
||
)
|
||
from finance.models import (
|
||
PayrollAuditEvent,
|
||
PayrollPeriod,
|
||
PayrollWorkRecord,
|
||
PayrollWorkSettings,
|
||
)
|
||
|
||
from .access import has_payroll_permission, has_simple_admin_access
|
||
from .config import build_rules
|
||
from .exceptions import PayrollOperationError, PayrollPermissionDenied
|
||
|
||
Employee = get_user_model()
|
||
|
||
ATTENDANCE_WORK_POLICY_CODE = "attendance_to_daily_points_v2"
|
||
ATTENDANCE_WORK_POLICY = {
|
||
"code": ATTENDANCE_WORK_POLICY_CODE,
|
||
"label": "Посещаемость переводится в баллы дневной нормы",
|
||
"description": (
|
||
"Полностью отработанный день даёт дневную норму баллов. Неполный день "
|
||
"и переработка рассчитываются пропорционально плановым часам."
|
||
),
|
||
"formula": ("Баллы дня = дневная норма × отработанные часы ÷ плановые часы"),
|
||
}
|
||
ATTENDANCE_IMPORT_MODES = {"missing_only", "replace_existing"}
|
||
ACTION_KEYS = ("create", "update", "revise", "unchanged", "skip", "blocked")
|
||
QUANTUM = Decimal("0.0001")
|
||
|
||
|
||
def _operation_error(code: str, message: str, **details):
|
||
raise PayrollOperationError(code, message, details=details)
|
||
|
||
|
||
def _issue(code: str, message: str) -> dict[str, str]:
|
||
return {"code": code, "message": message}
|
||
|
||
|
||
def _date_range(period: PayrollPeriod) -> list:
|
||
days = (period.date_to - period.date_from).days
|
||
return [period.date_from + timedelta(days=offset) for offset in range(days + 1)]
|
||
|
||
|
||
def _finite_decimal(value) -> Decimal | None:
|
||
if value is None:
|
||
return None
|
||
try:
|
||
number = Decimal(str(value))
|
||
except (InvalidOperation, TypeError, ValueError):
|
||
return None
|
||
if not number.is_finite() or not math.isfinite(float(number)):
|
||
return None
|
||
return number
|
||
|
||
|
||
def _is_explicit_absence(record: AttendanceRecord) -> bool:
|
||
if record.is_absent:
|
||
return True
|
||
values = [*record.statuses, *record.employee_issues]
|
||
return any(
|
||
marker in str(value).strip().lower()
|
||
for value in values
|
||
for marker in ABSENCE_ISSUE_MARKERS
|
||
)
|
||
|
||
|
||
def _calculate_attendance_day_points(
|
||
record: AttendanceRecord,
|
||
*,
|
||
daily_point_value: Decimal,
|
||
) -> 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:
|
||
return None
|
||
if record.personnel_status == ACTION_REMOTE and not (
|
||
record.is_manually_edited
|
||
and "work_hours" in (record.manual_edit_payload or {})
|
||
):
|
||
return None
|
||
if bool(record.arrival_time) != bool(record.departure_time):
|
||
return None
|
||
if (
|
||
expected is None
|
||
or worked is None
|
||
or expected <= 0
|
||
or expected > 24
|
||
or worked < 0
|
||
or worked > 24
|
||
):
|
||
return None
|
||
if worked == 0 and not record.arrival_time and not _is_explicit_absence(record):
|
||
return None
|
||
return daily_point_value * worked / expected
|
||
|
||
|
||
def calculate_attendance_day_points(
|
||
record: AttendanceRecord,
|
||
*,
|
||
daily_point_value: Decimal,
|
||
) -> Decimal | None:
|
||
"""Return a comparable daily projection or ``None`` for unusable data."""
|
||
|
||
points = _calculate_attendance_day_points(
|
||
record,
|
||
daily_point_value=daily_point_value,
|
||
)
|
||
return points.quantize(QUANTUM) if points is not None else None
|
||
|
||
|
||
def calculate_attendance_points_projection(
|
||
records: list[AttendanceRecord],
|
||
*,
|
||
daily_point_value: Decimal,
|
||
) -> Decimal:
|
||
"""Calculate a read-only point total from the available attendance rows.
|
||
|
||
This intentionally uses the same valid-day formula as the attendance import,
|
||
but does not require a complete period: the summary table can therefore show
|
||
the current projection while the month is still in progress.
|
||
"""
|
||
|
||
actual_points = Decimal("0")
|
||
for record in records:
|
||
day_points = _calculate_attendance_day_points(
|
||
record,
|
||
daily_point_value=daily_point_value,
|
||
)
|
||
if day_points is not None:
|
||
actual_points += day_points
|
||
return actual_points.quantize(QUANTUM)
|
||
|
||
|
||
def _employee_payload(employee) -> dict:
|
||
position = getattr(employee, "position", None)
|
||
links = list(getattr(employee, "departments_links", []).all())
|
||
department_link = next((link for link in links if link.is_active), None)
|
||
return {
|
||
"id": employee.pk,
|
||
"display_name": employee.get_full_name().strip() or f"Сотрудник #{employee.pk}",
|
||
"position": position.name if position is not None else None,
|
||
"department": (
|
||
department_link.department.name if department_link is not None else None
|
||
),
|
||
}
|
||
|
||
|
||
def _existing_payload(record: PayrollWorkRecord | None) -> dict | None:
|
||
if record is None:
|
||
return None
|
||
return {
|
||
"id": record.pk,
|
||
"revision": record.revision,
|
||
"status": record.status,
|
||
"source": record.source,
|
||
"target_points": str(record.target_points),
|
||
"actual_points": str(record.actual_points),
|
||
"created_by": {
|
||
"id": record.created_by_id,
|
||
"display_name": (
|
||
record.created_by.get_full_name().strip()
|
||
or f"Сотрудник #{record.created_by_id}"
|
||
),
|
||
},
|
||
}
|
||
|
||
|
||
def _attendance_snapshot_hash(records: list[AttendanceRecord]) -> str:
|
||
payload = [
|
||
{
|
||
"id": record.pk,
|
||
"run": record.analysis_run_id,
|
||
"run_status": record.analysis_run.status,
|
||
"schedule": record.analysis_run.schedule_payload,
|
||
"date": record.date.isoformat(),
|
||
"expected": record.expected_hours,
|
||
"worked": record.work_hours,
|
||
"workday": record.is_workday,
|
||
"effective_workday": record.effective_is_workday,
|
||
"arrival": record.arrival_time,
|
||
"departure": record.departure_time,
|
||
"absent": record.is_absent,
|
||
"technical": record.technical_issues,
|
||
"employee_issues": record.employee_issues,
|
||
"statuses": record.statuses,
|
||
"personnel_status": record.personnel_status,
|
||
"manual": record.is_manually_edited,
|
||
"updated_at": record.updated_at.isoformat(),
|
||
}
|
||
for record in records
|
||
]
|
||
encoded = json.dumps(
|
||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||
)
|
||
return hashlib.sha256(encoded.encode()).hexdigest()
|
||
|
||
|
||
def _analyze_attendance(
|
||
period: PayrollPeriod,
|
||
records: list[AttendanceRecord],
|
||
*,
|
||
daily_target_points: Decimal,
|
||
):
|
||
blockers: list[dict[str, str]] = []
|
||
warnings: list[dict[str, str]] = []
|
||
expected_dates = set(_date_range(period))
|
||
actual_dates = {record.date for record in records}
|
||
missing_dates = sorted(expected_dates - actual_dates)
|
||
if missing_dates:
|
||
blockers.append(
|
||
_issue(
|
||
"MISSING_ATTENDANCE_DAYS",
|
||
f"Не хватает записей посещаемости за {len(missing_dates)} дн.",
|
||
)
|
||
)
|
||
if period.date_to >= timezone.localdate():
|
||
blockers.append(
|
||
_issue(
|
||
"PERIOD_NOT_COMPLETE",
|
||
"Период ещё не завершён; итоговую выработку рассчитывать рано.",
|
||
)
|
||
)
|
||
|
||
target_points = Decimal("0")
|
||
actual_points = Decimal("0")
|
||
expected_hours = Decimal("0")
|
||
worked_hours = Decimal("0")
|
||
effective_workdays = 0
|
||
technical_issue_days = 0
|
||
absence_days = 0
|
||
manual_days = 0
|
||
overtime_days = 0
|
||
missing_schedule = False
|
||
|
||
for record in records:
|
||
if record.analysis_run.status != record.analysis_run.STATUS_SUCCESS:
|
||
blockers.append(
|
||
_issue(
|
||
"ATTENDANCE_ANALYSIS_FAILED",
|
||
"Один из дней получен из неуспешного анализа посещаемости.",
|
||
)
|
||
)
|
||
if not record.analysis_run.schedule_payload:
|
||
missing_schedule = True
|
||
|
||
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:
|
||
blockers.append(
|
||
_issue(
|
||
"OUTSIDE_SCHEDULE_WORK_REQUIRES_REVIEW",
|
||
"Есть работа вне рабочего графика; её нужно проверить вручную.",
|
||
)
|
||
)
|
||
continue
|
||
if not record.effective_is_workday:
|
||
continue
|
||
|
||
effective_workdays += 1
|
||
if record.technical_issues:
|
||
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 {})
|
||
):
|
||
blockers.append(
|
||
_issue(
|
||
"REMOTE_ATTENDANCE_UNMEASURABLE",
|
||
"Удалённый рабочий день не подтверждён ручной записью часов.",
|
||
)
|
||
)
|
||
continue
|
||
if bool(record.arrival_time) != bool(record.departure_time):
|
||
blockers.append(
|
||
_issue(
|
||
"OPEN_SHIFT",
|
||
f"За {record.date:%d.%m.%Y} не закрыта смена.",
|
||
)
|
||
)
|
||
continue
|
||
if (
|
||
expected is None
|
||
or worked is None
|
||
or expected <= 0
|
||
or expected > 24
|
||
or worked < 0
|
||
or worked > 24
|
||
):
|
||
blockers.append(
|
||
_issue(
|
||
"INVALID_ATTENDANCE_HOURS",
|
||
f"За {record.date:%d.%m.%Y} указаны некорректные часы.",
|
||
)
|
||
)
|
||
continue
|
||
explicit_absence = _is_explicit_absence(record)
|
||
if worked == 0 and not record.arrival_time and not explicit_absence:
|
||
blockers.append(
|
||
_issue(
|
||
"UNCLASSIFIED_ZERO_DAY",
|
||
f"Нулевые часы за {record.date:%d.%m.%Y} не объяснены отсутствием.",
|
||
)
|
||
)
|
||
continue
|
||
|
||
expected_hours += expected
|
||
worked_hours += worked
|
||
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)
|
||
|
||
if technical_issue_days:
|
||
blockers.append(
|
||
_issue(
|
||
"TECHNICAL_ATTENDANCE_ISSUES",
|
||
f"Технические проблемы обнаружены за {technical_issue_days} дн.",
|
||
)
|
||
)
|
||
if target_points <= 0:
|
||
blockers.append(
|
||
_issue(
|
||
"ZERO_TARGET_POINTS",
|
||
"По посещаемости не удалось получить положительную норму баллов.",
|
||
)
|
||
)
|
||
if missing_schedule:
|
||
warnings.append(
|
||
_issue(
|
||
"NO_SCHEDULE_SNAPSHOT",
|
||
"В старых данных нет снимка графика; проверьте норму часов.",
|
||
)
|
||
)
|
||
if absence_days:
|
||
warnings.append(
|
||
_issue(
|
||
"ATTENDANCE_ABSENCES_INCLUDED",
|
||
f"Подтверждённые отсутствия учтены как 0 часов: {absence_days} дн.",
|
||
)
|
||
)
|
||
if manual_days:
|
||
warnings.append(
|
||
_issue(
|
||
"MANUAL_ATTENDANCE_INCLUDED",
|
||
f"Использованы ручные исправления посещаемости: {manual_days} дн.",
|
||
)
|
||
)
|
||
if overtime_days:
|
||
warnings.append(
|
||
_issue(
|
||
"ATTENDANCE_OVERTIME_INCLUDED",
|
||
f"Факт включает переработку в рабочие дни: {overtime_days} дн.",
|
||
)
|
||
)
|
||
|
||
return {
|
||
"target_points": str(target_points.quantize(QUANTUM)),
|
||
"actual_points": str(actual_points.quantize(QUANTUM)),
|
||
"daily_target_points": str(daily_target_points.quantize(QUANTUM)),
|
||
"expected_hours": str(expected_hours.quantize(QUANTUM)),
|
||
"worked_hours": str(worked_hours.quantize(QUANTUM)),
|
||
"attendance_days": len(records),
|
||
"effective_workdays": effective_workdays,
|
||
"technical_issue_days": technical_issue_days,
|
||
"warnings": _deduplicate_issues(warnings),
|
||
"blockers": _deduplicate_issues(blockers),
|
||
"source_hash": _attendance_snapshot_hash(records),
|
||
}
|
||
|
||
|
||
def _deduplicate_issues(issues: list[dict[str, str]]) -> list[dict[str, str]]:
|
||
result = []
|
||
seen = set()
|
||
for issue in issues:
|
||
key = (issue["code"], issue["message"])
|
||
if key not in seen:
|
||
seen.add(key)
|
||
result.append(issue)
|
||
return result
|
||
|
||
|
||
def _active_work_record(records: list[PayrollWorkRecord]) -> PayrollWorkRecord | None:
|
||
drafts = [record for record in records if record.status == ApprovalStatus.DRAFT]
|
||
if drafts:
|
||
return max(drafts, key=lambda record: (record.revision, record.pk))
|
||
approved = [
|
||
record for record in records if record.status == ApprovalStatus.APPROVED
|
||
]
|
||
if approved:
|
||
return max(approved, key=lambda record: (record.revision, record.pk))
|
||
return None
|
||
|
||
|
||
def _same_metrics(record: PayrollWorkRecord, metrics: dict) -> bool:
|
||
return record.target_points == Decimal(
|
||
metrics["target_points"]
|
||
) and record.actual_points == Decimal(metrics["actual_points"])
|
||
|
||
|
||
def _actions_for_item(
|
||
*,
|
||
period: PayrollPeriod,
|
||
actor,
|
||
existing: PayrollWorkRecord | None,
|
||
metrics: dict,
|
||
) -> dict[str, str]:
|
||
if metrics["blockers"]:
|
||
return {"missing_only": "blocked", "replace_existing": "blocked"}
|
||
if existing is None:
|
||
if period.status == PayrollPeriodStatus.PUBLISHED:
|
||
return {"missing_only": "blocked", "replace_existing": "blocked"}
|
||
return {"missing_only": "create", "replace_existing": "create"}
|
||
if existing.status == ApprovalStatus.DRAFT:
|
||
replacement = (
|
||
"blocked"
|
||
if existing.created_by_id != actor.pk and not has_simple_admin_access(actor)
|
||
else ("unchanged" if _same_metrics(existing, metrics) else "update")
|
||
)
|
||
return {"missing_only": "skip", "replace_existing": replacement}
|
||
replacement = "unchanged" if _same_metrics(existing, metrics) else "revise"
|
||
return {"missing_only": "skip", "replace_existing": replacement}
|
||
|
||
|
||
def _mode_summaries(items: list[dict]) -> dict:
|
||
summaries = {}
|
||
for mode in sorted(ATTENDANCE_IMPORT_MODES):
|
||
summary = {key: 0 for key in ACTION_KEYS}
|
||
for item in items:
|
||
summary[item["actions"][mode]] += 1
|
||
summary["changes"] = summary["create"] + summary["update"] + summary["revise"]
|
||
summaries[mode] = summary
|
||
return summaries
|
||
|
||
|
||
def _preview_token(period: PayrollPeriod, actor, items: list[dict]) -> str:
|
||
canonical_items = [
|
||
{
|
||
"employee_id": item["employee"]["id"],
|
||
"target": item["target_points"],
|
||
"actual": item["actual_points"],
|
||
"source_hash": item["_source_hash"],
|
||
"blockers": item["blockers"],
|
||
"actions": item["actions"],
|
||
"existing": item["_existing_state"],
|
||
}
|
||
for item in items
|
||
]
|
||
payload = {
|
||
"policy": ATTENDANCE_WORK_POLICY_CODE,
|
||
"period": period.pk,
|
||
"period_lock_version": period.lock_version,
|
||
"period_status": period.status,
|
||
"actor": actor.pk,
|
||
"items": canonical_items,
|
||
}
|
||
encoded = json.dumps(
|
||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||
)
|
||
return hashlib.sha256(encoded.encode()).hexdigest()
|
||
|
||
|
||
def build_attendance_work_preview(
|
||
period: PayrollPeriod,
|
||
*,
|
||
actor,
|
||
lock: bool = False,
|
||
) -> dict:
|
||
"""Build a deterministic, non-mutating attendance import preview."""
|
||
|
||
if build_rules().point_policy not in {
|
||
PointPolicy.DISABLED,
|
||
PointPolicy.PROPORTIONAL_WITH_EXCESS,
|
||
}:
|
||
_operation_error(
|
||
"ATTENDANCE_UNIT_POLICY_CONFLICT",
|
||
"Текущая политика начисления несовместима с импортом часов.",
|
||
)
|
||
|
||
attendance = AttendanceRecord.objects.filter(
|
||
date__gte=period.date_from,
|
||
date__lte=period.date_to,
|
||
).select_related("analysis_run", "employee", "employee__position")
|
||
work = PayrollWorkRecord.objects.filter(period=period).select_related(
|
||
"employee", "employee__position", "created_by"
|
||
)
|
||
if lock:
|
||
# Both projections contain nullable outer joins (analysis run and
|
||
# employee position). Lock only the source rows being imported.
|
||
attendance = attendance.select_for_update(of=("self",))
|
||
work = work.select_for_update(of=("self",))
|
||
attendance_records = list(attendance.order_by("employee_id", "date", "id"))
|
||
work_records = list(work.order_by("employee_id", "revision", "id"))
|
||
|
||
attendance_by_employee = defaultdict(list)
|
||
work_by_employee = defaultdict(list)
|
||
for record in attendance_records:
|
||
attendance_by_employee[record.employee_id].append(record)
|
||
for record in work_records:
|
||
work_by_employee[record.employee_id].append(record)
|
||
employee_ids = sorted(set(attendance_by_employee) | set(work_by_employee))
|
||
employees = {
|
||
employee.pk: employee
|
||
for employee in Employee.objects.filter(pk__in=employee_ids)
|
||
.select_related("position")
|
||
.prefetch_related("departments_links__department")
|
||
}
|
||
daily_target_points = PayrollWorkSettings.get_daily_target_points()
|
||
|
||
items = []
|
||
for employee_id in employee_ids:
|
||
records = attendance_by_employee[employee_id]
|
||
metrics = _analyze_attendance(
|
||
period,
|
||
records,
|
||
daily_target_points=daily_target_points,
|
||
)
|
||
if not records:
|
||
metrics["blockers"].append(
|
||
_issue(
|
||
"NO_ATTENDANCE_DATA",
|
||
"За выбранный период нет данных посещаемости.",
|
||
)
|
||
)
|
||
existing = _active_work_record(work_by_employee[employee_id])
|
||
actions = _actions_for_item(
|
||
period=period,
|
||
actor=actor,
|
||
existing=existing,
|
||
metrics=metrics,
|
||
)
|
||
if (
|
||
existing is not None
|
||
and existing.status == ApprovalStatus.DRAFT
|
||
and existing.created_by_id != actor.pk
|
||
and not has_simple_admin_access(actor)
|
||
):
|
||
metrics["warnings"].append(
|
||
_issue(
|
||
"FOREIGN_DRAFT_PROTECTED",
|
||
"Черновик создан другим сотрудником и не будет перезаписан.",
|
||
)
|
||
)
|
||
if period.status == PayrollPeriodStatus.PUBLISHED and existing is None:
|
||
metrics["blockers"].append(
|
||
_issue(
|
||
"PUBLISHED_WORK_REPLACEMENT_REQUIRED",
|
||
"В опубликованный период можно добавить только ревизию существующей записи.",
|
||
)
|
||
)
|
||
existing_state = (
|
||
{
|
||
"id": existing.pk,
|
||
"status": existing.status,
|
||
"revision": existing.revision,
|
||
"lock_version": existing.lock_version,
|
||
"created_by": existing.created_by_id,
|
||
"replaces": existing.replaces_id,
|
||
"target": str(existing.target_points),
|
||
"actual": str(existing.actual_points),
|
||
}
|
||
if existing is not None
|
||
else None
|
||
)
|
||
items.append(
|
||
{
|
||
"employee": _employee_payload(employees[employee_id]),
|
||
**{
|
||
key: value for key, value in metrics.items() if key != "source_hash"
|
||
},
|
||
"existing_record": _existing_payload(existing),
|
||
"actions": actions,
|
||
"_source_hash": metrics["source_hash"],
|
||
"_existing_state": existing_state,
|
||
}
|
||
)
|
||
|
||
token = _preview_token(period, actor, items)
|
||
summaries = _mode_summaries(items)
|
||
public_items = [
|
||
{key: value for key, value in item.items() if not key.startswith("_")}
|
||
for item in items
|
||
]
|
||
return {
|
||
"period_id": period.pk,
|
||
"generated_at": timezone.now().isoformat(),
|
||
"preview_token": token,
|
||
"policy": {
|
||
**ATTENDANCE_WORK_POLICY,
|
||
"daily_target_points": str(daily_target_points.quantize(QUANTUM)),
|
||
},
|
||
"summary": {
|
||
"attendance_employees": len(attendance_by_employee),
|
||
"existing": sum(item["existing_record"] is not None for item in items),
|
||
"blocked": sum(bool(item["blockers"]) for item in items),
|
||
"modes": summaries,
|
||
},
|
||
"items": public_items,
|
||
"_internal_items": items,
|
||
}
|
||
|
||
|
||
def _ensure_period_accepts_attendance_import(period: PayrollPeriod):
|
||
if period.status == PayrollPeriodStatus.CLOSED:
|
||
_operation_error(
|
||
"PERIOD_INPUTS_LOCKED", "Данные закрытого периода заблокированы."
|
||
)
|
||
if period.current_run is not None and period.current_run.status in {
|
||
PayrollRunStatus.CALCULATED,
|
||
PayrollRunStatus.REVIEW,
|
||
PayrollRunStatus.APPROVED,
|
||
}:
|
||
_operation_error(
|
||
"PERIOD_INPUTS_LOCKED",
|
||
"Сначала верните текущий расчёт на исправление.",
|
||
run_id=period.current_run_id,
|
||
)
|
||
|
||
|
||
def _audit_mutation(*, actor, action, record, period, metadata):
|
||
PayrollAuditEvent.objects.create(
|
||
actor=actor,
|
||
action=action,
|
||
object_type=record._meta.label_lower,
|
||
object_id=str(record.pk),
|
||
period=period,
|
||
metadata={"channel": "attendance_import", **metadata},
|
||
)
|
||
|
||
|
||
@transaction.atomic
|
||
def apply_attendance_work_preview(
|
||
period_id: int,
|
||
*,
|
||
actor,
|
||
mode: str,
|
||
preview_token: str,
|
||
expected_period_lock_version: int,
|
||
reason: str = "",
|
||
) -> dict:
|
||
"""Atomically apply a previously inspected preview as payroll drafts."""
|
||
|
||
if (
|
||
not actor
|
||
or not actor.is_authenticated
|
||
or not has_payroll_permission(actor, "finance.manage_payroll_inputs")
|
||
):
|
||
raise PayrollPermissionDenied("finance.manage_payroll_inputs")
|
||
if mode not in ATTENDANCE_IMPORT_MODES:
|
||
_operation_error("INVALID_ATTENDANCE_IMPORT_MODE", "Неизвестный режим импорта.")
|
||
reason = reason.strip()
|
||
if mode == "replace_existing" and not reason:
|
||
_operation_error(
|
||
"ATTENDANCE_RECALCULATION_REASON_REQUIRED",
|
||
"Для пересчёта существующих записей укажите причину.",
|
||
)
|
||
|
||
period = (
|
||
PayrollPeriod.objects.select_for_update(of=("self",))
|
||
.select_related("current_run")
|
||
.get(pk=period_id)
|
||
)
|
||
_ensure_period_accepts_attendance_import(period)
|
||
if period.lock_version != expected_period_lock_version:
|
||
_operation_error(
|
||
"STALE_PERIOD",
|
||
"Период уже изменён; обновите предпросмотр.",
|
||
expected_lock_version=expected_period_lock_version,
|
||
actual_lock_version=period.lock_version,
|
||
)
|
||
preview = build_attendance_work_preview(period, actor=actor, lock=True)
|
||
if preview["preview_token"] != preview_token:
|
||
_operation_error(
|
||
"ATTENDANCE_PREVIEW_STALE",
|
||
"Посещаемость или выработка изменились; обновите предпросмотр.",
|
||
)
|
||
|
||
records_by_id = {
|
||
record.pk: record
|
||
for record in PayrollWorkRecord.objects.select_for_update().filter(
|
||
period=period
|
||
)
|
||
}
|
||
summary = {
|
||
"created": 0,
|
||
"updated": 0,
|
||
"revised": 0,
|
||
"unchanged": 0,
|
||
"skipped": 0,
|
||
"blocked": 0,
|
||
}
|
||
changed_records = []
|
||
default_reason = (
|
||
f"Заполнено по посещаемости за {period.date_from:%d.%m.%Y}–"
|
||
f"{period.date_to:%d.%m.%Y}."
|
||
)
|
||
try:
|
||
for item in preview["_internal_items"]:
|
||
action = item["actions"][mode]
|
||
if action == "blocked":
|
||
summary["blocked"] += 1
|
||
continue
|
||
if action == "skip":
|
||
summary["skipped"] += 1
|
||
continue
|
||
if action == "unchanged":
|
||
summary["unchanged"] += 1
|
||
continue
|
||
|
||
employee_id = item["employee"]["id"]
|
||
existing_data = item["existing_record"]
|
||
existing = records_by_id.get(existing_data["id"]) if existing_data else None
|
||
prior_max = max(
|
||
(
|
||
record.revision
|
||
for record in records_by_id.values()
|
||
if record.employee_id == employee_id
|
||
),
|
||
default=0,
|
||
)
|
||
revision = (
|
||
existing.revision
|
||
if action == "update"
|
||
else existing.revision + 1
|
||
if existing is not None
|
||
else prior_max + 1
|
||
)
|
||
source_ref = (
|
||
f"attendance-points-v2:{period.pk}:{employee_id}:r{revision}:"
|
||
f"{preview_token[:24]}"
|
||
)
|
||
metrics = {
|
||
"target_points": Decimal(item["target_points"]),
|
||
"actual_points": Decimal(item["actual_points"]),
|
||
}
|
||
mutation_reason = reason or default_reason
|
||
|
||
if action == "create":
|
||
record = PayrollWorkRecord(
|
||
period=period,
|
||
employee_id=employee_id,
|
||
revision=revision,
|
||
status=ApprovalStatus.DRAFT,
|
||
source=InputSource.ATTENDANCE,
|
||
source_ref=source_ref,
|
||
target_points_overridden=False,
|
||
reason=mutation_reason,
|
||
created_by=actor,
|
||
**metrics,
|
||
)
|
||
record.full_clean()
|
||
record.save(force_insert=True)
|
||
summary["created"] += 1
|
||
audit_action = "payroll.work_record_attendance_draft_created"
|
||
elif action == "update":
|
||
record = existing
|
||
record._expected_lock_version = record.lock_version
|
||
record.target_points = metrics["target_points"]
|
||
record.target_points_overridden = False
|
||
record.actual_points = metrics["actual_points"]
|
||
record.source = InputSource.ATTENDANCE
|
||
record.source_ref = source_ref
|
||
record.reason = mutation_reason
|
||
record.full_clean()
|
||
record.save()
|
||
summary["updated"] += 1
|
||
audit_action = "payroll.work_record_attendance_draft_updated"
|
||
else:
|
||
record = PayrollWorkRecord(
|
||
period=period,
|
||
employee_id=employee_id,
|
||
revision=existing.revision + 1,
|
||
replaces=existing,
|
||
status=ApprovalStatus.DRAFT,
|
||
source=InputSource.ATTENDANCE,
|
||
source_ref=source_ref,
|
||
target_points_overridden=False,
|
||
reason=mutation_reason,
|
||
created_by=actor,
|
||
expected_point_amount=existing.expected_point_amount,
|
||
expected_gross=existing.expected_gross,
|
||
expected_recalculated_gross=existing.expected_recalculated_gross,
|
||
expected_payable=existing.expected_payable,
|
||
**metrics,
|
||
)
|
||
record.full_clean()
|
||
record.save(force_insert=True)
|
||
summary["revised"] += 1
|
||
audit_action = "payroll.work_record_attendance_revision_created"
|
||
|
||
records_by_id[record.pk] = record
|
||
changed_records.append(record)
|
||
_audit_mutation(
|
||
actor=actor,
|
||
action=audit_action,
|
||
record=record,
|
||
period=period,
|
||
metadata={
|
||
"policy": ATTENDANCE_WORK_POLICY_CODE,
|
||
"mode": mode,
|
||
"preview_token": preview_token,
|
||
"attendance_input_hash": item["_source_hash"],
|
||
"target_points": item["target_points"],
|
||
"actual_points": item["actual_points"],
|
||
"replaces_id": record.replaces_id,
|
||
},
|
||
)
|
||
except ValidationError as exc:
|
||
_operation_error(
|
||
"ATTENDANCE_IMPORT_VALIDATION_FAILED",
|
||
"Черновик выработки не прошёл проверку.",
|
||
errors=(exc.message_dict if hasattr(exc, "message_dict") else exc.messages),
|
||
)
|
||
except IntegrityError:
|
||
_operation_error(
|
||
"ATTENDANCE_IMPORT_CONFLICT",
|
||
"Параллельная операция уже изменила выработку; обновите предпросмотр.",
|
||
)
|
||
|
||
PayrollAuditEvent.objects.create(
|
||
actor=actor,
|
||
action="payroll.attendance_work_imported",
|
||
object_type=period._meta.label_lower,
|
||
object_id=str(period.pk),
|
||
period=period,
|
||
metadata={
|
||
"channel": "attendance_import",
|
||
"policy": ATTENDANCE_WORK_POLICY_CODE,
|
||
"mode": mode,
|
||
"preview_token": preview_token,
|
||
"summary": summary,
|
||
},
|
||
)
|
||
return {"mode": mode, "summary": summary, "records": changed_records}
|