1901 lines
69 KiB
Python
1901 lines
69 KiB
Python
"""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=(
|
||
"Если список пуст, блок доступен всем администраторам и сотрудникам "
|
||
"с финансовыми правами. Заполните список, чтобы ограничить доступ."
|
||
),
|
||
)
|
||
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="Текущая ревизия расчёта",
|
||
)
|
||
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 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
|
||
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 immutable 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,
|
||
)
|
||
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="Начальный период",
|
||
)
|
||
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
|
||
|
||
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": "Для выбранной области укажите расчётный период."}
|
||
)
|
||
|
||
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.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"))],
|
||
)
|
||
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(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.amount}"
|
||
|
||
@property
|
||
def component(self):
|
||
from finance.payroll.components import get_payroll_component
|
||
|
||
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": "Для перерасчёта прошлого периода укажите основание."}
|
||
)
|
||
|
||
|
||
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)
|
||
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}"
|