Corp/backend/attendance/services.py

632 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from calendar import monthrange
from typing import Any
from django.db import transaction
from django.utils.dateparse import parse_date
from attendance.models import (
AttendanceAnalysisRun,
AttendanceRecord,
EmployeeWorkSchedule,
)
from employees.constants import (
ACTION_DISMISSED,
ACTION_ON_DAY_OFF,
ACTION_ON_LEAVE,
ACTION_ON_MATERNITY,
ACTION_ON_SICK_LEAVE,
)
from employees.models import Employee
PERSONNEL_STATUS_NORMAL = "normal"
MONTHS_RU = {
1: "Январь",
2: "Февраль",
3: "Март",
4: "Апрель",
5: "Май",
6: "Июнь",
7: "Июль",
8: "Август",
9: "Сентябрь",
10: "Октябрь",
11: "Ноябрь",
12: "Декабрь",
}
DAYS_RU = {
0: "Пн",
1: "Вт",
2: "Ср",
3: "Чт",
4: "Пт",
5: "Сб",
6: "Вс",
}
NON_WORKING_PERSONNEL_ACTIONS = {
ACTION_ON_LEAVE,
ACTION_ON_SICK_LEAVE,
ACTION_ON_DAY_OFF,
ACTION_ON_MATERNITY,
ACTION_DISMISSED,
}
SUPPRESSED_EMPLOYEE_ISSUE_MARKERS = (
"absence",
"absent",
"late",
"early",
"underwork",
"отсутств",
"опозд",
"ранн",
"недоработ",
)
DEFAULT_WORK_SCHEDULE_PAYLOAD = {
"start_time": "08:00",
"end_time": "17:00",
"expected_hours": 9,
"workdays": list(EmployeeWorkSchedule.DEFAULT_WORKDAYS),
"date_overrides": [],
}
@dataclass(frozen=True)
class PersonnelDayState:
status: str = PERSONNEL_STATUS_NORMAL
label: str = ""
action_id: int | None = None
is_non_working: bool = False
def save_logstorm_attendance_result(
*,
employee: Employee,
period_start: date,
period_end: date,
request_payload: dict[str, Any],
response_payload: dict[str, Any],
schedule_payload: dict[str, Any] | None = None,
triggered_by: Employee | None = None,
) -> AttendanceAnalysisRun:
records = response_payload.get("records")
if not isinstance(records, list):
records = []
personnel_actions = list(
employee.actions.filter(date__date__lte=period_end).order_by("date", "id")
)
with transaction.atomic():
run = AttendanceAnalysisRun.objects.create(
employee=employee,
period_start=period_start,
period_end=period_end,
status=AttendanceAnalysisRun.STATUS_SUCCESS,
schedule_payload=schedule_payload,
request_payload=request_payload,
response_payload=response_payload,
triggered_by=triggered_by
if getattr(triggered_by, "is_authenticated", False)
else None,
)
for raw_record in records:
if not isinstance(raw_record, dict):
continue
record_date = _parse_record_date(raw_record.get("date"))
if record_date is None:
continue
personnel_state = resolve_employee_day_state(
personnel_actions,
record_date,
)
record_defaults = build_attendance_record_defaults(
raw_record,
personnel_state,
)
existing_record = AttendanceRecord.objects.filter(
employee=employee,
date=record_date,
).first()
if (
existing_record
and existing_record.is_manually_edited
and not raw_record.get("manual_edited")
):
record_defaults = apply_manual_edit_payload_to_defaults(
record_defaults,
existing_record.manual_edit_payload,
)
record_defaults.update(
{
"is_manually_edited": True,
"manual_edit_payload": existing_record.manual_edit_payload,
"manual_edited_by": existing_record.manual_edited_by,
"manual_edited_at": existing_record.manual_edited_at,
}
)
AttendanceRecord.objects.update_or_create(
employee=employee,
date=record_date,
defaults={"analysis_run": run, **record_defaults},
)
return run
def get_employee_work_schedule_payload(
employee: Employee,
) -> dict[str, Any] | None:
try:
schedule = employee.work_schedule
except EmployeeWorkSchedule.DoesNotExist:
return None
if schedule is None or not schedule.is_active:
return None
return schedule.to_logstorm_payload()
def get_default_work_schedule_payload() -> dict[str, Any]:
return {
**DEFAULT_WORK_SCHEDULE_PAYLOAD,
"workdays": list(DEFAULT_WORK_SCHEDULE_PAYLOAD["workdays"]),
"date_overrides": list(DEFAULT_WORK_SCHEDULE_PAYLOAD["date_overrides"]),
}
def build_monthly_attendance_matrix(
*,
employees,
records,
year: int,
month: int,
) -> dict[str, Any]:
"""Build an Excel-like monthly attendance matrix for API/UI usage."""
employees = list(employees)
records_by_key = {
(record.employee_id, record.date): record
for record in records
}
days_in_month = monthrange(year, month)[1]
dates = [date(year, month, day) for day in range(1, days_in_month + 1)]
rows = []
for current_date in dates:
row = {
"date": current_date.isoformat(),
"label": f"{current_date.day} ({DAYS_RU[current_date.weekday()]})",
"weekday": DAYS_RU[current_date.weekday()],
"is_weekend": current_date.weekday() >= 5,
"cells": {},
}
for employee in employees:
record = records_by_key.get((employee.id, current_date))
row["cells"][str(employee.id)] = _monthly_matrix_cell(record)
rows.append(row)
summary_rows = [
("accounted_days", "Учтено(дней)", _summary_accounted_days),
("unaccounted_days", "Неучтено(дней)", _summary_unaccounted_days),
("worked_hours", "Отработано(часов)", _summary_worked_hours),
("late_days", "Опозданий", _summary_late_days),
("early_leave_days", "Ранних уходов", _summary_early_leave_days),
("overtime_days", "Переработок", _summary_overtime_days),
("absent_days", "Отсутствий", _summary_absent_days),
]
summary = []
for key, label, counter in summary_rows:
summary.append(
{
"key": key,
"label": label,
"values": {
str(employee.id): counter(
[
records_by_key[(employee.id, current_date)]
for current_date in dates
if (employee.id, current_date) in records_by_key
]
)
for employee in employees
},
}
)
return {
"month": f"{year:04d}-{month:02d}",
"month_label": f"{MONTHS_RU[month]} {year}",
"employees": [
{
"id": employee.id,
"name": _employee_display_name(employee),
"email": getattr(employee, "email", "") or "",
}
for employee in employees
],
"rows": rows,
"summary": summary,
}
def resolve_employee_day_state(
personnel_actions,
record_date: date,
) -> PersonnelDayState:
day_actions = [
action
for action in personnel_actions
if action.date and action.date.date() <= record_date
]
if not day_actions:
return PersonnelDayState()
action = day_actions[-1]
if action.action not in NON_WORKING_PERSONNEL_ACTIONS:
return PersonnelDayState(action_id=action.id)
return PersonnelDayState(
status=action.action,
label=action.get_action_display(),
action_id=action.id,
is_non_working=True,
)
def build_attendance_record_defaults(
raw_record: dict[str, Any],
personnel_state: PersonnelDayState,
) -> dict[str, Any]:
work_hours = _nullable_float(raw_record.get("work_hours"))
has_work = bool(
_nullable_string(raw_record.get("arrival_time"))
or _nullable_string(raw_record.get("departure_time"))
or (work_hours is not None and work_hours > 0)
)
is_workday = bool(raw_record.get("is_workday", True))
if "effective_is_workday" in raw_record:
effective_is_workday = bool(raw_record.get("effective_is_workday"))
else:
effective_is_workday = is_workday and not personnel_state.is_non_working
is_overtime = bool(raw_record.get("is_overtime", False))
overtime_hours = _nullable_float(raw_record.get("overtime_hours"))
is_late = bool(raw_record.get("is_late", False))
late_minutes = _nullable_int(raw_record.get("late_minutes"))
is_early_leave = bool(raw_record.get("is_early_leave", False))
early_leave_minutes = _nullable_int(raw_record.get("early_leave_minutes"))
is_underwork = bool(raw_record.get("is_underwork", False))
underwork_hours = _nullable_float(raw_record.get("underwork_hours"))
is_absent = bool(raw_record.get("is_absent", False))
statuses = _list_value(raw_record.get("statuses"))
employee_issues = _list_value(raw_record.get("employee_issues"))
technical_issues = _list_value(raw_record.get("technical_issues"))
non_working_reason = _non_working_reason(
is_workday=is_workday,
effective_is_workday=effective_is_workday,
personnel_state=personnel_state,
)
if not effective_is_workday:
is_late = False
late_minutes = None
is_early_leave = False
early_leave_minutes = None
is_underwork = False
underwork_hours = None
is_absent = False
statuses = _suppress_personnel_non_working_issues(statuses)
employee_issues = _suppress_personnel_non_working_issues(employee_issues)
technical_issues = _suppress_personnel_non_working_issues(technical_issues)
if has_work:
is_overtime = True
if overtime_hours is None:
overtime_hours = work_hours
statuses = _append_unique(statuses, "work_outside_personnel_schedule")
return {
"display_name": str(raw_record.get("display_name") or ""),
"arrival_time": _nullable_string(raw_record.get("arrival_time")),
"departure_time": _nullable_string(raw_record.get("departure_time")),
"work_hours": work_hours,
"expected_hours": _nullable_float(raw_record.get("expected_hours")),
"is_workday": is_workday,
"effective_is_workday": effective_is_workday,
"is_late": is_late,
"late_minutes": late_minutes,
"is_early_leave": is_early_leave,
"early_leave_minutes": early_leave_minutes,
"is_underwork": is_underwork,
"underwork_hours": underwork_hours,
"is_overtime": is_overtime,
"overtime_hours": overtime_hours,
"is_absent": is_absent,
"statuses": statuses,
"employee_issues": employee_issues,
"technical_issues": technical_issues,
"personnel_status": personnel_state.status,
"personnel_status_label": personnel_state.label,
"personnel_action_id": personnel_state.action_id,
"is_manually_edited": bool(raw_record.get("manual_edited", False)),
"manual_edit_payload": (
raw_record.get("manual_edit_payload")
if isinstance(raw_record.get("manual_edit_payload"), dict)
else {}
),
"raw_data": {**raw_record, "non_working_reason": non_working_reason},
}
def apply_manual_edit_payload_to_defaults(
defaults: dict[str, Any],
payload: dict[str, Any],
) -> dict[str, Any]:
"""Apply saved manual fields to a freshly analyzed record payload."""
if not isinstance(payload, dict):
return defaults
result = dict(defaults)
editable_fields = {
"arrival_time",
"departure_time",
"work_hours",
"expected_hours",
"is_workday",
"effective_is_workday",
"is_late",
"late_minutes",
"is_early_leave",
"early_leave_minutes",
"is_underwork",
"underwork_hours",
"is_overtime",
"overtime_hours",
"is_absent",
}
for field in editable_fields:
if field in payload:
result[field] = payload[field]
_normalize_issues_after_manual_payload(result, payload)
result["raw_data"] = {
**result.get("raw_data", {}),
"manual_edited": True,
"manual_edit_payload": {
key: value
for key, value in payload.items()
if key in editable_fields
},
}
return result
def normalize_attendance_record_manual_issues(
record: AttendanceRecord,
payload: dict[str, Any],
) -> None:
fields = {
"statuses": list(record.statuses),
"employee_issues": list(record.employee_issues),
}
_normalize_issues_after_manual_payload(fields, payload)
record.statuses = fields["statuses"]
record.employee_issues = fields["employee_issues"]
def _normalize_issues_after_manual_payload(
defaults: dict[str, Any],
payload: dict[str, Any],
) -> None:
markers_by_flag = {
"is_late": ("late", "опозд"),
"is_early_leave": ("early", "ранн"),
"is_underwork": ("underwork", "недоработ"),
"is_absent": ("absence", "absent", "отсутств"),
}
for flag, markers in markers_by_flag.items():
if payload.get(flag) is not False:
continue
for field in ("statuses", "employee_issues"):
defaults[field] = [
item
for item in _list_value(defaults.get(field))
if not any(marker in str(item).lower() for marker in markers)
]
def _parse_record_date(value: Any) -> date | None:
if isinstance(value, date):
return value
if value is None:
return None
return parse_date(str(value))
def _nullable_string(value: Any) -> str | None:
if value in (None, ""):
return None
return str(value)
def _nullable_float(value: Any) -> float | None:
if value in (None, ""):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _nullable_int(value: Any) -> int | None:
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _list_value(value: Any) -> list[Any]:
return value if isinstance(value, list) else []
def _suppress_personnel_non_working_issues(values: list[Any]) -> list[Any]:
result = []
for value in values:
normalized = str(value).lower()
if any(marker in normalized for marker in SUPPRESSED_EMPLOYEE_ISSUE_MARKERS):
continue
result.append(value)
return result
def _append_unique(values: list[Any], value: str) -> list[Any]:
if value in values:
return values
return [*values, value]
def _monthly_matrix_cell(record: AttendanceRecord | None) -> dict[str, Any]:
if record is None:
return {
"record_id": None,
"arrival_time": None,
"departure_time": None,
"work_hours": None,
"expected_hours": None,
"status": "empty",
"issues": [],
"is_workday": None,
"effective_is_workday": None,
"non_working_reason": "",
"comments_count": 0,
}
issues = [
*record.statuses,
*record.employee_issues,
*record.technical_issues,
]
comments_count = getattr(record, "comments_count", 0) or 0
return {
"record_id": record.id,
"arrival_time": record.arrival_time,
"departure_time": record.departure_time,
"work_hours": record.work_hours,
"expected_hours": record.expected_hours,
"status": _monthly_matrix_status(record),
"issues": issues,
"is_workday": record.is_workday,
"effective_is_workday": record.effective_is_workday,
"non_working_reason": record.raw_data.get("non_working_reason")
or _record_non_working_reason(record),
"is_late": record.is_late,
"is_early_leave": record.is_early_leave,
"is_underwork": record.is_underwork,
"is_overtime": record.is_overtime,
"is_absent": record.is_absent,
"personnel_status": record.personnel_status,
"personnel_status_label": record.personnel_status_label,
"comments_count": comments_count,
}
def _monthly_matrix_status(record: AttendanceRecord) -> str:
if not record.effective_is_workday:
if record.is_overtime:
return "overtime"
return "non_working"
if record.technical_issues:
return "technical"
if record.is_underwork:
return "underwork"
if record.is_late:
return "late"
if record.is_overtime:
return "overtime"
if record.is_absent:
return "absent"
return "normal"
def _non_working_reason(
*,
is_workday: bool,
effective_is_workday: bool,
personnel_state: PersonnelDayState,
) -> str:
if effective_is_workday:
return ""
if personnel_state.is_non_working:
return personnel_state.label or "Кадровое нерабочее состояние"
if not is_workday:
return "Выходной по графику/календарю"
return "Нерабочий день"
def _record_non_working_reason(record: AttendanceRecord) -> str:
if record.effective_is_workday:
return ""
if record.personnel_status_label:
return record.personnel_status_label
if not record.is_workday:
return "Выходной по графику/календарю"
return "Нерабочий день"
def _is_valid_workday_record(record: AttendanceRecord) -> bool:
return bool(record.effective_is_workday and not record.technical_issues)
def _summary_accounted_days(records: list[AttendanceRecord]) -> int:
return sum(1 for record in records if _is_valid_workday_record(record))
def _summary_unaccounted_days(records: list[AttendanceRecord]) -> int:
return sum(1 for record in records if record.effective_is_workday and record.technical_issues)
def _summary_worked_hours(records: list[AttendanceRecord]) -> float:
total = sum(
record.work_hours or 0
for record in records
if _is_valid_workday_record(record)
)
return round(total, 2)
def _summary_late_days(records: list[AttendanceRecord]) -> int:
return sum(1 for record in records if _is_valid_workday_record(record) and record.is_late)
def _summary_early_leave_days(records: list[AttendanceRecord]) -> int:
return sum(
1
for record in records
if _is_valid_workday_record(record) and record.is_early_leave
)
def _summary_overtime_days(records: list[AttendanceRecord]) -> int:
return sum(1 for record in records if _is_valid_workday_record(record) and record.is_overtime)
def _summary_absent_days(records: list[AttendanceRecord]) -> int:
return sum(1 for record in records if _is_valid_workday_record(record) and record.is_absent)
def _employee_display_name(employee: Employee) -> str:
full_name = f"{employee.last_name or ''} {employee.first_name or ''}".strip()
return full_name or employee.email or f"#{employee.id}"