"""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, }