Corp/backend/api/v1/finance/admin_views.py

2631 lines
96 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Native, permission-gated payroll administration API.
The endpoints in this module are an HTTP adapter around the existing finance
models and payroll services. Generic Django model permissions never grant
access to payroll data.
"""
from __future__ import annotations
import logging
import uuid
from decimal import Decimal
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError, transaction
from django.db.models import Count, Max, Q
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import permissions, status
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.response import Response
from rest_framework.views import APIView
from communications import comments_helpers
from communications.models import Message
from employees.constants import ACTION_DISMISSED
from employees.services.personnel_state import resolve_employee_personnel_state
from finance.enums import (
ApprovalStatus,
InputSource,
PayrollComponentKind,
PayrollPeriodStatus,
PayrollRunStatus,
)
from finance.models import (
EmployeePayRate,
FinanceSettings,
PayrollAuditEvent,
PayrollCarryover,
PayrollCellCommentThread,
PayrollComponent,
PayrollInputLine,
PayrollInputCopyConfiguration,
PayrollPeriod,
PayrollRun,
PayrollStatement,
PayrollStatementPaymentConfirmation,
PayrollWorkRecord,
)
from finance.payroll.config import (
base_rate_code,
build_rules,
rules_cover_period,
ruleset_not_effective_message,
ruleset_period_details,
)
from finance.payroll.carryovers import cancel_carryover
from finance.payroll.access import (
can_access_payroll_management,
has_payroll_permission,
has_simple_admin_access,
)
from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied
from finance.payroll.services import (
approve_input_line,
approve_pay_rate,
approve_period_drafts,
approve_run,
approve_work_record,
bulk_set_pay_rate,
bulk_set_point_rate,
bulk_set_target_points,
calculate_period,
clear_component_input_lines,
close_period,
complete_period_workflow,
publish_run,
pay_rates_relevant_to_period,
return_run_for_correction,
submit_run_for_review,
)
from finance.payroll.reporting import (
SYSTEM_COMPONENT_CODES,
build_payroll_employee_point_breakdown,
build_payroll_period_table,
)
from finance.payroll.attendance import (
apply_attendance_work_preview,
build_attendance_work_preview,
)
from finance.payroll.workbook_import import (
apply_workbook_import,
build_workbook_preview,
parse_mappings,
)
from .admin_serializers import (
AttendanceWorkImportCommandSerializer,
ApprovalCommandSerializer,
BulkPayRateCommandSerializer,
BulkPointRateCommandSerializer,
BulkTargetPointsCommandSerializer,
CalculatePayrollCommandSerializer,
ClearComponentCellCommandSerializer,
CopyPayrollInputLinesCommandSerializer,
EmployeePayRateRevisionSerializer,
EmployeePayRateSerializer,
EmployeePayRateWriteSerializer,
PayrollComponentSerializer,
PayrollCellCommentSerializer,
PayrollCellCommentWriteSerializer,
PayrollCarryoverSerializer,
PayrollCarryoverInclusionSerializer,
PayrollEmployeeMiniSerializer,
PayrollInputLineSerializer,
PayrollInputLineWriteSerializer,
PayrollPeriodSerializer,
PayrollPeriodWriteSerializer,
PayrollPaymentConfirmationCommandSerializer,
PayrollRunSerializer,
PayrollUserRefSerializer,
PayrollWorkRecordRevisionSerializer,
PayrollWorkRecordSerializer,
PayrollWorkRecordWriteSerializer,
ReturnPayrollCommandSerializer,
WorkbookWorkImportSerializer,
WorkbookWorkPreviewSerializer,
_validation_detail,
)
from .views import NoStorePayrollResponseMixin, PayrollConflict
Employee = get_user_model()
logger = logging.getLogger(__name__)
PAYROLL_PERMISSIONS = {
"manage_inputs": "finance.manage_payroll_inputs",
"approve_inputs": "finance.approve_payroll_inputs",
"calculate": "finance.calculate_payroll",
"approve_run": "finance.approve_payroll",
"override_approval": "finance.override_payroll_approval",
"publish": "finance.publish_payroll",
"view_all": "finance.view_all_payroll",
"audit": "finance.audit_payroll",
}
OPERATIONAL_PERMISSIONS = tuple(
permission
for key, permission in PAYROLL_PERMISSIONS.items()
if key not in {"audit", "override_approval"}
)
INPUT_VIEW_PERMISSIONS = (
PAYROLL_PERMISSIONS["manage_inputs"],
PAYROLL_PERMISSIONS["approve_inputs"],
PAYROLL_PERMISSIONS["view_all"],
)
RUN_VIEW_PERMISSIONS = (
PAYROLL_PERMISSIONS["calculate"],
PAYROLL_PERMISSIONS["approve_run"],
PAYROLL_PERMISSIONS["publish"],
PAYROLL_PERMISSIONS["view_all"],
)
PAYROLL_TABLE_FIXED_COLUMN_IDS = {
"employee",
"rate_amount",
"in_norm_point_rate",
"point_rate",
"target_points",
"attendance_points",
"personnel_points",
"actual_points",
"point_delta",
"point_amount",
"gross_before_adjustments",
"adjustment_total",
"gross_total",
"deduction_total",
"net_pay",
"payment_total",
"payable",
"carryovers",
"status",
}
def _permission_error(permission):
raise PermissionDenied(
detail={
"code": "PERMISSION_DENIED",
"message": "Недостаточно прав для операции с зарплатой.",
"details": {"permission": permission},
}
)
def _conflict(code: str, message: str, **details):
raise PayrollConflict(detail={"code": code, "message": message, "details": details})
def _optional_int(request, name):
raw_value = request.query_params.get(name)
if raw_value in (None, ""):
return None
try:
value = int(raw_value)
except (TypeError, ValueError) as exc:
raise ValidationError({name: "Ожидается целое число."}) from exc
if value < 1:
raise ValidationError({name: "Значение должно быть положительным."})
return value
def _audit_api_mutation(*, actor, action, instance, period=None, metadata=None):
PayrollAuditEvent.objects.create(
actor=actor,
action=action,
object_type=instance._meta.label_lower,
object_id=str(instance.pk),
period=period,
metadata={"channel": "admin_api", **(metadata or {})},
)
def _payroll_history_action_label(action):
if "voided" in action or "cancelled" in action:
return "Запись аннулирована"
if action.endswith("_approved"):
return "Запись утверждена"
if "revision" in action and action.endswith("_created"):
return "Создана новая версия"
if action.endswith("_created"):
return "Запись добавлена"
if action.endswith("_updated") or action.endswith("_replaced"):
return "Запись изменена"
return "Состояние записи изменено"
def _validate_payroll_table_column(column_id):
if column_id in PAYROLL_TABLE_FIXED_COLUMN_IDS:
return column_id
prefix, separator, component_code = column_id.partition(":")
if (
prefix == "component"
and separator
and component_code not in SYSTEM_COMPONENT_CODES
and PayrollComponent.objects.filter(
code=component_code,
is_active=True,
).exists()
):
return column_id
raise ValidationError({"column_id": "Колонка отсутствует в расчётной таблице."})
def _cell_comment_summaries(period):
threads = list(
PayrollCellCommentThread.objects.filter(period=period).values(
"id",
"subject_employee_id",
"column_id",
)
)
if not threads:
return []
thread_by_id = {thread["id"]: thread for thread in threads}
content_type = ContentType.objects.get_for_model(PayrollCellCommentThread)
totals = (
Message.objects.filter(
chat__type="comments",
chat__context_content_type=content_type,
chat__context_object_id__in=thread_by_id,
is_deleted=False,
)
.values("chat__context_object_id")
.annotate(count=Count("id"), latest_at=Max("created_at"))
)
result = []
for total in totals:
thread = thread_by_id[int(total["chat__context_object_id"])]
result.append(
{
"employee_id": thread["subject_employee_id"],
"column_id": thread["column_id"],
"count": total["count"],
"latest_at": total["latest_at"],
}
)
return result
def _serialize_cell_comment(message, request):
return PayrollCellCommentSerializer(
{
"id": message.id,
"author": message.author,
"text": message.content,
"created_at": message.created_at,
},
context={"request": request},
).data
def _carryover_summary(period):
statements = PayrollStatement.objects.filter(
run=period.current_run,
) if period.current_run_id else PayrollStatement.objects.none()
return {
"outgoing": statements.exclude(closing_balance=0).count(),
"incoming": statements.exclude(opening_balance=0).count(),
"pending": 0,
}
def _automatic_balance_summaries(rows):
summaries = []
for row in rows:
balance = Decimal(row["closing_balance"])
if balance == 0:
continue
summaries.append(
{
"employee_id": row["employee"]["id"],
"count": 1,
"company_owes": str(max(balance, Decimal("0.00"))),
"employee_owes": str(max(-balance, Decimal("0.00"))),
"pending": 0,
}
)
return summaries
def _validate_input_copy_periods(target_period, source_period):
if source_period.pk == target_period.pk:
raise ValidationError(
{"source_period_id": "Период-источник должен отличаться от текущего."}
)
if source_period.date_to >= target_period.date_from:
raise ValidationError(
{"source_period_id": "Переносить записи можно только из более раннего периода."}
)
if source_period.currency != target_period.currency:
raise ValidationError(
{"source_period_id": "Валюты исходного и текущего периодов различаются."}
)
def _copyable_input_lines(source_period):
return (
PayrollInputLine.objects.filter(
period=source_period,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
component__is_active=True,
)
.exclude(
source=InputSource.API,
source_ref__startswith="payroll-carryover:",
)
.select_related(
"employee",
"employee__position",
"component",
"relates_to_period",
)
.prefetch_related(
"employee__departments_links__department",
"employee__actions__source_request",
)
.order_by("employee_id", "component__display_order", "id")
)
def _employee_active_for_period(employee, period):
if not employee.is_active:
return False
actions = employee._prefetched_objects_cache.get("actions")
state = resolve_employee_personnel_state(
employee,
period.date_to,
actions=actions,
)
return state.status != ACTION_DISMISSED
def _input_copy_configuration_payload(actor):
configuration = PayrollInputCopyConfiguration.objects.filter(owner=actor).first()
if configuration is None:
return {
"saved": False,
"component_ids": [],
"employee_scope": "active",
"employee_ids": [],
"existing_mode": "skip",
"auto_apply_on_period_creation": False,
}
return {
"saved": True,
"component_ids": configuration.component_ids,
"employee_scope": configuration.employee_scope,
"employee_ids": configuration.employee_ids,
"existing_mode": configuration.existing_mode,
"auto_apply_on_period_creation": (
configuration.auto_apply_on_period_creation
),
}
def _input_copy_preview(target_period, source_period, *, actor):
_validate_input_copy_periods(target_period, source_period)
lines = list(_copyable_input_lines(source_period))
component_rows = {
component.pk: {
"component": component,
"line_count": 0,
"employee_ids": set(),
"amount_total": Decimal("0.00"),
}
for component in PayrollComponent.objects.filter(is_active=True).order_by(
"display_order",
"code",
)
}
employee_rows = {}
for line in lines:
component_row = component_rows[line.component_id]
component_row["line_count"] += 1
component_row["employee_ids"].add(line.employee_id)
component_row["amount_total"] += line.amount
employee_row = employee_rows.get(line.employee_id)
if employee_row is None:
employee_row = {
"employee": line.employee,
"is_active": _employee_active_for_period(
line.employee,
target_period,
),
"line_count": 0,
"component_counts": {},
}
employee_rows[line.employee_id] = employee_row
employee_row["line_count"] += 1
employee_row["component_counts"][str(line.component_id)] = (
employee_row["component_counts"].get(str(line.component_id), 0) + 1
)
existing_counts = {}
if employee_rows and component_rows:
for row in (
PayrollInputLine.objects.filter(
period=target_period,
employee_id__in=employee_rows,
component_id__in=component_rows,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
)
.exclude(
source=InputSource.API,
source_ref__startswith="payroll-carryover:",
)
.values("employee_id", "component_id")
.annotate(count=Count("id"))
):
existing_counts.setdefault(row["employee_id"], {})[
str(row["component_id"])
] = row["count"]
components = [
{
**PayrollComponentSerializer(row["component"]).data,
"line_count": row["line_count"],
"employee_count": len(row["employee_ids"]),
"amount_total": str(row["amount_total"]),
}
for row in component_rows.values()
]
employees = [
{
**PayrollEmployeeMiniSerializer(row["employee"]).data,
"is_active": row["is_active"],
"line_count": row["line_count"],
"component_counts": row["component_counts"],
"existing_component_counts": existing_counts.get(
row["employee"].pk,
{},
),
}
for row in employee_rows.values()
]
employees.sort(key=lambda item: item["display_name"].casefold())
return {
"source_period": PayrollPeriodSerializer(source_period).data,
"target_period": PayrollPeriodSerializer(target_period).data,
"summary": {
"eligible_lines": len(lines),
"employees": len(employees),
"active_employees": sum(item["is_active"] for item in employees),
},
"components": components,
"employees": employees,
"configuration": _input_copy_configuration_payload(actor),
}
def _apply_input_copy(*, target_period, source_period, actor, data):
copy_prefix = (
f"payroll-period-copy:{target_period.pk}:{source_period.pk}:"
f"{data['idempotency_key']}:"
)
existing = list(
PayrollInputLine.objects.filter(
period=target_period,
source_ref__startswith=copy_prefix,
)
.order_by("id")
.values_list("id", flat=True)
)
if existing:
return {
"source_period_id": source_period.pk,
"target_period_id": target_period.pk,
"summary": {
"created": len(existing),
"employees": PayrollInputLine.objects.filter(
pk__in=existing
).values("employee_id").distinct().count(),
"skipped": 0,
"replaced_cells": 0,
"voided": 0,
},
"record_ids": existing,
"idempotent_replay": True,
}
lines = list(
_copyable_input_lines(source_period).filter(
component_id__in=data["component_ids"]
)
)
if data["employee_scope"] == "selected":
selected_ids = set(data["employee_ids"])
lines = [line for line in lines if line.employee_id in selected_ids]
elif data["employee_scope"] == "active":
lines = [
line
for line in lines
if _employee_active_for_period(line.employee, target_period)
]
selected_cells = {
(line.employee_id, line.component_id)
for line in lines
}
existing_lines = []
if selected_cells:
possible_existing_lines = list(
PayrollInputLine.objects.select_for_update()
.filter(
period=target_period,
employee_id__in={employee_id for employee_id, _ in selected_cells},
component_id__in={component_id for _, component_id in selected_cells},
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
)
.order_by("pk")
)
existing_lines = [
line
for line in possible_existing_lines
if (line.employee_id, line.component_id) in selected_cells
and not (
line.source == InputSource.API
and line.source_ref.startswith("payroll-carryover:")
)
]
occupied_cells = {
(line.employee_id, line.component_id)
for line in existing_lines
}
skipped = 0
replaced_cells = 0
voided = 0
if data["existing_mode"] == "skip" and occupied_cells:
kept_lines = []
for line in lines:
if (line.employee_id, line.component_id) in occupied_cells:
skipped += 1
else:
kept_lines.append(line)
lines = kept_lines
elif data["existing_mode"] == "replace" and existing_lines:
replaced_cells = len(occupied_cells)
voided = len(existing_lines)
now = timezone.now()
for line in existing_lines:
previous_status = line.status
line.status = ApprovalStatus.VOIDED
line.voided_by = actor
line.voided_at = now
line.void_reason = (
f"Заменено переносом записей из периода {source_period.code}"
)
line.save(
update_fields=[
"status",
"voided_by",
"voided_at",
"void_reason",
]
)
_audit_api_mutation(
actor=actor,
action="payroll.input_line_voided_from_period_copy",
instance=line,
period=target_period,
metadata={
"previous_status": previous_status,
"source_period_id": source_period.pk,
},
)
records = PayrollInputLine.objects.bulk_create(
[
PayrollInputLine(
period=target_period,
employee=line.employee,
component=line.component,
amount=line.amount,
relates_to_period=line.relates_to_period,
reason=line.reason,
status=ApprovalStatus.DRAFT,
source=InputSource.API,
source_ref=f"{copy_prefix}{line.pk}",
include_in_carryover=line.include_in_carryover,
created_by=actor,
)
for line in lines
]
)
PayrollAuditEvent.objects.bulk_create(
[
PayrollAuditEvent(
actor=actor,
action="payroll.input_line_draft_created",
object_type=PayrollInputLine._meta.label_lower,
object_id=str(record.pk),
period=target_period,
metadata={
"channel": "admin_api",
"copied_from_period_id": source_period.pk,
"copied_from_input_line_id": source.pk,
},
)
for record, source in zip(records, lines, strict=True)
]
)
return {
"source_period_id": source_period.pk,
"target_period_id": target_period.pk,
"summary": {
"created": len(records),
"employees": len({record.employee_id for record in records}),
"skipped": skipped,
"replaced_cells": replaced_cells,
"voided": voided,
},
"record_ids": [record.pk for record in records],
"idempotent_replay": False,
}
def _carryover_ledger(period):
published_period_ids = set(
PayrollRun.objects.filter(
status=PayrollRunStatus.PUBLISHED,
period__currency=period.currency,
period__date_to__lte=period.date_to,
).values_list("period_id", flat=True)
)
target_is_published = period.pk in published_period_ids
lines = (
PayrollInputLine.objects.filter(
period__currency=period.currency,
period__date_to__lte=period.date_to,
component__kind__in=(
PayrollComponentKind.ADJUSTMENT_CREDIT,
PayrollComponentKind.ADJUSTMENT_DEBIT,
),
)
.exclude(
source=InputSource.API,
source_ref__startswith="payroll-carryover:",
)
.select_related(
"period",
"employee",
"employee__position",
"component",
"created_by",
)
.prefetch_related("employee__departments_links__department")
.order_by("-period__date_to", "-created_at", "-id")
)
entries = []
balances_by_employee = {}
included_count = 0
excluded_count = 0
for line in lines:
current_period_line = line.period_id == period.pk
active_status = line.status == ApprovalStatus.APPROVED or (
current_period_line
and not target_is_published
and line.status == ApprovalStatus.DRAFT
)
active_period = current_period_line or line.period_id in published_period_ids
counts_in_balance = bool(
line.include_in_carryover and active_status and active_period
)
raw_contribution = (
line.amount
if line.component.kind == PayrollComponentKind.ADJUSTMENT_DEBIT
else -line.amount
)
contribution = raw_contribution if counts_in_balance else Decimal("0.00")
balances_by_employee[line.employee_id] = (
balances_by_employee.get(line.employee_id, Decimal("0.00"))
+ contribution
)
if line.include_in_carryover:
included_count += 1
else:
excluded_count += 1
entries.append(
{
"id": line.pk,
"period": {
"id": line.period_id,
"code": line.period.code,
"name": line.period.name,
"date_from": line.period.date_from,
"date_to": line.period.date_to,
"currency": line.period.currency,
},
"employee": PayrollEmployeeMiniSerializer(line.employee).data,
"component": PayrollComponentSerializer(line.component).data,
"amount": str(line.amount),
"raw_contribution": str(raw_contribution),
"contribution": str(contribution),
"reason": line.reason,
"status": line.status,
"status_label": line.get_status_display(),
"include_in_carryover": line.include_in_carryover,
"counts_in_balance": counts_in_balance,
"created_by": PayrollUserRefSerializer(line.created_by).data,
"created_at": line.created_at,
}
)
company_owes = sum(
(max(balance, Decimal("0.00")) for balance in balances_by_employee.values()),
Decimal("0.00"),
)
employee_owes = sum(
(max(-balance, Decimal("0.00")) for balance in balances_by_employee.values()),
Decimal("0.00"),
)
return {
"period_id": period.pk,
"currency": period.currency,
"summary": {
"included_count": included_count,
"excluded_count": excluded_count,
"company_owes": str(company_owes),
"employee_owes": str(employee_owes),
"balance": str(company_owes - employee_owes),
},
"entries": entries,
}
def _lock_period_accepting_draft_edits(period_id):
period = get_object_or_404(
PayrollPeriod.objects.select_for_update(of=("self",)).select_related(
"current_run"
),
pk=period_id,
)
if period.status == PayrollPeriodStatus.CLOSED:
_conflict(
"PERIOD_INPUTS_LOCKED",
"Данные закрытого периода заблокированы.",
)
if period.current_run is not None and period.current_run.status in {
PayrollRunStatus.CALCULATED,
PayrollRunStatus.REVIEW,
PayrollRunStatus.APPROVED,
}:
_conflict(
"PERIOD_INPUTS_LOCKED",
"Сначала верните текущий расчёт на исправление.",
run_id=period.current_run_id,
)
return period
class PayrollAdminAPIView(NoStorePayrollResponseMixin, APIView):
permission_classes = [permissions.IsAuthenticated]
allowed_permissions = OPERATIONAL_PERMISSIONS
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if not can_access_payroll_management(request.user):
_permission_error("finance.payroll_management_access")
if not any(
has_payroll_permission(request.user, code)
for code in self.allowed_permissions
):
_permission_error(self.allowed_permissions[0])
def require_permission(self, request, code):
if not has_payroll_permission(request.user, code):
_permission_error(code)
def include_amounts(self, request):
return has_payroll_permission(
request.user,
PAYROLL_PERMISSIONS["view_all"],
)
def run_payload(self, request, run):
return PayrollRunSerializer(
run,
context={"include_amounts": self.include_amounts(request)},
).data
def handle_exception(self, exc):
if isinstance(exc, PayrollPermissionDenied):
exc = PermissionDenied(
detail={
"code": exc.code,
"message": exc.message,
"details": exc.details,
}
)
elif isinstance(exc, PayrollOperationError):
exc = PayrollConflict(
detail={
"code": exc.code,
"message": exc.message,
"details": exc.details,
}
)
return super().handle_exception(exc)
class PayrollAdminWorkspaceView(PayrollAdminAPIView):
"""One bounded bootstrap response for the native payroll screen."""
def get(self, request):
finance_settings = FinanceSettings.get_solo()
permissions_payload = {
key: has_payroll_permission(request.user, permission)
for key, permission in PAYROLL_PERMISSIONS.items()
}
permissions_payload["full_access"] = has_simple_admin_access(request.user)
periods = list(
PayrollPeriod.objects.select_related("current_run").order_by(
"-date_from", "-id"
)
)
selected_period_id = _optional_int(request, "period_id")
if selected_period_id is None:
selected_period = periods[0] if periods else None
else:
selected_period = next(
(period for period in periods if period.pk == selected_period_id),
None,
)
if selected_period is None:
raise Http404
employees = (
Employee.objects.filter(is_active=True)
.select_related("position")
.prefetch_related("departments_links__department")
.order_by("last_name", "first_name", "id")
)
rate_employees = (
Employee.objects.all()
.select_related("position")
.prefetch_related("departments_links__department")
.order_by("last_name", "first_name", "id")
)
components = PayrollComponent.objects.order_by("display_order", "code")
current_run = None
runs = []
readiness = {
"rates": {
"ready": False,
"total": 0,
"approved": 0,
"draft": 0,
"missing_employee_ids": [],
},
"work_records": {
"ready": False,
"total": 0,
"approved": 0,
"draft": 0,
},
"input_lines": {"approved": 0, "draft": 0},
"calculation": {
"ready": False,
"blockers": [
{
"code": "PERIOD_NOT_SELECTED",
"message": "Создайте или выберите расчётный период.",
}
],
},
}
pending_approvals = {"rates": 0, "work_records": 0, "input_lines": 0}
summary = None
if selected_period is not None:
runs = list(
PayrollRun.objects.filter(period=selected_period)
.select_related("period", "requested_by", "approved_by", "published_by")
.order_by("-revision")
)
current_run = selected_period.current_run
approved_work = PayrollWorkRecord.objects.filter(
period=selected_period,
status=ApprovalStatus.APPROVED,
)
draft_work = PayrollWorkRecord.objects.filter(
period=selected_period,
status=ApprovalStatus.DRAFT,
)
approved_employee_ids = list(
approved_work.values_list("employee_id", flat=True)
)
draft_work_employee_ids = list(
draft_work.values_list("employee_id", flat=True)
)
roster_employee_ids = sorted(
set(approved_employee_ids) | set(draft_work_employee_ids)
)
employees_with_rate = set(
EmployeePayRate.objects.filter(
employee_id__in=roster_employee_ids,
rate_code=base_rate_code(),
status=ApprovalStatus.APPROVED,
effective_from__lte=selected_period.date_from,
).values_list("employee_id", flat=True)
)
missing_rate_employee_ids = [
employee_id
for employee_id in roster_employee_ids
if employee_id not in employees_with_rate
]
approved_input_count = PayrollInputLine.objects.filter(
period=selected_period,
status=ApprovalStatus.APPROVED,
).count()
draft_inputs = PayrollInputLine.objects.filter(
period=selected_period,
status=ApprovalStatus.DRAFT,
)
draft_input_count = draft_inputs.count()
draft_input_employee_ids = list(
draft_inputs.order_by().values_list("employee_id", flat=True).distinct()
)
pending_rates = EmployeePayRate.objects.filter(
employee_id__in=roster_employee_ids,
rate_code=base_rate_code(),
status=ApprovalStatus.DRAFT,
effective_from__lte=selected_period.date_to,
)
pending_rate_count = pending_rates.count()
pending_rate_employee_ids = list(
pending_rates.order_by()
.values_list("employee_id", flat=True)
.distinct()
)
pending_rate_at_start_employee_id_set = set(
pending_rates.filter(
effective_from__lte=selected_period.date_from,
).values_list("employee_id", flat=True)
)
blockers = []
active_rules = build_rules()
if not rules_cover_period(
active_rules,
period_from=selected_period.date_from,
period_to=selected_period.date_to,
):
blockers.append(
{
"code": "RULESET_NOT_EFFECTIVE",
"message": ruleset_not_effective_message(active_rules),
"details": ruleset_period_details(
active_rules,
period_from=selected_period.date_from,
period_to=selected_period.date_to,
),
}
)
if not roster_employee_ids:
blockers.append(
{
"code": "NO_APPROVED_WORK_RECORDS",
"message": (
"В разделе «Выработка» нет ни одной записи за "
f"{selected_period.date_from:%d.%m.%Y}–"
f"{selected_period.date_to:%d.%m.%Y}; состав "
"периода пуст."
),
}
)
missing_rate_without_draft = [
employee_id
for employee_id in missing_rate_employee_ids
if employee_id not in pending_rate_at_start_employee_id_set
]
if missing_rate_without_draft:
blockers.append(
{
"code": "MISSING_APPROVED_RATE",
"message": (
"Сотрудников без утверждённой ставки на "
f"{selected_period.date_from:%d.%m.%Y}: "
f"{len(missing_rate_without_draft)}."
),
"count": len(missing_rate_without_draft),
"employee_ids": missing_rate_without_draft[:20],
"employee_count": len(missing_rate_without_draft),
}
)
if draft_work_employee_ids:
blockers.append(
{
"code": "DRAFT_WORK_RECORDS",
"message": (
"Записей выработки в статусе «Черновик»: "
f"{len(draft_work_employee_ids)}."
),
"count": len(draft_work_employee_ids),
"employee_ids": draft_work_employee_ids[:20],
"employee_count": len(draft_work_employee_ids),
}
)
if draft_input_count:
blockers.append(
{
"code": "DRAFT_INPUT_LINES",
"message": (
"Начислений или удержаний в статусе «Черновик»: "
f"{draft_input_count}."
),
"count": draft_input_count,
"employee_ids": draft_input_employee_ids[:20],
"employee_count": len(draft_input_employee_ids),
}
)
if pending_rate_count:
blockers.append(
{
"code": "DRAFT_PAY_RATES",
"message": (
"Ставок сотрудников в статусе «Черновик»: "
f"{pending_rate_count}."
),
"count": pending_rate_count,
"employee_ids": pending_rate_employee_ids[:20],
"employee_count": len(pending_rate_employee_ids),
}
)
if selected_period.status == PayrollPeriodStatus.CLOSED:
blockers.append(
{
"code": "PERIOD_CLOSED",
"message": "Закрытый период нельзя рассчитать.",
}
)
readiness = {
"rates": {
"ready": bool(roster_employee_ids)
and not missing_rate_employee_ids
and pending_rate_count == 0,
"total": len(roster_employee_ids),
"approved": len(roster_employee_ids)
- len(missing_rate_employee_ids),
"draft": pending_rate_count,
"missing_employee_ids": missing_rate_employee_ids,
},
"work_records": {
"ready": bool(approved_employee_ids)
and not draft_work_employee_ids,
"total": len(roster_employee_ids),
"approved": len(approved_employee_ids),
"draft": len(draft_work_employee_ids),
},
"input_lines": {
"approved": approved_input_count,
"draft": draft_input_count,
},
"calculation": {
"ready": not blockers,
"blockers": blockers,
},
}
pending_approvals = {
"rates": pending_rate_count,
"work_records": len(draft_work_employee_ids),
"input_lines": draft_input_count,
}
if current_run is not None and self.include_amounts(request):
summary = {
"employee_count": current_run.employee_count,
"gross_total": str(current_run.gross_total),
"deduction_total": str(current_run.deduction_total),
"payable_total": str(current_run.payable_total),
}
run_context = {"include_amounts": self.include_amounts(request)}
return Response(
{
"permissions": permissions_payload,
"access_settings": {
"restricted": finance_settings.management_access_is_restricted(),
"management_user_count": finance_settings.management_users.count(),
"can_configure": (
request.user.is_superuser
or request.user.has_perm("finance.change_financesettings")
),
},
"employees": PayrollEmployeeMiniSerializer(employees, many=True).data,
"rate_employees": PayrollEmployeeMiniSerializer(
rate_employees,
many=True,
).data,
"components": PayrollComponentSerializer(components, many=True).data,
"periods": PayrollPeriodSerializer(periods, many=True).data,
"selected_period": (
PayrollPeriodSerializer(selected_period).data
if selected_period is not None
else None
),
"readiness": readiness,
"current_run": (
PayrollRunSerializer(current_run, context=run_context).data
if current_run is not None
else None
),
"runs": PayrollRunSerializer(
runs,
many=True,
context=run_context,
).data,
"summary": summary,
"pending_approvals": pending_approvals,
"carryovers": (
_carryover_summary(selected_period)
if selected_period is not None
else {"outgoing": 0, "incoming": 0, "pending": 0}
),
}
)
class PayrollManagementAccessSettingsView(NoStorePayrollResponseMixin, APIView):
"""Read and update the allow-list shown in the native finance workspace."""
permission_classes = [permissions.IsAuthenticated]
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if not (
request.user.is_superuser
or request.user.has_perm("finance.change_financesettings")
):
_permission_error("finance.change_financesettings")
def _payload(self):
from ..employees.serializers import EmployeeBriefSerializer
finance_settings = FinanceSettings.get_solo()
selected_ids = list(
finance_settings.management_users.values_list("id", flat=True)
)
candidates = Employee.objects.filter(
Q(is_active=True) & (Q(is_staff=True) | Q(is_superuser=True))
| Q(id__in=selected_ids)
).order_by("last_name", "first_name", "id")
return {
"restricted": bool(selected_ids),
"management_user_ids": selected_ids,
"management_user_count": len(selected_ids),
"candidates": EmployeeBriefSerializer(candidates, many=True).data,
}
def get(self, request):
return Response(self._payload())
def patch(self, request):
raw_ids = request.data.get("management_user_ids")
if not isinstance(raw_ids, list):
raise ValidationError(
{"management_user_ids": "Передайте список администраторов."}
)
try:
requested_ids = {int(user_id) for user_id in raw_ids}
except (TypeError, ValueError):
raise ValidationError(
{"management_user_ids": "Идентификаторы должны быть числами."}
)
if any(user_id <= 0 for user_id in requested_ids):
raise ValidationError(
{"management_user_ids": "Некорректный идентификатор пользователя."}
)
eligible_users = Employee.objects.filter(
id__in=requested_ids,
is_active=True,
).filter(Q(is_staff=True) | Q(is_superuser=True))
eligible_ids = set(eligible_users.values_list("id", flat=True))
invalid_ids = requested_ids - eligible_ids
if invalid_ids:
raise ValidationError(
{
"management_user_ids": (
"Выбирать можно только активных администраторов."
)
}
)
finance_settings = FinanceSettings.get_solo()
finance_settings.management_users.set(eligible_users)
return Response(self._payload())
class PayrollAdminEmployeeListView(PayrollAdminAPIView):
def get(self, request):
employees = (
Employee.objects.filter(is_active=True)
.select_related("position")
.prefetch_related("departments_links__department")
.order_by("last_name", "first_name", "id")
)
return Response(PayrollEmployeeMiniSerializer(employees, many=True).data)
class PayrollAdminComponentListView(PayrollAdminAPIView):
allowed_permissions = INPUT_VIEW_PERMISSIONS
def get(self, request):
components = PayrollComponent.objects.order_by("display_order", "code")
return Response(PayrollComponentSerializer(components, many=True).data)
class PayrollAdminPeriodListCreateView(PayrollAdminAPIView):
def get(self, request):
periods = PayrollPeriod.objects.select_related("current_run").order_by(
"-date_from", "-id"
)
return Response(PayrollPeriodSerializer(periods, many=True).data)
@transaction.atomic
def post(self, request):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
serializer = PayrollPeriodWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
period_values = dict(serializer.validated_data)
period_values.pop("expected_lock_version", None)
period = PayrollPeriod(
**period_values,
created_by=request.user,
)
try:
period.full_clean()
period.save(force_insert=True)
except DjangoValidationError as exc:
raise ValidationError(_validation_detail(exc)) from exc
except IntegrityError:
_conflict(
"PERIOD_CONFLICT",
(
"Не удалось создать расчётный период: сохранённые периоды "
"пересекаются с указанными датами. Обновите список периодов."
),
)
_audit_api_mutation(
actor=request.user,
action="payroll.period_created",
instance=period,
period=period,
)
automatic_copy = None
configuration = PayrollInputCopyConfiguration.objects.filter(
owner=request.user,
auto_apply_on_period_creation=True,
).first()
if configuration is not None:
source_period = (
PayrollPeriod.objects.select_for_update(of=("self",))
.filter(
currency=period.currency,
date_to__lt=period.date_from,
)
.exclude(pk=period.pk)
.order_by("-date_to", "-id")
.first()
)
if source_period is not None:
automatic_copy = _apply_input_copy(
target_period=period,
source_period=source_period,
actor=request.user,
data={
"component_ids": configuration.component_ids,
"employee_scope": configuration.employee_scope,
"employee_ids": configuration.employee_ids,
"existing_mode": configuration.existing_mode,
"idempotency_key": uuid.uuid4(),
},
)
_audit_api_mutation(
actor=request.user,
action="payroll.period_input_copy_auto_applied",
instance=period,
period=period,
metadata={
"source_period_id": source_period.pk,
**automatic_copy["summary"],
},
)
payload = PayrollPeriodSerializer(period).data
payload["automatic_input_copy"] = automatic_copy
return Response(
payload,
status=status.HTTP_201_CREATED,
)
class PayrollAdminPeriodDetailView(PayrollAdminAPIView):
def get(self, request, pk):
period = get_object_or_404(PayrollPeriod, pk=pk)
return Response(PayrollPeriodSerializer(period).data)
@transaction.atomic
def patch(self, request, pk):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
period = get_object_or_404(
PayrollPeriod.objects.select_for_update(),
pk=pk,
)
serializer = PayrollPeriodWriteSerializer(
period,
data=request.data,
partial=True,
)
serializer.is_valid(raise_exception=True)
expected_lock_version = serializer.validated_data.pop("expected_lock_version")
if period.lock_version != expected_lock_version:
_conflict(
"STALE_PERIOD",
"Период уже изменён; обновите данные.",
expected_lock_version=expected_lock_version,
actual_lock_version=period.lock_version,
)
if period.runs.exists():
_conflict(
"IMMUTABLE_PERIOD",
"Реквизиты периода нельзя менять после первого расчёта.",
)
changed_fields = list(serializer.validated_data)
if not changed_fields:
raise ValidationError(
{"non_field_errors": "Не передано ни одного изменения."}
)
for field_name, value in serializer.validated_data.items():
setattr(period, field_name, value)
period.lock_version += 1
try:
period.full_clean()
period.save(update_fields=[*changed_fields, "lock_version", "updated_at"])
except DjangoValidationError as exc:
raise ValidationError(_validation_detail(exc)) from exc
_audit_api_mutation(
actor=request.user,
action="payroll.period_updated",
instance=period,
period=period,
metadata={"lock_version": period.lock_version},
)
return Response(PayrollPeriodSerializer(period).data)
class PayrollPeriodTableView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
def get(self, request, pk):
period = get_object_or_404(
PayrollPeriod.objects.select_related("current_run"),
pk=pk,
)
payload = build_payroll_period_table(period)
payload["cell_comments"] = _cell_comment_summaries(period)
payload["carryovers"] = _automatic_balance_summaries(payload["rows"])
return Response(payload)
class PayrollStatementPaymentConfirmationView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
@transaction.atomic
def post(self, request, pk, employee_id):
command = PayrollPaymentConfirmationCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
period = get_object_or_404(
PayrollPeriod.objects.select_for_update().select_related("current_run"),
pk=pk,
)
if period.status != PayrollPeriodStatus.CLOSED:
_conflict(
"PERIOD_NOT_CLOSED",
"Подтвердить фактическую выплату можно только в закрытом периоде.",
)
if period.current_run_id is None:
_conflict(
"PAYROLL_STATEMENT_NOT_FOUND",
"В закрытом периоде нет итогового расчёта.",
)
statement = get_object_or_404(
PayrollStatement.objects.select_for_update(),
run_id=period.current_run_id,
employee_id=employee_id,
)
confirmed = command.validated_data["confirmed"]
if confirmed:
confirmation, _ = PayrollStatementPaymentConfirmation.objects.get_or_create(
statement=statement,
defaults={"confirmed_by": request.user},
)
action = "payroll.statement_payment_confirmed"
else:
PayrollStatementPaymentConfirmation.objects.filter(
statement=statement,
).delete()
confirmation = None
action = "payroll.statement_payment_confirmation_revoked"
_audit_api_mutation(
actor=request.user,
action=action,
instance=statement,
period=period,
metadata={"employee_id": employee_id},
)
return Response(
{
"statement_id": statement.pk,
"employee_id": employee_id,
"confirmed": confirmation is not None,
"confirmed_at": (
confirmation.confirmed_at.isoformat()
if confirmation is not None
else None
),
"confirmed_by": (
PayrollUserRefSerializer(confirmation.confirmed_by).data
if confirmation is not None
else None
),
}
)
class PayrollPeriodCarryoverListCreateView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
def get(self, request, pk):
period = get_object_or_404(PayrollPeriod, pk=pk)
return Response(_carryover_ledger(period))
def post(self, request, pk):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
return Response(
{
"code": "MANUAL_CARRYOVER_DISABLED",
"message": (
"Отдельные переносы не создаются. Баланс складывается из "
"положительных и отрицательных корректировок."
),
},
status=status.HTTP_410_GONE,
)
class PayrollCarryoverDetailView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
def delete(self, request, pk):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
if not PayrollCarryover.objects.filter(pk=pk).exists():
raise Http404
carryover = cancel_carryover(pk, actor=request.user)
carryover = PayrollCarryover.objects.select_related(
"source_period",
"target_period",
"employee",
"created_by",
"generated_input_line",
).get(pk=carryover.pk)
return Response(PayrollCarryoverSerializer(carryover).data)
class PayrollInputLineCarryoverInclusionView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
@transaction.atomic
def patch(self, request, pk):
command = PayrollCarryoverInclusionSerializer(data=request.data)
command.is_valid(raise_exception=True)
line = get_object_or_404(
PayrollInputLine.objects.select_for_update(of=("self",)).select_related(
"period", "component"
),
pk=pk,
component__kind__in=(
PayrollComponentKind.ADJUSTMENT_CREDIT,
PayrollComponentKind.ADJUSTMENT_DEBIT,
),
)
included = command.validated_data["included"]
previous = line.include_in_carryover
if previous != included:
line.include_in_carryover = included
line.save(update_fields=["include_in_carryover", "updated_at"])
_audit_api_mutation(
actor=request.user,
action="payroll.input_line_carryover_inclusion_updated",
instance=line,
period=line.period,
metadata={"before": previous, "after": included},
)
return Response(
{
"id": line.pk,
"include_in_carryover": line.include_in_carryover,
"message": (
"Запись участвует в переходящем остатке. "
"Если период уже рассчитан, запустите перерасчёт."
if line.include_in_carryover
else (
"Запись исключена только из переходящего остатка; "
"зарплата исходного периода не изменена. Если период "
"уже рассчитан, запустите перерасчёт."
)
),
}
)
class PayrollCellCommentsView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
def get_context(self, pk, employee_id, column_id):
period = get_object_or_404(PayrollPeriod, pk=pk)
employee = get_object_or_404(Employee, pk=employee_id)
return period, employee, _validate_payroll_table_column(column_id)
def get(self, request, pk, employee_id, column_id):
period, employee, column_id = self.get_context(
pk,
employee_id,
column_id,
)
thread = PayrollCellCommentThread.objects.filter(
period=period,
subject_employee=employee,
column_id=column_id,
).first()
if thread is None:
return Response([])
messages = comments_helpers.get_comments(thread)
return Response(
[_serialize_cell_comment(message, request) for message in messages]
)
@transaction.atomic
def post(self, request, pk, employee_id, column_id):
period, employee, column_id = self.get_context(
pk,
employee_id,
column_id,
)
serializer = PayrollCellCommentWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
thread, _ = PayrollCellCommentThread.objects.get_or_create(
period=period,
subject_employee=employee,
column_id=column_id,
)
message = comments_helpers.create_comment(
obj=thread,
author=request.user,
content=serializer.validated_data["text"],
)
return Response(
_serialize_cell_comment(message, request),
status=status.HTTP_201_CREATED,
)
class PayrollCellCommentDetailView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
def delete(self, request, pk, employee_id, column_id, comment_id):
period = get_object_or_404(PayrollPeriod, pk=pk)
employee = get_object_or_404(Employee, pk=employee_id)
column_id = _validate_payroll_table_column(column_id)
thread = get_object_or_404(
PayrollCellCommentThread,
period=period,
subject_employee=employee,
column_id=column_id,
)
chat = comments_helpers.get_comments_chat_if_exists(thread)
if chat is None:
raise Http404
message = get_object_or_404(Message, pk=comment_id, chat=chat, is_deleted=False)
if message.author_id != request.user.id:
raise PermissionDenied("Удалить комментарий может только его автор.")
comments_helpers.delete_comment(
message=message,
deleted_by=request.user,
soft_delete=True,
)
return Response(status=status.HTTP_204_NO_CONTENT)
class PayrollPeriodPointBreakdownView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
def get(self, request, pk, employee_id):
period = get_object_or_404(
PayrollPeriod.objects.select_related("current_run"),
pk=pk,
)
employee = get_object_or_404(
Employee.objects.select_related("position").prefetch_related(
"departments_links__department"
),
pk=employee_id,
)
return Response(build_payroll_employee_point_breakdown(period, employee))
class PayrollPeriodApproveDraftsView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["approve_inputs"],)
def post(self, request, pk):
if not PayrollPeriod.objects.filter(pk=pk).exists():
raise Http404
summary = approve_period_drafts(pk, actor=request.user)
return Response({"period_id": pk, "summary": summary})
class DraftCollectionView(PayrollAdminAPIView):
allowed_permissions = INPUT_VIEW_PERMISSIONS
model = None
read_serializer_class = None
write_serializer_class = None
audit_label = "input"
period_scoped = False
def get_queryset(self):
return self.model.objects.all()
def filter_queryset(self, request, queryset):
employee_id = _optional_int(request, "employee_id")
if employee_id is not None:
queryset = queryset.filter(employee_id=employee_id)
status_value = request.query_params.get("status")
if status_value:
queryset = queryset.filter(status=status_value)
search = request.query_params.get("search", "").strip()
if search:
queryset = queryset.filter(
Q(employee__last_name__icontains=search)
| Q(employee__first_name__icontains=search)
| Q(employee__patronymic__icontains=search)
)
return queryset
def get(self, request):
queryset = self.filter_queryset(request, self.get_queryset())
return Response(self.read_serializer_class(queryset, many=True).data)
def validate_create(self, validated_data):
if not self.period_scoped:
return
period = _lock_period_accepting_draft_edits(validated_data["period"].pk)
validated_data["period"] = period
@transaction.atomic
def post(self, request):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
serializer = self.write_serializer_class(
data=request.data,
context={"actor": request.user},
)
serializer.is_valid(raise_exception=True)
self.validate_create(serializer.validated_data)
try:
instance = serializer.save()
except IntegrityError:
logger.exception(
"Database rejected payroll draft creation",
extra={
"payroll_model": self.model._meta.label,
"payroll_actor_id": request.user.pk,
},
)
_conflict(
"DRAFT_SAVE_FAILED",
(
"Не удалось сохранить данные расчёта: база данных отклонила "
"запись. Обновите страницу и повторите попытку."
),
)
period = getattr(instance, "period", None)
_audit_api_mutation(
actor=request.user,
action=f"payroll.{self.audit_label}_draft_created",
instance=instance,
period=period,
metadata={"lock_version": instance.lock_version},
)
instance = self.get_queryset().get(pk=instance.pk)
return Response(
self.read_serializer_class(instance).data,
status=status.HTTP_201_CREATED,
)
class PayrollInputHistoryView(PayrollAdminAPIView):
allowed_permissions = INPUT_VIEW_PERMISSIONS
def get(self, request):
period_id = _optional_int(request, "period_id")
if period_id is None:
raise ValidationError({"period_id": "Укажите расчётный период."})
period = get_object_or_404(PayrollPeriod, pk=period_id)
kind = request.query_params.get("kind", "").strip()
if kind == "rates":
records = pay_rates_relevant_to_period(
period,
queryset=EmployeePayRate.objects.select_related(
"employee",
"employee__position",
).prefetch_related("employee__departments_links__department"),
)
object_type = EmployeePayRate._meta.label_lower
subjects = {record.pk: "Ставка" for record in records}
elif kind == "work":
records = (
PayrollWorkRecord.objects.filter(period=period)
.select_related(
"employee",
"employee__position",
)
.prefetch_related("employee__departments_links__department")
)
object_type = PayrollWorkRecord._meta.label_lower
subjects = {record.pk: "Выработка" for record in records}
elif kind == "inputs":
records = (
PayrollInputLine.objects.filter(period=period)
.select_related(
"employee",
"employee__position",
"component",
)
.prefetch_related("employee__departments_links__department")
)
object_type = PayrollInputLine._meta.label_lower
subjects = {record.pk: record.component.name for record in records}
else:
raise ValidationError(
{"kind": "Допустимые значения: rates, work или inputs."}
)
records_by_id = {record.pk: record for record in records}
events = (
PayrollAuditEvent.objects.filter(
object_type=object_type,
object_id__in=[str(record_id) for record_id in records_by_id],
)
.select_related("actor")
.order_by("-created_at", "-id")
)
result = []
for event in events:
try:
record_id = int(event.object_id)
except (TypeError, ValueError):
continue
record = records_by_id.get(record_id)
if record is None:
continue
result.append(
{
"id": event.pk,
"record_id": record_id,
"action": event.action,
"action_label": _payroll_history_action_label(event.action),
"subject": subjects[record_id],
"employee": PayrollEmployeeMiniSerializer(record.employee).data,
"actor": (
PayrollUserRefSerializer(event.actor).data
if event.actor is not None
else None
),
"created_at": event.created_at,
}
)
return Response(result)
class DraftDetailView(PayrollAdminAPIView):
allowed_permissions = INPUT_VIEW_PERMISSIONS
model = None
read_serializer_class = None
write_serializer_class = None
audit_label = "input"
period_scoped = False
immutable_patch_fields = ()
def get_queryset(self):
return self.model.objects.all()
def get(self, request, pk):
instance = get_object_or_404(self.get_queryset(), pk=pk)
return Response(self.read_serializer_class(instance).data)
@transaction.atomic
def patch(self, request, pk):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
forbidden_fields = set(request.data) & set(self.immutable_patch_fields)
if forbidden_fields:
raise ValidationError(
{
field_name: "Поле нельзя менять после создания черновика."
for field_name in sorted(forbidden_fields)
}
)
# Do not reveal whether an out-of-scope or immutable payroll record exists.
scoped_queryset = self.get_queryset().filter(
pk=pk,
status=ApprovalStatus.DRAFT,
)
if not has_simple_admin_access(request.user):
scoped_queryset = scoped_queryset.filter(created_by=request.user)
if self.period_scoped:
reference_queryset = self.model.objects.only("period_id").filter(
pk=pk,
status=ApprovalStatus.DRAFT,
)
if not has_simple_admin_access(request.user):
reference_queryset = reference_queryset.filter(created_by=request.user)
reference = get_object_or_404(reference_queryset)
period = _lock_period_accepting_draft_edits(reference.period_id)
else:
period = None
# Read serializers join nullable audit/revision relations. PostgreSQL
# cannot apply FOR UPDATE to the nullable side of those outer joins,
# and only the draft itself needs to be locked here.
instance = get_object_or_404(scoped_queryset.select_for_update(of=("self",)))
if period is not None:
instance._state.fields_cache["period"] = period
self.validate_period_update(instance, period)
serializer = self.write_serializer_class(
instance,
data=request.data,
partial=True,
context={"actor": request.user},
)
serializer.is_valid(raise_exception=True)
expected_lock_version = serializer.validated_data.get("expected_lock_version")
if instance.lock_version != expected_lock_version:
_conflict(
"STALE_DRAFT",
"Черновик уже изменён; обновите данные.",
expected_lock_version=expected_lock_version,
actual_lock_version=instance.lock_version,
)
try:
instance = serializer.save()
except IntegrityError:
_conflict(
"DRAFT_CONFLICT",
(
"Не удалось сохранить изменения: база данных отклонила "
"запись. Обновите данные и повторите попытку."
),
)
_audit_api_mutation(
actor=request.user,
action=f"payroll.{self.audit_label}_draft_updated",
instance=instance,
period=getattr(instance, "period", None),
metadata={"lock_version": instance.lock_version},
)
instance = self.get_queryset().get(pk=instance.pk)
return Response(self.read_serializer_class(instance).data)
def validate_period_update(self, instance, period):
return None
class EmployeePayRateListCreateView(DraftCollectionView):
model = EmployeePayRate
read_serializer_class = EmployeePayRateSerializer
write_serializer_class = EmployeePayRateWriteSerializer
audit_label = "rate"
def get_queryset(self):
return (
EmployeePayRate.objects.select_related(
"employee",
"employee__position",
"created_by",
"approved_by",
)
.prefetch_related("employee__departments_links__department")
.order_by("-created_at", "-id")
)
def filter_queryset(self, request, queryset):
queryset = super().filter_queryset(request, queryset)
period_id = _optional_int(request, "period_id")
if period_id is None:
return queryset
period = get_object_or_404(PayrollPeriod, pk=period_id)
return pay_rates_relevant_to_period(period, queryset=queryset)
class EmployeePayRateDetailView(DraftDetailView):
model = EmployeePayRate
read_serializer_class = EmployeePayRateSerializer
write_serializer_class = EmployeePayRateWriteSerializer
audit_label = "rate"
immutable_patch_fields = ("employee_id", "rate_code", "currency")
def get_queryset(self):
return EmployeePayRate.objects.select_related(
"employee",
"employee__position",
"created_by",
"approved_by",
).prefetch_related("employee__departments_links__department")
class PayrollBulkPointRateView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
def post(self, request, pk):
get_object_or_404(PayrollPeriod.objects.only("id"), pk=pk)
command = BulkPointRateCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
result = bulk_set_point_rate(
pk,
actor=request.user,
**command.validated_data,
)
return Response(
{
"mode": result["mode"],
"point_rate": (
f"{result['point_rate']:.4f}"
if result["point_rate"] is not None
else None
),
"summary": result["summary"],
}
)
class PayrollBulkPayRateView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
def post(self, request, pk):
get_object_or_404(PayrollPeriod.objects.only("id"), pk=pk)
command = BulkPayRateCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
result = bulk_set_pay_rate(
pk,
actor=request.user,
**command.validated_data,
)
return Response(
{
"amount": f"{result['amount']:.4f}",
"effective_from": result["effective_from"].isoformat(),
"summary": result["summary"],
}
)
class PayrollBulkTargetPointsView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
def post(self, request, pk):
get_object_or_404(PayrollPeriod.objects.only("id"), pk=pk)
command = BulkTargetPointsCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
result = bulk_set_target_points(
pk,
actor=request.user,
**command.validated_data,
)
return Response(
{
"mode": result["mode"],
"target_points": (
f"{result['target_points']:.4f}"
if result["target_points"] is not None
else None
),
"summary": result["summary"],
}
)
class PayrollWorkRecordListCreateView(DraftCollectionView):
model = PayrollWorkRecord
read_serializer_class = PayrollWorkRecordSerializer
write_serializer_class = PayrollWorkRecordWriteSerializer
audit_label = "work_record"
period_scoped = True
def validate_create(self, validated_data):
super().validate_create(validated_data)
if validated_data["period"].status == PayrollPeriodStatus.PUBLISHED:
_conflict(
"PUBLISHED_WORK_REPLACEMENT_REQUIRED",
"После публикации показатели меняются только новой ревизией.",
)
def get_queryset(self):
return (
PayrollWorkRecord.objects.select_related(
"period",
"employee",
"employee__position",
"created_by",
"approved_by",
)
.prefetch_related("employee__departments_links__department")
.order_by("-created_at", "-id")
)
def filter_queryset(self, request, queryset):
queryset = super().filter_queryset(request, queryset)
period_id = _optional_int(request, "period_id")
return queryset.filter(period_id=period_id) if period_id else queryset
class PayrollWorkRecordDetailView(DraftDetailView):
model = PayrollWorkRecord
read_serializer_class = PayrollWorkRecordSerializer
write_serializer_class = PayrollWorkRecordWriteSerializer
audit_label = "work_record"
period_scoped = True
immutable_patch_fields = ("period_id", "employee_id")
def validate_period_update(self, instance, period):
if period.status == PayrollPeriodStatus.PUBLISHED and not instance.replaces_id:
_conflict(
"PUBLISHED_WORK_REPLACEMENT_REQUIRED",
"После публикации показатели меняются только новой ревизией.",
)
def get_queryset(self):
return PayrollWorkRecord.objects.select_related(
"period",
"employee",
"employee__position",
"created_by",
"approved_by",
).prefetch_related("employee__departments_links__department")
class PayrollInputLineListCreateView(DraftCollectionView):
model = PayrollInputLine
read_serializer_class = PayrollInputLineSerializer
write_serializer_class = PayrollInputLineWriteSerializer
audit_label = "input_line"
period_scoped = True
def get_queryset(self):
return (
PayrollInputLine.objects.select_related(
"period",
"employee",
"employee__position",
"component",
"relates_to_period",
"created_by",
"approved_by",
)
.prefetch_related("employee__departments_links__department")
.order_by("-created_at", "-id")
)
def filter_queryset(self, request, queryset):
queryset = super().filter_queryset(request, queryset)
period_id = _optional_int(request, "period_id")
component_id = _optional_int(request, "component_id")
if period_id:
queryset = queryset.filter(period_id=period_id)
if component_id:
queryset = queryset.filter(component_id=component_id)
return queryset
class PayrollInputLineDetailView(DraftDetailView):
model = PayrollInputLine
read_serializer_class = PayrollInputLineSerializer
write_serializer_class = PayrollInputLineWriteSerializer
audit_label = "input_line"
period_scoped = True
immutable_patch_fields = ("period_id", "employee_id", "component_id")
def get_queryset(self):
return PayrollInputLine.objects.select_related(
"period",
"employee",
"employee__position",
"component",
"relates_to_period",
"created_by",
"approved_by",
).prefetch_related("employee__departments_links__department")
class PayrollComponentCellClearView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
def post(self, request, pk):
command = ClearComponentCellCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
summary = clear_component_input_lines(
pk,
employee_id=command.validated_data["employee_id"],
component_id=command.validated_data["component_id"],
actor=request.user,
)
return Response(summary)
class PayrollInputLinePeriodCopyView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
def get(self, request, pk):
target_period = get_object_or_404(PayrollPeriod, pk=pk)
source_period_id = _optional_int(request, "source_period_id")
if source_period_id is None:
raise ValidationError(
{"source_period_id": "Выберите период-источник."}
)
source_period = get_object_or_404(PayrollPeriod, pk=source_period_id)
return Response(
_input_copy_preview(
target_period,
source_period,
actor=request.user,
)
)
@transaction.atomic
def post(self, request, pk):
command = CopyPayrollInputLinesCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
data = command.validated_data
target_period = _lock_period_accepting_draft_edits(pk)
if target_period.lock_version != data["expected_period_lock_version"]:
_conflict(
"STALE_PERIOD",
"Период уже изменён; обновите данные и повторите перенос.",
expected_lock_version=data["expected_period_lock_version"],
actual_lock_version=target_period.lock_version,
)
source_period = get_object_or_404(
PayrollPeriod.objects.select_for_update(of=("self",)),
pk=data["source_period_id"],
)
_validate_input_copy_periods(target_period, source_period)
if data["remember_configuration"]:
PayrollInputCopyConfiguration.objects.update_or_create(
owner=request.user,
defaults={
"component_ids": data["component_ids"],
"employee_scope": data["employee_scope"],
"employee_ids": data["employee_ids"],
"existing_mode": data["existing_mode"],
"auto_apply_on_period_creation": data[
"auto_apply_on_period_creation"
],
},
)
else:
PayrollInputCopyConfiguration.objects.filter(owner=request.user).delete()
result = _apply_input_copy(
target_period=target_period,
source_period=source_period,
actor=request.user,
data=data,
)
return Response(
result,
status=(
status.HTTP_200_OK
if result["idempotent_replay"]
else status.HTTP_201_CREATED
),
)
class DraftApprovalView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["approve_inputs"],)
service = None
queryset = None
read_serializer_class = None
def post(self, request, pk):
serializer = ApprovalCommandSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
if not self.queryset.filter(pk=pk).exists():
raise Http404
instance = self.service(
pk,
actor=request.user,
expected_lock_version=serializer.validated_data["expected_lock_version"],
)
instance = self.queryset.get(pk=instance.pk)
return Response(self.read_serializer_class(instance).data)
class EmployeePayRateApproveView(DraftApprovalView):
service = staticmethod(approve_pay_rate)
queryset = EmployeePayRate.objects.select_related(
"employee", "employee__position", "created_by", "approved_by"
).prefetch_related("employee__departments_links__department")
read_serializer_class = EmployeePayRateSerializer
class PayrollWorkRecordApproveView(DraftApprovalView):
service = staticmethod(approve_work_record)
queryset = PayrollWorkRecord.objects.select_related(
"period", "employee", "employee__position", "created_by", "approved_by"
).prefetch_related("employee__departments_links__department")
read_serializer_class = PayrollWorkRecordSerializer
class PayrollInputLineApproveView(DraftApprovalView):
service = staticmethod(approve_input_line)
queryset = PayrollInputLine.objects.select_related(
"period",
"employee",
"employee__position",
"component",
"created_by",
"approved_by",
).prefetch_related("employee__departments_links__department")
read_serializer_class = PayrollInputLineSerializer
class EmployeePayRateReviseView(PayrollAdminAPIView):
allowed_permissions = INPUT_VIEW_PERMISSIONS
@transaction.atomic
def post(self, request, pk):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
source = get_object_or_404(
EmployeePayRate.objects.select_for_update(of=("self",)).select_related(
"employee"
),
pk=pk,
status=ApprovalStatus.APPROVED,
)
if EmployeePayRate.objects.filter(replaces=source).exists():
_conflict(
"REVISION_ALREADY_EXISTS",
"Для этой ставки уже создана следующая ревизия.",
)
command = EmployeePayRateRevisionSerializer(data=request.data)
command.is_valid(raise_exception=True)
values = {
"amount": source.amount,
"point_rate": source.point_rate,
"currency": source.currency,
**command.validated_data,
}
revision = EmployeePayRate(
employee=source.employee,
rate_code=source.rate_code,
effective_from=source.effective_from,
revision=source.revision + 1,
replaces=source,
status=ApprovalStatus.DRAFT,
source="manual",
created_by=request.user,
**values,
)
try:
revision.full_clean()
revision.save(force_insert=True)
except DjangoValidationError as exc:
raise ValidationError(_validation_detail(exc)) from exc
except IntegrityError:
_conflict(
"REVISION_CONFLICT",
"Не удалось создать новую версию ставки. Обновите данные и повторите попытку.",
)
_audit_api_mutation(
actor=request.user,
action="payroll.rate_revision_draft_created",
instance=revision,
metadata={"replaces_id": source.pk, "revision": revision.revision},
)
revision = (
EmployeePayRate.objects.select_related(
"employee", "employee__position", "created_by", "approved_by"
)
.prefetch_related("employee__departments_links__department")
.get(pk=revision.pk)
)
return Response(
EmployeePayRateSerializer(revision).data,
status=status.HTTP_201_CREATED,
)
class PayrollWorkRecordReviseView(PayrollAdminAPIView):
allowed_permissions = INPUT_VIEW_PERMISSIONS
@transaction.atomic
def post(self, request, pk):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
reference = get_object_or_404(
PayrollWorkRecord.objects.only("period_id"),
pk=pk,
status=ApprovalStatus.APPROVED,
)
period = _lock_period_accepting_draft_edits(reference.period_id)
source = get_object_or_404(
PayrollWorkRecord.objects.select_for_update(of=("self",)).select_related(
"period", "employee"
),
pk=pk,
status=ApprovalStatus.APPROVED,
)
source._state.fields_cache["period"] = period
if PayrollWorkRecord.objects.filter(replaces=source).exists():
_conflict(
"REVISION_ALREADY_EXISTS",
"Для показателей уже создана следующая ревизия.",
)
command = PayrollWorkRecordRevisionSerializer(data=request.data)
command.is_valid(raise_exception=True)
values = {
"target_points": source.target_points,
"target_points_overridden": source.target_points_overridden,
"actual_points": source.actual_points,
"expected_point_amount": source.expected_point_amount,
"expected_gross": source.expected_gross,
"expected_recalculated_gross": source.expected_recalculated_gross,
"expected_payable": source.expected_payable,
**command.validated_data,
}
if "target_points" in command.validated_data:
values["target_points_overridden"] = True
revision = PayrollWorkRecord(
period=source.period,
employee=source.employee,
revision=source.revision + 1,
replaces=source,
status=ApprovalStatus.DRAFT,
source="manual",
created_by=request.user,
**values,
)
try:
revision.full_clean()
revision.save(force_insert=True)
except DjangoValidationError as exc:
raise ValidationError(_validation_detail(exc)) from exc
except IntegrityError:
_conflict(
"REVISION_CONFLICT",
"Не удалось создать новую версию показателей. Обновите данные и повторите попытку.",
)
_audit_api_mutation(
actor=request.user,
action="payroll.work_record_revision_draft_created",
instance=revision,
period=revision.period,
metadata={"replaces_id": source.pk, "revision": revision.revision},
)
revision = (
PayrollWorkRecord.objects.select_related(
"period",
"employee",
"employee__position",
"created_by",
"approved_by",
)
.prefetch_related("employee__departments_links__department")
.get(pk=revision.pk)
)
return Response(
PayrollWorkRecordSerializer(revision).data,
status=status.HTTP_201_CREATED,
)
class PayrollAttendanceWorkRecordsView(PayrollAdminAPIView):
"""Preview and apply attendance-derived payroll work drafts."""
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
def get(self, request, pk):
period = get_object_or_404(PayrollPeriod, pk=pk)
preview = build_attendance_work_preview(period, actor=request.user)
preview.pop("_internal_items", None)
return Response(preview)
def post(self, request, pk):
get_object_or_404(PayrollPeriod.objects.only("id"), pk=pk)
command = AttendanceWorkImportCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
result = apply_attendance_work_preview(
pk,
actor=request.user,
**command.validated_data,
)
record_ids = [record.pk for record in result["records"]]
records = list(
PayrollWorkRecord.objects.filter(pk__in=record_ids)
.select_related(
"period",
"employee",
"employee__position",
"created_by",
"approved_by",
)
.prefetch_related("employee__departments_links__department")
.order_by("employee_id", "revision", "id")
)
return Response(
{
"mode": result["mode"],
"summary": result["summary"],
"records": PayrollWorkRecordSerializer(records, many=True).data,
}
)
class PayrollWorkbookWorkPreviewView(PayrollAdminAPIView):
"""Inspect a legacy schedule workbook without changing payroll data."""
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
def post(self, request, pk):
command = WorkbookWorkPreviewSerializer(data=request.data)
command.is_valid(raise_exception=True)
period = get_object_or_404(PayrollPeriod, pk=pk)
uploaded = command.validated_data["file"]
return Response(
build_workbook_preview(
period,
payload=uploaded.read(),
filename=uploaded.name,
)
)
class PayrollWorkbookWorkImportView(PayrollAdminAPIView):
"""Apply a previously inspected schedule workbook atomically."""
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
def post(self, request, pk):
command = WorkbookWorkImportSerializer(data=request.data)
command.is_valid(raise_exception=True)
uploaded = command.validated_data["file"]
result = apply_workbook_import(
pk,
actor=request.user,
payload=uploaded.read(),
filename=uploaded.name,
mappings=parse_mappings(command.validated_data["mappings"]),
mode=command.validated_data["mode"],
expected_file_hash=command.validated_data["expected_file_hash"],
expected_period_lock_version=command.validated_data[
"expected_period_lock_version"
],
)
return Response(result)
class PayrollPeriodCalculateView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["calculate"],)
@transaction.atomic
def post(self, request, pk):
command = CalculatePayrollCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
period = get_object_or_404(
PayrollPeriod.objects.select_for_update(),
pk=pk,
)
expected = command.validated_data["expected_lock_version"]
if period.lock_version != expected:
_conflict(
"STALE_PERIOD",
"Период уже изменён; обновите данные перед расчётом.",
expected_lock_version=expected,
actual_lock_version=period.lock_version,
)
run = calculate_period(
period.pk,
actor=request.user,
idempotency_key=command.validated_data.get("idempotency_key"),
recalculation_reason=command.validated_data["recalculation_reason"],
)
return Response(self.run_payload(request, run), status=status.HTTP_201_CREATED)
class PayrollPeriodCompleteWorkflowView(PayrollAdminAPIView):
"""Complete calculation, review, approval, publication and closing at once."""
allowed_permissions = (PAYROLL_PERMISSIONS["calculate"],)
def post(self, request, pk):
if not has_simple_admin_access(request.user):
_permission_error("finance.full_payroll_admin")
command = CalculatePayrollCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
if not PayrollPeriod.objects.filter(pk=pk).exists():
raise Http404
result = complete_period_workflow(
pk,
actor=request.user,
expected_lock_version=command.validated_data["expected_lock_version"],
idempotency_key=command.validated_data.get("idempotency_key"),
recalculation_reason=command.validated_data["recalculation_reason"],
)
return Response(
{
"period": PayrollPeriodSerializer(result["period"]).data,
"run": self.run_payload(request, result["run"]),
"completed_steps": result["completed_steps"],
}
)
class PayrollRunDetailView(PayrollAdminAPIView):
allowed_permissions = RUN_VIEW_PERMISSIONS
def get(self, request, pk):
run = get_object_or_404(
PayrollRun.objects.select_related(
"period", "requested_by", "approved_by", "published_by"
),
pk=pk,
)
return Response(self.run_payload(request, run))
class PayrollRunSubmitReviewView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["calculate"],)
def post(self, request, pk):
if not PayrollRun.objects.filter(pk=pk).exists():
raise Http404
return Response(
self.run_payload(request, submit_run_for_review(pk, actor=request.user))
)
class PayrollRunReturnView(PayrollAdminAPIView):
allowed_permissions = (
PAYROLL_PERMISSIONS["calculate"],
PAYROLL_PERMISSIONS["approve_run"],
)
def post(self, request, pk):
command = ReturnPayrollCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
if not PayrollRun.objects.filter(pk=pk).exists():
raise Http404
run = return_run_for_correction(
pk,
actor=request.user,
reason=command.validated_data["reason"],
)
return Response(self.run_payload(request, run))
class PayrollRunApproveView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["approve_run"],)
def post(self, request, pk):
if not PayrollRun.objects.filter(pk=pk).exists():
raise Http404
return Response(self.run_payload(request, approve_run(pk, actor=request.user)))
class PayrollRunPublishView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["publish"],)
def post(self, request, pk):
if not PayrollRun.objects.filter(pk=pk).exists():
raise Http404
return Response(self.run_payload(request, publish_run(pk, actor=request.user)))
class PayrollPeriodCloseView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["publish"],)
def post(self, request, pk):
if not PayrollPeriod.objects.filter(pk=pk).exists():
raise Http404
period = close_period(pk, actor=request.user)
return Response(PayrollPeriodSerializer(period).data)