988 lines
33 KiB
Python
988 lines
33 KiB
Python
"""Serializers for the native payroll administration workspace.
|
||
|
||
Workflow fields, hashes, source references and idempotency keys intentionally
|
||
never form part of a writable/read payload here. State transitions are owned
|
||
by :mod:`finance.payroll.services`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from decimal import Decimal
|
||
|
||
from django.contrib.auth import get_user_model
|
||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||
from rest_framework import serializers
|
||
|
||
from api.v1.employees.serializers import EmployeeBriefSerializer
|
||
from finance.enums import (
|
||
ApprovalStatus,
|
||
InputSource,
|
||
PayrollCarryoverDirection,
|
||
PayrollCarryoverStatus,
|
||
)
|
||
from finance.models import (
|
||
EmployeePayRate,
|
||
PayrollCarryover,
|
||
PayrollInputLine,
|
||
PayrollPeriod,
|
||
PayrollRun,
|
||
PayrollWorkRecord,
|
||
)
|
||
from finance.payroll.components import (
|
||
PAYROLL_COMPONENT_CODE_CHOICES,
|
||
)
|
||
from finance.payroll.work_norm import calculate_period_target_points
|
||
|
||
Employee = get_user_model()
|
||
|
||
|
||
def _validation_detail(exc: DjangoValidationError):
|
||
if hasattr(exc, "message_dict"):
|
||
return exc.message_dict
|
||
return {"non_field_errors": exc.messages}
|
||
|
||
|
||
class PayrollEmployeeMiniSerializer(serializers.ModelSerializer):
|
||
display_name = serializers.SerializerMethodField()
|
||
position = serializers.SerializerMethodField()
|
||
department = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = Employee
|
||
fields = ["id", "display_name", "position", "department"]
|
||
read_only_fields = fields
|
||
|
||
def get_display_name(self, obj) -> str:
|
||
return obj.get_full_name().strip() or f"Сотрудник #{obj.pk}"
|
||
|
||
def get_position(self, obj) -> str | None:
|
||
position = getattr(obj, "position", None)
|
||
return position.name if position is not None else None
|
||
|
||
def get_department(self, obj) -> str | None:
|
||
prefetched = getattr(obj, "_prefetched_objects_cache", {}).get(
|
||
"departments_links"
|
||
)
|
||
if prefetched is None:
|
||
link = (
|
||
obj.departments_links.filter(is_active=True)
|
||
.select_related("department")
|
||
.order_by("department__name", "id")
|
||
.first()
|
||
)
|
||
else:
|
||
link = next(
|
||
(
|
||
item
|
||
for item in sorted(
|
||
prefetched,
|
||
key=lambda item: (item.department.name, item.pk),
|
||
)
|
||
if item.is_active
|
||
),
|
||
None,
|
||
)
|
||
return link.department.name if link is not None else None
|
||
|
||
|
||
class PayrollUserRefSerializer(serializers.ModelSerializer):
|
||
display_name = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = Employee
|
||
fields = ["id", "display_name"]
|
||
read_only_fields = fields
|
||
|
||
def get_display_name(self, obj) -> str:
|
||
return obj.get_full_name().strip() or f"Сотрудник #{obj.pk}"
|
||
|
||
|
||
class PayrollCellCommentSerializer(serializers.Serializer):
|
||
id = serializers.IntegerField(read_only=True)
|
||
author = EmployeeBriefSerializer(read_only=True)
|
||
text = serializers.CharField(read_only=True)
|
||
created_at = serializers.DateTimeField(read_only=True)
|
||
|
||
|
||
class PayrollCellCommentWriteSerializer(serializers.Serializer):
|
||
text = serializers.CharField(max_length=10000, trim_whitespace=True)
|
||
|
||
|
||
class PayrollComponentSerializer(serializers.Serializer):
|
||
id = serializers.CharField(read_only=True)
|
||
code = serializers.CharField(read_only=True)
|
||
name = serializers.CharField(read_only=True)
|
||
kind = serializers.CharField(read_only=True)
|
||
kind_label = serializers.CharField(source="get_kind_display", read_only=True)
|
||
requires_reason = serializers.BooleanField(read_only=True)
|
||
is_active = serializers.BooleanField(read_only=True)
|
||
display_order = serializers.IntegerField(read_only=True)
|
||
|
||
|
||
class PayrollPeriodSerializer(serializers.ModelSerializer):
|
||
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
||
current_run_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
created_by_id = serializers.IntegerField(read_only=True)
|
||
|
||
class Meta:
|
||
model = PayrollPeriod
|
||
fields = [
|
||
"id",
|
||
"code",
|
||
"name",
|
||
"date_from",
|
||
"date_to",
|
||
"pay_date",
|
||
"currency",
|
||
"status",
|
||
"status_label",
|
||
"current_run_id",
|
||
"lock_version",
|
||
"created_by_id",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
|
||
class PayrollCarryoverPeriodSerializer(serializers.ModelSerializer):
|
||
class Meta:
|
||
model = PayrollPeriod
|
||
fields = ["id", "code", "name", "date_from", "date_to", "currency"]
|
||
read_only_fields = fields
|
||
|
||
|
||
class PayrollCarryoverSerializer(serializers.ModelSerializer):
|
||
employee = PayrollEmployeeMiniSerializer(read_only=True)
|
||
source_period = PayrollCarryoverPeriodSerializer(read_only=True)
|
||
target_period = PayrollCarryoverPeriodSerializer(read_only=True)
|
||
direction_label = serializers.CharField(
|
||
source="get_direction_display",
|
||
read_only=True,
|
||
)
|
||
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
||
created_by = PayrollUserRefSerializer(read_only=True)
|
||
generated_input_line_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
can_cancel = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = PayrollCarryover
|
||
fields = [
|
||
"id",
|
||
"source_period",
|
||
"target_period",
|
||
"employee",
|
||
"direction",
|
||
"direction_label",
|
||
"amount",
|
||
"reason",
|
||
"status",
|
||
"status_label",
|
||
"generated_input_line_id",
|
||
"created_by",
|
||
"created_at",
|
||
"transferred_at",
|
||
"cancelled_at",
|
||
"can_cancel",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
def get_can_cancel(self, obj) -> bool:
|
||
return obj.status != PayrollCarryoverStatus.CANCELLED and (
|
||
obj.generated_input_line_id is None
|
||
or obj.generated_input_line.status == ApprovalStatus.DRAFT
|
||
)
|
||
|
||
|
||
class PayrollCarryoverWriteSerializer(serializers.Serializer):
|
||
employee_id = serializers.PrimaryKeyRelatedField(
|
||
source="employee",
|
||
queryset=Employee.objects.all(),
|
||
)
|
||
direction = serializers.ChoiceField(choices=PayrollCarryoverDirection.choices)
|
||
amount = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=2,
|
||
min_value=Decimal("0.01"),
|
||
)
|
||
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
|
||
|
||
|
||
class PayrollPeriodWriteSerializer(serializers.ModelSerializer):
|
||
expected_lock_version = serializers.IntegerField(
|
||
min_value=0,
|
||
required=False,
|
||
write_only=True,
|
||
)
|
||
|
||
class Meta:
|
||
model = PayrollPeriod
|
||
fields = [
|
||
"code",
|
||
"name",
|
||
"date_from",
|
||
"date_to",
|
||
"pay_date",
|
||
"currency",
|
||
"expected_lock_version",
|
||
]
|
||
|
||
def validate(self, attrs):
|
||
if self.instance is not None and "expected_lock_version" not in attrs:
|
||
raise serializers.ValidationError(
|
||
{"expected_lock_version": "Укажите открытую версию периода."}
|
||
)
|
||
return attrs
|
||
|
||
|
||
class DraftWriteSerializerMixin:
|
||
"""Create manual drafts and update them through model optimistic locking."""
|
||
|
||
expected_lock_version = serializers.IntegerField(
|
||
min_value=0,
|
||
required=False,
|
||
write_only=True,
|
||
)
|
||
|
||
def validate(self, attrs):
|
||
attrs = super().validate(attrs)
|
||
if self.instance is not None and "expected_lock_version" not in attrs:
|
||
raise serializers.ValidationError(
|
||
{"expected_lock_version": "Укажите открытую версию черновика."}
|
||
)
|
||
return attrs
|
||
|
||
def create(self, validated_data):
|
||
validated_data.pop("expected_lock_version", None)
|
||
instance = self.Meta.model(
|
||
**validated_data,
|
||
status=ApprovalStatus.DRAFT,
|
||
source=InputSource.MANUAL,
|
||
created_by=self.context["actor"],
|
||
)
|
||
try:
|
||
instance.full_clean()
|
||
except DjangoValidationError as exc:
|
||
raise serializers.ValidationError(_validation_detail(exc)) from exc
|
||
instance.save(force_insert=True)
|
||
return instance
|
||
|
||
def update(self, instance, validated_data):
|
||
expected_lock_version = validated_data.pop("expected_lock_version")
|
||
for field_name, value in validated_data.items():
|
||
setattr(instance, field_name, value)
|
||
instance._expected_lock_version = expected_lock_version
|
||
try:
|
||
instance.full_clean()
|
||
instance.save()
|
||
except DjangoValidationError as exc:
|
||
raise serializers.ValidationError(_validation_detail(exc)) from exc
|
||
return instance
|
||
|
||
|
||
class EmployeePayRateSerializer(serializers.ModelSerializer):
|
||
employee = PayrollEmployeeMiniSerializer(read_only=True)
|
||
employee_id = serializers.IntegerField(read_only=True)
|
||
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
||
created_by_id = serializers.IntegerField(read_only=True)
|
||
approved_by_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
voided_by_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
replaces_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
created_by = PayrollUserRefSerializer(read_only=True)
|
||
approved_by = PayrollUserRefSerializer(read_only=True)
|
||
|
||
class Meta:
|
||
model = EmployeePayRate
|
||
fields = [
|
||
"id",
|
||
"employee_id",
|
||
"employee",
|
||
"rate_code",
|
||
"amount",
|
||
"point_rate",
|
||
"currency",
|
||
"effective_from",
|
||
"revision",
|
||
"status",
|
||
"status_label",
|
||
"lock_version",
|
||
"replaces_id",
|
||
"reason",
|
||
"source",
|
||
"created_by_id",
|
||
"created_by",
|
||
"created_at",
|
||
"updated_at",
|
||
"approved_by_id",
|
||
"approved_by",
|
||
"approved_at",
|
||
"voided_by_id",
|
||
"voided_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
|
||
class EmployeePayRateWriteSerializer(
|
||
DraftWriteSerializerMixin,
|
||
serializers.ModelSerializer,
|
||
):
|
||
expected_lock_version = DraftWriteSerializerMixin.expected_lock_version
|
||
employee_id = serializers.PrimaryKeyRelatedField(
|
||
source="employee",
|
||
queryset=Employee.objects.all(),
|
||
)
|
||
|
||
class Meta:
|
||
model = EmployeePayRate
|
||
fields = [
|
||
"employee_id",
|
||
"rate_code",
|
||
"amount",
|
||
"point_rate",
|
||
"currency",
|
||
"effective_from",
|
||
"reason",
|
||
"expected_lock_version",
|
||
]
|
||
|
||
|
||
class EmployeePayRateRevisionSerializer(serializers.Serializer):
|
||
amount = serializers.DecimalField(max_digits=19, decimal_places=4, required=False)
|
||
point_rate = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=4,
|
||
min_value=0,
|
||
required=False,
|
||
allow_null=True,
|
||
)
|
||
currency = serializers.CharField(max_length=3, required=False)
|
||
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
|
||
|
||
|
||
class BulkPointRateCommandSerializer(serializers.Serializer):
|
||
MODE_FIXED = "fixed"
|
||
MODE_IN_NORM = "in_norm"
|
||
|
||
employee_ids = serializers.ListField(
|
||
child=serializers.IntegerField(min_value=1),
|
||
allow_empty=False,
|
||
)
|
||
mode = serializers.ChoiceField(
|
||
choices=(MODE_FIXED, MODE_IN_NORM),
|
||
required=False,
|
||
)
|
||
point_rate = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=4,
|
||
min_value=0,
|
||
required=False,
|
||
allow_null=True,
|
||
)
|
||
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
|
||
|
||
def validate_employee_ids(self, value):
|
||
return list(dict.fromkeys(value))
|
||
|
||
def validate(self, attrs):
|
||
attrs = super().validate(attrs)
|
||
mode = attrs.get("mode")
|
||
if mode is None:
|
||
mode = (
|
||
self.MODE_FIXED
|
||
if attrs.get("point_rate") is not None
|
||
else self.MODE_IN_NORM
|
||
)
|
||
attrs["mode"] = mode
|
||
if mode == self.MODE_FIXED and attrs.get("point_rate") is None:
|
||
raise serializers.ValidationError(
|
||
{
|
||
"point_rate": "Укажите фиксированную цену или выберите автоматический расчёт."
|
||
}
|
||
)
|
||
if mode == self.MODE_IN_NORM:
|
||
attrs["point_rate"] = None
|
||
return attrs
|
||
|
||
|
||
class BulkPayRateCommandSerializer(serializers.Serializer):
|
||
employee_ids = serializers.ListField(
|
||
child=serializers.IntegerField(min_value=1),
|
||
allow_empty=False,
|
||
)
|
||
amount = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=4,
|
||
min_value=Decimal("0.0001"),
|
||
)
|
||
effective_from = serializers.DateField()
|
||
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
|
||
|
||
def validate_employee_ids(self, value):
|
||
return list(dict.fromkeys(value))
|
||
|
||
|
||
class BulkTargetPointsCommandSerializer(serializers.Serializer):
|
||
MODE_FIXED = "fixed"
|
||
MODE_AUTOMATIC = "automatic"
|
||
|
||
employee_ids = serializers.ListField(
|
||
child=serializers.IntegerField(min_value=1),
|
||
allow_empty=False,
|
||
)
|
||
mode = serializers.ChoiceField(choices=(MODE_FIXED, MODE_AUTOMATIC))
|
||
target_points = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=4,
|
||
min_value=Decimal("0.0001"),
|
||
required=False,
|
||
allow_null=True,
|
||
)
|
||
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
|
||
|
||
def validate_employee_ids(self, value):
|
||
return list(dict.fromkeys(value))
|
||
|
||
def validate(self, attrs):
|
||
attrs = super().validate(attrs)
|
||
if attrs["mode"] == self.MODE_FIXED and attrs.get("target_points") is None:
|
||
raise serializers.ValidationError(
|
||
{"target_points": "Укажите норму баллов."}
|
||
)
|
||
if attrs["mode"] == self.MODE_AUTOMATIC:
|
||
attrs["target_points"] = None
|
||
return attrs
|
||
|
||
|
||
class PayrollWorkRecordSerializer(serializers.ModelSerializer):
|
||
employee = PayrollEmployeeMiniSerializer(read_only=True)
|
||
employee_id = serializers.IntegerField(read_only=True)
|
||
period_id = serializers.IntegerField(read_only=True)
|
||
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
||
created_by_id = serializers.IntegerField(read_only=True)
|
||
approved_by_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
voided_by_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
replaces_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
created_by = PayrollUserRefSerializer(read_only=True)
|
||
approved_by = PayrollUserRefSerializer(read_only=True)
|
||
|
||
class Meta:
|
||
model = PayrollWorkRecord
|
||
fields = [
|
||
"id",
|
||
"period_id",
|
||
"employee_id",
|
||
"employee",
|
||
"target_points",
|
||
"target_points_overridden",
|
||
"actual_points",
|
||
"expected_point_amount",
|
||
"expected_gross",
|
||
"expected_recalculated_gross",
|
||
"expected_payable",
|
||
"revision",
|
||
"status",
|
||
"status_label",
|
||
"lock_version",
|
||
"replaces_id",
|
||
"reason",
|
||
"source",
|
||
"created_by_id",
|
||
"created_by",
|
||
"created_at",
|
||
"updated_at",
|
||
"approved_by_id",
|
||
"approved_by",
|
||
"approved_at",
|
||
"voided_by_id",
|
||
"voided_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
|
||
class PayrollWorkRecordWriteSerializer(
|
||
DraftWriteSerializerMixin,
|
||
serializers.ModelSerializer,
|
||
):
|
||
expected_lock_version = DraftWriteSerializerMixin.expected_lock_version
|
||
period_id = serializers.PrimaryKeyRelatedField(
|
||
source="period",
|
||
queryset=PayrollPeriod.objects.all(),
|
||
)
|
||
employee_id = serializers.PrimaryKeyRelatedField(
|
||
source="employee",
|
||
queryset=Employee.objects.all(),
|
||
)
|
||
target_points = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=4,
|
||
min_value=Decimal("0.0001"),
|
||
required=False,
|
||
allow_null=True,
|
||
)
|
||
|
||
class Meta:
|
||
model = PayrollWorkRecord
|
||
fields = [
|
||
"period_id",
|
||
"employee_id",
|
||
"target_points",
|
||
"actual_points",
|
||
"expected_point_amount",
|
||
"expected_gross",
|
||
"expected_recalculated_gross",
|
||
"expected_payable",
|
||
"reason",
|
||
"expected_lock_version",
|
||
]
|
||
|
||
def validate(self, attrs):
|
||
attrs = super().validate(attrs)
|
||
target_was_sent = "target_points" in attrs
|
||
should_calculate = (self.instance is None and not target_was_sent) or attrs.get(
|
||
"target_points"
|
||
) is None
|
||
if should_calculate:
|
||
period = attrs.get("period") or self.instance.period
|
||
employee = attrs.get("employee") or self.instance.employee
|
||
target_points, _, _ = calculate_period_target_points(
|
||
period,
|
||
employee=employee,
|
||
)
|
||
attrs["target_points"] = target_points
|
||
attrs["target_points_overridden"] = False
|
||
elif target_was_sent:
|
||
attrs["target_points_overridden"] = True
|
||
return attrs
|
||
|
||
|
||
class PayrollWorkRecordRevisionSerializer(serializers.Serializer):
|
||
target_points = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=4,
|
||
min_value=0,
|
||
required=False,
|
||
)
|
||
actual_points = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=4,
|
||
min_value=0,
|
||
required=False,
|
||
)
|
||
expected_point_amount = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=2,
|
||
allow_null=True,
|
||
required=False,
|
||
)
|
||
expected_gross = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=2,
|
||
allow_null=True,
|
||
required=False,
|
||
)
|
||
expected_recalculated_gross = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=2,
|
||
allow_null=True,
|
||
required=False,
|
||
)
|
||
expected_payable = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=2,
|
||
allow_null=True,
|
||
required=False,
|
||
)
|
||
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
|
||
|
||
|
||
class PayrollInputLineSerializer(serializers.ModelSerializer):
|
||
employee = PayrollEmployeeMiniSerializer(read_only=True)
|
||
employee_id = serializers.IntegerField(read_only=True)
|
||
period_id = serializers.IntegerField(read_only=True)
|
||
component = serializers.SerializerMethodField()
|
||
component_code = serializers.CharField(read_only=True)
|
||
component_id = serializers.CharField(source="component_code", read_only=True)
|
||
relates_to_period_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
relates_to_period = PayrollCarryoverPeriodSerializer(read_only=True)
|
||
is_automatic_carryover = serializers.SerializerMethodField()
|
||
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
||
created_by_id = serializers.IntegerField(read_only=True)
|
||
approved_by_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
voided_by_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
created_by = PayrollUserRefSerializer(read_only=True)
|
||
approved_by = PayrollUserRefSerializer(read_only=True)
|
||
|
||
class Meta:
|
||
model = PayrollInputLine
|
||
fields = [
|
||
"id",
|
||
"period_id",
|
||
"employee_id",
|
||
"employee",
|
||
"component_code",
|
||
"component_id",
|
||
"component",
|
||
"amount",
|
||
"relates_to_period_id",
|
||
"relates_to_period",
|
||
"is_automatic_carryover",
|
||
"include_in_carryover",
|
||
"reason",
|
||
"status",
|
||
"status_label",
|
||
"lock_version",
|
||
"source",
|
||
"created_by_id",
|
||
"created_by",
|
||
"created_at",
|
||
"approved_by_id",
|
||
"approved_by",
|
||
"approved_at",
|
||
"voided_by_id",
|
||
"voided_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
def get_is_automatic_carryover(self, obj) -> bool:
|
||
return obj.source == InputSource.API and obj.source_ref.startswith(
|
||
"payroll-carryover:"
|
||
)
|
||
|
||
def get_component(self, obj) -> dict:
|
||
return PayrollComponentSerializer(obj.component).data
|
||
|
||
|
||
class PayrollCarryoverInclusionSerializer(serializers.Serializer):
|
||
included = serializers.BooleanField()
|
||
|
||
|
||
class PayrollInputLineWriteSerializer(
|
||
DraftWriteSerializerMixin,
|
||
serializers.ModelSerializer,
|
||
):
|
||
expected_lock_version = DraftWriteSerializerMixin.expected_lock_version
|
||
period_id = serializers.PrimaryKeyRelatedField(
|
||
source="period",
|
||
queryset=PayrollPeriod.objects.all(),
|
||
)
|
||
employee_id = serializers.PrimaryKeyRelatedField(
|
||
source="employee",
|
||
queryset=Employee.objects.all(),
|
||
)
|
||
component_code = serializers.ChoiceField(
|
||
choices=PAYROLL_COMPONENT_CODE_CHOICES,
|
||
required=False,
|
||
)
|
||
component_id = serializers.ChoiceField(
|
||
source="component_code",
|
||
choices=PAYROLL_COMPONENT_CODE_CHOICES,
|
||
required=False,
|
||
write_only=True,
|
||
)
|
||
relates_to_period_id = serializers.PrimaryKeyRelatedField(
|
||
source="relates_to_period",
|
||
queryset=PayrollPeriod.objects.all(),
|
||
allow_null=True,
|
||
required=False,
|
||
)
|
||
|
||
class Meta:
|
||
model = PayrollInputLine
|
||
fields = [
|
||
"period_id",
|
||
"employee_id",
|
||
"component_code",
|
||
"component_id",
|
||
"amount",
|
||
"relates_to_period_id",
|
||
"reason",
|
||
"expected_lock_version",
|
||
]
|
||
|
||
def validate(self, attrs):
|
||
if not attrs.get("component_code"):
|
||
raise serializers.ValidationError(
|
||
{"component_code": "Обязательное поле."}
|
||
)
|
||
return attrs
|
||
|
||
|
||
class ApprovalCommandSerializer(serializers.Serializer):
|
||
expected_lock_version = serializers.IntegerField(min_value=0)
|
||
|
||
|
||
class ClearComponentCellCommandSerializer(serializers.Serializer):
|
||
employee_id = serializers.IntegerField(min_value=1)
|
||
component_code = serializers.ChoiceField(
|
||
choices=PAYROLL_COMPONENT_CODE_CHOICES,
|
||
required=False,
|
||
)
|
||
component_id = serializers.ChoiceField(
|
||
source="component_code",
|
||
choices=PAYROLL_COMPONENT_CODE_CHOICES,
|
||
required=False,
|
||
write_only=True,
|
||
)
|
||
|
||
def validate(self, attrs):
|
||
if not attrs.get("component_code"):
|
||
raise serializers.ValidationError({"component_code": "Обязательное поле."})
|
||
return attrs
|
||
|
||
|
||
class SetComponentCellCommandSerializer(ClearComponentCellCommandSerializer):
|
||
amount = serializers.DecimalField(
|
||
max_digits=19,
|
||
decimal_places=2,
|
||
min_value=Decimal("0.01"),
|
||
)
|
||
|
||
|
||
class PayrollPaymentConfirmationCommandSerializer(serializers.Serializer):
|
||
confirmed = serializers.BooleanField()
|
||
|
||
|
||
class CopyPayrollInputLinesCommandSerializer(serializers.Serializer):
|
||
source_period_id = serializers.IntegerField(min_value=1)
|
||
component_codes = serializers.ListField(
|
||
child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES),
|
||
allow_empty=False,
|
||
required=False,
|
||
)
|
||
component_ids = serializers.ListField(
|
||
source="component_codes",
|
||
child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES),
|
||
allow_empty=False,
|
||
required=False,
|
||
write_only=True,
|
||
)
|
||
employee_scope = serializers.ChoiceField(
|
||
choices=("all", "active", "selected"),
|
||
)
|
||
existing_mode = serializers.ChoiceField(choices=("skip", "replace"))
|
||
employee_ids = serializers.ListField(
|
||
child=serializers.IntegerField(min_value=1),
|
||
required=False,
|
||
default=list,
|
||
)
|
||
expected_period_lock_version = serializers.IntegerField(min_value=0)
|
||
idempotency_key = serializers.UUIDField()
|
||
remember_configuration = serializers.BooleanField(default=False)
|
||
auto_apply_on_period_creation = serializers.BooleanField(default=False)
|
||
|
||
def validate(self, attrs):
|
||
if not attrs.get("component_codes"):
|
||
raise serializers.ValidationError(
|
||
{"component_codes": "Выберите хотя бы один вид операции."}
|
||
)
|
||
attrs["component_codes"] = list(dict.fromkeys(attrs["component_codes"]))
|
||
attrs["employee_ids"] = list(dict.fromkeys(attrs["employee_ids"]))
|
||
if attrs["employee_scope"] == "selected" and not attrs["employee_ids"]:
|
||
raise serializers.ValidationError(
|
||
{"employee_ids": "Выберите хотя бы одного сотрудника."}
|
||
)
|
||
if attrs["employee_scope"] != "selected":
|
||
attrs["employee_ids"] = []
|
||
if attrs["auto_apply_on_period_creation"] and not attrs["remember_configuration"]:
|
||
raise serializers.ValidationError(
|
||
{
|
||
"auto_apply_on_period_creation": (
|
||
"Для автоприменения сначала сохраните конфигурацию."
|
||
)
|
||
}
|
||
)
|
||
return attrs
|
||
|
||
|
||
class CalculatePayrollCommandSerializer(serializers.Serializer):
|
||
expected_lock_version = serializers.IntegerField(min_value=0)
|
||
recalculation_reason = serializers.CharField(
|
||
allow_blank=True,
|
||
required=False,
|
||
default="",
|
||
trim_whitespace=True,
|
||
)
|
||
idempotency_key = serializers.UUIDField(required=False, allow_null=True)
|
||
|
||
|
||
class AttendanceWorkImportCommandSerializer(serializers.Serializer):
|
||
mode = serializers.ChoiceField(
|
||
choices=("missing_only", "replace_existing"),
|
||
)
|
||
preview_token = serializers.CharField(min_length=64, max_length=64)
|
||
expected_period_lock_version = serializers.IntegerField(min_value=0)
|
||
reason = serializers.CharField(
|
||
allow_blank=True,
|
||
required=False,
|
||
default="",
|
||
trim_whitespace=True,
|
||
)
|
||
|
||
def validate(self, attrs):
|
||
if attrs["mode"] == "replace_existing" and not attrs["reason"]:
|
||
raise serializers.ValidationError(
|
||
{"reason": "Для пересчёта существующих записей укажите причину."}
|
||
)
|
||
return attrs
|
||
|
||
|
||
class WorkbookWorkPreviewSerializer(serializers.Serializer):
|
||
file = serializers.FileField()
|
||
|
||
def validate_file(self, value):
|
||
if not value.name.lower().endswith(".xlsx"):
|
||
raise serializers.ValidationError("Выберите файл Excel в формате .xlsx.")
|
||
if value.size > 10 * 1024 * 1024:
|
||
raise serializers.ValidationError("Excel-файл должен быть не больше 10 МБ.")
|
||
return value
|
||
|
||
|
||
class WorkbookWorkImportSerializer(WorkbookWorkPreviewSerializer):
|
||
mode = serializers.ChoiceField(choices=("skip_existing", "replace_existing"))
|
||
mappings = serializers.CharField(required=False, default="{}")
|
||
expected_file_hash = serializers.RegexField(regex=r"^[0-9a-f]{64}$")
|
||
expected_period_lock_version = serializers.IntegerField(min_value=0)
|
||
|
||
|
||
class ReturnPayrollCommandSerializer(serializers.Serializer):
|
||
reason = serializers.CharField(allow_blank=False, trim_whitespace=True)
|
||
|
||
|
||
class PayrollRunSerializer(serializers.ModelSerializer):
|
||
period_id = serializers.IntegerField(read_only=True)
|
||
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
||
requested_by_id = serializers.IntegerField(read_only=True)
|
||
approved_by_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
published_by_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
supersedes_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||
requested_by = PayrollUserRefSerializer(read_only=True)
|
||
approved_by = PayrollUserRefSerializer(read_only=True)
|
||
published_by = PayrollUserRefSerializer(read_only=True)
|
||
|
||
class Meta:
|
||
model = PayrollRun
|
||
fields = [
|
||
"id",
|
||
"period_id",
|
||
"revision",
|
||
"status",
|
||
"status_label",
|
||
"supersedes_id",
|
||
"recalculation_reason",
|
||
"employee_count",
|
||
"gross_total",
|
||
"deduction_total",
|
||
"payable_total",
|
||
"requested_by_id",
|
||
"requested_by",
|
||
"requested_at",
|
||
"approved_by_id",
|
||
"approved_by",
|
||
"approved_at",
|
||
"published_by_id",
|
||
"published_by",
|
||
"published_at",
|
||
]
|
||
read_only_fields = fields
|
||
|
||
def to_representation(self, instance):
|
||
data = super().to_representation(instance)
|
||
if not self.context.get("include_amounts", False):
|
||
for field_name in ("gross_total", "deduction_total", "payable_total"):
|
||
data[field_name] = None
|
||
return data
|
||
|
||
|
||
class PayrollAccessSettingsWriteSerializer(serializers.Serializer):
|
||
management_user_ids = serializers.ListField(
|
||
child=serializers.IntegerField(min_value=1),
|
||
)
|
||
|
||
|
||
class PayrollAccessSettingsSerializer(serializers.Serializer):
|
||
restricted = serializers.BooleanField(read_only=True)
|
||
management_user_ids = serializers.ListField(
|
||
child=serializers.IntegerField(),
|
||
read_only=True,
|
||
)
|
||
management_user_count = serializers.IntegerField(read_only=True)
|
||
candidates = EmployeeBriefSerializer(many=True, read_only=True)
|
||
|
||
|
||
class PayrollAdminWorkspaceSerializer(serializers.Serializer):
|
||
permissions = serializers.DictField(
|
||
child=serializers.BooleanField(),
|
||
read_only=True,
|
||
)
|
||
access_settings = serializers.JSONField(read_only=True)
|
||
employees = PayrollEmployeeMiniSerializer(many=True, read_only=True)
|
||
rate_employees = PayrollEmployeeMiniSerializer(many=True, read_only=True)
|
||
components = PayrollComponentSerializer(many=True, read_only=True)
|
||
periods = PayrollPeriodSerializer(many=True, read_only=True)
|
||
selected_period = PayrollPeriodSerializer(read_only=True, allow_null=True)
|
||
readiness = serializers.JSONField(read_only=True)
|
||
current_run = PayrollRunSerializer(read_only=True, allow_null=True)
|
||
runs = PayrollRunSerializer(many=True, read_only=True)
|
||
summary = serializers.JSONField(read_only=True, allow_null=True)
|
||
pending_approvals = serializers.JSONField(read_only=True)
|
||
carryovers = serializers.JSONField(read_only=True)
|
||
|
||
|
||
class PayrollPeriodTableSerializer(serializers.Serializer):
|
||
period_id = serializers.IntegerField(read_only=True)
|
||
period_status = serializers.CharField(read_only=True)
|
||
currency = serializers.CharField(read_only=True)
|
||
calculation_rules = serializers.JSONField(read_only=True)
|
||
run = serializers.JSONField(read_only=True, allow_null=True)
|
||
component_columns = serializers.ListField(
|
||
child=serializers.JSONField(),
|
||
read_only=True,
|
||
)
|
||
cell_comments = serializers.ListField(
|
||
child=serializers.JSONField(),
|
||
read_only=True,
|
||
)
|
||
carryovers = serializers.ListField(
|
||
child=serializers.JSONField(),
|
||
read_only=True,
|
||
)
|
||
rows = serializers.ListField(child=serializers.JSONField(), read_only=True)
|
||
summary = serializers.JSONField(read_only=True)
|
||
|
||
|
||
class PayrollPaymentConfirmationSerializer(serializers.Serializer):
|
||
statement_id = serializers.IntegerField(read_only=True)
|
||
employee_id = serializers.IntegerField(read_only=True)
|
||
confirmed = serializers.BooleanField(read_only=True)
|
||
confirmed_at = serializers.DateTimeField(read_only=True, allow_null=True)
|
||
confirmed_by = PayrollUserRefSerializer(read_only=True, allow_null=True)
|
||
|
||
|
||
class PayrollCarryoverLedgerSerializer(serializers.Serializer):
|
||
period_id = serializers.IntegerField(read_only=True)
|
||
currency = serializers.CharField(read_only=True)
|
||
summary = serializers.JSONField(read_only=True)
|
||
entries = serializers.ListField(child=serializers.JSONField(), read_only=True)
|
||
|
||
|
||
class PayrollPeriodDraftApprovalSerializer(serializers.Serializer):
|
||
period_id = serializers.IntegerField(read_only=True)
|
||
summary = serializers.JSONField(read_only=True)
|
||
|
||
|
||
class PayrollCompleteWorkflowSerializer(serializers.Serializer):
|
||
period = PayrollPeriodSerializer(read_only=True)
|
||
run = PayrollRunSerializer(read_only=True)
|
||
completed_steps = serializers.ListField(
|
||
child=serializers.CharField(),
|
||
read_only=True,
|
||
)
|
||
|
||
|
||
class PayrollOperationResultSerializer(serializers.Serializer):
|
||
"""Именованный контракт для составных payroll-команд с JSON-сводкой."""
|
||
|
||
summary = serializers.JSONField(read_only=True, required=False)
|
||
code = serializers.CharField(read_only=True, required=False)
|
||
message = serializers.CharField(read_only=True, required=False)
|