2625 lines
96 KiB
Python
2625 lines
96 KiB
Python
"""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,
|
||
PayrollInputLine,
|
||
PayrollInputCopyConfiguration,
|
||
PayrollPeriod,
|
||
PayrollRun,
|
||
PayrollStatement,
|
||
PayrollStatementPaymentConfirmation,
|
||
PayrollWorkRecord,
|
||
)
|
||
from finance.payroll.components import (
|
||
PAYROLL_COMPONENTS,
|
||
PAYROLL_COMPONENTS_BY_CODE,
|
||
)
|
||
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,
|
||
set_component_input_line_value,
|
||
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,
|
||
SetComponentCellCommandSerializer,
|
||
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 component_code in PAYROLL_COMPONENTS_BY_CODE
|
||
):
|
||
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_code__in=PAYROLL_COMPONENTS_BY_CODE,
|
||
)
|
||
.exclude(
|
||
source=InputSource.API,
|
||
source_ref__startswith="payroll-carryover:",
|
||
)
|
||
.select_related(
|
||
"employee",
|
||
"employee__position",
|
||
"relates_to_period",
|
||
)
|
||
.prefetch_related(
|
||
"employee__departments_links__department",
|
||
"employee__actions__source_request",
|
||
)
|
||
.order_by("employee_id", "component_code", "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_codes": [],
|
||
"component_ids": [],
|
||
"employee_scope": "active",
|
||
"employee_ids": [],
|
||
"existing_mode": "skip",
|
||
"auto_apply_on_period_creation": False,
|
||
}
|
||
return {
|
||
"saved": True,
|
||
"component_codes": configuration.component_codes,
|
||
"component_ids": configuration.component_codes,
|
||
"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.code: {
|
||
"component": component,
|
||
"line_count": 0,
|
||
"employee_ids": set(),
|
||
"amount_total": Decimal("0.00"),
|
||
}
|
||
for component in PAYROLL_COMPONENTS
|
||
}
|
||
employee_rows = {}
|
||
for line in lines:
|
||
component_row = component_rows[line.component_code]
|
||
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"][line.component_code] = (
|
||
employee_row["component_counts"].get(line.component_code, 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_code__in=component_rows,
|
||
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
|
||
)
|
||
.exclude(
|
||
source=InputSource.API,
|
||
source_ref__startswith="payroll-carryover:",
|
||
)
|
||
.values("employee_id", "component_code")
|
||
.annotate(count=Count("id"))
|
||
):
|
||
existing_counts.setdefault(row["employee_id"], {})[
|
||
row["component_code"]
|
||
] = 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_code__in=data["component_codes"]
|
||
)
|
||
)
|
||
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_code)
|
||
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_code__in={component_code for _, component_code 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_code) in selected_cells
|
||
and not (
|
||
line.source == InputSource.API
|
||
and line.source_ref.startswith("payroll-carryover:")
|
||
)
|
||
]
|
||
occupied_cells = {
|
||
(line.employee_id, line.component_code)
|
||
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_code) 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_code=line.component_code,
|
||
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):
|
||
lines = (
|
||
PayrollInputLine.objects.filter(
|
||
period__currency=period.currency,
|
||
period__date_to__lte=period.date_to,
|
||
component_code__in=("CORRECTION_CREDIT", "CORRECTION_DEBIT"),
|
||
)
|
||
.exclude(
|
||
source=InputSource.API,
|
||
source_ref__startswith="payroll-carryover:",
|
||
)
|
||
.select_related(
|
||
"period",
|
||
"employee",
|
||
"employee__position",
|
||
"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 period.status != PayrollPeriodStatus.CLOSED
|
||
and line.status == ApprovalStatus.DRAFT
|
||
)
|
||
counts_in_balance = bool(line.include_in_carryover and active_status)
|
||
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 = PAYROLL_COMPONENTS
|
||
|
||
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):
|
||
return Response(PayrollComponentSerializer(PAYROLL_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_codes": configuration.component_codes,
|
||
"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"),
|
||
pk=pk,
|
||
component_code__in=("CORRECTION_CREDIT", "CORRECTION_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",
|
||
)
|
||
.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",
|
||
"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_code = request.query_params.get("component_code", "").strip()
|
||
if period_id:
|
||
queryset = queryset.filter(period_id=period_id)
|
||
if component_code:
|
||
queryset = queryset.filter(component_code=component_code)
|
||
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_code")
|
||
|
||
def get_queryset(self):
|
||
return PayrollInputLine.objects.select_related(
|
||
"period",
|
||
"employee",
|
||
"employee__position",
|
||
"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_code=command.validated_data["component_code"],
|
||
actor=request.user,
|
||
)
|
||
return Response(summary)
|
||
|
||
|
||
class PayrollComponentCellSetView(PayrollAdminAPIView):
|
||
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)
|
||
|
||
def post(self, request, pk):
|
||
command = SetComponentCellCommandSerializer(data=request.data)
|
||
command.is_valid(raise_exception=True)
|
||
line = set_component_input_line_value(
|
||
pk,
|
||
employee_id=command.validated_data["employee_id"],
|
||
component_code=command.validated_data["component_code"],
|
||
amount=command.validated_data["amount"],
|
||
actor=request.user,
|
||
)
|
||
return Response(
|
||
PayrollInputLineSerializer(line).data,
|
||
status=status.HTTP_201_CREATED,
|
||
)
|
||
|
||
|
||
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_codes": data["component_codes"],
|
||
"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",
|
||
"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)
|