2222 lines
78 KiB
Python
2222 lines
78 KiB
Python
"""Transactional orchestration around the pure payroll calculation core."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import uuid
|
||
from collections import defaultdict
|
||
from decimal import Decimal
|
||
|
||
from django.contrib.auth import get_user_model
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import IntegrityError, transaction
|
||
from django.db.models import F, Max, OuterRef, Q, Subquery, Sum
|
||
from django.utils import timezone
|
||
|
||
from payroll_core import (
|
||
DeterministicPayrollCalculator,
|
||
PayrollValidationError,
|
||
)
|
||
|
||
from finance.enums import (
|
||
ApprovalStatus,
|
||
InputSource,
|
||
PayrollPeriodStatus,
|
||
PayrollRunStatus,
|
||
)
|
||
from finance.models import (
|
||
EmployeePayRate,
|
||
PayrollAuditEvent,
|
||
PayrollComponent,
|
||
PayrollDailyWorkEntry,
|
||
PayrollInputLine,
|
||
PayrollPeriod,
|
||
PayrollRun,
|
||
PayrollStatement,
|
||
PayrollStatementAcknowledgement,
|
||
PayrollStatementLine,
|
||
PayrollWorkSettings,
|
||
PayrollWorkRecord,
|
||
)
|
||
|
||
from .adapter import build_core_request, employee_snapshot
|
||
from .access import (
|
||
SELF_APPROVAL_OVERRIDE_PERMISSION,
|
||
can_self_approve_payroll,
|
||
has_payroll_permission,
|
||
has_simple_admin_access,
|
||
)
|
||
from .config import (
|
||
base_rate_code,
|
||
build_rules,
|
||
ruleset_not_effective_message,
|
||
ruleset_period_details,
|
||
)
|
||
from .exceptions import PayrollOperationError, PayrollPermissionDenied
|
||
from .work_norm import calculate_period_target_points
|
||
|
||
Employee = get_user_model()
|
||
|
||
|
||
def _require_permission(actor, permission: str) -> None:
|
||
if (
|
||
actor is None
|
||
or not getattr(actor, "is_authenticated", False)
|
||
or not has_payroll_permission(actor, permission)
|
||
):
|
||
raise PayrollPermissionDenied(permission)
|
||
|
||
|
||
def _approval_audit_metadata(actor, *, self_approval: bool) -> dict[str, object]:
|
||
"""Keep legacy separation metadata only outside the temporary admin mode."""
|
||
|
||
if has_simple_admin_access(actor):
|
||
return {}
|
||
if not self_approval:
|
||
return {
|
||
"self_approval_overridden": False,
|
||
"approval_mode": "maker_checker",
|
||
}
|
||
return {
|
||
"self_approval_overridden": True,
|
||
"approval_mode": "self_override",
|
||
"override_permission": SELF_APPROVAL_OVERRIDE_PERMISSION,
|
||
}
|
||
|
||
|
||
def _hash_items(items: list[str]) -> str:
|
||
payload = json.dumps(sorted(items), separators=(",", ":")).encode("utf-8")
|
||
return hashlib.sha256(payload).hexdigest()
|
||
|
||
|
||
def _audit(
|
||
*,
|
||
actor,
|
||
action: str,
|
||
instance,
|
||
period: PayrollPeriod | None,
|
||
before_hash: str = "",
|
||
after_hash: str = "",
|
||
metadata: dict[str, object] | None = None,
|
||
) -> None:
|
||
PayrollAuditEvent.objects.create(
|
||
actor=actor,
|
||
action=action,
|
||
object_type=instance._meta.label_lower,
|
||
object_id=str(instance.pk),
|
||
period=period,
|
||
before_hash=before_hash,
|
||
after_hash=after_hash,
|
||
metadata=metadata or {},
|
||
)
|
||
|
||
|
||
def _operation_error(code: str, message: str, **details):
|
||
raise PayrollOperationError(code, message, details=details)
|
||
|
||
|
||
def _resolve_rates(
|
||
period: PayrollPeriod,
|
||
employee_ids: list[int],
|
||
) -> list[EmployeePayRate]:
|
||
rates = list(
|
||
EmployeePayRate.objects.filter(
|
||
employee_id__in=employee_ids,
|
||
rate_code=base_rate_code(),
|
||
status=ApprovalStatus.APPROVED,
|
||
effective_from__lte=period.date_to,
|
||
)
|
||
.select_related("employee", "employee__position")
|
||
.order_by("employee_id", "effective_from", "revision")
|
||
)
|
||
grouped = defaultdict(list)
|
||
for rate in rates:
|
||
grouped[rate.employee_id].append(rate)
|
||
|
||
selected = []
|
||
for employee_id in employee_ids:
|
||
employee_rates = grouped.get(employee_id, [])
|
||
period_start_rates = [
|
||
rate for rate in employee_rates if rate.effective_from <= period.date_from
|
||
]
|
||
if not period_start_rates:
|
||
_operation_error(
|
||
"MISSING_RATE_AT_PERIOD_START",
|
||
"Нет утверждённой ставки на начало периода.",
|
||
employee_id=employee_id,
|
||
)
|
||
mid_period_rates = [
|
||
rate
|
||
for rate in employee_rates
|
||
if period.date_from < rate.effective_from <= period.date_to
|
||
]
|
||
if mid_period_rates:
|
||
_operation_error(
|
||
"MID_PERIOD_RATE_CHANGE_UNSUPPORTED",
|
||
"Изменение ставки внутри периода требует правила пропорционирования.",
|
||
employee_id=employee_id,
|
||
rate_ids=[rate.pk for rate in mid_period_rates],
|
||
)
|
||
selected.append(
|
||
max(
|
||
period_start_rates,
|
||
key=lambda rate: (
|
||
rate.effective_from,
|
||
rate.revision,
|
||
rate.pk,
|
||
),
|
||
)
|
||
)
|
||
|
||
if not selected:
|
||
_operation_error(
|
||
"NO_APPROVED_RATES",
|
||
"Для периода нет ни одной утверждённой базовой ставки.",
|
||
)
|
||
return sorted(selected, key=lambda rate: rate.employee_id)
|
||
|
||
|
||
def pay_rates_relevant_to_period(
|
||
period: PayrollPeriod,
|
||
*,
|
||
queryset=None,
|
||
):
|
||
"""Return rate revisions shown as inputs for a payroll period."""
|
||
|
||
queryset = queryset if queryset is not None else EmployeePayRate.objects.all()
|
||
latest_effective_date = (
|
||
EmployeePayRate.objects.filter(
|
||
employee_id=OuterRef("employee_id"),
|
||
rate_code=OuterRef("rate_code"),
|
||
effective_from__lte=period.date_from,
|
||
)
|
||
.order_by("-effective_from")
|
||
.values("effective_from")[:1]
|
||
)
|
||
return queryset.annotate(
|
||
_latest_effective_date=Subquery(latest_effective_date)
|
||
).filter(
|
||
Q(effective_from=F("_latest_effective_date"))
|
||
| Q(
|
||
effective_from__gt=period.date_from,
|
||
effective_from__lte=period.date_to,
|
||
)
|
||
)
|
||
|
||
|
||
def _current_work_records(period):
|
||
return {
|
||
record.employee_id: record
|
||
for record in PayrollWorkRecord.objects.filter(
|
||
period=period,
|
||
status=ApprovalStatus.APPROVED,
|
||
).select_related("employee")
|
||
}
|
||
|
||
|
||
def _input_lines_by_employee(period, employee_ids):
|
||
grouped = defaultdict(list)
|
||
records = (
|
||
PayrollInputLine.objects.filter(
|
||
period=period,
|
||
employee_id__in=employee_ids,
|
||
status=ApprovalStatus.APPROVED,
|
||
)
|
||
.select_related("component", "relates_to_period")
|
||
.order_by("employee_id", "component__display_order", "id")
|
||
)
|
||
for record in records:
|
||
grouped[record.employee_id].append(record)
|
||
return grouped
|
||
|
||
|
||
def _calculate_current_period_inputs(
|
||
period: PayrollPeriod,
|
||
*,
|
||
calculator=None,
|
||
rules=None,
|
||
):
|
||
"""Resolve and calculate the currently approved source-of-truth inputs."""
|
||
|
||
active_rules = rules or build_rules()
|
||
active_calculator = calculator or DeterministicPayrollCalculator()
|
||
work_records = _current_work_records(period)
|
||
if not work_records:
|
||
_operation_error(
|
||
"NO_APPROVED_WORK_RECORDS",
|
||
"Не задан утверждённый состав сотрудников за период.",
|
||
)
|
||
employee_ids = sorted(work_records)
|
||
rates = _resolve_rates(period, employee_ids)
|
||
outside_roster = list(
|
||
PayrollInputLine.objects.filter(
|
||
period=period,
|
||
status=ApprovalStatus.APPROVED,
|
||
)
|
||
.exclude(employee_id__in=employee_ids)
|
||
.values_list("employee_id", flat=True)
|
||
.distinct()[:20]
|
||
)
|
||
if outside_roster:
|
||
_operation_error(
|
||
"INPUT_EMPLOYEE_NOT_IN_ROSTER",
|
||
"Утверждённые строки найдены для сотрудников вне состава периода.",
|
||
employee_ids=outside_roster,
|
||
)
|
||
input_lines = _input_lines_by_employee(period, employee_ids)
|
||
|
||
requests = []
|
||
results = []
|
||
for rate in rates:
|
||
if rate.currency != period.currency:
|
||
_operation_error(
|
||
"RATE_CURRENCY_MISMATCH",
|
||
"Валюта ставки не совпадает с валютой периода.",
|
||
employee_id=rate.employee_id,
|
||
rate_id=rate.pk,
|
||
)
|
||
work_record = work_records.get(rate.employee_id)
|
||
if work_record is None:
|
||
_operation_error(
|
||
"MISSING_APPROVED_WORK_RECORD",
|
||
"Нет утверждённых показателей за период.",
|
||
employee_id=rate.employee_id,
|
||
)
|
||
request = build_core_request(
|
||
period=period,
|
||
rate=rate,
|
||
work_record=work_record,
|
||
input_lines=input_lines[rate.employee_id],
|
||
rounding=active_rules.rounding.value,
|
||
)
|
||
try:
|
||
result = active_calculator.calculate(request, active_rules)
|
||
except PayrollValidationError as exc:
|
||
issues = [issue.to_dict() for issue in exc.issues]
|
||
if any(issue.code == "RULESET_NOT_EFFECTIVE" for issue in exc.issues):
|
||
raise PayrollOperationError(
|
||
"RULESET_NOT_EFFECTIVE",
|
||
ruleset_not_effective_message(active_rules),
|
||
details={
|
||
"employee_id": rate.employee_id,
|
||
"issues": issues,
|
||
**ruleset_period_details(
|
||
active_rules,
|
||
period_from=period.date_from,
|
||
period_to=period.date_to,
|
||
),
|
||
},
|
||
) from exc
|
||
raise PayrollOperationError(
|
||
"CALCULATION_VALIDATION_FAILED",
|
||
"Ядро отклонило входные данные сотрудника.",
|
||
details={
|
||
"employee_id": rate.employee_id,
|
||
"issues": issues,
|
||
},
|
||
) from exc
|
||
requests.append(request)
|
||
results.append((rate, result))
|
||
|
||
return active_rules, requests, results
|
||
|
||
|
||
def _ensure_run_is_fresh(run: PayrollRun, period: PayrollPeriod) -> None:
|
||
"""Fail closed if approved inputs, rules or persisted snapshots changed."""
|
||
|
||
_, _, results = _calculate_current_period_inputs(period)
|
||
current_input_hash = _hash_items([result.input_hash for _, result in results])
|
||
current_result_hash = _hash_items([result.result_hash for _, result in results])
|
||
first_result = results[0][1]
|
||
statements = {
|
||
statement.employee_id: statement for statement in run.statements.all()
|
||
}
|
||
current_employee_ids = {rate.employee_id for rate, _ in results}
|
||
snapshots_match = set(statements) == current_employee_ids
|
||
if snapshots_match:
|
||
for rate, result in results:
|
||
statement = statements[rate.employee_id]
|
||
stored_warnings = statement.result_snapshot.get("warnings")
|
||
current_warnings = [warning.to_dict() for warning in result.warnings]
|
||
if (
|
||
statement.input_hash != result.input_hash
|
||
or statement.result_hash != result.result_hash
|
||
or statement.result_snapshot.get("result_hash") != result.result_hash
|
||
or statement.result_snapshot.get("reconciliation_hash")
|
||
!= result.reconciliation_hash
|
||
or stored_warnings != current_warnings
|
||
):
|
||
snapshots_match = False
|
||
break
|
||
if (
|
||
run.employee_count != len(results)
|
||
or run.input_hash != current_input_hash
|
||
or run.result_hash != current_result_hash
|
||
or run.ruleset_id != first_result.ruleset_id
|
||
or run.ruleset_version != first_result.ruleset_version
|
||
or run.ruleset_hash != first_result.ruleset_hash
|
||
or run.calculator_version != first_result.calculator_version
|
||
or not snapshots_match
|
||
):
|
||
_operation_error(
|
||
"RUN_STALE_RECALCULATION_REQUIRED",
|
||
"Утверждённые данные или правила изменились; выполните новый расчёт.",
|
||
run_id=run.pk,
|
||
)
|
||
|
||
|
||
@transaction.atomic
|
||
def calculate_period(
|
||
period_id: int,
|
||
*,
|
||
actor,
|
||
idempotency_key: uuid.UUID | None = None,
|
||
recalculation_reason: str = "",
|
||
calculator=None,
|
||
rules=None,
|
||
) -> PayrollRun:
|
||
"""Calculate a complete period and persist a new immutable revision."""
|
||
|
||
_require_permission(actor, "finance.calculate_payroll")
|
||
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
|
||
previous_current = None
|
||
if period.current_run_id is not None:
|
||
previous_current = PayrollRun.objects.select_for_update().get(
|
||
pk=period.current_run_id
|
||
)
|
||
if idempotency_key is not None:
|
||
existing = PayrollRun.objects.filter(idempotency_key=idempotency_key).first()
|
||
if existing is not None:
|
||
if existing.period_id != period_id:
|
||
_operation_error(
|
||
"IDEMPOTENCY_KEY_CONFLICT",
|
||
"Ключ уже использован для другого периода.",
|
||
)
|
||
return existing
|
||
if period.status == PayrollPeriodStatus.CLOSED:
|
||
_operation_error(
|
||
"PERIOD_CLOSED",
|
||
"Закрытый период нельзя пересчитать.",
|
||
period_id=period.pk,
|
||
)
|
||
if period.status in {
|
||
PayrollPeriodStatus.REVIEW,
|
||
PayrollPeriodStatus.APPROVED,
|
||
}:
|
||
_operation_error(
|
||
"PERIOD_UNDER_APPROVAL",
|
||
"Сначала верните текущую ревизию на исправление.",
|
||
period_id=period.pk,
|
||
)
|
||
if previous_current is not None and not recalculation_reason.strip():
|
||
_operation_error(
|
||
"RECALCULATION_REASON_REQUIRED",
|
||
"Для новой ревизии расчёта требуется основание.",
|
||
period_id=period.pk,
|
||
)
|
||
|
||
active_rules, requests, results = _calculate_current_period_inputs(
|
||
period,
|
||
calculator=calculator,
|
||
rules=rules,
|
||
)
|
||
employee_ids = [rate.employee_id for rate, _ in results]
|
||
|
||
next_revision = (
|
||
PayrollRun.objects.filter(period=period).aggregate(Max("revision"))[
|
||
"revision__max"
|
||
]
|
||
or 0
|
||
) + 1
|
||
aggregate_input_hash = _hash_items([result.input_hash for _, result in results])
|
||
aggregate_result_hash = _hash_items([result.result_hash for _, result in results])
|
||
gross_total = sum(
|
||
(result.totals.gross_after_adjustments for _, result in results),
|
||
Decimal("0"),
|
||
)
|
||
deduction_total = sum(
|
||
(result.totals.deduction_total for _, result in results),
|
||
Decimal("0"),
|
||
)
|
||
payable_total = sum(
|
||
(result.totals.payable for _, result in results),
|
||
Decimal("0"),
|
||
)
|
||
first_result = results[0][1]
|
||
run = PayrollRun(
|
||
period=period,
|
||
revision=next_revision,
|
||
status=PayrollRunStatus.CALCULATED,
|
||
supersedes=previous_current,
|
||
recalculation_reason=recalculation_reason.strip(),
|
||
idempotency_key=idempotency_key or uuid.uuid4(),
|
||
ruleset_id=first_result.ruleset_id,
|
||
ruleset_version=first_result.ruleset_version,
|
||
ruleset_hash=first_result.ruleset_hash,
|
||
calculator_version=first_result.calculator_version,
|
||
input_hash=aggregate_input_hash,
|
||
result_hash=aggregate_result_hash,
|
||
employee_count=len(results),
|
||
gross_total=gross_total,
|
||
deduction_total=deduction_total,
|
||
payable_total=payable_total,
|
||
requested_by=actor,
|
||
)
|
||
run.clean()
|
||
try:
|
||
with transaction.atomic():
|
||
run.save(force_insert=True)
|
||
except IntegrityError as exc:
|
||
existing = None
|
||
if idempotency_key is not None:
|
||
existing = PayrollRun.objects.filter(
|
||
idempotency_key=idempotency_key
|
||
).first()
|
||
if existing is not None and existing.period_id == period_id:
|
||
return existing
|
||
if existing is not None:
|
||
raise PayrollOperationError(
|
||
"IDEMPOTENCY_KEY_CONFLICT",
|
||
"Ключ уже использован для другого периода.",
|
||
) from exc
|
||
raise PayrollOperationError(
|
||
"CONCURRENT_CALCULATION_CONFLICT",
|
||
"Параллельный расчёт создал конфликт ревизий; повторите операцию.",
|
||
) from exc
|
||
|
||
prior_statements = {}
|
||
if previous_current is not None:
|
||
prior_statements = {
|
||
statement.employee_id: statement
|
||
for statement in PayrollStatement.objects.filter(
|
||
run=previous_current,
|
||
employee_id__in=employee_ids,
|
||
)
|
||
}
|
||
|
||
for request, (rate, result) in zip(requests, results, strict=True):
|
||
totals = result.totals
|
||
statement = PayrollStatement(
|
||
run=run,
|
||
employee=rate.employee,
|
||
supersedes=prior_statements.get(rate.employee_id),
|
||
employee_snapshot=employee_snapshot(rate.employee),
|
||
currency=result.currency,
|
||
point_delta=result.point_delta,
|
||
gross_before_adjustments=totals.gross_before_adjustments,
|
||
adjustment_total=totals.adjustment_total,
|
||
gross_total=totals.gross_after_adjustments,
|
||
deduction_total=totals.deduction_total,
|
||
net_pay=totals.net_pay,
|
||
payment_total=totals.payment_total,
|
||
payable=totals.payable,
|
||
input_hash=result.input_hash,
|
||
result_hash=result.result_hash,
|
||
input_snapshot=request.to_dict(),
|
||
result_snapshot=result.to_dict(),
|
||
)
|
||
statement.clean()
|
||
statement.save(force_insert=True)
|
||
PayrollStatementLine.objects.bulk_create(
|
||
[
|
||
PayrollStatementLine(
|
||
statement=statement,
|
||
position=position,
|
||
line_id=line.line_id,
|
||
code=line.code,
|
||
label=line.label,
|
||
kind=line.kind.value,
|
||
amount=line.amount,
|
||
source_ref=line.source_ref,
|
||
reason=line.reason,
|
||
source_period_from=(
|
||
line.source_period.start if line.source_period else None
|
||
),
|
||
source_period_to=(
|
||
line.source_period.end if line.source_period else None
|
||
),
|
||
is_retro=line.is_retro,
|
||
calculated=line.calculated,
|
||
)
|
||
for position, line in enumerate(result.lines, start=1)
|
||
]
|
||
)
|
||
|
||
if previous_current and previous_current.status != PayrollRunStatus.PUBLISHED:
|
||
previous_status = previous_current.status
|
||
previous_current.status = PayrollRunStatus.SUPERSEDED
|
||
previous_current.save(update_fields=["status"])
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.run_superseded",
|
||
instance=previous_current,
|
||
period=period,
|
||
before_hash=previous_current.result_hash,
|
||
after_hash=run.result_hash,
|
||
metadata={"previous_status": previous_status},
|
||
)
|
||
|
||
period.current_run = run
|
||
period.status = PayrollPeriodStatus.CALCULATED
|
||
period.lock_version += 1
|
||
period.save(update_fields=["current_run", "status", "lock_version", "updated_at"])
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.calculated",
|
||
instance=run,
|
||
period=period,
|
||
after_hash=run.result_hash,
|
||
metadata={
|
||
"revision": run.revision,
|
||
"employee_count": run.employee_count,
|
||
"is_recalculation": previous_current is not None,
|
||
},
|
||
)
|
||
return run
|
||
|
||
|
||
def _lock_current_run(run_id: int) -> tuple[PayrollRun, PayrollPeriod]:
|
||
run_reference = PayrollRun.objects.only("period_id").get(pk=run_id)
|
||
period = PayrollPeriod.objects.select_for_update().get(pk=run_reference.period_id)
|
||
run = PayrollRun.objects.select_for_update().get(pk=run_id)
|
||
if period.current_run_id != run.pk:
|
||
_operation_error(
|
||
"RUN_NOT_CURRENT",
|
||
"Операция разрешена только для текущей ревизии.",
|
||
run_id=run.pk,
|
||
)
|
||
return run, period
|
||
|
||
|
||
@transaction.atomic
|
||
def submit_run_for_review(run_id: int, *, actor) -> PayrollRun:
|
||
_require_permission(actor, "finance.calculate_payroll")
|
||
run, period = _lock_current_run(run_id)
|
||
if run.status != PayrollRunStatus.CALCULATED:
|
||
_operation_error("INVALID_RUN_STATE", "Расчёт уже передан дальше.")
|
||
_ensure_run_is_fresh(run, period)
|
||
reconciliation_errors = []
|
||
for statement in run.statements.only(
|
||
"employee_id",
|
||
"result_snapshot",
|
||
):
|
||
warning_codes = {
|
||
warning.get("code")
|
||
for warning in statement.result_snapshot.get("warnings", [])
|
||
}
|
||
if "LEGACY_EXPECTED_TOTAL_MISMATCH" in warning_codes:
|
||
reconciliation_errors.append(statement.employee_id)
|
||
if reconciliation_errors:
|
||
_operation_error(
|
||
"RECONCILIATION_MISMATCH",
|
||
"Контрольные итоги Excel не совпадают с расчётом.",
|
||
employee_ids=reconciliation_errors[:20],
|
||
)
|
||
run.status = PayrollRunStatus.REVIEW
|
||
run.save(update_fields=["status"])
|
||
period.status = PayrollPeriodStatus.REVIEW
|
||
period.save(update_fields=["status", "updated_at"])
|
||
_audit(actor=actor, action="payroll.review_requested", instance=run, period=period)
|
||
return run
|
||
|
||
|
||
@transaction.atomic
|
||
def return_run_for_correction(
|
||
run_id: int,
|
||
*,
|
||
actor,
|
||
reason: str,
|
||
) -> PayrollRun:
|
||
"""Return a reviewed run so the operator can create a new revision."""
|
||
|
||
if not reason.strip():
|
||
_operation_error(
|
||
"RETURN_REASON_REQUIRED",
|
||
"Укажите причину возврата расчёта.",
|
||
)
|
||
run, period = _lock_current_run(run_id)
|
||
if run.status == PayrollRunStatus.CALCULATED:
|
||
_require_permission(actor, "finance.calculate_payroll")
|
||
else:
|
||
_require_permission(actor, "finance.approve_payroll")
|
||
if run.status not in {
|
||
PayrollRunStatus.CALCULATED,
|
||
PayrollRunStatus.REVIEW,
|
||
PayrollRunStatus.APPROVED,
|
||
}:
|
||
_operation_error(
|
||
"INVALID_RUN_STATE",
|
||
"Вернуть можно расчёт на проверке или после утверждения.",
|
||
)
|
||
previous_status = run.status
|
||
run.status = PayrollRunStatus.RETURNED
|
||
run.save(update_fields=["status"])
|
||
period.status = PayrollPeriodStatus.CALCULATED
|
||
period.save(update_fields=["status", "updated_at"])
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.returned_for_correction",
|
||
instance=run,
|
||
period=period,
|
||
metadata={
|
||
"previous_status": previous_status,
|
||
"reason": reason.strip(),
|
||
},
|
||
)
|
||
return run
|
||
|
||
|
||
@transaction.atomic
|
||
def approve_run(run_id: int, *, actor) -> PayrollRun:
|
||
_require_permission(actor, "finance.approve_payroll")
|
||
run, period = _lock_current_run(run_id)
|
||
if run.status != PayrollRunStatus.REVIEW:
|
||
_operation_error("INVALID_RUN_STATE", "Расчёт не находится на проверке.")
|
||
_ensure_run_is_fresh(run, period)
|
||
self_approval_overridden = run.requested_by_id == actor.pk
|
||
if self_approval_overridden and not can_self_approve_payroll(actor):
|
||
_operation_error(
|
||
"SELF_APPROVAL_FORBIDDEN",
|
||
"Автор расчёта не может сам его утвердить.",
|
||
)
|
||
run.status = PayrollRunStatus.APPROVED
|
||
run.approved_by = actor
|
||
run.approved_at = timezone.now()
|
||
run.self_approval_overridden = self_approval_overridden
|
||
run.save(
|
||
update_fields=[
|
||
"status",
|
||
"approved_by",
|
||
"approved_at",
|
||
"self_approval_overridden",
|
||
]
|
||
)
|
||
period.status = PayrollPeriodStatus.APPROVED
|
||
period.save(update_fields=["status", "updated_at"])
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.approved",
|
||
instance=run,
|
||
period=period,
|
||
metadata=_approval_audit_metadata(
|
||
actor,
|
||
self_approval=self_approval_overridden,
|
||
),
|
||
)
|
||
return run
|
||
|
||
|
||
@transaction.atomic
|
||
def publish_run(run_id: int, *, actor) -> PayrollRun:
|
||
_require_permission(actor, "finance.publish_payroll")
|
||
run, period = _lock_current_run(run_id)
|
||
if run.status != PayrollRunStatus.APPROVED:
|
||
_operation_error("INVALID_RUN_STATE", "Расчёт ещё не утверждён.")
|
||
_ensure_run_is_fresh(run, period)
|
||
|
||
prior_published_runs = list(
|
||
PayrollRun.objects.select_for_update()
|
||
.filter(
|
||
period=period,
|
||
status=PayrollRunStatus.PUBLISHED,
|
||
)
|
||
.exclude(pk=run.pk)
|
||
)
|
||
for prior_run in prior_published_runs:
|
||
prior_run.status = PayrollRunStatus.SUPERSEDED
|
||
prior_run.save(update_fields=["status"])
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.run_superseded_on_publish",
|
||
instance=prior_run,
|
||
period=period,
|
||
before_hash=prior_run.result_hash,
|
||
after_hash=run.result_hash,
|
||
metadata={"replacement_run_id": run.pk},
|
||
)
|
||
now = timezone.now()
|
||
run.status = PayrollRunStatus.PUBLISHED
|
||
run.published_by = actor
|
||
run.published_at = now
|
||
run.save(update_fields=["status", "published_by", "published_at"])
|
||
period.status = PayrollPeriodStatus.PUBLISHED
|
||
period.save(update_fields=["status", "updated_at"])
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.published",
|
||
instance=run,
|
||
period=period,
|
||
after_hash=run.result_hash,
|
||
metadata={"revision": run.revision, "employee_count": run.employee_count},
|
||
)
|
||
return run
|
||
|
||
|
||
@transaction.atomic
|
||
def close_period(period_id: int, *, actor) -> PayrollPeriod:
|
||
_require_permission(actor, "finance.publish_payroll")
|
||
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
|
||
if period.status != PayrollPeriodStatus.PUBLISHED:
|
||
_operation_error(
|
||
"INVALID_PERIOD_STATE",
|
||
"Закрыть можно только опубликованный период.",
|
||
)
|
||
period.status = PayrollPeriodStatus.CLOSED
|
||
period.save(update_fields=["status", "updated_at"])
|
||
_audit(actor=actor, action="payroll.period_closed", instance=period, period=period)
|
||
return period
|
||
|
||
|
||
@transaction.atomic
|
||
def bulk_set_point_rate(
|
||
period_id: int,
|
||
*,
|
||
actor,
|
||
employee_ids: list[int],
|
||
mode: str = "fixed",
|
||
point_rate: Decimal | None = None,
|
||
reason: str,
|
||
) -> dict[str, object]:
|
||
"""Set a fixed excess price or clear it to use the dynamic in-norm price."""
|
||
|
||
_require_permission(actor, "finance.manage_payroll_inputs")
|
||
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
|
||
_ensure_period_accepts_approvals(period)
|
||
|
||
selected_employee_ids = sorted(set(employee_ids))
|
||
rates = list(
|
||
EmployeePayRate.objects.select_for_update(of=("self",))
|
||
.filter(
|
||
employee_id__in=selected_employee_ids,
|
||
rate_code=base_rate_code(),
|
||
effective_from__lte=period.date_to,
|
||
)
|
||
.exclude(status=ApprovalStatus.VOIDED)
|
||
.select_related("employee", "replaces")
|
||
.order_by("employee_id", "effective_from", "revision", "pk")
|
||
)
|
||
rates_by_employee: dict[int, list[EmployeePayRate]] = defaultdict(list)
|
||
for rate in rates:
|
||
rates_by_employee[rate.employee_id].append(rate)
|
||
|
||
summary = {
|
||
"selected_employees": len(selected_employee_ids),
|
||
"updated_drafts": 0,
|
||
"created_revisions": 0,
|
||
"unchanged": 0,
|
||
"skipped": 0,
|
||
}
|
||
changed_records: list[EmployeePayRate] = []
|
||
|
||
for employee_id in selected_employee_ids:
|
||
employee_rates = rates_by_employee.get(employee_id, [])
|
||
period_start_dates = [
|
||
rate.effective_from
|
||
for rate in employee_rates
|
||
if rate.effective_from <= period.date_from
|
||
]
|
||
relevant_dates = set()
|
||
if period_start_dates:
|
||
relevant_dates.add(max(period_start_dates))
|
||
relevant_dates.update(
|
||
rate.effective_from
|
||
for rate in employee_rates
|
||
if period.date_from < rate.effective_from <= period.date_to
|
||
)
|
||
|
||
if not relevant_dates:
|
||
summary["skipped"] += 1
|
||
continue
|
||
|
||
for effective_from in sorted(relevant_dates):
|
||
current = max(
|
||
(
|
||
rate
|
||
for rate in employee_rates
|
||
if rate.effective_from == effective_from
|
||
),
|
||
key=lambda rate: (rate.revision, rate.pk),
|
||
)
|
||
if mode == "in_norm":
|
||
resolved_point_rate = None
|
||
else:
|
||
resolved_point_rate = point_rate
|
||
if mode == "fixed" and resolved_point_rate is None:
|
||
_operation_error(
|
||
"POINT_RATE_REQUIRED",
|
||
"Не задана фиксированная цена балла сверх нормы.",
|
||
)
|
||
if current.point_rate == resolved_point_rate:
|
||
summary["unchanged"] += 1
|
||
continue
|
||
|
||
if current.status == ApprovalStatus.DRAFT:
|
||
if (
|
||
not has_simple_admin_access(actor)
|
||
and current.created_by_id != actor.pk
|
||
):
|
||
summary["skipped"] += 1
|
||
continue
|
||
previous_point_rate = current.point_rate
|
||
current.point_rate = resolved_point_rate
|
||
current._expected_lock_version = current.lock_version
|
||
try:
|
||
current.full_clean()
|
||
current.save()
|
||
except ValidationError as exc:
|
||
_operation_error(
|
||
"INPUT_VALIDATION_FAILED",
|
||
"Цена балла не прошла проверку.",
|
||
errors=(
|
||
exc.message_dict
|
||
if hasattr(exc, "message_dict")
|
||
else exc.messages
|
||
),
|
||
)
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.rate_bulk_point_rate_updated",
|
||
instance=current,
|
||
period=period,
|
||
metadata={
|
||
"previous_point_rate": (
|
||
str(previous_point_rate)
|
||
if previous_point_rate is not None
|
||
else None
|
||
),
|
||
"point_rate": (
|
||
str(resolved_point_rate)
|
||
if resolved_point_rate is not None
|
||
else None
|
||
),
|
||
"point_rate_mode": mode,
|
||
"reason": reason,
|
||
},
|
||
)
|
||
summary["updated_drafts"] += 1
|
||
changed_records.append(current)
|
||
continue
|
||
|
||
if current.status != ApprovalStatus.APPROVED:
|
||
summary["skipped"] += 1
|
||
continue
|
||
|
||
revision = EmployeePayRate(
|
||
employee=current.employee,
|
||
rate_code=current.rate_code,
|
||
amount=current.amount,
|
||
point_rate=resolved_point_rate,
|
||
currency=current.currency,
|
||
effective_from=current.effective_from,
|
||
revision=current.revision + 1,
|
||
replaces=current,
|
||
reason=reason,
|
||
status=ApprovalStatus.DRAFT,
|
||
source=InputSource.MANUAL,
|
||
created_by=actor,
|
||
)
|
||
try:
|
||
revision.full_clean()
|
||
revision.save(force_insert=True)
|
||
except ValidationError as exc:
|
||
_operation_error(
|
||
"INPUT_VALIDATION_FAILED",
|
||
"Новая версия ставки не прошла проверку.",
|
||
errors=(
|
||
exc.message_dict
|
||
if hasattr(exc, "message_dict")
|
||
else exc.messages
|
||
),
|
||
)
|
||
except IntegrityError:
|
||
_operation_error(
|
||
"RATE_REVISION_CONFLICT",
|
||
("Для одной из ставок уже создана новая версия; обновите данные."),
|
||
rate_id=current.pk,
|
||
)
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.rate_bulk_point_rate_revision_created",
|
||
instance=revision,
|
||
period=period,
|
||
metadata={
|
||
"replaces_id": current.pk,
|
||
"previous_point_rate": (
|
||
str(current.point_rate)
|
||
if current.point_rate is not None
|
||
else None
|
||
),
|
||
"point_rate": (
|
||
str(resolved_point_rate)
|
||
if resolved_point_rate is not None
|
||
else None
|
||
),
|
||
"point_rate_mode": mode,
|
||
"reason": reason,
|
||
},
|
||
)
|
||
summary["created_revisions"] += 1
|
||
changed_records.append(revision)
|
||
|
||
return {
|
||
"mode": mode,
|
||
"point_rate": point_rate,
|
||
"summary": summary,
|
||
"records": changed_records,
|
||
}
|
||
|
||
|
||
@transaction.atomic
|
||
def bulk_set_pay_rate(
|
||
period_id: int,
|
||
*,
|
||
actor,
|
||
employee_ids: list[int],
|
||
amount: Decimal,
|
||
effective_from,
|
||
reason: str,
|
||
) -> dict[str, object]:
|
||
"""Create or revise one base rate for each selected employee."""
|
||
|
||
_require_permission(actor, "finance.manage_payroll_inputs")
|
||
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
|
||
_ensure_period_accepts_approvals(period)
|
||
if not period.date_from <= effective_from <= period.date_to:
|
||
_operation_error(
|
||
"RATE_DATE_OUTSIDE_PERIOD",
|
||
"Дата начала ставки должна входить в выбранный расчётный период.",
|
||
period_date_from=str(period.date_from),
|
||
period_date_to=str(period.date_to),
|
||
)
|
||
|
||
selected_employee_ids = sorted(set(employee_ids))
|
||
employees = {
|
||
employee.pk: employee
|
||
for employee in Employee.objects.filter(
|
||
pk__in=selected_employee_ids,
|
||
is_active=True,
|
||
).order_by("pk")
|
||
}
|
||
rates = list(
|
||
EmployeePayRate.objects.select_for_update(of=("self",))
|
||
.filter(
|
||
employee_id__in=employees,
|
||
rate_code=base_rate_code(),
|
||
effective_from__lte=effective_from,
|
||
)
|
||
.select_related("employee", "replaces")
|
||
.order_by("employee_id", "effective_from", "revision", "pk")
|
||
)
|
||
rates_by_employee: dict[int, list[EmployeePayRate]] = defaultdict(list)
|
||
for rate in rates:
|
||
rates_by_employee[rate.employee_id].append(rate)
|
||
|
||
summary = {
|
||
"selected_employees": len(selected_employee_ids),
|
||
"created_drafts": 0,
|
||
"updated_drafts": 0,
|
||
"created_revisions": 0,
|
||
"unchanged": 0,
|
||
"skipped": 0,
|
||
}
|
||
changed_records: list[EmployeePayRate] = []
|
||
|
||
for employee_id in selected_employee_ids:
|
||
employee = employees.get(employee_id)
|
||
if employee is None:
|
||
summary["skipped"] += 1
|
||
continue
|
||
|
||
employee_rates = rates_by_employee.get(employee_id, [])
|
||
exact_rates = [
|
||
rate for rate in employee_rates if rate.effective_from == effective_from
|
||
]
|
||
current_rates = [
|
||
rate for rate in exact_rates if rate.status != ApprovalStatus.VOIDED
|
||
]
|
||
current = (
|
||
max(current_rates, key=lambda rate: (rate.revision, rate.pk))
|
||
if current_rates
|
||
else None
|
||
)
|
||
|
||
if current is not None and current.amount == amount:
|
||
summary["unchanged"] += 1
|
||
continue
|
||
|
||
if current is not None and current.status == ApprovalStatus.DRAFT:
|
||
if not has_simple_admin_access(actor) and current.created_by_id != actor.pk:
|
||
summary["skipped"] += 1
|
||
continue
|
||
previous_amount = current.amount
|
||
current.amount = amount
|
||
current._expected_lock_version = current.lock_version
|
||
try:
|
||
current.full_clean()
|
||
current.save()
|
||
except ValidationError as exc:
|
||
_operation_error(
|
||
"INPUT_VALIDATION_FAILED",
|
||
"Ставка не прошла проверку.",
|
||
errors=(
|
||
exc.message_dict
|
||
if hasattr(exc, "message_dict")
|
||
else exc.messages
|
||
),
|
||
)
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.rate_bulk_amount_updated",
|
||
instance=current,
|
||
period=period,
|
||
metadata={
|
||
"previous_amount": str(previous_amount),
|
||
"amount": str(amount),
|
||
"reason": reason,
|
||
},
|
||
)
|
||
summary["updated_drafts"] += 1
|
||
changed_records.append(current)
|
||
continue
|
||
|
||
if current is not None and current.status == ApprovalStatus.APPROVED:
|
||
rate = EmployeePayRate(
|
||
employee=current.employee,
|
||
rate_code=current.rate_code,
|
||
amount=amount,
|
||
point_rate=current.point_rate,
|
||
currency=current.currency,
|
||
effective_from=current.effective_from,
|
||
revision=current.revision + 1,
|
||
replaces=current,
|
||
reason=reason,
|
||
status=ApprovalStatus.DRAFT,
|
||
source=InputSource.MANUAL,
|
||
created_by=actor,
|
||
)
|
||
action = "payroll.rate_bulk_amount_revision_created"
|
||
metadata = {
|
||
"replaces_id": current.pk,
|
||
"previous_amount": str(current.amount),
|
||
"amount": str(amount),
|
||
"reason": reason,
|
||
}
|
||
summary_key = "created_revisions"
|
||
else:
|
||
previous_rates = [
|
||
rate
|
||
for rate in employee_rates
|
||
if rate.status != ApprovalStatus.VOIDED
|
||
and rate.effective_from < effective_from
|
||
]
|
||
previous = (
|
||
max(
|
||
previous_rates,
|
||
key=lambda rate: (
|
||
rate.effective_from,
|
||
rate.revision,
|
||
rate.pk,
|
||
),
|
||
)
|
||
if previous_rates
|
||
else None
|
||
)
|
||
rate = EmployeePayRate(
|
||
employee=employee,
|
||
rate_code=base_rate_code(),
|
||
amount=amount,
|
||
point_rate=(previous.point_rate if previous else None),
|
||
currency=period.currency,
|
||
effective_from=effective_from,
|
||
revision=max((item.revision for item in exact_rates), default=0) + 1,
|
||
reason=reason,
|
||
status=ApprovalStatus.DRAFT,
|
||
source=InputSource.MANUAL,
|
||
created_by=actor,
|
||
)
|
||
action = "payroll.rate_bulk_created"
|
||
metadata = {
|
||
"amount": str(amount),
|
||
"point_rate": (
|
||
str(rate.point_rate) if rate.point_rate is not None else None
|
||
),
|
||
"reason": reason,
|
||
}
|
||
summary_key = "created_drafts"
|
||
|
||
try:
|
||
rate.full_clean()
|
||
rate.save(force_insert=True)
|
||
except ValidationError as exc:
|
||
_operation_error(
|
||
"INPUT_VALIDATION_FAILED",
|
||
"Новая ставка не прошла проверку.",
|
||
errors=(
|
||
exc.message_dict if hasattr(exc, "message_dict") else exc.messages
|
||
),
|
||
)
|
||
except IntegrityError:
|
||
_operation_error(
|
||
"RATE_BULK_CONFLICT",
|
||
"Одна из ставок уже изменена; обновите экран и повторите.",
|
||
employee_id=employee_id,
|
||
)
|
||
_audit(
|
||
actor=actor,
|
||
action=action,
|
||
instance=rate,
|
||
period=period,
|
||
metadata=metadata,
|
||
)
|
||
summary[summary_key] += 1
|
||
changed_records.append(rate)
|
||
|
||
return {
|
||
"amount": amount,
|
||
"effective_from": effective_from,
|
||
"summary": summary,
|
||
"records": changed_records,
|
||
}
|
||
|
||
|
||
@transaction.atomic
|
||
def bulk_set_target_points(
|
||
period_id: int,
|
||
*,
|
||
actor,
|
||
employee_ids: list[int],
|
||
mode: str,
|
||
target_points: Decimal | None,
|
||
reason: str,
|
||
) -> dict[str, object]:
|
||
"""Set one target for selected employees or restore their automatic target."""
|
||
|
||
_require_permission(actor, "finance.manage_payroll_inputs")
|
||
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
|
||
_ensure_period_accepts_approvals(period)
|
||
selected_employee_ids = sorted(set(employee_ids))
|
||
employees = {
|
||
employee.pk: employee
|
||
for employee in Employee.objects.filter(
|
||
pk__in=selected_employee_ids,
|
||
is_active=True,
|
||
)
|
||
.select_related("position")
|
||
.order_by("pk")
|
||
}
|
||
records = list(
|
||
PayrollWorkRecord.objects.select_for_update(of=("self",))
|
||
.filter(period=period, employee_id__in=employees)
|
||
.select_related("employee", "replaces")
|
||
.order_by("employee_id", "revision", "pk")
|
||
)
|
||
records_by_employee: dict[int, list[PayrollWorkRecord]] = defaultdict(list)
|
||
for record in records:
|
||
records_by_employee[record.employee_id].append(record)
|
||
|
||
summary = {
|
||
"selected_employees": len(selected_employee_ids),
|
||
"created_drafts": 0,
|
||
"updated_drafts": 0,
|
||
"created_revisions": 0,
|
||
"unchanged": 0,
|
||
"skipped": 0,
|
||
}
|
||
changed_records: list[PayrollWorkRecord] = []
|
||
|
||
for employee_id in selected_employee_ids:
|
||
employee = employees.get(employee_id)
|
||
if employee is None:
|
||
summary["skipped"] += 1
|
||
continue
|
||
employee_records = records_by_employee.get(employee_id, [])
|
||
active_records = [
|
||
record
|
||
for record in employee_records
|
||
if record.status != ApprovalStatus.VOIDED
|
||
]
|
||
current = (
|
||
max(active_records, key=lambda record: (record.revision, record.pk))
|
||
if active_records
|
||
else None
|
||
)
|
||
automatic = mode == "automatic"
|
||
resolved_target = (
|
||
calculate_period_target_points(period, employee=employee)[0]
|
||
if automatic
|
||
else target_points
|
||
)
|
||
if resolved_target is None or resolved_target <= 0:
|
||
_operation_error(
|
||
"TARGET_POINTS_REQUIRED",
|
||
"Не удалось определить положительную норму баллов.",
|
||
employee_id=employee_id,
|
||
)
|
||
if current is None and automatic:
|
||
# The table already calculates this value dynamically when no work
|
||
# record exists, so an empty automatic record would only add noise.
|
||
summary["unchanged"] += 1
|
||
continue
|
||
if (
|
||
current is not None
|
||
and current.target_points == resolved_target
|
||
and current.target_points_overridden is not automatic
|
||
):
|
||
summary["unchanged"] += 1
|
||
continue
|
||
|
||
if current is not None and current.status == ApprovalStatus.DRAFT:
|
||
if not has_simple_admin_access(actor) and current.created_by_id != actor.pk:
|
||
summary["skipped"] += 1
|
||
continue
|
||
previous_target = current.target_points
|
||
previous_automatic = not current.target_points_overridden
|
||
current.target_points = resolved_target
|
||
current.target_points_overridden = not automatic
|
||
current.expected_point_amount = None
|
||
current.expected_gross = None
|
||
current.expected_recalculated_gross = None
|
||
current.expected_payable = None
|
||
current.reason = reason
|
||
current.source = InputSource.MANUAL
|
||
current.source_ref = ""
|
||
current._expected_lock_version = current.lock_version
|
||
try:
|
||
current.full_clean()
|
||
current.save()
|
||
except ValidationError as exc:
|
||
_operation_error(
|
||
"INPUT_VALIDATION_FAILED",
|
||
"Норма баллов не прошла проверку.",
|
||
errors=(
|
||
exc.message_dict
|
||
if hasattr(exc, "message_dict")
|
||
else exc.messages
|
||
),
|
||
)
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.work_bulk_target_updated",
|
||
instance=current,
|
||
period=period,
|
||
metadata={
|
||
"previous_target_points": str(previous_target),
|
||
"previous_automatic": previous_automatic,
|
||
"target_points": str(resolved_target),
|
||
"automatic": automatic,
|
||
"reason": reason,
|
||
},
|
||
)
|
||
summary["updated_drafts"] += 1
|
||
changed_records.append(current)
|
||
continue
|
||
|
||
if current is not None and current.status == ApprovalStatus.APPROVED:
|
||
if PayrollWorkRecord.objects.filter(replaces=current).exists():
|
||
summary["skipped"] += 1
|
||
continue
|
||
record = PayrollWorkRecord(
|
||
period=period,
|
||
employee=employee,
|
||
target_points=resolved_target,
|
||
target_points_overridden=not automatic,
|
||
actual_points=current.actual_points,
|
||
revision=current.revision + 1,
|
||
replaces=current,
|
||
reason=reason,
|
||
status=ApprovalStatus.DRAFT,
|
||
source=InputSource.MANUAL,
|
||
created_by=actor,
|
||
)
|
||
action = "payroll.work_bulk_target_revision_created"
|
||
metadata = {
|
||
"replaces_id": current.pk,
|
||
"previous_target_points": str(current.target_points),
|
||
"target_points": str(resolved_target),
|
||
"automatic": automatic,
|
||
"reason": reason,
|
||
}
|
||
summary_key = "created_revisions"
|
||
elif current is None:
|
||
record = PayrollWorkRecord(
|
||
period=period,
|
||
employee=employee,
|
||
target_points=resolved_target,
|
||
target_points_overridden=not automatic,
|
||
actual_points=Decimal("0"),
|
||
revision=max(
|
||
(record.revision for record in employee_records),
|
||
default=0,
|
||
)
|
||
+ 1,
|
||
reason=reason,
|
||
status=ApprovalStatus.DRAFT,
|
||
source=InputSource.MANUAL,
|
||
created_by=actor,
|
||
)
|
||
action = "payroll.work_bulk_target_created"
|
||
metadata = {
|
||
"target_points": str(resolved_target),
|
||
"automatic": automatic,
|
||
"reason": reason,
|
||
}
|
||
summary_key = "created_drafts"
|
||
else:
|
||
summary["skipped"] += 1
|
||
continue
|
||
|
||
try:
|
||
record.full_clean()
|
||
record.save(force_insert=True)
|
||
except ValidationError as exc:
|
||
_operation_error(
|
||
"INPUT_VALIDATION_FAILED",
|
||
"Норма баллов не прошла проверку.",
|
||
errors=(
|
||
exc.message_dict if hasattr(exc, "message_dict") else exc.messages
|
||
),
|
||
)
|
||
except IntegrityError:
|
||
_operation_error(
|
||
"WORK_BULK_CONFLICT",
|
||
"Одна из записей выработки уже изменена; обновите экран и повторите.",
|
||
employee_id=employee_id,
|
||
)
|
||
_audit(
|
||
actor=actor,
|
||
action=action,
|
||
instance=record,
|
||
period=period,
|
||
metadata=metadata,
|
||
)
|
||
summary[summary_key] += 1
|
||
changed_records.append(record)
|
||
|
||
return {
|
||
"mode": mode,
|
||
"target_points": target_points,
|
||
"summary": summary,
|
||
"records": changed_records,
|
||
}
|
||
|
||
|
||
def sync_daily_work_record(
|
||
*,
|
||
period: PayrollPeriod,
|
||
employee,
|
||
actor,
|
||
channel: str = "employee_api",
|
||
) -> PayrollWorkRecord:
|
||
totals = PayrollDailyWorkEntry.objects.filter(
|
||
period=period,
|
||
employee=employee,
|
||
).aggregate(
|
||
actual_points=Sum("actual_points"),
|
||
)
|
||
actual_points = totals["actual_points"] or Decimal("0")
|
||
|
||
records = list(
|
||
PayrollWorkRecord.objects.select_for_update()
|
||
.filter(period=period, employee=employee)
|
||
.order_by("revision", "pk")
|
||
)
|
||
active_records = [
|
||
record for record in records if record.status != ApprovalStatus.VOIDED
|
||
]
|
||
current = (
|
||
max(active_records, key=lambda record: (record.revision, record.pk))
|
||
if active_records
|
||
else None
|
||
)
|
||
calculated_target, workdays_count, target_source = calculate_period_target_points(
|
||
period, employee=employee
|
||
)
|
||
uses_saved_target = current is not None and current.target_points_overridden
|
||
target_points = current.target_points if uses_saved_target else calculated_target
|
||
if target_points <= 0:
|
||
_operation_error(
|
||
"DAILY_WORK_EMPTY",
|
||
"Для периода не удалось определить положительную норму выработки.",
|
||
)
|
||
if (
|
||
current is not None
|
||
and current.target_points == target_points
|
||
and current.actual_points == actual_points
|
||
):
|
||
return current
|
||
|
||
reason = "Агрегация ежедневной выработки сотрудника"
|
||
if current is not None and current.status == ApprovalStatus.DRAFT:
|
||
previous_target = current.target_points
|
||
previous_actual = current.actual_points
|
||
current.target_points = target_points
|
||
current.actual_points = actual_points
|
||
current.expected_point_amount = None
|
||
current.expected_gross = None
|
||
current.expected_recalculated_gross = None
|
||
current.expected_payable = None
|
||
if not uses_saved_target:
|
||
current.reason = reason
|
||
current.source = InputSource.API
|
||
current.source_ref = (
|
||
f"daily-work:{period.pk}:{employee.pk}:r{current.revision}"
|
||
)
|
||
current.target_points_overridden = False
|
||
current._expected_lock_version = current.lock_version
|
||
try:
|
||
current.full_clean()
|
||
current.save()
|
||
except ValidationError as exc:
|
||
_operation_error(
|
||
"INPUT_VALIDATION_FAILED",
|
||
"Итоговая выработка не прошла проверку.",
|
||
errors=(
|
||
exc.message_dict if hasattr(exc, "message_dict") else exc.messages
|
||
),
|
||
)
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.daily_work_aggregate_updated",
|
||
instance=current,
|
||
period=period,
|
||
metadata={
|
||
"channel": channel,
|
||
"previous_target_points": str(previous_target),
|
||
"previous_actual_points": str(previous_actual),
|
||
"target_points": str(target_points),
|
||
"actual_points": str(actual_points),
|
||
"target_source": (
|
||
"saved_record" if uses_saved_target else target_source
|
||
),
|
||
"workdays_count": workdays_count,
|
||
},
|
||
)
|
||
return current
|
||
|
||
if current is not None and current.status == ApprovalStatus.APPROVED:
|
||
if PayrollWorkRecord.objects.filter(replaces=current).exists():
|
||
_operation_error(
|
||
"WORK_REVISION_CONFLICT",
|
||
"Для итоговой выработки уже создавалась новая версия.",
|
||
record_id=current.pk,
|
||
)
|
||
revision = current.revision + 1
|
||
record = PayrollWorkRecord(
|
||
period=period,
|
||
employee=employee,
|
||
target_points=target_points,
|
||
target_points_overridden=uses_saved_target,
|
||
actual_points=actual_points,
|
||
revision=revision,
|
||
replaces=current,
|
||
reason=reason,
|
||
status=ApprovalStatus.DRAFT,
|
||
source=(current.source if uses_saved_target else InputSource.API),
|
||
source_ref=f"daily-work:{period.pk}:{employee.pk}:r{revision}",
|
||
created_by=actor,
|
||
)
|
||
action = "payroll.daily_work_aggregate_revision_created"
|
||
metadata = {
|
||
"channel": channel,
|
||
"replaces_id": current.pk,
|
||
"target_points": str(target_points),
|
||
"actual_points": str(actual_points),
|
||
"target_source": ("saved_record" if uses_saved_target else target_source),
|
||
"workdays_count": workdays_count,
|
||
}
|
||
elif current is None:
|
||
revision = max((record.revision for record in records), default=0) + 1
|
||
record = PayrollWorkRecord(
|
||
period=period,
|
||
employee=employee,
|
||
target_points=target_points,
|
||
target_points_overridden=False,
|
||
actual_points=actual_points,
|
||
revision=revision,
|
||
reason=reason,
|
||
status=ApprovalStatus.DRAFT,
|
||
source=InputSource.API,
|
||
source_ref=f"daily-work:{period.pk}:{employee.pk}:r{revision}",
|
||
created_by=actor,
|
||
)
|
||
action = "payroll.daily_work_aggregate_created"
|
||
metadata = {
|
||
"channel": channel,
|
||
"target_points": str(target_points),
|
||
"actual_points": str(actual_points),
|
||
"target_source": target_source,
|
||
"workdays_count": workdays_count,
|
||
}
|
||
else:
|
||
_operation_error(
|
||
"WORK_RECORD_STATE_INVALID",
|
||
"Итоговую запись выработки нельзя изменить.",
|
||
record_status=current.status,
|
||
)
|
||
|
||
try:
|
||
record.full_clean()
|
||
record.save(force_insert=True)
|
||
except ValidationError as exc:
|
||
_operation_error(
|
||
"INPUT_VALIDATION_FAILED",
|
||
"Итоговая выработка не прошла проверку.",
|
||
errors=(exc.message_dict if hasattr(exc, "message_dict") else exc.messages),
|
||
)
|
||
except IntegrityError:
|
||
_operation_error(
|
||
"WORK_RECORD_CONFLICT",
|
||
"Итоговая выработка уже изменена; обновите данные и повторите.",
|
||
)
|
||
_audit(
|
||
actor=actor,
|
||
action=action,
|
||
instance=record,
|
||
period=period,
|
||
metadata=metadata,
|
||
)
|
||
return record
|
||
|
||
|
||
@transaction.atomic
|
||
def save_own_daily_work_entry(
|
||
period_id: int,
|
||
*,
|
||
actor,
|
||
work_date,
|
||
actual_points: Decimal,
|
||
note: str = "",
|
||
expected_lock_version: int | None = None,
|
||
) -> tuple[PayrollDailyWorkEntry, PayrollWorkRecord, str]:
|
||
"""Upsert one daily entry and refresh the employee's period aggregate."""
|
||
|
||
if actor is None or not getattr(actor, "is_authenticated", False):
|
||
_operation_error(
|
||
"AUTHENTICATION_REQUIRED",
|
||
"Для сохранения выработки необходимо войти в систему.",
|
||
)
|
||
period = (
|
||
PayrollPeriod.objects.select_for_update(of=("self",))
|
||
.select_related("current_run")
|
||
.get(pk=period_id)
|
||
)
|
||
if period.status != PayrollPeriodStatus.OPEN:
|
||
_operation_error(
|
||
"WORK_PERIOD_LOCKED",
|
||
"Выработка за этот период уже закрыта для изменений.",
|
||
period_status=period.status,
|
||
)
|
||
if not period.date_from <= work_date <= period.date_to:
|
||
_operation_error(
|
||
"WORK_DATE_OUTSIDE_PERIOD",
|
||
"Дата выработки должна входить в выбранный расчётный период.",
|
||
period_date_from=str(period.date_from),
|
||
period_date_to=str(period.date_to),
|
||
)
|
||
if work_date > timezone.localdate():
|
||
_operation_error(
|
||
"WORK_DATE_IN_FUTURE",
|
||
"Нельзя указать выработку за будущую дату.",
|
||
)
|
||
|
||
entry = (
|
||
PayrollDailyWorkEntry.objects.select_for_update()
|
||
.filter(period=period, employee=actor, work_date=work_date)
|
||
.first()
|
||
)
|
||
note = note.strip()
|
||
if entry is not None:
|
||
if expected_lock_version is None:
|
||
_operation_error(
|
||
"DAILY_WORK_VERSION_REQUIRED",
|
||
"Обновите данные и повторите сохранение.",
|
||
)
|
||
if entry.lock_version != expected_lock_version:
|
||
_operation_error(
|
||
"STALE_DAILY_WORK_ENTRY",
|
||
"Запись уже изменена; обновите данные и повторите.",
|
||
expected_lock_version=expected_lock_version,
|
||
actual_lock_version=entry.lock_version,
|
||
)
|
||
if entry.actual_points == actual_points and entry.note == note:
|
||
aggregate = sync_daily_work_record(
|
||
period=period,
|
||
employee=actor,
|
||
actor=actor,
|
||
)
|
||
return entry, aggregate, "unchanged"
|
||
|
||
previous_actual = entry.actual_points
|
||
entry.actual_points = actual_points
|
||
entry.note = note
|
||
entry.lock_version += 1
|
||
operation = "updated"
|
||
action = "payroll.daily_work_entry_updated"
|
||
metadata = {
|
||
"channel": "employee_api",
|
||
"work_date": str(work_date),
|
||
"previous_actual_points": str(previous_actual),
|
||
"target_points": str(entry.target_points),
|
||
"actual_points": str(actual_points),
|
||
}
|
||
else:
|
||
target_points = PayrollWorkSettings.get_daily_target_points()
|
||
entry = PayrollDailyWorkEntry(
|
||
period=period,
|
||
employee=actor,
|
||
work_date=work_date,
|
||
target_points=target_points,
|
||
actual_points=actual_points,
|
||
note=note,
|
||
)
|
||
operation = "created"
|
||
action = "payroll.daily_work_entry_created"
|
||
metadata = {
|
||
"channel": "employee_api",
|
||
"work_date": str(work_date),
|
||
"target_points": str(target_points),
|
||
"actual_points": str(actual_points),
|
||
}
|
||
|
||
try:
|
||
entry.full_clean()
|
||
entry.save()
|
||
except ValidationError as exc:
|
||
_operation_error(
|
||
"INPUT_VALIDATION_FAILED",
|
||
"Ежедневная выработка не прошла проверку.",
|
||
errors=(exc.message_dict if hasattr(exc, "message_dict") else exc.messages),
|
||
)
|
||
except IntegrityError:
|
||
_operation_error(
|
||
"DAILY_WORK_ENTRY_CONFLICT",
|
||
"Запись за эту дату уже создана; обновите данные.",
|
||
)
|
||
_audit(
|
||
actor=actor,
|
||
action=action,
|
||
instance=entry,
|
||
period=period,
|
||
metadata=metadata,
|
||
)
|
||
aggregate = sync_daily_work_record(
|
||
period=period,
|
||
employee=actor,
|
||
actor=actor,
|
||
)
|
||
return entry, aggregate, operation
|
||
|
||
|
||
def _approve_record(
|
||
record,
|
||
*,
|
||
actor,
|
||
object_label: str,
|
||
expected_lock_version: int,
|
||
):
|
||
if (
|
||
not isinstance(expected_lock_version, int)
|
||
or isinstance(expected_lock_version, bool)
|
||
or expected_lock_version < 0
|
||
):
|
||
_operation_error(
|
||
"APPROVAL_VERSION_REQUIRED",
|
||
"Укажите версию черновика, которую проверил утверждающий.",
|
||
)
|
||
if record.lock_version != expected_lock_version:
|
||
_operation_error(
|
||
"APPROVAL_VERSION_CONFLICT",
|
||
"Черновик изменился после проверки; откройте и проверьте его заново.",
|
||
expected_lock_version=expected_lock_version,
|
||
actual_lock_version=record.lock_version,
|
||
)
|
||
if record.status != ApprovalStatus.DRAFT:
|
||
_operation_error("INVALID_INPUT_STATE", "Утвердить можно только черновик.")
|
||
self_approval_overridden = record.created_by_id == actor.pk
|
||
if self_approval_overridden and not can_self_approve_payroll(actor):
|
||
_operation_error(
|
||
"SELF_APPROVAL_FORBIDDEN",
|
||
"Автор входных данных не может сам их утвердить.",
|
||
)
|
||
now = timezone.now()
|
||
replaced = None
|
||
replaces_id = getattr(record, "replaces_id", None)
|
||
if replaces_id:
|
||
replaced = record.__class__.objects.select_for_update().get(pk=replaces_id)
|
||
record._state.fields_cache["replaces"] = replaced
|
||
previous_status = replaced.status if replaced is not None else ""
|
||
if replaced is not None and replaced.status != ApprovalStatus.APPROVED:
|
||
_operation_error(
|
||
"REPLACEMENT_SOURCE_NOT_APPROVED",
|
||
"Заменить можно только действующую утверждённую запись.",
|
||
replaced_id=replaced.pk,
|
||
)
|
||
if replaced:
|
||
replaced.status = ApprovalStatus.VOIDED
|
||
replaced.voided_by = actor
|
||
replaced.voided_at = now
|
||
replaced.save(update_fields=["status", "voided_by", "voided_at"])
|
||
record.status = ApprovalStatus.APPROVED
|
||
record.approved_by = actor
|
||
record.approved_at = now
|
||
record.self_approval_overridden = self_approval_overridden
|
||
try:
|
||
record.full_clean()
|
||
except ValidationError as exc:
|
||
raise PayrollOperationError(
|
||
"INPUT_VALIDATION_FAILED",
|
||
"Входные данные не прошли проверку.",
|
||
details={
|
||
"errors": exc.message_dict
|
||
if hasattr(exc, "message_dict")
|
||
else exc.messages
|
||
},
|
||
) from exc
|
||
record.save(
|
||
update_fields=[
|
||
"status",
|
||
"approved_by",
|
||
"approved_at",
|
||
"self_approval_overridden",
|
||
]
|
||
)
|
||
period = getattr(record, "period", None)
|
||
if replaced is not None:
|
||
_audit(
|
||
actor=actor,
|
||
action=f"payroll.{object_label}_voided_on_replacement",
|
||
instance=replaced,
|
||
period=period,
|
||
metadata={
|
||
"previous_status": previous_status,
|
||
"replacement_id": record.pk,
|
||
},
|
||
)
|
||
_audit(
|
||
actor=actor,
|
||
action=f"payroll.{object_label}_approved",
|
||
instance=record,
|
||
period=period,
|
||
metadata={
|
||
"approved_lock_version": record.lock_version,
|
||
**_approval_audit_metadata(
|
||
actor,
|
||
self_approval=self_approval_overridden,
|
||
),
|
||
},
|
||
)
|
||
return record
|
||
|
||
|
||
def _ensure_period_accepts_approvals(period: PayrollPeriod) -> None:
|
||
if period.status == PayrollPeriodStatus.CLOSED:
|
||
_operation_error(
|
||
"PERIOD_INPUTS_LOCKED",
|
||
"Данные закрытого периода заблокированы.",
|
||
)
|
||
if period.current_run_id is None:
|
||
return
|
||
current_run = PayrollRun.objects.select_for_update().get(pk=period.current_run_id)
|
||
if current_run.status in {
|
||
PayrollRunStatus.CALCULATED,
|
||
PayrollRunStatus.REVIEW,
|
||
PayrollRunStatus.APPROVED,
|
||
}:
|
||
_operation_error(
|
||
"PERIOD_INPUTS_LOCKED",
|
||
"Сначала верните текущий расчёт на исправление.",
|
||
run_id=current_run.pk,
|
||
)
|
||
|
||
|
||
@transaction.atomic
|
||
def clear_component_input_lines(
|
||
period_id: int,
|
||
*,
|
||
employee_id: int,
|
||
component_id: int,
|
||
actor,
|
||
) -> dict[str, int]:
|
||
"""Clear one spreadsheet-like component cell without losing its audit trail."""
|
||
|
||
_require_permission(actor, "finance.manage_payroll_inputs")
|
||
period = PayrollPeriod.objects.select_for_update().filter(pk=period_id).first()
|
||
if period is None:
|
||
_operation_error("PERIOD_NOT_FOUND", "Расчётный период не найден.")
|
||
_ensure_period_accepts_approvals(period)
|
||
if not Employee.objects.filter(pk=employee_id).exists():
|
||
_operation_error("EMPLOYEE_NOT_FOUND", "Сотрудник не найден.")
|
||
if not PayrollComponent.objects.filter(pk=component_id).exists():
|
||
_operation_error("COMPONENT_NOT_FOUND", "Вид операции не найден.")
|
||
|
||
lines = list(
|
||
PayrollInputLine.objects.select_for_update()
|
||
.filter(
|
||
period=period,
|
||
employee_id=employee_id,
|
||
component_id=component_id,
|
||
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
|
||
)
|
||
.order_by("pk")
|
||
)
|
||
now = timezone.now()
|
||
summary = {"draft": 0, "approved": 0, "total": len(lines)}
|
||
for line in lines:
|
||
previous_status = line.status
|
||
summary[previous_status] += 1
|
||
line.status = ApprovalStatus.VOIDED
|
||
line.voided_by = actor
|
||
line.voided_at = now
|
||
line.void_reason = "Значение очищено в итоговой таблице"
|
||
line.save(
|
||
update_fields=[
|
||
"status",
|
||
"voided_by",
|
||
"voided_at",
|
||
"void_reason",
|
||
]
|
||
)
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.input_line_voided_from_table",
|
||
instance=line,
|
||
period=period,
|
||
metadata={"previous_status": previous_status},
|
||
)
|
||
return summary
|
||
|
||
|
||
@transaction.atomic
|
||
def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]:
|
||
"""Approve every draft shown in a period queue as one transaction."""
|
||
|
||
_require_permission(actor, "finance.approve_payroll_inputs")
|
||
|
||
# Keep the global lock order used by single-record rate approval: periods,
|
||
# active runs, rates, work records, components, then input lines.
|
||
periods = list(PayrollPeriod.objects.select_for_update().order_by("pk"))
|
||
period = next((item for item in periods if item.pk == period_id), None)
|
||
if period is None:
|
||
_operation_error("PERIOD_NOT_FOUND", "Расчётный период не найден.")
|
||
|
||
open_periods = [
|
||
item for item in periods if item.status != PayrollPeriodStatus.CLOSED
|
||
]
|
||
current_run_ids = [
|
||
item.current_run_id for item in open_periods if item.current_run_id is not None
|
||
]
|
||
current_runs = list(
|
||
PayrollRun.objects.select_for_update()
|
||
.filter(pk__in=current_run_ids)
|
||
.order_by("pk")
|
||
)
|
||
_ensure_period_accepts_approvals(period)
|
||
|
||
rates = list(
|
||
pay_rates_relevant_to_period(
|
||
period,
|
||
queryset=EmployeePayRate.objects.select_for_update(),
|
||
)
|
||
.filter(status=ApprovalStatus.DRAFT)
|
||
.order_by("pk")
|
||
)
|
||
work_records = list(
|
||
PayrollWorkRecord.objects.select_for_update()
|
||
.select_related("period")
|
||
.filter(period=period, status=ApprovalStatus.DRAFT)
|
||
.order_by("pk")
|
||
)
|
||
input_refs = list(
|
||
PayrollInputLine.objects.filter(
|
||
period=period,
|
||
status=ApprovalStatus.DRAFT,
|
||
)
|
||
.order_by("pk")
|
||
.values_list("pk", "component_id")
|
||
)
|
||
components = {
|
||
component.pk: component
|
||
for component in PayrollComponent.objects.select_for_update()
|
||
.filter(pk__in={component_id for _, component_id in input_refs})
|
||
.order_by("pk")
|
||
}
|
||
input_lines = list(
|
||
PayrollInputLine.objects.select_for_update()
|
||
.select_related("period", "component")
|
||
.filter(pk__in=[line_id for line_id, _ in input_refs])
|
||
.order_by("pk")
|
||
)
|
||
|
||
active_run_statuses = {
|
||
PayrollRunStatus.CALCULATED,
|
||
PayrollRunStatus.REVIEW,
|
||
PayrollRunStatus.APPROVED,
|
||
}
|
||
for rate in rates:
|
||
for run in current_runs:
|
||
if (
|
||
run.status in active_run_statuses
|
||
and run.statements.filter(employee_id=rate.employee_id).exists()
|
||
):
|
||
_operation_error(
|
||
"RATE_USED_BY_ACTIVE_RUN",
|
||
"Ставку нельзя утвердить, пока расчёт сотрудника согласуется.",
|
||
run_id=run.pk,
|
||
)
|
||
|
||
for record in work_records:
|
||
if period.status == PayrollPeriodStatus.PUBLISHED and not record.replaces_id:
|
||
_operation_error(
|
||
"PUBLISHED_WORK_REPLACEMENT_REQUIRED",
|
||
"После публикации показатели меняются только новой ревизией.",
|
||
)
|
||
|
||
for line in input_lines:
|
||
component = components[line.component_id]
|
||
if not component.is_active:
|
||
_operation_error(
|
||
"PAYROLL_COMPONENT_INACTIVE",
|
||
"Нельзя утвердить строку с неактивным компонентом.",
|
||
component_id=component.pk,
|
||
)
|
||
line._state.fields_cache["component"] = component
|
||
|
||
for rate in rates:
|
||
_approve_record(
|
||
rate,
|
||
actor=actor,
|
||
object_label="rate",
|
||
expected_lock_version=rate.lock_version,
|
||
)
|
||
for record in work_records:
|
||
_approve_record(
|
||
record,
|
||
actor=actor,
|
||
object_label="work_record",
|
||
expected_lock_version=record.lock_version,
|
||
)
|
||
for line in input_lines:
|
||
_approve_record(
|
||
line,
|
||
actor=actor,
|
||
object_label="input_line",
|
||
expected_lock_version=line.lock_version,
|
||
)
|
||
|
||
summary = {
|
||
"rates": len(rates),
|
||
"work_records": len(work_records),
|
||
"input_lines": len(input_lines),
|
||
}
|
||
summary["total"] = sum(summary.values())
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.period_drafts_approved",
|
||
instance=period,
|
||
period=period,
|
||
metadata=summary,
|
||
)
|
||
return summary
|
||
|
||
|
||
@transaction.atomic
|
||
def approve_pay_rate(
|
||
rate_id: int,
|
||
*,
|
||
actor,
|
||
expected_lock_version: int,
|
||
) -> EmployeePayRate:
|
||
_require_permission(actor, "finance.approve_payroll_inputs")
|
||
# A rate can affect any open period. Lock periods before runs and the rate,
|
||
# matching the global payroll lock order and serializing against calculate.
|
||
periods = list(
|
||
PayrollPeriod.objects.select_for_update()
|
||
.exclude(status=PayrollPeriodStatus.CLOSED)
|
||
.order_by("pk")
|
||
)
|
||
current_run_ids = [
|
||
period.current_run_id for period in periods if period.current_run_id is not None
|
||
]
|
||
current_runs = list(
|
||
PayrollRun.objects.select_for_update()
|
||
.filter(pk__in=current_run_ids)
|
||
.order_by("pk")
|
||
)
|
||
record = EmployeePayRate.objects.select_for_update().get(pk=rate_id)
|
||
for run in current_runs:
|
||
if (
|
||
run.status
|
||
in {
|
||
PayrollRunStatus.CALCULATED,
|
||
PayrollRunStatus.REVIEW,
|
||
PayrollRunStatus.APPROVED,
|
||
}
|
||
and run.statements.filter(employee_id=record.employee_id).exists()
|
||
):
|
||
_operation_error(
|
||
"RATE_USED_BY_ACTIVE_RUN",
|
||
"Ставку нельзя утвердить, пока расчёт сотрудника согласуется.",
|
||
run_id=run.pk,
|
||
)
|
||
return _approve_record(
|
||
record,
|
||
actor=actor,
|
||
object_label="rate",
|
||
expected_lock_version=expected_lock_version,
|
||
)
|
||
|
||
|
||
@transaction.atomic
|
||
def approve_work_record(
|
||
record_id: int,
|
||
*,
|
||
actor,
|
||
expected_lock_version: int,
|
||
) -> PayrollWorkRecord:
|
||
_require_permission(actor, "finance.approve_payroll_inputs")
|
||
reference = PayrollWorkRecord.objects.only("period_id").get(pk=record_id)
|
||
period = PayrollPeriod.objects.select_for_update().get(pk=reference.period_id)
|
||
_ensure_period_accepts_approvals(period)
|
||
record = PayrollWorkRecord.objects.select_for_update().get(pk=record_id)
|
||
if period.status == PayrollPeriodStatus.PUBLISHED and not record.replaces_id:
|
||
_operation_error(
|
||
"PUBLISHED_WORK_REPLACEMENT_REQUIRED",
|
||
"После публикации показатели меняются только новой ревизией.",
|
||
)
|
||
return _approve_record(
|
||
record,
|
||
actor=actor,
|
||
object_label="work_record",
|
||
expected_lock_version=expected_lock_version,
|
||
)
|
||
|
||
|
||
@transaction.atomic
|
||
def approve_input_line(
|
||
line_id: int,
|
||
*,
|
||
actor,
|
||
expected_lock_version: int,
|
||
) -> PayrollInputLine:
|
||
_require_permission(actor, "finance.approve_payroll_inputs")
|
||
reference = PayrollInputLine.objects.only("period_id", "component_id").get(
|
||
pk=line_id
|
||
)
|
||
period = PayrollPeriod.objects.select_for_update().get(pk=reference.period_id)
|
||
_ensure_period_accepts_approvals(period)
|
||
component = PayrollComponent.objects.select_for_update().get(
|
||
pk=reference.component_id
|
||
)
|
||
if not component.is_active:
|
||
_operation_error(
|
||
"PAYROLL_COMPONENT_INACTIVE",
|
||
"Нельзя утвердить строку с неактивным компонентом.",
|
||
component_id=component.pk,
|
||
)
|
||
record = PayrollInputLine.objects.select_for_update().get(pk=line_id)
|
||
record._state.fields_cache["component"] = component
|
||
return _approve_record(
|
||
record,
|
||
actor=actor,
|
||
object_label="input_line",
|
||
expected_lock_version=expected_lock_version,
|
||
)
|
||
|
||
|
||
@transaction.atomic
|
||
def acknowledge_statement(
|
||
statement_id: int,
|
||
*,
|
||
actor,
|
||
idempotency_key: uuid.UUID | None = None,
|
||
) -> PayrollStatementAcknowledgement:
|
||
"""Acknowledge receiving a current published statement, idempotently."""
|
||
|
||
if actor is None or not getattr(actor, "is_authenticated", False):
|
||
raise PayrollPermissionDenied("finance.acknowledge_own_statement")
|
||
statement_reference = PayrollStatement.objects.only(
|
||
"run_id",
|
||
"employee_id",
|
||
).get(pk=statement_id)
|
||
run_reference = PayrollRun.objects.only("period_id").get(
|
||
pk=statement_reference.run_id
|
||
)
|
||
period = PayrollPeriod.objects.select_for_update().get(pk=run_reference.period_id)
|
||
run = PayrollRun.objects.select_for_update().get(pk=statement_reference.run_id)
|
||
statement = PayrollStatement.objects.select_for_update().get(pk=statement_id)
|
||
if statement.employee_id != actor.pk:
|
||
_operation_error(
|
||
"STATEMENT_NOT_FOUND",
|
||
"Расчётный листок не найден.",
|
||
)
|
||
latest_published = (
|
||
PayrollRun.objects.filter(
|
||
period_id=period.pk,
|
||
status=PayrollRunStatus.PUBLISHED,
|
||
)
|
||
.order_by("-revision")
|
||
.first()
|
||
)
|
||
if latest_published is None or latest_published.pk != run.pk:
|
||
_operation_error(
|
||
"STATEMENT_NOT_CURRENT",
|
||
"Подтвердить можно только актуальный опубликованный листок.",
|
||
)
|
||
|
||
if idempotency_key is not None:
|
||
keyed_acknowledgement = PayrollStatementAcknowledgement.objects.filter(
|
||
idempotency_key=idempotency_key
|
||
).first()
|
||
if keyed_acknowledgement is not None:
|
||
if keyed_acknowledgement.statement_id != statement.pk:
|
||
_operation_error(
|
||
"IDEMPOTENCY_KEY_CONFLICT",
|
||
"Ключ уже использован для другого листка.",
|
||
)
|
||
return keyed_acknowledgement
|
||
|
||
acknowledgement = PayrollStatementAcknowledgement.objects.filter(
|
||
statement=statement
|
||
).first()
|
||
if acknowledgement and acknowledgement.acknowledged_at:
|
||
return acknowledgement
|
||
|
||
now = timezone.now()
|
||
if acknowledgement is None:
|
||
acknowledgement = PayrollStatementAcknowledgement(
|
||
statement=statement,
|
||
employee=actor,
|
||
content_hash=statement.result_hash,
|
||
idempotency_key=idempotency_key or uuid.uuid4(),
|
||
viewed_at=now,
|
||
acknowledged_at=now,
|
||
)
|
||
else:
|
||
acknowledgement.viewed_at = acknowledgement.viewed_at or now
|
||
acknowledgement.acknowledged_at = now
|
||
acknowledgement.full_clean()
|
||
try:
|
||
with transaction.atomic():
|
||
acknowledgement.save()
|
||
except IntegrityError as exc:
|
||
existing = None
|
||
if idempotency_key is not None:
|
||
existing = PayrollStatementAcknowledgement.objects.filter(
|
||
idempotency_key=idempotency_key
|
||
).first()
|
||
if existing is not None and existing.statement_id == statement.pk:
|
||
return existing
|
||
raise PayrollOperationError(
|
||
"ACKNOWLEDGEMENT_CONFLICT",
|
||
"Подтверждение уже обработано другой операцией.",
|
||
) from exc
|
||
_audit(
|
||
actor=actor,
|
||
action="payroll.statement_acknowledged",
|
||
instance=acknowledgement,
|
||
period=period,
|
||
after_hash=statement.result_hash,
|
||
)
|
||
return acknowledgement
|