Игорь Надеин fc534e3bf3
All checks were successful
Deploy production / deploy (push) Successful in 10m54s
Control employee work entry by period
2026-11-04 15:29:27 +03:00

317 lines
11 KiB
Python

"""Read models used by the Finance Admin API."""
from decimal import Decimal
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Max, OuterRef, Subquery
from communications.models import Message
from finance.balance_adjustments.services import locked_periods
from finance.enums import (
ApprovalStatus,
InputSource,
PayrollPeriodStatus,
)
from finance.models import (
PayrollBalanceAdjustment,
PayrollCellCommentThread,
PayrollInputLine,
PayrollPeriod,
PayrollStatement,
)
from finance.payroll.components import get_payroll_components
from payroll_core.balances import carryover_contribution
from ..admin_serializers import (
PayrollCellCommentSerializer,
PayrollComponentSerializer,
PayrollEmployeeMiniSerializer,
PayrollUserRefSerializer,
)
def _cell_comment_summaries(period):
threads = list(
PayrollCellCommentThread.objects.filter(
period=period,
subject_employee__is_hidden_in_ui=False,
).values(
"id",
"subject_employee_id",
"column_id",
)
)
if not threads:
return []
thread_by_id = {thread["id"]: thread for thread in threads}
content_type = ContentType.objects.get_for_model(PayrollCellCommentThread)
totals = (
Message.objects.filter(
chat__type="comments",
chat__context_content_type=content_type,
chat__context_object_id__in=thread_by_id,
is_deleted=False,
)
.values("chat__context_object_id")
.annotate(
count=Count("id"),
latest_at=Max("created_at"),
latest_text=Subquery(
Message.objects.filter(
chat__type="comments",
chat__context_content_type=content_type,
chat__context_object_id=OuterRef("chat__context_object_id"),
is_deleted=False,
)
.order_by("-created_at", "-id")
.values("content")[:1]
),
)
)
result = []
for total in totals:
thread = thread_by_id[int(total["chat__context_object_id"])]
result.append(
{
"employee_id": thread["subject_employee_id"],
"column_id": thread["column_id"],
"count": total["count"],
"latest_at": total["latest_at"],
"latest_text": total["latest_text"],
}
)
return result
def _serialize_cell_comment(message, request):
return PayrollCellCommentSerializer(
{
"id": message.id,
"author": message.author,
"text": message.content,
"created_at": message.created_at,
},
context={"request": request},
).data
def _carryover_summary(period):
statements = (
PayrollStatement.objects.filter(
run=period.current_run,
employee__is_hidden_in_ui=False,
)
if period.current_run_id
else PayrollStatement.objects.none()
)
return {
"outgoing": statements.exclude(closing_balance=0).count(),
"incoming": statements.exclude(opening_balance=0).count(),
"pending": 0,
}
def _automatic_balance_summaries(rows):
summaries = []
for row in rows:
balance = Decimal(row["closing_balance"])
if balance == 0:
continue
summaries.append(
{
"employee_id": row["employee"]["id"],
"count": 1,
"company_owes": str(max(balance, Decimal("0.00"))),
"employee_owes": str(max(-balance, Decimal("0.00"))),
"pending": 0,
}
)
return summaries
def _carryover_ledger(period):
lines = list(
PayrollInputLine.objects.filter(
period__currency=period.currency,
period__date_to__lte=period.date_to,
employee__is_hidden_in_ui=False,
)
.exclude(
source=InputSource.API,
source_ref__startswith="payroll-carryover:",
)
.select_related(
"period",
"employee",
"employee__position",
"created_by",
)
.prefetch_related("employee__departments_links__department")
.order_by("-period__date_to", "-created_at", "-id")
)
components_by_period = {
source_period.pk: {
component.code: component
for component in get_payroll_components(source_period)
if component.include_in_carryover
}
for source_period in {line.period for line in lines}
}
entries = []
balances_by_employee = {}
included_count = 0
excluded_count = 0
for line in lines:
component = components_by_period.get(line.period_id, {}).get(
line.component_code
)
if component is None:
continue
current_period_line = line.period_id == period.pk
active_status = line.status == ApprovalStatus.APPROVED or (
current_period_line
and period.status != PayrollPeriodStatus.CLOSED
and line.status == ApprovalStatus.DRAFT
)
counts_in_balance = bool(line.include_in_carryover and active_status)
raw_contribution = carryover_contribution(
component.kind, line.amount, version=line.period.balance_rules_version
)
contribution = raw_contribution if counts_in_balance else Decimal("0.00")
balances_by_employee[line.employee_id] = (
balances_by_employee.get(line.employee_id, Decimal("0.00")) + contribution
)
if line.include_in_carryover:
included_count += 1
else:
excluded_count += 1
entries.append(
{
"id": line.pk,
"period": {
"id": line.period_id,
"code": line.period.code,
"name": line.period.name,
"date_from": line.period.date_from,
"date_to": line.period.date_to,
"currency": line.period.currency,
},
"employee": PayrollEmployeeMiniSerializer(line.employee).data,
"component": PayrollComponentSerializer(component).data,
"amount": str(line.amount),
"raw_contribution": str(raw_contribution),
"contribution": str(contribution),
"reason": line.reason,
"status": line.status,
"status_label": line.get_status_display(),
"include_in_carryover": line.include_in_carryover,
"counts_in_balance": counts_in_balance,
"created_by": PayrollUserRefSerializer(line.created_by).data,
"created_at": line.created_at,
}
)
frozen_starts = list(
locked_periods(
PayrollPeriod.objects.filter(currency=period.currency)
).values_list("date_from", flat=True)
)
adjustments = (
PayrollBalanceAdjustment.objects.filter(
effective_period__currency=period.currency,
effective_period__date_from__lte=period.date_from,
employee__is_hidden_in_ui=False,
)
.select_related(
"effective_period",
"source_period",
"employee",
"employee__position",
"created_by",
"voided_by",
)
.prefetch_related("employee__departments_links__department")
)
for adjustment in adjustments:
active = adjustment.voided_at is None
contribution = adjustment.contribution if active else Decimal("0.00")
balances_by_employee[adjustment.employee_id] = (
balances_by_employee.get(adjustment.employee_id, Decimal("0.00"))
+ contribution
)
included_count += int(active)
excluded_count += int(not active)
effective = adjustment.effective_period
entries.append(
{
"id": adjustment.pk,
"entry_type": "manual_adjustment",
"period": {
"id": effective.pk,
"code": effective.code,
"name": effective.name,
"date_from": effective.date_from,
"date_to": effective.date_to,
"currency": effective.currency,
},
"source_period": (
{
"id": adjustment.source_period_id,
"name": adjustment.source_period.name,
"code": adjustment.source_period.code,
}
if adjustment.source_period_id
else None
),
"employee": PayrollEmployeeMiniSerializer(adjustment.employee).data,
"component": {
"code": "MANUAL_BALANCE_ADJUSTMENT",
"name": "Ручная корректировка остатка",
},
"amount": str(adjustment.amount),
"raw_contribution": str(adjustment.contribution),
"contribution": str(contribution),
"reason": adjustment.reason,
"status": "approved" if active else "voided",
"status_label": "Действует" if active else "Аннулирована",
"include_in_carryover": active,
"counts_in_balance": active,
"created_by": PayrollUserRefSerializer(adjustment.created_by).data,
"created_at": adjustment.created_at,
"can_void": active
and not any(start >= effective.date_from for start in frozen_starts),
"voided_by": PayrollUserRefSerializer(adjustment.voided_by).data
if adjustment.voided_by_id
else None,
"voided_at": adjustment.voided_at,
"void_reason": adjustment.void_reason,
}
)
entries.sort(
key=lambda entry: (
entry["period"]["date_from"],
entry["created_at"],
entry["id"],
),
reverse=True,
)
company_owes = sum(
(max(balance, Decimal("0.00")) for balance in balances_by_employee.values()),
Decimal("0.00"),
)
employee_owes = sum(
(max(-balance, Decimal("0.00")) for balance in balances_by_employee.values()),
Decimal("0.00"),
)
return {
"period_id": period.pk,
"currency": period.currency,
"summary": {
"included_count": included_count,
"excluded_count": excluded_count,
"company_owes": str(company_owes),
"employee_owes": str(employee_owes),
"balance": str(company_owes - employee_owes),
},
"entries": entries,
}