Merge branch 'develop'

This commit is contained in:
lizerk 2026-04-27 11:30:43 +03:00
commit 2bf79f6c67
3 changed files with 84 additions and 8 deletions

View File

@ -61,6 +61,7 @@ DAYS_RU = {
MATRIX_STATUS_LABELS = {
"empty": "Нет записи",
"technical": "Техсбой",
"personnel_issue": "Кадровое событие",
"underwork": "Недоработка",
"late": "Опоздание",
"overtime": "Переработка",
@ -72,6 +73,7 @@ MATRIX_STATUS_LABELS = {
MATRIX_STATUS_SHORT_LABELS = {
"empty": "",
"technical": "Тех",
"personnel_issue": "Кадр",
"underwork": "НД",
"late": "ОП",
"overtime": "ПР",
@ -99,6 +101,7 @@ PERSONNEL_STATUS_LEGEND = {
MATRIX_STATUS_FILLS = {
"empty": "F1F5F9",
"technical": "FEE2E2",
"personnel_issue": "FEF3C7",
"underwork": "FEF3C7",
"late": "FEF3C7",
"absent": "FEF3C7",
@ -262,6 +265,10 @@ def get_attendance_auto_sync_settings() -> AttendanceAutoSyncSettings:
return settings
def get_attendance_auto_sync_employees():
return Employee.objects.prefetch_related("actions").order_by("-id")
def run_attendance_auto_sync(*, force: bool = False) -> AttendanceAutoSyncSettings:
from common.logstorm_attendance import (
analyze_employee_attendance,
@ -316,7 +323,7 @@ def run_attendance_auto_sync(*, force: bool = False) -> AttendanceAutoSyncSettin
error_count = 0
errors: list[str] = []
for employee in Employee.objects.get_active():
for employee in get_attendance_auto_sync_employees():
schedule_payload = (
get_employee_work_schedule_payload(employee)
or get_standard_work_schedule_payload()
@ -614,6 +621,7 @@ def _append_attendance_matrix_legend_sheet(workbook: Workbook) -> None:
("ОТГ", "Отгул"),
("ДЕКР", "Декрет"),
("Вне штата", "Уволен или еще не принят в выбранный день"),
("Кадровое событие", "Проход при кадровом статусе вне штата"),
("+ Nч", "Зафиксирована работа в нерабочий день"),
("К: N", "Количество комментариев EUSRR к записи"),
("Ссылка", "Клик по ячейке записи открывает модал событий LogStorm в EUSRR"),
@ -1172,6 +1180,8 @@ def _matrix_detail_lines(
def _monthly_matrix_status(record: AttendanceRecord) -> str:
if not record.effective_is_workday:
if record.personnel_status == ACTION_DISMISSED and record.is_overtime:
return "personnel_issue"
if record.is_overtime:
return "overtime"
return "non_working"

View File

@ -15,7 +15,11 @@ from attendance.models import (
StandardWorkSchedule,
)
from common.logstorm_client import LogStormClientError
from employees.constants import ACTION_ON_LEAVE, ACTION_RETURNED_FROM_LEAVE
from employees.constants import (
ACTION_DISMISSED,
ACTION_ON_LEAVE,
ACTION_RETURNED_FROM_LEAVE,
)
from employees.models import EmployeeAction
pytestmark = pytest.mark.django_db
@ -482,12 +486,18 @@ def test_auto_sync_disabled_settings_do_not_run_analysis(user_factory):
assert settings.last_status == "idle"
def test_auto_sync_updates_due_active_employees_and_uses_schedules(user_factory):
def test_auto_sync_updates_due_employees_and_uses_schedules(user_factory):
from attendance.services import run_attendance_auto_sync
employee = user_factory(first_name="Ivan", last_name="Petrov")
other = user_factory(first_name="Anna", last_name="Sidorova")
user_factory(active=False)
inactive = user_factory(first_name="Inactive", last_name="User", active=False)
dismissed = user_factory(first_name="Dismissed", last_name="User", active=False)
EmployeeAction.objects.create(
employee=dismissed,
action=ACTION_DISMISSED,
date=timezone.now() - timedelta(days=10),
)
EmployeeWorkSchedule.objects.create(
employee=employee,
start_time="10:00",
@ -519,22 +529,28 @@ def test_auto_sync_updates_due_active_employees_and_uses_schedules(user_factory)
) as analyze:
settings = run_attendance_auto_sync(force=False)
assert analyze.call_count == 2
assert analyze.call_count == 4
calls_by_employee_id = {
call.kwargs["employee"].id: call.kwargs
for call in analyze.call_args_list
}
employee_call = calls_by_employee_id[employee.id]
other_call = calls_by_employee_id[other.id]
inactive_call = calls_by_employee_id[inactive.id]
dismissed_call = calls_by_employee_id[dismissed.id]
assert employee_call["period_start"] == today - timedelta(days=2)
assert employee_call["period_end"] == today
assert employee_call["schedule"]["start_time"] == "10:00"
assert other_call["schedule"]["start_time"] == "07:30"
assert inactive_call["schedule"]["start_time"] == "07:30"
assert dismissed_call["schedule"]["start_time"] == "07:30"
assert settings.last_status == "success"
assert settings.last_success_count == 2
assert settings.last_success_count == 4
assert settings.last_error_count == 0
assert settings.next_run_at > settings.last_finished_at
assert AttendanceRecord.objects.filter(employee__in=[employee, other]).count() == 2
assert AttendanceRecord.objects.filter(
employee__in=[employee, other, inactive, dismissed],
).count() == 4
def test_auto_sync_continues_after_employee_error(user_factory):
@ -1481,6 +1497,50 @@ def test_personnel_non_working_state_counts_work_as_overtime(
assert worked_record.statuses == ["work_outside_personnel_schedule"]
def test_dismissed_employee_work_is_highlighted_as_personnel_issue(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory(active=False)
_move_auto_hired_before_test_period(employee)
client = auth_client_factory(staff)
EmployeeAction.objects.create(
employee=employee,
action=ACTION_DISMISSED,
date=_aware_datetime(2026, 4, 20),
)
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=_logstorm_result(),
):
response = client.post(_analyze_url(), _payload(employee.id), format="json")
assert response.status_code == 200
worked_record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
assert worked_record.personnel_status == ACTION_DISMISSED
assert worked_record.effective_is_workday is False
assert worked_record.is_overtime is True
assert worked_record.statuses == ["work_outside_personnel_schedule"]
matrix_response = client.get(
_monthly_matrix_url(),
{
"employee_ids": str(employee.id),
"month": "2026-04",
},
)
matrix_row = next(
row for row in matrix_response.json()["rows"] if row["date"] == "2026-04-20"
)
matrix_cell = matrix_row["cells"][str(employee.id)]
assert matrix_cell["status"] == "personnel_issue"
assert matrix_cell["short_label"] == "Вне штата"
assert matrix_cell["display_text"] == "Вне штата + 8.75ч"
assert matrix_cell["primary_label"] == "Уволен / вне штата"
def test_personnel_state_is_recalculated_when_actions_change(
auth_client_factory,
user_factory,

View File

@ -183,7 +183,12 @@ function getMonthKeysBetween(start: string, end: string) {
function matrixCellTone(status: string) {
if (status === "technical") return "bg-red-500/15 text-red-200 hover:bg-red-500/20";
if (status === "underwork" || status === "late" || status === "absent") {
if (
status === "personnel_issue"
|| status === "underwork"
|| status === "late"
|| status === "absent"
) {
return "bg-amber-500/15 text-amber-100 hover:bg-amber-500/20";
}
if (status === "overtime") return "bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/20";
@ -201,6 +206,7 @@ function matrixStatusLabel(status: string) {
const labels: Record<string, string> = {
empty: "Нет записи",
technical: "Техсбой",
personnel_issue: "Кадровое событие",
underwork: "Недоработка",
late: "Опоздание",
overtime: "Переработка",