Corp/backend/finance/models.py

2070 lines
76 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Persistence models for the EUSRR payroll integration.
Calculation rules live in the project-independent :mod:`payroll_core` package.
These models resolve EUSRR identities, approval state and immutable snapshots.
"""
from __future__ import annotations
import uuid
from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, RegexValidator
from django.db import models, transaction
from django.db.models import F, Q
from .analytics.models import ReportingEntry # noqa: F401
from .enums import (
ApprovalStatus,
InputSource,
PayrollCarryoverDirection,
PayrollCarryoverStatus,
PayrollComponentKind,
PayrollComponentPeriodScope,
PayrollPeriodStatus,
PayrollRunStatus,
VacationPayInstructionStatus,
)
currency_validator = RegexValidator(
regex=r"^[A-Z]{3}$",
message="Валюта должна быть трёхбуквенным кодом в верхнем регистре.",
)
component_code_validator = RegexValidator(
regex=r"^[A-Z][A-Z0-9_.-]{0,63}$",
message="Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-",
)
payroll_cell_column_validator = RegexValidator(
regex=r"^[a-z][a-z0-9_]*(?::[A-Z][A-Z0-9_.-]{0,63})?$",
message="Некорректный идентификатор колонки расчётной таблицы.",
)
DEFAULT_DAILY_TARGET_POINTS = Decimal("5")
def generate_custom_payroll_component_code() -> str:
return f"CUSTOM_{uuid.uuid4().hex.upper()}"
class FinanceSettings(models.Model):
"""Global access settings for the native finance management workspace."""
name = models.CharField(
"Название",
max_length=100,
default="Основные настройки",
)
management_users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="finance_management_settings",
verbose_name="Доступ к блоку «Управление»",
help_text=(
"Если список пуст, блок доступен всем администраторам и сотрудникам "
"с финансовыми правами. Заполните список, чтобы ограничить доступ."
),
)
payroll_component_overrides = models.JSONField(
"Настройки зарплатных колонок",
default=dict,
blank=True,
)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Настройки финансов"
verbose_name_plural = "Настройки финансов"
def __str__(self):
return self.name
@classmethod
def get_solo(cls):
instance = cls.objects.first()
return instance if instance else cls.objects.create()
def management_access_is_restricted(self) -> bool:
return self.management_users.exists()
def allows_management_user(self, user) -> bool:
if user is None or not getattr(user, "is_authenticated", False):
return False
if not self.management_access_is_restricted():
return True
return self.management_users.filter(pk=user.pk, is_active=True).exists()
class PayrollPeriod(models.Model):
"""A calculation and publication period controlled by one active run."""
code = models.CharField("Код", max_length=32, unique=True)
name = models.CharField("Название", max_length=120, blank=True)
date_from = models.DateField("Начало периода")
date_to = models.DateField("Конец периода")
pay_date = models.DateField("Дата выплаты", null=True, blank=True)
currency = models.CharField(
"Валюта",
max_length=3,
default="RUB",
validators=[currency_validator],
)
status = models.CharField(
"Статус",
max_length=20,
choices=PayrollPeriodStatus.choices,
default=PayrollPeriodStatus.OPEN,
)
current_run = models.ForeignKey(
"PayrollRun",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="active_for_periods",
verbose_name="Текущая ревизия расчёта",
)
component_snapshot = models.JSONField(
"Снимок зарплатных колонок",
default=dict,
blank=True,
editable=False,
)
lock_version = models.PositiveIntegerField("Версия блокировки", default=0)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="created_payroll_periods",
verbose_name="Создал",
help_text="Не заполнено для периодов, созданных автоматически.",
)
created_at = models.DateTimeField("Создан", auto_now_add=True)
updated_at = models.DateTimeField("Обновлён", auto_now=True)
class Meta:
verbose_name = "Расчётный период"
verbose_name_plural = "Расчётные периоды"
ordering = ["-date_from", "-id"]
indexes = [
models.Index(
fields=["status", "date_from"],
name="pay_period_status_date_idx",
)
]
constraints = [
models.CheckConstraint(
condition=Q(date_from__lte=F("date_to")),
name="pay_period_dates_valid",
),
models.UniqueConstraint(
fields=["date_from", "date_to"],
name="pay_period_dates_unique",
),
]
permissions = [
("manage_payroll_inputs", "Может управлять данными для расчёта"),
("approve_payroll_inputs", "Может утверждать данные для расчёта"),
("calculate_payroll", "Может запускать расчёт зарплаты"),
("approve_payroll", "Может утверждать расчёт зарплаты"),
(
"override_payroll_approval",
"Может самоутверждать данные и расчёты зарплаты",
),
("publish_payroll", "Может публиковать расчётные листки"),
("view_all_payroll", "Может просматривать все расчёты"),
("audit_payroll", "Может просматривать аудит расчётов"),
]
def __str__(self):
return self.name or self.code
def _validate_immutable_fields(self, original=None):
if self.pk:
original = original or PayrollPeriod.objects.filter(pk=self.pk).first()
if original and original.runs.exists():
immutable_values = (
self.code,
self.name,
self.date_from,
self.date_to,
self.pay_date,
self.currency,
)
original_values = (
original.code,
original.name,
original.date_from,
original.date_to,
original.pay_date,
original.currency,
)
if immutable_values != original_values:
raise ValidationError(
"Показываемые сотруднику реквизиты периода нельзя "
"менять после расчёта."
)
def clean(self):
super().clean()
self._validate_immutable_fields()
if self.date_from and self.date_to and self.date_from > self.date_to:
raise ValidationError(
{"date_to": "Конец периода не может быть раньше начала."}
)
if self.date_from and self.date_to:
overlaps = PayrollPeriod.objects.filter(
date_from__lte=self.date_to,
date_to__gte=self.date_from,
)
if self.pk:
overlaps = overlaps.exclude(pk=self.pk)
if overlaps.exists():
raise ValidationError("Расчётные периоды не должны пересекаться.")
@transaction.atomic
def save(self, *args, **kwargs):
update_fields = kwargs.get("update_fields")
if self.pk and not self._state.adding:
original = PayrollPeriod.objects.select_for_update().get(pk=self.pk)
self._validate_immutable_fields(original)
if (
original.status == PayrollPeriodStatus.CLOSED
and original.component_snapshot
and self.component_snapshot != original.component_snapshot
):
raise ValidationError(
"Снимок колонок закрытого периода нельзя изменять."
)
if update_fields is None:
# Full-form saves may carry stale workflow fields. Those fields
# are changed only by services with explicit update_fields.
self.status = original.status
self.current_run_id = original.current_run_id
self.lock_version = original.lock_version
self.created_by_id = original.created_by_id
self.created_at = original.created_at
self.component_snapshot = original.component_snapshot
return super().save(*args, **kwargs)
class PayrollTableReviewMark(models.Model):
"""Personal visual review progress for a payroll period table row."""
period = models.ForeignKey(
PayrollPeriod,
on_delete=models.CASCADE,
related_name="table_review_marks",
verbose_name="Расчётный период",
)
reviewer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="payroll_table_review_marks",
verbose_name="Проверяющий",
)
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="payroll_table_row_review_marks",
verbose_name="Сотрудник",
)
created_at = models.DateTimeField("Отмечено", auto_now_add=True)
class Meta:
verbose_name = "Контрольная отметка расчётной таблицы"
verbose_name_plural = "Контрольные отметки расчётной таблицы"
ordering = ["employee_id"]
constraints = [
models.UniqueConstraint(
fields=["period", "reviewer", "employee"],
name="pay_table_review_mark_unique",
),
]
indexes = [
models.Index(
fields=["reviewer", "period"],
name="pay_review_user_period_idx",
),
]
def __str__(self):
return f"{self.reviewer_id}: {self.period_id}/{self.employee_id}"
class PayrollCellCommentThread(models.Model):
"""Stable address of one employee cell used by generic comments."""
period = models.ForeignKey(
PayrollPeriod,
on_delete=models.CASCADE,
related_name="cell_comment_threads",
verbose_name="Расчётный период",
)
subject_employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="payroll_cell_comment_threads",
verbose_name="Сотрудник в строке",
)
column_id = models.CharField(
"Колонка",
max_length=80,
validators=[payroll_cell_column_validator],
)
created_at = models.DateTimeField("Создан", auto_now_add=True)
class Meta:
verbose_name = "Обсуждение ячейки расчёта"
verbose_name_plural = "Обсуждения ячеек расчёта"
ordering = ["period_id", "subject_employee_id", "column_id"]
constraints = [
models.UniqueConstraint(
fields=["period", "subject_employee", "column_id"],
name="pay_cell_comment_thread_unique",
)
]
def __str__(self):
return (
f"{self.period.code}: сотрудник #{self.subject_employee_id}, "
f"колонка {self.column_id}"
)
class PayrollCarryover(models.Model):
"""Explicit debt or additional payment transferred to the next period."""
source_period = models.ForeignKey(
PayrollPeriod,
on_delete=models.PROTECT,
related_name="outgoing_carryovers",
verbose_name="Исходный период",
)
target_period = models.ForeignKey(
PayrollPeriod,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="incoming_carryovers",
verbose_name="Период переноса",
)
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="payroll_carryovers",
verbose_name="Сотрудник",
)
direction = models.CharField(
"Вид остатка",
max_length=20,
choices=PayrollCarryoverDirection.choices,
)
amount = models.DecimalField(
"Сумма",
max_digits=19,
decimal_places=2,
validators=[MinValueValidator(Decimal("0.01"))],
)
reason = models.TextField("Основание")
status = models.CharField(
"Статус",
max_length=16,
choices=PayrollCarryoverStatus.choices,
default=PayrollCarryoverStatus.PENDING,
)
generated_input_line = models.OneToOneField(
"PayrollInputLine",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="generated_from_carryover",
verbose_name="Созданная операция",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_payroll_carryovers",
verbose_name="Создал",
)
created_at = models.DateTimeField("Создан", auto_now_add=True)
updated_at = models.DateTimeField("Обновлён", auto_now=True)
transferred_at = models.DateTimeField("Перенесён", null=True, blank=True)
cancelled_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="cancelled_payroll_carryovers",
verbose_name="Отменил",
)
cancelled_at = models.DateTimeField("Отменён", null=True, blank=True)
class Meta:
verbose_name = "Долг или доплата"
verbose_name_plural = "Долги и доплаты"
ordering = ["-created_at", "-id"]
indexes = [
models.Index(
fields=["status", "source_period"],
name="pay_carry_status_source_idx",
),
models.Index(
fields=["target_period", "employee"],
name="pay_carry_target_employee_idx",
),
]
constraints = [
models.CheckConstraint(
condition=Q(amount__gt=0),
name="pay_carry_amount_positive",
)
]
def clean(self):
super().clean()
if not self.reason.strip():
raise ValidationError({"reason": "Укажите основание переноса."})
if self.target_period_id:
if self.target_period.date_from <= self.source_period.date_to:
raise ValidationError(
{"target_period": "Перенос возможен только в следующий период."}
)
if self.target_period.currency != self.source_period.currency:
raise ValidationError(
{
"target_period": "Валюты исходного и целевого периодов различаются."
}
)
def __str__(self):
return (
f"{self.get_direction_display()}: {self.employee_id}, "
f"{self.amount} {self.source_period.currency}"
)
class DraftVersionedModel(models.Model):
"""Optimistic version for maker-checker records edited as drafts."""
lock_version = models.PositiveIntegerField("Версия черновика", default=0)
class Meta:
abstract = True
@transaction.atomic
def save(self, *args, **kwargs):
update_fields = kwargs.get("update_fields")
if self.pk and not self._state.adding and update_fields is None:
current = self.__class__.objects.select_for_update().get(pk=self.pk)
if (
current.status != ApprovalStatus.DRAFT
or self.status != ApprovalStatus.DRAFT
):
raise ValidationError(
"Изменять можно только актуальную версию черновика."
)
expected_lock_version = getattr(
self,
"_expected_lock_version",
self.lock_version,
)
if current.lock_version != expected_lock_version:
raise ValidationError(
"Черновик уже изменён другой операцией; обновите данные."
)
self.lock_version = current.lock_version + 1
result = super().save(*args, **kwargs)
if hasattr(self, "_expected_lock_version"):
del self._expected_lock_version
return result
class EmployeePayRate(DraftVersionedModel):
"""Effective-dated, approved rate event for an employee."""
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="pay_rates",
verbose_name="Сотрудник",
)
rate_code = models.CharField(
"Код ставки",
max_length=64,
default="BASE",
validators=[component_code_validator],
)
amount = models.DecimalField(
"Сумма ставки",
max_digits=19,
decimal_places=4,
validators=[MinValueValidator(Decimal("0.0001"))],
)
point_rate = models.DecimalField(
"Цена балла сверх нормы",
max_digits=19,
decimal_places=4,
null=True,
blank=True,
validators=[MinValueValidator(Decimal("0"))],
)
currency = models.CharField(
"Валюта",
max_length=3,
default="RUB",
validators=[currency_validator],
)
effective_from = models.DateField("Действует с")
revision = models.PositiveIntegerField("Ревизия", default=1)
status = models.CharField(
"Статус",
max_length=16,
choices=ApprovalStatus.choices,
default=ApprovalStatus.DRAFT,
)
replaces = models.OneToOneField(
"self",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="replaced_by",
verbose_name="Заменяет ставку",
)
reason = models.TextField("Основание", blank=True)
source = models.CharField(
"Источник",
max_length=16,
choices=InputSource.choices,
default=InputSource.MANUAL,
)
source_ref = models.CharField(
"Идентификатор в источнике",
max_length=255,
blank=True,
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_pay_rates",
verbose_name="Создал",
)
created_at = models.DateTimeField("Создана", auto_now_add=True)
updated_at = models.DateTimeField("Обновлена", auto_now=True)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="approved_pay_rates",
verbose_name="Утвердил",
)
approved_at = models.DateTimeField("Утверждена", null=True, blank=True)
self_approval_overridden = models.BooleanField(
"Самоутверждение по особому праву",
default=False,
editable=False,
)
voided_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="voided_pay_rates",
verbose_name="Аннулировал",
)
voided_at = models.DateTimeField("Аннулирована", null=True, blank=True)
void_reason = models.TextField("Причина аннулирования", blank=True)
class Meta:
verbose_name = "Ставка сотрудника"
verbose_name_plural = "Ставки сотрудников"
ordering = ["employee_id", "-effective_from", "-revision"]
indexes = [
models.Index(
fields=["employee", "status", "effective_from"],
name="pay_rate_employee_date_idx",
)
]
constraints = [
models.CheckConstraint(
condition=Q(amount__gt=0),
name="pay_rate_amount_positive",
),
models.CheckConstraint(
condition=Q(point_rate__gte=0),
name="pay_rate_point_nonnegative",
),
models.CheckConstraint(
condition=Q(approved_by__isnull=True)
| ~Q(created_by=F("approved_by"))
| Q(self_approval_overridden=True),
name="pay_rate_maker_not_approver",
),
models.CheckConstraint(
condition=Q(self_approval_overridden=False)
| Q(
approved_by__isnull=False,
created_by=F("approved_by"),
),
name="pay_rate_self_override_valid",
),
models.CheckConstraint(
condition=~Q(status=ApprovalStatus.APPROVED)
| Q(approved_by__isnull=False, approved_at__isnull=False),
name="pay_rate_approval_complete",
),
models.UniqueConstraint(
fields=["employee", "rate_code", "effective_from", "revision"],
name="pay_rate_revision_unique",
),
models.UniqueConstraint(
fields=["employee", "rate_code", "effective_from"],
condition=Q(status=ApprovalStatus.APPROVED),
name="pay_rate_one_approved_event",
),
models.UniqueConstraint(
fields=["source", "source_ref"],
condition=~Q(source_ref=""),
name="pay_rate_source_ref_unique",
),
]
def __str__(self):
return f"{self.employee} — {self.amount} {self.currency}"
def clean(self):
super().clean()
if self.replaces_id:
if self.pk and self.replaces_id == self.pk:
raise ValidationError({"replaces": "Ставка не может заменять себя."})
if self.replaces.employee_id != self.employee_id:
raise ValidationError(
{"replaces": "Заменяемая ставка относится к другому сотруднику."}
)
if self.replaces.rate_code != self.rate_code:
raise ValidationError(
{"replaces": "Код заменяемой ставки должен совпадать."}
)
if self.replaces.effective_from != self.effective_from:
raise ValidationError(
{"replaces": "Дата начала заменяемой ставки должна совпадать."}
)
if self.revision != self.replaces.revision + 1:
raise ValidationError(
{"revision": "Ревизия должна следовать за заменяемой ставкой."}
)
if self.status == ApprovalStatus.APPROVED:
if not self.approved_by_id or not self.approved_at:
raise ValidationError(
"Утверждённая ставка должна содержать автора и время решения."
)
class PayrollWorkSettings(models.Model):
"""Global settings for employee-entered daily work metrics."""
singleton = models.BooleanField(default=True, unique=True, editable=False)
daily_target_points = models.DecimalField(
"Дневная норма баллов",
max_digits=19,
decimal_places=4,
default=DEFAULT_DAILY_TARGET_POINTS,
validators=[MinValueValidator(Decimal("0.0001"))],
help_text="Единая дневная норма для всех сотрудников.",
)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Настройки выработки"
verbose_name_plural = "Настройки выработки"
def save(self, *args, **kwargs):
self.singleton = True
super().save(*args, **kwargs)
def __str__(self):
return f"Дневная норма: {self.daily_target_points}"
@classmethod
def get_daily_target_points(cls):
value = (
cls.objects.filter(singleton=True)
.values_list("daily_target_points", flat=True)
.first()
)
return value if value is not None else DEFAULT_DAILY_TARGET_POINTS
class PayrollDailyWorkEntry(models.Model):
"""Employee-entered daily work metrics aggregated into a payroll period."""
period = models.ForeignKey(
PayrollPeriod,
on_delete=models.CASCADE,
related_name="daily_work_entries",
verbose_name="Период",
)
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="payroll_daily_work_entries",
verbose_name="Сотрудник",
)
work_date = models.DateField("Дата выработки")
target_points = models.DecimalField(
"Норма баллов за день",
max_digits=19,
decimal_places=4,
validators=[MinValueValidator(Decimal("0.0001"))],
)
actual_points = models.DecimalField(
"Фактические баллы за день",
max_digits=19,
decimal_places=4,
validators=[MinValueValidator(Decimal("0"))],
)
note = models.TextField("Комментарий", blank=True)
lock_version = models.PositiveIntegerField("Версия записи", default=0)
created_at = models.DateTimeField("Создана", auto_now_add=True)
updated_at = models.DateTimeField("Обновлена", auto_now=True)
class Meta:
verbose_name = "Ежедневная выработка"
verbose_name_plural = "Ежедневная выработка"
ordering = ["-work_date", "-id"]
indexes = [
models.Index(
fields=["period", "employee", "work_date"],
name="pay_daily_period_employee_idx",
)
]
constraints = [
models.CheckConstraint(
condition=Q(target_points__gt=0),
name="pay_daily_target_positive",
),
models.CheckConstraint(
condition=Q(actual_points__gte=0),
name="pay_daily_actual_nonnegative",
),
models.UniqueConstraint(
fields=["period", "employee", "work_date"],
name="pay_daily_employee_date_unique",
),
]
def __str__(self):
return f"{self.work_date}: {self.employee} ({self.actual_points})"
def clean(self):
super().clean()
if (
self.period_id
and self.work_date
and not self.period.date_from <= self.work_date <= self.period.date_to
):
raise ValidationError(
{"work_date": "Дата выработки должна входить в расчётный период."}
)
class PayrollWorkRecord(DraftVersionedModel):
"""Approved monthly work metrics and optional Excel control totals."""
period = models.ForeignKey(
PayrollPeriod,
on_delete=models.CASCADE,
related_name="work_records",
verbose_name="Период",
)
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="payroll_work_records",
verbose_name="Сотрудник",
)
target_points = models.DecimalField(
"Норма баллов",
max_digits=19,
decimal_places=4,
validators=[MinValueValidator(Decimal("0.0001"))],
)
target_points_overridden = models.BooleanField(
"Норма указана вручную",
default=True,
editable=False,
)
actual_points = models.DecimalField(
"Фактические баллы",
max_digits=19,
decimal_places=4,
validators=[MinValueValidator(Decimal("0"))],
)
expected_point_amount = models.DecimalField(
"Контроль: выработка по баллам",
max_digits=19,
decimal_places=2,
null=True,
blank=True,
)
expected_gross = models.DecimalField(
"Контроль: итого начислено",
max_digits=19,
decimal_places=2,
null=True,
blank=True,
)
expected_recalculated_gross = models.DecimalField(
"Контроль: перерасчёт",
max_digits=19,
decimal_places=2,
null=True,
blank=True,
)
expected_payable = models.DecimalField(
"Контроль: к выплате",
max_digits=19,
decimal_places=2,
null=True,
blank=True,
)
revision = models.PositiveIntegerField("Ревизия", default=1)
status = models.CharField(
"Статус",
max_length=16,
choices=ApprovalStatus.choices,
default=ApprovalStatus.DRAFT,
)
replaces = models.OneToOneField(
"self",
on_delete=models.RESTRICT,
null=True,
blank=True,
related_name="replaced_by",
verbose_name="Заменяет запись",
)
reason = models.TextField("Основание изменения", blank=True)
source = models.CharField(
"Источник",
max_length=16,
choices=InputSource.choices,
default=InputSource.MANUAL,
)
source_ref = models.CharField(
"Идентификатор в источнике",
max_length=255,
blank=True,
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_payroll_work_records",
verbose_name="Создал",
)
created_at = models.DateTimeField("Создана", auto_now_add=True)
updated_at = models.DateTimeField("Обновлена", auto_now=True)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="approved_payroll_work_records",
verbose_name="Утвердил",
)
approved_at = models.DateTimeField("Утверждена", null=True, blank=True)
self_approval_overridden = models.BooleanField(
"Самоутверждение по особому праву",
default=False,
editable=False,
)
voided_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="voided_payroll_work_records",
verbose_name="Аннулировал",
)
voided_at = models.DateTimeField("Аннулирована", null=True, blank=True)
class Meta:
verbose_name = "Выработка за период"
verbose_name_plural = "Выработка за периоды"
ordering = ["period_id", "employee_id", "-revision"]
constraints = [
models.CheckConstraint(
condition=Q(target_points__gt=0),
name="pay_work_target_positive",
),
models.CheckConstraint(
condition=Q(actual_points__gte=0),
name="pay_work_actual_nonnegative",
),
models.CheckConstraint(
condition=Q(approved_by__isnull=True)
| ~Q(created_by=F("approved_by"))
| Q(self_approval_overridden=True),
name="pay_work_maker_not_approver",
),
models.CheckConstraint(
condition=Q(self_approval_overridden=False)
| Q(
approved_by__isnull=False,
created_by=F("approved_by"),
),
name="pay_work_self_override_valid",
),
models.CheckConstraint(
condition=~Q(status=ApprovalStatus.APPROVED)
| Q(approved_by__isnull=False, approved_at__isnull=False),
name="pay_work_approval_complete",
),
models.UniqueConstraint(
fields=["period", "employee", "revision"],
name="pay_work_revision_unique",
),
models.UniqueConstraint(
fields=["period", "employee"],
condition=Q(status=ApprovalStatus.APPROVED),
name="pay_work_one_approved",
),
models.UniqueConstraint(
fields=["source", "source_ref"],
condition=~Q(source_ref=""),
name="pay_work_source_ref_unique",
),
]
def __str__(self):
return f"{self.period}: {self.employee} ({self.actual_points})"
def clean(self):
super().clean()
if not self.replaces_id:
return
if self.pk and self.replaces_id == self.pk:
raise ValidationError({"replaces": "Запись не может заменять себя."})
if self.replaces.employee_id != self.employee_id:
raise ValidationError(
{"replaces": "Заменяемая запись относится к другому сотруднику."}
)
if self.replaces.period_id != self.period_id:
raise ValidationError(
{"replaces": "Заменяемая запись относится к другому периоду."}
)
if self.revision != self.replaces.revision + 1:
raise ValidationError(
{"revision": "Ревизия должна следовать за заменяемой записью."}
)
if not self.reason.strip():
raise ValidationError(
{"reason": "Для новой ревизии укажите основание изменения."}
)
class _PayrollComponentRegistryManager:
def get(self, **criteria):
from finance.payroll.components import PayrollComponent
return PayrollComponent.objects.get(**criteria)
def filter(self, **criteria):
from finance.payroll.components import PayrollComponent
return PayrollComponent.objects.filter(**criteria)
def all(self):
from finance.payroll.components import PayrollComponent
return PayrollComponent.objects.all()
def order_by(self, *fields):
from finance.payroll.components import PayrollComponent
return PayrollComponent.objects.order_by(*fields)
class PayrollComponent:
"""Compatibility facade; payroll component definitions are stored in code."""
objects = _PayrollComponentRegistryManager()
class PayrollCustomComponent(models.Model):
"""User-created payroll column with configurable calculation semantics."""
code = models.CharField(
"Код",
max_length=64,
unique=True,
default=generate_custom_payroll_component_code,
editable=False,
validators=[component_code_validator],
)
name = models.CharField("Название", max_length=80)
kind = models.CharField(
"Влияние на расчёт",
max_length=24,
choices=PayrollComponentKind.choices,
)
requires_reason = models.BooleanField(
"Требовать основание",
default=False,
)
include_in_point_base = models.BooleanField(
"Учитывать в балльной базе",
default=False,
)
include_in_carryover = models.BooleanField(
"Учитывать в переходящих остатках",
default=False,
)
visible_to_employee = models.BooleanField(
"Показывать сотруднику в расчётке",
default=True,
)
highlight_color = models.CharField(
"Цвет выделения",
max_length=16,
blank=True,
choices=(
("", "Без выделения"),
("green", "Зелёный"),
("red", "Красный"),
("amber", "Янтарный"),
("blue", "Синий"),
("violet", "Фиолетовый"),
),
default="",
)
scheduled_target_component_code = models.CharField(
"Колонка назначения",
max_length=64,
blank=True,
validators=[component_code_validator],
help_text="Заполняется только для запланированного изменения.",
)
is_active = models.BooleanField("Активна", default=True)
display_order = models.PositiveIntegerField("Порядок", default=900)
period_scope = models.CharField(
"Периоды отображения",
max_length=24,
choices=PayrollComponentPeriodScope.choices,
default=PayrollComponentPeriodScope.ALL,
)
scope_period = models.ForeignKey(
PayrollPeriod,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="scoped_custom_components",
verbose_name="Начальный период",
)
hidden_from_period = models.ForeignKey(
PayrollPeriod,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="hidden_custom_components_from_period",
verbose_name="Скрыта начиная с периода",
)
excluded_periods = models.ManyToManyField(
PayrollPeriod,
blank=True,
related_name="excluded_custom_components",
verbose_name="Скрыта в отдельных периодах",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_payroll_components",
verbose_name="Создал",
)
created_at = models.DateTimeField("Создана", auto_now_add=True)
class Meta:
verbose_name = "Пользовательская зарплатная колонка"
verbose_name_plural = "Пользовательские зарплатные колонки"
ordering = ["display_order", "name", "id"]
def __str__(self):
return self.name
@property
def is_custom(self):
return True
def clean(self):
super().clean()
self.name = self.name.strip()
if not self.name:
raise ValidationError({"name": "Укажите название колонки."})
if self.period_scope == PayrollComponentPeriodScope.ALL:
self.scope_period = None
elif self.scope_period_id is None:
raise ValidationError(
{"scope_period": "Для выбранной области укажите расчётный период."}
)
is_scheduled = self.kind == PayrollComponentKind.SCHEDULED_CHANGE
if is_scheduled:
if self.period_scope != PayrollComponentPeriodScope.CURRENT_AND_FUTURE:
raise ValidationError(
{
"period_scope": (
"Запланированное изменение действует от выбранного "
"периода до периода применения."
)
}
)
if not self.scheduled_target_component_code:
raise ValidationError(
{"scheduled_target_component_code": "Выберите колонку назначения."}
)
try:
from finance.payroll.components import get_payroll_component
target = get_payroll_component(
self.scheduled_target_component_code,
period=self.scope_period,
)
except ValueError as exc:
raise ValidationError(
{
"scheduled_target_component_code": (
"Колонка назначения недоступна в начальном периоде."
)
}
) from exc
if target.kind == PayrollComponentKind.SCHEDULED_CHANGE:
raise ValidationError(
{
"scheduled_target_component_code": (
"Запланированное изменение не может изменять другой маркер."
)
}
)
self.include_in_point_base = False
self.include_in_carryover = False
self.visible_to_employee = False
elif self.scheduled_target_component_code:
raise ValidationError(
{
"scheduled_target_component_code": (
"Колонку назначения можно выбрать только для "
"запланированного изменения."
)
}
)
if not self.is_active:
return
from finance.payroll.components import PAYROLL_COMPONENTS
normalized_name = self.name.casefold()
has_builtin_conflict = any(
component.name.casefold() == normalized_name
for component in PAYROLL_COMPONENTS
)
custom_components = (
PayrollCustomComponent.objects.filter(is_active=True)
.exclude(pk=self.pk)
.select_related("scope_period")
)
has_custom_conflict = any(
component.name.casefold() == normalized_name
and self.period_scope_overlaps(component)
for component in custom_components
)
if has_builtin_conflict or has_custom_conflict:
raise ValidationError(
{
"name": (
"Колонка с таким названием уже существует "
"в одном из выбранных периодов."
)
}
)
def period_scope_overlaps(self, other):
"""Return whether two component columns can appear in the same period."""
if (
self.period_scope == PayrollComponentPeriodScope.ALL
or other.period_scope == PayrollComponentPeriodScope.ALL
):
return True
if self.scope_period_id is None or other.scope_period_id is None:
return False
if (
self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY
and other.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY
):
return self.scope_period_id == other.scope_period_id
if self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
return self.scope_period.date_from >= other.scope_period.date_from
if other.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
return other.scope_period.date_from >= self.scope_period.date_from
return True
def applies_to_period(self, period):
if self.hidden_from_period_id and (
period.date_from >= self.hidden_from_period.date_from
):
return False
if self.pk and any(
excluded.pk == period.pk for excluded in self.excluded_periods.all()
):
return False
if self.period_scope == PayrollComponentPeriodScope.ALL:
return True
if period is None or self.scope_period_id is None:
return False
if self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
return period.pk == self.scope_period_id
return period.date_from >= self.scope_period.date_from
class PayrollInputLine(DraftVersionedModel):
"""Append-only bonus, correction, deduction or recorded payment input."""
period = models.ForeignKey(
PayrollPeriod,
on_delete=models.CASCADE,
related_name="input_lines",
verbose_name="Период выплаты",
)
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="payroll_input_lines",
verbose_name="Сотрудник",
)
component_code = models.CharField(
"Вид операции",
max_length=64,
validators=[component_code_validator],
)
amount = models.DecimalField(
"Сумма",
max_digits=19,
decimal_places=2,
validators=[MinValueValidator(Decimal("0.01"))],
)
value_sign = models.SmallIntegerField(
"Знак значения",
choices=((1, "+"), (-1, "−")),
default=1,
)
relates_to_period = models.ForeignKey(
PayrollPeriod,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="retro_input_lines",
verbose_name="Относится к периоду",
help_text="Заполняется для перерасчёта прошлого периода.",
)
reason = models.TextField("Основание", blank=True)
status = models.CharField(
"Статус",
max_length=16,
choices=ApprovalStatus.choices,
default=ApprovalStatus.DRAFT,
)
reversal_of = models.OneToOneField(
"self",
on_delete=models.RESTRICT,
null=True,
blank=True,
related_name="reversed_by",
verbose_name="Сторнирует строку",
)
source = models.CharField(
"Источник",
max_length=16,
choices=InputSource.choices,
default=InputSource.MANUAL,
)
source_ref = models.CharField(
"Идентификатор в источнике",
max_length=255,
blank=True,
)
idempotency_key = models.UUIDField(
"Ключ идемпотентности",
default=uuid.uuid4,
unique=True,
editable=False,
)
include_in_carryover = models.BooleanField(
"Учитывать в переходящем остатке",
default=True,
help_text=(
"Учитывается, когда для колонки включены переходящие остатки; "
"на расчёт зарплаты самой строки не влияет и не копирует её значение."
),
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_payroll_input_lines",
verbose_name="Создал",
)
created_at = models.DateTimeField("Создана", auto_now_add=True)
updated_at = models.DateTimeField("Обновлена", auto_now=True)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="approved_payroll_input_lines",
verbose_name="Утвердил",
)
approved_at = models.DateTimeField("Утверждена", null=True, blank=True)
self_approval_overridden = models.BooleanField(
"Самоутверждение по особому праву",
default=False,
editable=False,
)
voided_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="voided_payroll_input_lines",
verbose_name="Аннулировал",
)
voided_at = models.DateTimeField("Аннулирована", null=True, blank=True)
void_reason = models.TextField("Причина аннулирования", blank=True)
class Meta:
verbose_name = "Строка начисления/удержания"
verbose_name_plural = "Строки начислений/удержаний"
ordering = ["period_id", "employee_id", "component_code", "id"]
indexes = [
models.Index(
fields=["period", "employee", "status"],
name="pay_input_period_employee_idx",
)
]
constraints = [
models.CheckConstraint(
condition=Q(amount__gt=0),
name="pay_input_amount_positive",
),
models.CheckConstraint(
condition=Q(value_sign__in=(-1, 1)),
name="pay_input_value_sign_valid",
),
models.CheckConstraint(
condition=Q(approved_by__isnull=True)
| ~Q(created_by=F("approved_by"))
| Q(self_approval_overridden=True),
name="pay_input_maker_not_approver",
),
models.CheckConstraint(
condition=Q(self_approval_overridden=False)
| Q(
approved_by__isnull=False,
created_by=F("approved_by"),
),
name="pay_input_self_override_valid",
),
models.CheckConstraint(
condition=~Q(status=ApprovalStatus.APPROVED)
| Q(approved_by__isnull=False, approved_at__isnull=False),
name="pay_input_approval_complete",
),
models.UniqueConstraint(
fields=["source", "source_ref"],
condition=~Q(source_ref=""),
name="pay_input_source_ref_unique",
),
]
def __str__(self):
return f"{self.employee}: {self.component_code} {self.signed_amount}"
@property
def signed_amount(self):
return self.amount * self.value_sign
@property
def component(self):
from finance.payroll.components import get_payroll_component
# Historical and voided rows must remain serializable after a custom
# component is hidden from their period. Write validation below still
# resolves the component with the target period and rejects new rows.
return get_payroll_component(self.component_code)
@component.setter
def component(self, value):
self.component_code = value.code
@property
def component_id(self):
return self.component_code
def clean(self):
super().clean()
if self.reversal_of_id:
raise ValidationError(
{
"reversal_of": (
"Связанное сторно ещё не поддерживается. Используйте "
"утверждённую корректировку с обязательным основанием."
)
}
)
try:
from finance.payroll.components import get_payroll_component
component = get_payroll_component(
self.component_code,
period=self.period if self.period_id else None,
)
except ValueError as exc:
raise ValidationError({"component_code": str(exc)}) from exc
if component.requires_reason and not self.reason:
raise ValidationError({"reason": "Для компонента требуется основание."})
if self.relates_to_period_id == self.period_id:
raise ValidationError(
{"relates_to_period": "Для текущего периода это поле не требуется."}
)
if self.relates_to_period_id and not self.reason:
raise ValidationError(
{"reason": "Для перерасчёта прошлого периода укажите основание."}
)
if self.value_sign not in (-1, 1):
raise ValidationError({"value_sign": "Некорректный знак значения."})
if (
self.value_sign < 0
and component.kind != PayrollComponentKind.SCHEDULED_CHANGE
):
is_derived_value = self.source == InputSource.API and (
self.source_ref.startswith("payroll-scheduled-change:")
or self.source_ref.startswith("payroll-period-copy:")
)
if not is_derived_value:
raise ValidationError(
{
"value_sign": (
"Отрицательный знак доступен только запланированным "
"изменениям."
)
}
)
class VacationPayInstruction(models.Model):
"""Поручение финансам рассчитать отпускные по согласованным условиям."""
vacation_terms = models.OneToOneField(
"requests_app.RequestVacationTerms",
on_delete=models.PROTECT,
related_name="finance_instruction",
verbose_name="Условия отпуска",
)
period = models.ForeignKey(
PayrollPeriod,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="vacation_pay_instructions",
verbose_name="Расчётный период",
)
paid_days = models.PositiveSmallIntegerField("Оплачиваемые дни")
amount = models.DecimalField(
"Сумма отпускных",
max_digits=19,
decimal_places=2,
null=True,
blank=True,
validators=[MinValueValidator(Decimal("0.01"))],
)
status = models.CharField(
"Статус",
max_length=20,
choices=VacationPayInstructionStatus.choices,
default=VacationPayInstructionStatus.PENDING_AMOUNT,
)
payroll_input_line = models.OneToOneField(
PayrollInputLine,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="vacation_pay_instruction",
verbose_name="Строка начисления",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_vacation_pay_instructions",
verbose_name="Создал",
)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Поручение на отпускные"
verbose_name_plural = "Поручения на отпускные"
ordering = ["-created_at", "-id"]
def clean(self):
if self.paid_days < 1:
raise ValidationError(
{"paid_days": "В финансы можно передать только оплачиваемый отпуск."}
)
def __str__(self):
return f"Отпускные по заявлению #{self.vacation_terms.request_id}"
class PayrollInputCopyConfiguration(models.Model):
"""Per-operator defaults for copying input lines between payroll periods."""
owner = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="payroll_input_copy_configuration",
verbose_name="Пользователь",
)
component_codes = models.JSONField("Виды операций", default=list)
employee_scope = models.CharField(
"Охват сотрудников",
max_length=16,
choices=(
("all", "Все из источника"),
("active", "Все активные"),
("selected", "Выбранные"),
),
default="active",
)
employee_ids = models.JSONField("Выбранные сотрудники", default=list)
existing_mode = models.CharField(
"Заполненные значения",
max_length=16,
choices=(("skip", "Пропустить"), ("replace", "Заменить")),
default="skip",
)
auto_apply_on_period_creation = models.BooleanField(
"Автоматически применять при создании периода",
default=False,
)
updated_at = models.DateTimeField("Обновлена", auto_now=True)
class Meta:
verbose_name = "Конфигурация переноса зарплатных записей"
verbose_name_plural = "Конфигурации переноса зарплатных записей"
def __str__(self):
return f"Перенос зарплатных записей: {self.owner_id}"
class PayrollRun(models.Model):
"""Immutable calculation revision plus its controlled workflow state."""
period = models.ForeignKey(
PayrollPeriod,
on_delete=models.CASCADE,
related_name="runs",
verbose_name="Период",
)
revision = models.PositiveIntegerField("Ревизия")
status = models.CharField(
"Статус",
max_length=20,
choices=PayrollRunStatus.choices,
default=PayrollRunStatus.CALCULATED,
)
supersedes = models.ForeignKey(
"self",
on_delete=models.RESTRICT,
null=True,
blank=True,
related_name="superseded_by_runs",
verbose_name="Пересчитывает ревизию",
)
recalculation_reason = models.TextField(
"Основание перерасчёта",
blank=True,
)
idempotency_key = models.UUIDField(
"Ключ идемпотентности",
default=uuid.uuid4,
unique=True,
editable=False,
)
ruleset_id = models.CharField("Набор правил", max_length=120)
ruleset_version = models.CharField("Версия правил", max_length=64)
ruleset_hash = models.CharField("Хэш правил", max_length=64)
calculator_version = models.CharField("Версия калькулятора", max_length=64)
input_hash = models.CharField("Хэш входных данных", max_length=64)
result_hash = models.CharField("Хэш результата", max_length=64)
employee_count = models.PositiveIntegerField("Сотрудников", default=0)
gross_total = models.DecimalField(
"Всего начислено",
max_digits=21,
decimal_places=2,
default=Decimal("0"),
)
deduction_total = models.DecimalField(
"Всего удержано",
max_digits=21,
decimal_places=2,
default=Decimal("0"),
)
payable_total = models.DecimalField(
"Всего к выплате",
max_digits=21,
decimal_places=2,
default=Decimal("0"),
)
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="requested_payroll_runs",
verbose_name="Запустил",
)
requested_at = models.DateTimeField("Рассчитан", auto_now_add=True)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="approved_payroll_runs",
verbose_name="Утвердил",
)
approved_at = models.DateTimeField("Утверждён", null=True, blank=True)
self_approval_overridden = models.BooleanField(
"Самоутверждение по особому праву",
default=False,
editable=False,
)
published_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="published_payroll_runs",
verbose_name="Опубликовал",
)
published_at = models.DateTimeField("Опубликован", null=True, blank=True)
class Meta:
verbose_name = "Запуск расчёта"
verbose_name_plural = "Запуски расчёта"
ordering = ["-period__date_from", "-revision"]
indexes = [
models.Index(
fields=["period", "status", "revision"],
name="pay_run_period_status_idx",
)
]
constraints = [
models.UniqueConstraint(
fields=["period", "revision"],
name="pay_run_revision_unique",
),
models.UniqueConstraint(
fields=["period"],
condition=Q(status=PayrollRunStatus.PUBLISHED),
name="pay_run_one_published_per_period",
),
models.CheckConstraint(
condition=Q(approved_by__isnull=True)
| ~Q(requested_by=F("approved_by"))
| Q(self_approval_overridden=True),
name="pay_run_maker_not_approver",
),
models.CheckConstraint(
condition=Q(self_approval_overridden=False)
| Q(
approved_by__isnull=False,
requested_by=F("approved_by"),
),
name="pay_run_self_override_valid",
),
models.CheckConstraint(
condition=~Q(
status__in=[
PayrollRunStatus.APPROVED,
PayrollRunStatus.PUBLISHED,
]
)
| Q(approved_by__isnull=False, approved_at__isnull=False),
name="pay_run_approval_complete",
),
models.CheckConstraint(
condition=~Q(status=PayrollRunStatus.PUBLISHED)
| Q(published_by__isnull=False, published_at__isnull=False),
name="pay_run_publication_complete",
),
models.CheckConstraint(
condition=Q(gross_total__gte=0),
name="pay_run_gross_nonnegative",
),
models.CheckConstraint(
condition=Q(deduction_total__gte=0),
name="pay_run_deduction_nonnegative",
),
]
def __str__(self):
return f"{self.period} / ревизия {self.revision}"
def clean(self):
super().clean()
if not self.supersedes_id:
return
if self.pk and self.supersedes_id == self.pk:
raise ValidationError({"supersedes": "Расчёт не может заменять себя."})
if self.supersedes.period_id != self.period_id:
raise ValidationError(
{"supersedes": "Заменяемый расчёт относится к другому периоду."}
)
if self.revision != self.supersedes.revision + 1:
raise ValidationError(
{"revision": "Ревизия должна следовать за заменяемым расчётом."}
)
class PayrollStatement(models.Model):
"""Employee result snapshot. Amount fields never change after creation."""
run = models.ForeignKey(
PayrollRun,
on_delete=models.CASCADE,
related_name="statements",
verbose_name="Запуск",
)
public_id = models.UUIDField(
"Публичный идентификатор",
default=uuid.uuid4,
unique=True,
editable=False,
)
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="payroll_statements",
verbose_name="Сотрудник",
)
supersedes = models.OneToOneField(
"self",
on_delete=models.RESTRICT,
null=True,
blank=True,
related_name="superseded_by_statement",
verbose_name="Заменяет листок",
)
employee_snapshot = models.JSONField("Снимок сотрудника", default=dict)
currency = models.CharField(
"Валюта",
max_length=3,
validators=[currency_validator],
)
point_delta = models.DecimalField(
"Отклонение баллов",
max_digits=19,
decimal_places=4,
null=True,
blank=True,
)
gross_before_adjustments = models.DecimalField(
"Начислено до корректировок",
max_digits=21,
decimal_places=2,
)
adjustment_total = models.DecimalField(
"Корректировки",
max_digits=21,
decimal_places=2,
)
gross_total = models.DecimalField(
"Итого начислено",
max_digits=21,
decimal_places=2,
)
deduction_total = models.DecimalField(
"Удержания",
max_digits=21,
decimal_places=2,
)
net_pay = models.DecimalField(
"После удержаний",
max_digits=21,
decimal_places=2,
)
payment_total = models.DecimalField(
"Уже выплачено",
max_digits=21,
decimal_places=2,
)
opening_balance = models.DecimalField(
"Входящий баланс",
max_digits=21,
decimal_places=2,
default=Decimal("0"),
)
closing_balance = models.DecimalField(
"Исходящий баланс",
max_digits=21,
decimal_places=2,
default=Decimal("0"),
)
payable = models.DecimalField(
"К выплате",
max_digits=21,
decimal_places=2,
)
input_hash = models.CharField("Хэш входных данных", max_length=64)
result_hash = models.CharField("Хэш результата", max_length=64)
input_snapshot = models.JSONField("Входной снимок", default=dict)
result_snapshot = models.JSONField("Результат расчёта", default=dict)
created_at = models.DateTimeField("Создан", auto_now_add=True)
class Meta:
verbose_name = "Расчётный листок"
verbose_name_plural = "Расчётные листки"
ordering = ["-run__period__date_from", "employee_id"]
indexes = [
models.Index(
fields=["employee", "run"],
name="pay_statement_employee_idx",
)
]
constraints = [
models.UniqueConstraint(
fields=["run", "employee"],
name="pay_statement_run_employee_unique",
),
models.CheckConstraint(
condition=Q(gross_before_adjustments__gte=0),
name="pay_statement_pre_gross_nonnegative",
),
models.CheckConstraint(
condition=Q(gross_total__gte=0),
name="pay_statement_gross_nonnegative",
),
models.CheckConstraint(
condition=Q(deduction_total__gte=0),
name="pay_statement_deduction_nonnegative",
),
models.CheckConstraint(
condition=Q(payment_total__gte=0),
name="pay_statement_payment_nonnegative",
),
]
def __str__(self):
return f"{self.run.period}: {self.employee}"
def clean(self):
super().clean()
if not self.supersedes_id:
return
if self.pk and self.supersedes_id == self.pk:
raise ValidationError({"supersedes": "Листок не может заменять себя."})
if self.supersedes.employee_id != self.employee_id:
raise ValidationError(
{"supersedes": "Заменяемый листок принадлежит другому сотруднику."}
)
if self.supersedes.run.period_id != self.run.period_id:
raise ValidationError(
{"supersedes": "Заменяемый листок относится к другому периоду."}
)
class PayrollStatementLine(models.Model):
"""A rounded line copied from the core result for a statement."""
statement = models.ForeignKey(
PayrollStatement,
on_delete=models.CASCADE,
related_name="lines",
verbose_name="Расчётный листок",
)
position = models.PositiveIntegerField("Позиция")
line_id = models.CharField("Идентификатор строки", max_length=160)
code = models.CharField("Код", max_length=64)
label = models.CharField("Название", max_length=160)
kind = models.CharField(
"Тип",
max_length=24,
choices=PayrollComponentKind.choices,
)
amount = models.DecimalField(
"Сумма",
max_digits=21,
decimal_places=2,
)
source_ref = models.CharField("Источник", max_length=255)
reason = models.TextField("Основание", blank=True)
visible_to_employee = models.BooleanField(
"Показывать сотруднику",
default=True,
)
source_period_from = models.DateField(
"Период-источник: начало",
null=True,
blank=True,
)
source_period_to = models.DateField(
"Период-источник: конец",
null=True,
blank=True,
)
is_retro = models.BooleanField("Перерасчёт прошлого периода", default=False)
calculated = models.BooleanField("Рассчитана ядром", default=False)
class Meta:
verbose_name = "Строка расчётного листка"
verbose_name_plural = "Строки расчётных листков"
ordering = ["statement_id", "position"]
constraints = [
models.UniqueConstraint(
fields=["statement", "position"],
name="pay_statement_line_position_unique",
),
models.UniqueConstraint(
fields=["statement", "line_id"],
name="pay_statement_line_id_unique",
),
models.CheckConstraint(
condition=Q(amount__gte=0),
name="pay_statement_line_amount_nonnegative",
),
]
def __str__(self):
return f"{self.statement_id}: {self.code} {self.amount}"
class PayrollStatementAcknowledgement(models.Model):
"""Acknowledgement of a payslip, explicitly not proof of bank payment."""
statement = models.OneToOneField(
PayrollStatement,
on_delete=models.CASCADE,
related_name="acknowledgement",
verbose_name="Расчётный листок",
)
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="payroll_acknowledgements",
verbose_name="Сотрудник",
)
content_hash = models.CharField("Хэш подтверждённого листка", max_length=64)
idempotency_key = models.UUIDField(
"Ключ идемпотентности",
default=uuid.uuid4,
unique=True,
editable=False,
)
viewed_at = models.DateTimeField("Просмотрен", null=True, blank=True)
acknowledged_at = models.DateTimeField(
"Получение подтверждено",
null=True,
blank=True,
)
disputed_at = models.DateTimeField("Оспорен", null=True, blank=True)
dispute_reason = models.TextField("Причина оспаривания", blank=True)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Подтверждение расчётного листка"
verbose_name_plural = "Подтверждения расчётных листков"
def __str__(self):
return f"{self.employee}: листок {self.statement_id}"
def clean(self):
super().clean()
if self.statement_id and self.employee_id != self.statement.employee_id:
raise ValidationError(
{"employee": "Подтвердить листок может только его сотрудник."}
)
if self.statement_id and self.content_hash != self.statement.result_hash:
raise ValidationError(
{"content_hash": "Хэш не совпадает с расчётным листком."}
)
class PayrollStatementPaymentConfirmation(models.Model):
"""Administrative confirmation that the final payable amount was issued."""
statement = models.OneToOneField(
PayrollStatement,
on_delete=models.CASCADE,
related_name="payment_confirmation",
verbose_name="Расчётный листок",
)
confirmed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="confirmed_payroll_statement_payments",
verbose_name="Подтвердил выплату",
)
confirmed_at = models.DateTimeField("Выплата подтверждена", auto_now_add=True)
class Meta:
verbose_name = "Подтверждение выплаты зарплаты"
verbose_name_plural = "Подтверждения выплаты зарплаты"
def __str__(self):
return f"Выплата по листку {self.statement_id}"
class PayrollAuditEvent(models.Model):
"""Append-only redacted audit trail for payroll commands and reads."""
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="payroll_audit_events",
verbose_name="Инициатор",
)
action = models.CharField("Действие", max_length=80)
object_type = models.CharField("Тип объекта", max_length=80)
object_id = models.CharField("Идентификатор объекта", max_length=80)
period = models.ForeignKey(
PayrollPeriod,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="audit_events",
verbose_name="Период",
)
before_hash = models.CharField("Хэш до", max_length=64, blank=True)
after_hash = models.CharField("Хэш после", max_length=64, blank=True)
metadata = models.JSONField("Безопасные метаданные", default=dict, blank=True)
created_at = models.DateTimeField("Создано", auto_now_add=True)
class Meta:
verbose_name = "Событие аудита зарплаты"
verbose_name_plural = "События аудита зарплаты"
ordering = ["-created_at", "-id"]
indexes = [
models.Index(
fields=["object_type", "object_id", "created_at"],
name="pay_audit_object_time_idx",
),
models.Index(
fields=["period", "created_at"],
name="pay_audit_period_time_idx",
),
]
def __str__(self):
return f"{self.action}: {self.object_type}#{self.object_id}"