All checks were successful
Deploy production / deploy (push) Successful in 12m30s
1147 lines
42 KiB
Python
1147 lines
42 KiB
Python
"""Read-only reporting projections for payroll administration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from dataclasses import replace
|
|
from datetime import date, timedelta
|
|
from decimal import Decimal
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.db.models import Prefetch, Q
|
|
from django.utils import timezone
|
|
|
|
from attendance.models import AttendanceRecord
|
|
from attendance.services import ABSENCE_ISSUE_MARKERS
|
|
from employees.constants import ACTION_REMOTE, ACTIVATING_MARKER_ACTIONS
|
|
from employees.models import EmployeeAction
|
|
from employees.services.personnel_state import resolve_employee_personnel_state
|
|
from finance.enums import ApprovalStatus, InputSource
|
|
from finance.models import (
|
|
EmployeePayRate,
|
|
PayrollBalanceAdjustment,
|
|
PayrollDailyWorkEntry,
|
|
PayrollInputLine,
|
|
PayrollPeriod,
|
|
PayrollWorkRecord,
|
|
PayrollWorkSettings,
|
|
)
|
|
from finance.payroll.components import get_payroll_components
|
|
from payroll_core import DeterministicPayrollCalculator
|
|
from requests_app.enums import RequestStatus
|
|
from requests_app.models import Request
|
|
|
|
from .adapter import (
|
|
POINT_BASE_NEGATIVE_KINDS,
|
|
build_core_preview_request,
|
|
)
|
|
from .attendance import (
|
|
calculate_attendance_day_points,
|
|
calculate_attendance_points_projection,
|
|
)
|
|
from .balances import opening_balances
|
|
from .config import base_rate_code, build_rules
|
|
from .scheduled_changes import applied_change_payloads, effective_period_lines
|
|
from .work_norm import (
|
|
calculate_period_personnel_daily_points,
|
|
calculate_period_personnel_points,
|
|
calculate_period_target_points,
|
|
period_workdates,
|
|
resolve_employee_schedule,
|
|
)
|
|
|
|
Employee = get_user_model()
|
|
|
|
SYSTEM_COMPONENT_CODES = {"BASE", "POINT_EXCESS", "POINT_ADJUSTMENT"}
|
|
|
|
|
|
def _decimal_text(value):
|
|
if value in (None, ""):
|
|
return None
|
|
return str(value)
|
|
|
|
|
|
def _in_norm_point_rate(amount, target_points, *, rounding):
|
|
if amount in (None, "") or target_points in (None, ""):
|
|
return None
|
|
target = Decimal(str(target_points))
|
|
normalized_amount = Decimal(str(amount))
|
|
if target <= 0 or normalized_amount < 0:
|
|
return None
|
|
return str(
|
|
(normalized_amount / target).quantize(
|
|
Decimal("0.0001"),
|
|
rounding=rounding,
|
|
)
|
|
)
|
|
|
|
|
|
def _point_base_amount(rate_amount, component_amounts, component_definitions):
|
|
if rate_amount in (None, ""):
|
|
return None
|
|
amount = Decimal(str(rate_amount))
|
|
for code, component_amount in component_amounts.items():
|
|
component = component_definitions.get(code)
|
|
if (
|
|
not component
|
|
or not component["include_in_point_base"]
|
|
or not component["include_in_salary"]
|
|
):
|
|
continue
|
|
direction = -1 if component["kind"] in POINT_BASE_NEGATIVE_KINDS else 1
|
|
amount += Decimal(str(component_amount)) * direction
|
|
return amount
|
|
|
|
|
|
def _signed_point_amount(lines):
|
|
point_line = next(
|
|
(line for line in lines if line.code in {"POINT_EXCESS", "POINT_ADJUSTMENT"}),
|
|
None,
|
|
)
|
|
if point_line is None:
|
|
return Decimal("0")
|
|
kind = getattr(point_line.kind, "value", point_line.kind)
|
|
return -point_line.amount if kind == "adjustment_debit" else point_line.amount
|
|
|
|
|
|
def _employee_department(employee):
|
|
links = getattr(employee, "_prefetched_objects_cache", {}).get(
|
|
"departments_links",
|
|
[],
|
|
)
|
|
active_links = sorted(
|
|
(link for link in links if link.is_active),
|
|
key=lambda link: (link.department.name, link.pk),
|
|
)
|
|
return active_links[0].department.name if active_links else None
|
|
|
|
|
|
def _employee_payload(employee):
|
|
return {
|
|
"id": employee.pk,
|
|
"display_name": employee.get_full_name().strip() or f"Сотрудник #{employee.pk}",
|
|
"last_name": employee.last_name,
|
|
"first_name": employee.first_name,
|
|
"patronymic": employee.patronymic,
|
|
"position": employee.position.name if employee.position_id else None,
|
|
"department": _employee_department(employee),
|
|
"is_active": employee.is_active,
|
|
}
|
|
|
|
|
|
def _latest_rate_by_employee(period, employee_ids):
|
|
grouped = defaultdict(list)
|
|
rates = EmployeePayRate.objects.filter(
|
|
employee_id__in=employee_ids,
|
|
rate_code=base_rate_code(),
|
|
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
|
|
effective_from__lte=period.date_from,
|
|
).order_by("employee_id", "effective_from", "revision", "id")
|
|
for rate in rates:
|
|
grouped[rate.employee_id].append(rate)
|
|
return {
|
|
employee_id: max(
|
|
records,
|
|
key=lambda record: (
|
|
record.effective_from,
|
|
record.revision,
|
|
record.pk,
|
|
),
|
|
)
|
|
for employee_id, records in grouped.items()
|
|
}
|
|
|
|
|
|
def _latest_work_by_employee(period, employee_ids):
|
|
records = PayrollWorkRecord.objects.filter(
|
|
period=period,
|
|
employee_id__in=employee_ids,
|
|
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
|
|
).order_by("employee_id", "revision", "id")
|
|
selected = {}
|
|
for record in records:
|
|
selected[record.employee_id] = record
|
|
return selected
|
|
|
|
|
|
def _input_lines_by_employee(period, employee_ids):
|
|
grouped = defaultdict(list)
|
|
records = (
|
|
PayrollInputLine.objects.filter(
|
|
period=period,
|
|
employee_id__in=employee_ids,
|
|
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
|
|
)
|
|
.exclude(source=InputSource.API, source_ref__startswith="payroll-carryover:")
|
|
.select_related("relates_to_period")
|
|
.order_by("employee_id", "component_code", "id")
|
|
)
|
|
for record in records:
|
|
grouped[record.employee_id].append(record)
|
|
return grouped
|
|
|
|
|
|
def _component_definition(
|
|
code,
|
|
label,
|
|
kind,
|
|
order,
|
|
*,
|
|
requires_reason=False,
|
|
include_in_point_base=False,
|
|
include_in_salary=True,
|
|
include_in_carryover=False,
|
|
visible_to_employee=True,
|
|
period_scope="all",
|
|
scope_period_id=None,
|
|
is_custom=False,
|
|
highlight_color="",
|
|
scheduled_target_component_code="",
|
|
):
|
|
return {
|
|
"code": code,
|
|
"label": label,
|
|
"kind": kind,
|
|
"display_order": order,
|
|
"requires_reason": requires_reason,
|
|
"include_in_point_base": include_in_point_base,
|
|
"include_in_salary": include_in_salary,
|
|
"include_in_carryover": include_in_carryover,
|
|
"visible_to_employee": visible_to_employee,
|
|
"period_scope": period_scope,
|
|
"scope_period_id": scope_period_id,
|
|
"is_custom": is_custom,
|
|
"highlight_color": highlight_color,
|
|
"scheduled_target_component_code": scheduled_target_component_code,
|
|
}
|
|
|
|
|
|
def _component_amounts(lines, component_definitions, component_order):
|
|
amounts = defaultdict(lambda: Decimal("0"))
|
|
for line in lines:
|
|
code = line.code
|
|
if code in SYSTEM_COMPONENT_CODES:
|
|
continue
|
|
amounts[code] += line.amount
|
|
component = component_definitions.get(code)
|
|
component_order.setdefault(
|
|
code,
|
|
_component_definition(
|
|
code,
|
|
line.label,
|
|
line.kind,
|
|
component["display_order"] if component else 10_000,
|
|
),
|
|
)
|
|
return {code: str(amount) for code, amount in amounts.items()}
|
|
|
|
|
|
def _source_component_amounts(records, component_definitions, component_order):
|
|
amounts = defaultdict(lambda: Decimal("0"))
|
|
for record in records:
|
|
component = record.component
|
|
amounts[component.code] += record.signed_amount
|
|
component_order.setdefault(
|
|
component.code,
|
|
_component_definition(
|
|
component.code,
|
|
component.name,
|
|
component.kind,
|
|
component.display_order,
|
|
),
|
|
)
|
|
return {code: str(amount) for code, amount in amounts.items()}
|
|
|
|
|
|
def _row_money_total(rows, field):
|
|
values = [Decimal(row[field]) for row in rows if row[field] is not None]
|
|
return str(sum(values, Decimal("0"))) if values else None
|
|
|
|
|
|
def _attendance_record_has_absence(record: AttendanceRecord) -> bool:
|
|
if record.is_absent:
|
|
return True
|
|
values = [*(record.statuses or []), *(record.employee_issues or [])]
|
|
return any(
|
|
marker in str(value).strip().lower()
|
|
for value in values
|
|
for marker in ABSENCE_ISSUE_MARKERS
|
|
)
|
|
|
|
|
|
def _first_employment_date(actions) -> date | None:
|
|
dates = [
|
|
timezone.localtime(action.date).date()
|
|
for action in actions
|
|
if action.action in ACTIVATING_MARKER_ACTIONS and action.date
|
|
]
|
|
return min(dates) if dates else None
|
|
|
|
|
|
def _attendance_alert(
|
|
period: PayrollPeriod,
|
|
*,
|
|
employee,
|
|
actions,
|
|
records,
|
|
schedule,
|
|
target_points,
|
|
actual_points,
|
|
):
|
|
"""Describe explicit absences not covered by a personnel event.
|
|
|
|
Missing attendance rows are deliberately ignored: without a saved analysis
|
|
result the system cannot distinguish an absence from an incomplete sync.
|
|
Remote days and dates covered by personnel state never become warnings.
|
|
"""
|
|
|
|
workdates = set(period_workdates(period, schedule))
|
|
today = timezone.localdate()
|
|
employment_start = _first_employment_date(actions)
|
|
absence_dates = []
|
|
for record in records:
|
|
if record.date not in workdates or record.date >= today:
|
|
continue
|
|
if employment_start is not None and record.date < employment_start:
|
|
continue
|
|
if not _attendance_record_has_absence(record):
|
|
continue
|
|
state = resolve_employee_personnel_state(
|
|
employee,
|
|
record.date,
|
|
actions=actions,
|
|
)
|
|
if (
|
|
record.personnel_status == ACTION_REMOTE
|
|
or state.status == ACTION_REMOTE
|
|
or not state.expects_attendance
|
|
):
|
|
continue
|
|
absence_dates.append(record.date)
|
|
|
|
if not absence_dates:
|
|
return None
|
|
|
|
target = Decimal(str(target_points)) if target_points not in (None, "") else None
|
|
actual = Decimal(str(actual_points)) if actual_points not in (None, "") else None
|
|
points_in_norm = bool(
|
|
target is not None and actual is not None and target > 0 and actual >= target
|
|
)
|
|
ordered_dates = sorted(set(absence_dates))
|
|
return {
|
|
"kind": ("compensated_absence" if points_in_norm else "unconfirmed_absence"),
|
|
"severity": "warning" if points_in_norm else "danger",
|
|
"count": len(ordered_dates),
|
|
"dates": [item.isoformat() for item in ordered_dates],
|
|
"points_in_norm": points_in_norm,
|
|
}
|
|
|
|
|
|
def _balance_adjustment_reference(adjustment):
|
|
"""Read-only links, intentionally outside payroll snapshots and totals."""
|
|
effective = adjustment.effective_period
|
|
return {
|
|
"id": adjustment.pk,
|
|
"employee": {
|
|
"id": adjustment.employee_id,
|
|
"display_name": adjustment.employee.get_full_name().strip()
|
|
or f"Сотрудник #{adjustment.employee_id}",
|
|
"is_active": adjustment.employee.is_active,
|
|
},
|
|
"effective_period": {
|
|
"id": effective.pk,
|
|
"code": effective.code,
|
|
"name": effective.name,
|
|
},
|
|
"source_period_id": adjustment.source_period_id,
|
|
"source_period": (
|
|
{
|
|
"id": adjustment.source_period_id,
|
|
"code": adjustment.source_period.code,
|
|
"name": adjustment.source_period.name,
|
|
}
|
|
if adjustment.source_period_id
|
|
else None
|
|
),
|
|
"amount": str(adjustment.amount),
|
|
"contribution": str(adjustment.contribution),
|
|
"reason": adjustment.reason,
|
|
"is_voided": adjustment.voided_at is not None,
|
|
}
|
|
|
|
|
|
def build_payroll_period_table(period: PayrollPeriod):
|
|
"""Include active employees and inactive employees with period data."""
|
|
|
|
current_run = period.current_run
|
|
statements = {}
|
|
if current_run is not None:
|
|
statements = {
|
|
statement.employee_id: statement
|
|
for statement in current_run.statements.select_related(
|
|
"employee",
|
|
"employee__position",
|
|
"payment_confirmation__confirmed_by",
|
|
).prefetch_related("lines")
|
|
}
|
|
|
|
period_employee_ids = set(
|
|
PayrollWorkRecord.objects.filter(
|
|
period=period,
|
|
status__in=[ApprovalStatus.DRAFT, ApprovalStatus.APPROVED],
|
|
actual_points__gt=0,
|
|
).values_list("employee_id", flat=True)
|
|
)
|
|
approved_input_employee_ids = set(
|
|
PayrollInputLine.objects.filter(
|
|
period=period,
|
|
status=ApprovalStatus.APPROVED,
|
|
).values_list("employee_id", flat=True)
|
|
)
|
|
existing_employee_ids = (
|
|
set(
|
|
Employee.objects.visible_in_ui()
|
|
.filter(is_active=True)
|
|
.values_list("id", flat=True)
|
|
)
|
|
| period_employee_ids
|
|
| approved_input_employee_ids
|
|
| set(statements)
|
|
)
|
|
# References to later adjustments are presentation metadata, never inputs
|
|
# to the original period's calculation or its immutable statements.
|
|
adjustments = list(
|
|
PayrollBalanceAdjustment.objects.filter(
|
|
Q(effective_period__date_from__lte=period.date_from)
|
|
| Q(
|
|
source_period__date_from__lte=period.date_from,
|
|
effective_period__date_from__gt=period.date_to,
|
|
),
|
|
effective_period__currency=period.currency,
|
|
employee__is_hidden_in_ui=False,
|
|
)
|
|
.select_related("employee", "effective_period", "source_period")
|
|
.order_by("effective_period__date_from", "created_at", "pk")
|
|
)
|
|
active_balance_employee_ids = {
|
|
item.employee_id
|
|
for item in adjustments
|
|
if item.voided_at is None
|
|
and item.effective_period.date_from <= period.date_from
|
|
}
|
|
current_adjustment_employee_ids = {
|
|
item.employee_id
|
|
for item in adjustments
|
|
if item.voided_at is None and item.effective_period_id == period.pk
|
|
}
|
|
visible_employee_ids = set(
|
|
Employee.objects.visible_in_ui()
|
|
.filter(id__in=existing_employee_ids | active_balance_employee_ids)
|
|
.values_list("id", flat=True)
|
|
)
|
|
balances = opening_balances(period, visible_employee_ids)
|
|
balance_employee_ids = {
|
|
employee_id
|
|
for employee_id in active_balance_employee_ids
|
|
if employee_id in current_adjustment_employee_ids
|
|
or balances.get(employee_id, Decimal("0")) != 0
|
|
}
|
|
employee_ids = visible_employee_ids & (existing_employee_ids | balance_employee_ids)
|
|
current_adjustments = defaultdict(list)
|
|
later_adjustments = []
|
|
for adjustment in adjustments:
|
|
reference = _balance_adjustment_reference(adjustment)
|
|
if adjustment.effective_period_id == period.pk:
|
|
current_adjustments[adjustment.employee_id].append(reference)
|
|
if (
|
|
adjustment.source_period_id is not None
|
|
and adjustment.source_period.date_from <= period.date_from
|
|
and adjustment.effective_period.date_from > period.date_to
|
|
):
|
|
later_adjustments.append(reference)
|
|
employees = list(
|
|
Employee.objects.filter(id__in=employee_ids)
|
|
.select_related("position")
|
|
.prefetch_related(
|
|
"departments_links__department",
|
|
Prefetch(
|
|
"actions",
|
|
queryset=EmployeeAction.objects.filter(
|
|
Q(source_request__isnull=True)
|
|
| Q(source_request__status=RequestStatus.APPROVED),
|
|
).select_related("source_request"),
|
|
to_attr="payroll_personnel_actions",
|
|
),
|
|
)
|
|
.order_by("last_name", "first_name", "id")
|
|
)
|
|
|
|
attendance_by_employee = defaultdict(list)
|
|
attendance_records = AttendanceRecord.objects.filter(
|
|
employee_id__in=employee_ids,
|
|
date__range=(period.date_from, period.date_to),
|
|
).select_related("analysis_run")
|
|
for record in attendance_records:
|
|
attendance_by_employee[record.employee_id].append(record)
|
|
|
|
rates = _latest_rate_by_employee(period, employee_ids)
|
|
work_records = _latest_work_by_employee(period, employee_ids)
|
|
input_lines = _input_lines_by_employee(period, employee_ids)
|
|
components = get_payroll_components(period)
|
|
components_by_code = {component.code: component for component in components}
|
|
component_definitions = {
|
|
component.code: _component_definition(
|
|
component.code,
|
|
component.name,
|
|
component.kind,
|
|
component.display_order,
|
|
requires_reason=component.requires_reason,
|
|
include_in_point_base=component.include_in_point_base,
|
|
include_in_salary=component.include_in_salary,
|
|
include_in_carryover=component.include_in_carryover,
|
|
visible_to_employee=component.visible_to_employee,
|
|
period_scope=component.period_scope,
|
|
scope_period_id=component.scope_period_id,
|
|
is_custom=component.is_custom,
|
|
highlight_color=component.highlight_color,
|
|
scheduled_target_component_code=(component.scheduled_target_component_code),
|
|
)
|
|
for component in components
|
|
}
|
|
component_order = {
|
|
component.code: component_definitions[component.code]
|
|
for component in components
|
|
if component.code not in SYSTEM_COMPONENT_CODES
|
|
}
|
|
scheduled_component_codes = {
|
|
code
|
|
for code, definition in component_definitions.items()
|
|
if definition["kind"] == "scheduled_change"
|
|
}
|
|
calculator = DeterministicPayrollCalculator()
|
|
active_rules = build_rules()
|
|
preview_rules = replace(active_rules, allow_negative_payable=True)
|
|
daily_target_points = PayrollWorkSettings.get_daily_target_points()
|
|
rows = []
|
|
|
|
for employee in employees:
|
|
calculation_issue = None
|
|
statement = statements.get(employee.pk)
|
|
rate = rates.get(employee.pk)
|
|
work = work_records.get(employee.pk)
|
|
raw_source_lines = input_lines.get(employee.pk, [])
|
|
source_lines = effective_period_lines(
|
|
period,
|
|
employee,
|
|
raw_source_lines,
|
|
scheduled_codes=scheduled_component_codes,
|
|
)
|
|
balance_only = bool(
|
|
statement is None
|
|
and work is None
|
|
and (source_lines or employee.pk in balance_employee_ids)
|
|
and not any(
|
|
components_by_code[line.component_code].include_in_salary
|
|
for line in source_lines
|
|
)
|
|
)
|
|
if balance_only:
|
|
# A balance correction/payment is not a new salary or payslip.
|
|
rate = None
|
|
display_source_lines = [
|
|
line
|
|
for line in raw_source_lines
|
|
if line in source_lines
|
|
or component_definitions.get(line.component_code, {}).get("kind")
|
|
== "scheduled_change"
|
|
]
|
|
applied_scheduled_changes = applied_change_payloads(source_lines)
|
|
schedule, target_points_source = resolve_employee_schedule(employee)
|
|
automatic_target_points, scheduled_workdays, _ = calculate_period_target_points(
|
|
period,
|
|
employee=employee,
|
|
daily_target_points=daily_target_points,
|
|
schedule=schedule,
|
|
)
|
|
if statement is not None:
|
|
snapshot = statement.input_snapshot or {}
|
|
result_lines = list(statement.lines.all())
|
|
point_amount = _signed_point_amount(result_lines)
|
|
component_amounts = _component_amounts(
|
|
result_lines,
|
|
component_definitions,
|
|
component_order,
|
|
)
|
|
component_amounts.update(
|
|
_source_component_amounts(
|
|
[
|
|
line
|
|
for line in display_source_lines
|
|
if component_definitions.get(line.component_code, {}).get(
|
|
"kind"
|
|
)
|
|
== "scheduled_change"
|
|
],
|
|
component_definitions,
|
|
component_order,
|
|
)
|
|
)
|
|
status = "calculated"
|
|
totals = {
|
|
"gross_before_adjustments": str(statement.gross_before_adjustments),
|
|
"adjustment_total": str(statement.adjustment_total),
|
|
"gross_total": str(statement.gross_total),
|
|
"deduction_total": str(statement.deduction_total),
|
|
"net_pay": str(statement.net_pay),
|
|
"payment_total": str(statement.payment_total),
|
|
"non_salary_payment_total": str(
|
|
(statement.result_snapshot or {})
|
|
.get("totals", {})
|
|
.get("non_salary_payment_total", "0")
|
|
),
|
|
"payable": str(statement.payable),
|
|
}
|
|
rate_amount = snapshot.get("base_accrual")
|
|
# Keep the configured value nullable in the editable projection.
|
|
# The snapshot contains the effective value used historically.
|
|
point_rate = _decimal_text(rate.point_rate) if rate else None
|
|
target_points = snapshot.get("target_points")
|
|
actual_points = snapshot.get("actual_points")
|
|
point_delta = _decimal_text(statement.point_delta)
|
|
target_points_automatic = bool(
|
|
work is None or not work.target_points_overridden
|
|
)
|
|
totals_preliminary = False
|
|
opening_balance = statement.opening_balance
|
|
closing_balance = statement.closing_balance
|
|
confirmation = getattr(statement, "payment_confirmation", None)
|
|
payment_confirmation = (
|
|
{
|
|
"confirmed_at": confirmation.confirmed_at.isoformat(),
|
|
"confirmed_by": {
|
|
"id": confirmation.confirmed_by_id,
|
|
"display_name": (
|
|
confirmation.confirmed_by.get_full_name().strip()
|
|
or f"Сотрудник #{confirmation.confirmed_by_id}"
|
|
),
|
|
},
|
|
}
|
|
if confirmation is not None
|
|
else None
|
|
)
|
|
else:
|
|
component_amounts = _source_component_amounts(
|
|
display_source_lines,
|
|
component_definitions,
|
|
component_order,
|
|
)
|
|
rate_amount = _decimal_text(rate.amount) if rate else None
|
|
point_rate = _decimal_text(rate.point_rate) if rate else None
|
|
preview_target_points = (
|
|
work.target_points if work else automatic_target_points
|
|
)
|
|
preview_actual_points = work.actual_points if work else Decimal("0")
|
|
target_points = _decimal_text(preview_target_points)
|
|
actual_points = _decimal_text(work.actual_points) if work else None
|
|
target_points_automatic = bool(
|
|
work is None or not work.target_points_overridden
|
|
)
|
|
point_delta = (
|
|
str(preview_actual_points - preview_target_points)
|
|
if work is not None
|
|
else None
|
|
)
|
|
preview_request = build_core_preview_request(
|
|
period=period,
|
|
employee_id=employee.pk,
|
|
rate=rate,
|
|
target_points=preview_target_points,
|
|
actual_points=preview_actual_points,
|
|
input_lines=source_lines,
|
|
opening_balance=balances.get(employee.pk, Decimal("0")),
|
|
rounding=preview_rules.rounding.value,
|
|
component_definitions=components_by_code,
|
|
)
|
|
if preview_request.point_base_accrual < 0:
|
|
calculation_issue = {
|
|
"code": "NEGATIVE_POINT_BASE",
|
|
"message": (
|
|
"Балльная база отрицательная. Перерасчёт по баллам "
|
|
"остановлен до исправления значений или настроек колонок."
|
|
),
|
|
"point_base": str(preview_request.point_base_accrual),
|
|
}
|
|
preview_request = replace(
|
|
preview_request,
|
|
point_base_accrual=Decimal("0"),
|
|
point_rate=Decimal("0"),
|
|
point_rate_overridden=False,
|
|
)
|
|
preview_result = calculator.calculate(preview_request, preview_rules)
|
|
point_amount = _signed_point_amount(preview_result.lines)
|
|
preview_totals = preview_result.totals
|
|
totals = {
|
|
"gross_before_adjustments": str(
|
|
preview_totals.gross_before_adjustments
|
|
),
|
|
"adjustment_total": str(preview_totals.adjustment_total),
|
|
"gross_total": str(preview_totals.gross_after_adjustments),
|
|
"deduction_total": str(preview_totals.deduction_total),
|
|
"net_pay": str(preview_totals.net_pay),
|
|
"payment_total": str(preview_totals.payment_total),
|
|
"non_salary_payment_total": str(
|
|
preview_totals.non_salary_payment_total
|
|
),
|
|
"payable": str(preview_totals.payable),
|
|
}
|
|
totals_preliminary = True
|
|
opening_balance = preview_request.opening_balance
|
|
closing_balance = preview_totals.closing_balance
|
|
payment_confirmation = None
|
|
has_draft = (
|
|
getattr(rate, "status", None) == ApprovalStatus.DRAFT
|
|
or getattr(work, "status", None) == ApprovalStatus.DRAFT
|
|
or any(line.status == ApprovalStatus.DRAFT for line in source_lines)
|
|
)
|
|
if rate is None or work is None:
|
|
status = "incomplete"
|
|
elif has_draft:
|
|
status = "draft"
|
|
else:
|
|
status = "ready"
|
|
if calculation_issue is not None:
|
|
status = "incomplete"
|
|
if balance_only:
|
|
status = "balance_only"
|
|
|
|
projection_target_points = Decimal(str(target_points or "0"))
|
|
daily_point_value = (
|
|
projection_target_points / Decimal(scheduled_workdays)
|
|
if scheduled_workdays
|
|
else Decimal("0")
|
|
)
|
|
attendance_records_for_employee = attendance_by_employee.get(employee.pk, [])
|
|
attendance_points = (
|
|
calculate_attendance_points_projection(
|
|
attendance_records_for_employee,
|
|
daily_point_value=daily_point_value,
|
|
)
|
|
if attendance_records_for_employee
|
|
else None
|
|
)
|
|
personnel_points, _ = calculate_period_personnel_points(
|
|
period,
|
|
employee=employee,
|
|
actions=employee.payroll_personnel_actions,
|
|
period_target_points=projection_target_points,
|
|
schedule=schedule,
|
|
)
|
|
attendance_alert = (
|
|
None
|
|
if balance_only
|
|
else _attendance_alert(
|
|
period,
|
|
employee=employee,
|
|
actions=employee.payroll_personnel_actions,
|
|
records=attendance_records_for_employee,
|
|
schedule=schedule,
|
|
target_points=target_points,
|
|
actual_points=actual_points,
|
|
)
|
|
)
|
|
|
|
point_base_amount = _point_base_amount(
|
|
rate_amount,
|
|
component_amounts,
|
|
component_definitions,
|
|
)
|
|
in_norm_point_rate = _in_norm_point_rate(
|
|
point_base_amount,
|
|
target_points,
|
|
rounding=preview_rules.rounding.value,
|
|
)
|
|
|
|
rows.append(
|
|
{
|
|
"employee": _employee_payload(employee),
|
|
"status": status,
|
|
"rate_status": rate.status if rate else None,
|
|
"work_status": work.status if work else None,
|
|
"rate_amount": rate_amount,
|
|
"in_norm_point_rate": in_norm_point_rate,
|
|
"point_rate": point_rate,
|
|
"target_points": target_points,
|
|
"target_points_automatic": target_points_automatic,
|
|
"target_points_source": target_points_source,
|
|
"attendance_points": _decimal_text(attendance_points),
|
|
"attendance_alert": attendance_alert,
|
|
"personnel_points": _decimal_text(personnel_points),
|
|
"actual_points": actual_points,
|
|
"point_delta": point_delta,
|
|
"point_amount": _decimal_text(point_amount),
|
|
"component_amounts": component_amounts,
|
|
"totals_preliminary": totals_preliminary,
|
|
"opening_balance": str(opening_balance),
|
|
"closing_balance": str(closing_balance),
|
|
"payment_confirmation": payment_confirmation,
|
|
"calculation_issue": calculation_issue,
|
|
"applied_scheduled_changes": applied_scheduled_changes,
|
|
"manual_balance_adjustments": current_adjustments.get(employee.pk, []),
|
|
**totals,
|
|
}
|
|
)
|
|
|
|
status_counts = defaultdict(int)
|
|
for row in rows:
|
|
status_counts[row["status"]] += 1
|
|
component_columns = sorted(
|
|
component_order.values(),
|
|
key=lambda item: (item["display_order"], item["label"], item["code"]),
|
|
)
|
|
|
|
return {
|
|
"period_id": period.pk,
|
|
"balance_rules_version": period.balance_rules_version,
|
|
"period_status": period.status,
|
|
"currency": period.currency,
|
|
"calculation_rules": active_rules.to_dict(),
|
|
"run": (
|
|
{
|
|
"id": current_run.pk,
|
|
"revision": current_run.revision,
|
|
"status": current_run.status,
|
|
}
|
|
if current_run is not None
|
|
else None
|
|
),
|
|
"component_columns": component_columns,
|
|
"later_balance_adjustments": later_adjustments,
|
|
"rows": rows,
|
|
"summary": {
|
|
"employee_count": len(rows),
|
|
"calculated_count": status_counts["calculated"],
|
|
"ready_count": status_counts["ready"],
|
|
"draft_count": status_counts["draft"],
|
|
"incomplete_count": status_counts["incomplete"],
|
|
"preliminary_count": sum(1 for row in rows if row["totals_preliminary"]),
|
|
"attendance_alert_employee_count": sum(
|
|
1 for row in rows if row["attendance_alert"] is not None
|
|
),
|
|
"attendance_alert_day_count": sum(
|
|
row["attendance_alert"]["count"]
|
|
for row in rows
|
|
if row["attendance_alert"] is not None
|
|
),
|
|
"calculation_issue_count": sum(
|
|
1 for row in rows if row["calculation_issue"] is not None
|
|
),
|
|
"gross_total": _row_money_total(rows, "gross_total"),
|
|
"deduction_total": _row_money_total(rows, "deduction_total"),
|
|
"payable_total": _row_money_total(rows, "payable"),
|
|
},
|
|
}
|
|
|
|
|
|
def build_payroll_employee_point_breakdown(
|
|
period: PayrollPeriod,
|
|
employee,
|
|
):
|
|
"""Build a read-only day-by-day comparison of the three point sources."""
|
|
|
|
actions = list(
|
|
EmployeeAction.objects.filter(employee=employee)
|
|
.filter(
|
|
Q(source_request__isnull=True)
|
|
| Q(source_request__status=RequestStatus.APPROVED)
|
|
)
|
|
.select_related("source_request")
|
|
.order_by("date", "id")
|
|
)
|
|
first_employment_date = _first_employment_date(actions)
|
|
requests = list(
|
|
Request.objects.filter(employee=employee)
|
|
.filter(
|
|
Q(
|
|
date_from__lte=period.date_to,
|
|
date_to__gte=period.date_from,
|
|
)
|
|
| Q(
|
|
date_from__range=(period.date_from, period.date_to),
|
|
date_to__isnull=True,
|
|
)
|
|
| Q(
|
|
date_from__isnull=True,
|
|
created_at__date__range=(period.date_from, period.date_to),
|
|
)
|
|
)
|
|
.select_related("approver")
|
|
.order_by("date_from", "created_at", "id")
|
|
)
|
|
schedule, _ = resolve_employee_schedule(employee)
|
|
workdates = period_workdates(period, schedule)
|
|
workdate_set = set(workdates)
|
|
automatic_target_points, _, _ = calculate_period_target_points(
|
|
period,
|
|
employee=employee,
|
|
schedule=schedule,
|
|
)
|
|
work = _latest_work_by_employee(period, [employee.pk]).get(employee.pk)
|
|
statement = None
|
|
if period.current_run_id:
|
|
statement = period.current_run.statements.filter(employee=employee).first()
|
|
|
|
if statement is not None:
|
|
snapshot = statement.input_snapshot or {}
|
|
target_points = Decimal(
|
|
str(snapshot.get("target_points", automatic_target_points))
|
|
)
|
|
actual_points = Decimal(str(snapshot.get("actual_points", "0")))
|
|
else:
|
|
target_points = work.target_points if work else automatic_target_points
|
|
actual_points = work.actual_points if work else None
|
|
|
|
daily_target_points = (
|
|
target_points / Decimal(len(workdates)) if workdates else Decimal("0")
|
|
)
|
|
attendance_records = {
|
|
record.date: record
|
|
for record in AttendanceRecord.objects.filter(
|
|
employee=employee,
|
|
date__range=(period.date_from, period.date_to),
|
|
).select_related("analysis_run")
|
|
}
|
|
attendance_alert = _attendance_alert(
|
|
period,
|
|
employee=employee,
|
|
actions=actions,
|
|
records=attendance_records.values(),
|
|
schedule=schedule,
|
|
target_points=target_points,
|
|
actual_points=actual_points,
|
|
)
|
|
attendance_alert_dates = set(
|
|
attendance_alert["dates"] if attendance_alert is not None else []
|
|
)
|
|
personnel_points = calculate_period_personnel_daily_points(
|
|
period,
|
|
employee=employee,
|
|
actions=actions,
|
|
period_target_points=target_points,
|
|
schedule=schedule,
|
|
)
|
|
work_entries = {
|
|
entry.work_date: entry
|
|
for entry in PayrollDailyWorkEntry.objects.filter(
|
|
period=period,
|
|
employee=employee,
|
|
)
|
|
}
|
|
dated_work_points_total = sum(
|
|
(entry.actual_points for entry in work_entries.values()),
|
|
Decimal("0"),
|
|
)
|
|
undated_work_points = (
|
|
(actual_points - dated_work_points_total).quantize(Decimal("0.0001"))
|
|
if actual_points is not None
|
|
else None
|
|
)
|
|
if undated_work_points == 0:
|
|
undated_work_points = None
|
|
if work_entries:
|
|
work_points_mode = "daily_entries"
|
|
work_points = {
|
|
workdate: entry.actual_points for workdate, entry in work_entries.items()
|
|
}
|
|
else:
|
|
work_points_mode = "unavailable"
|
|
work_points = {}
|
|
|
|
dates = []
|
|
current = period.date_from
|
|
while current <= period.date_to:
|
|
attendance_record = attendance_records.get(current)
|
|
attendance_day_points = (
|
|
calculate_attendance_day_points(
|
|
attendance_record,
|
|
daily_point_value=daily_target_points,
|
|
)
|
|
if attendance_record is not None
|
|
else None
|
|
)
|
|
personnel_state = resolve_employee_personnel_state(
|
|
employee,
|
|
current,
|
|
actions=actions,
|
|
)
|
|
related_actions = []
|
|
for action in actions:
|
|
action_date = (
|
|
timezone.localtime(action.date).date() if action.date else None
|
|
)
|
|
if action_date is None:
|
|
continue
|
|
action_is_related = action_date == current or (
|
|
action.date_to is not None and action_date <= current <= action.date_to
|
|
)
|
|
if personnel_state.action_id == action.pk:
|
|
action_is_related = True
|
|
if not action_is_related:
|
|
continue
|
|
related_actions.append(
|
|
{
|
|
"id": action.pk,
|
|
"action": action.action,
|
|
"label": action.get_action_display(),
|
|
"date": action_date.isoformat(),
|
|
"date_to": action.date_to.isoformat() if action.date_to else None,
|
|
"comment": action.comment,
|
|
"source_request_id": action.source_request_id,
|
|
}
|
|
)
|
|
related_requests = []
|
|
for personnel_request in requests:
|
|
request_date_from = personnel_request.date_from
|
|
request_date_to = personnel_request.date_to or request_date_from
|
|
request_is_related = bool(
|
|
request_date_from
|
|
and request_date_to
|
|
and request_date_from <= current <= request_date_to
|
|
)
|
|
if request_date_from is None:
|
|
request_is_related = (
|
|
timezone.localtime(personnel_request.created_at).date() == current
|
|
)
|
|
if not request_is_related:
|
|
continue
|
|
related_requests.append(
|
|
{
|
|
"id": personnel_request.pk,
|
|
"type": personnel_request.type,
|
|
"type_label": personnel_request.get_type_display(),
|
|
"title": personnel_request.display_title,
|
|
"status": personnel_request.status,
|
|
"status_label": personnel_request.get_status_display(),
|
|
"date_from": (
|
|
personnel_request.date_from.isoformat()
|
|
if personnel_request.date_from
|
|
else None
|
|
),
|
|
"date_to": (
|
|
personnel_request.date_to.isoformat()
|
|
if personnel_request.date_to
|
|
else None
|
|
),
|
|
"time_from": (
|
|
personnel_request.time_from.isoformat(timespec="minutes")
|
|
if personnel_request.time_from
|
|
else None
|
|
),
|
|
"time_to": (
|
|
personnel_request.time_to.isoformat(timespec="minutes")
|
|
if personnel_request.time_to
|
|
else None
|
|
),
|
|
"duration_minutes": personnel_request.duration_minutes,
|
|
"comment": personnel_request.comment,
|
|
}
|
|
)
|
|
attendance_payload = None
|
|
if attendance_record is not None:
|
|
attendance_payload = {
|
|
"id": attendance_record.pk,
|
|
"arrival_time": attendance_record.arrival_time,
|
|
"departure_time": attendance_record.departure_time,
|
|
"work_hours": attendance_record.work_hours,
|
|
"expected_hours": attendance_record.expected_hours,
|
|
"status_label": (
|
|
attendance_record.personnel_status_label
|
|
or ("Отсутствие" if attendance_record.is_absent else "Рабочий день")
|
|
),
|
|
"issues": list(
|
|
dict.fromkeys(
|
|
str(item)
|
|
for item in [
|
|
*(attendance_record.statuses or []),
|
|
*(attendance_record.employee_issues or []),
|
|
*(attendance_record.technical_issues or []),
|
|
]
|
|
if str(item).strip()
|
|
)
|
|
),
|
|
"is_manually_edited": attendance_record.is_manually_edited,
|
|
}
|
|
work_entry = work_entries.get(current)
|
|
dates.append(
|
|
{
|
|
"date": current.isoformat(),
|
|
"is_workday": current in workdate_set,
|
|
"attendance_points": _decimal_text(attendance_day_points),
|
|
"personnel_points": _decimal_text(personnel_points.get(current)),
|
|
"work_points": _decimal_text(work_points.get(current)),
|
|
"attendance_record": attendance_payload,
|
|
"attendance_alert": (
|
|
"unconfirmed_absence"
|
|
if current.isoformat() in attendance_alert_dates
|
|
else None
|
|
),
|
|
"personnel_detail": {
|
|
"state": (
|
|
{
|
|
"status": "before_employment",
|
|
"label": "До приёма на работу",
|
|
"expects_attendance": False,
|
|
}
|
|
if first_employment_date is not None
|
|
and current < first_employment_date
|
|
else {
|
|
"status": personnel_state.status,
|
|
"label": personnel_state.label or "Обычный рабочий день",
|
|
"expects_attendance": personnel_state.expects_attendance,
|
|
}
|
|
),
|
|
"actions": related_actions,
|
|
"requests": related_requests,
|
|
},
|
|
"work_entry": (
|
|
{
|
|
"id": work_entry.pk,
|
|
"target_points": _decimal_text(work_entry.target_points),
|
|
"actual_points": _decimal_text(work_entry.actual_points),
|
|
"note": work_entry.note,
|
|
"created_at": work_entry.created_at.isoformat(),
|
|
"updated_at": work_entry.updated_at.isoformat(),
|
|
}
|
|
if work_entry is not None
|
|
else None
|
|
),
|
|
}
|
|
)
|
|
current += timedelta(days=1)
|
|
|
|
return {
|
|
"period": {
|
|
"id": period.pk,
|
|
"name": period.name,
|
|
"date_from": period.date_from.isoformat(),
|
|
"date_to": period.date_to.isoformat(),
|
|
},
|
|
"employee": _employee_payload(employee),
|
|
"target_points": _decimal_text(target_points),
|
|
"actual_points": _decimal_text(actual_points),
|
|
"attendance_alert": attendance_alert,
|
|
"undated_work_points": _decimal_text(undated_work_points),
|
|
"undated_work_record": (
|
|
{
|
|
"id": work.pk,
|
|
"actual_points": _decimal_text(undated_work_points),
|
|
"note": work.reason,
|
|
"created_at": work.created_at.isoformat(),
|
|
"updated_at": work.updated_at.isoformat(),
|
|
}
|
|
if undated_work_points is not None and work is not None
|
|
else None
|
|
),
|
|
"work_points_mode": work_points_mode,
|
|
"dates": dates,
|
|
}
|