All checks were successful
Deploy production / deploy (push) Successful in 10m54s
317 lines
11 KiB
Python
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,
|
|
}
|