Merge branch 'develop'

This commit is contained in:
lizerk 2026-04-27 11:11:28 +03:00
commit a3d4e4e693
3 changed files with 126 additions and 13 deletions

View File

@ -75,7 +75,7 @@ MATRIX_STATUS_SHORT_LABELS = {
"underwork": "НД",
"late": "ОП",
"overtime": "ПР",
"absent": "Н",
"absent": "Отс.",
"non_working": "Нер",
"normal": "OK",
}
@ -127,6 +127,8 @@ SUPPRESSED_EMPLOYEE_ISSUE_MARKERS = (
"недоработ",
)
ABSENCE_ISSUE_MARKERS = ("absence", "absent", "отсутств")
DEFAULT_WORK_SCHEDULE_PAYLOAD = {
"start_time": "08:00",
"end_time": "17:00",
@ -604,7 +606,7 @@ def _append_attendance_matrix_legend_sheet(workbook: Workbook) -> None:
("ОП", "Опоздание"),
("НД", "Недоработка"),
("ПР", "Переработка"),
("Н", "Отсутствие"),
("Отс.", "Отсутствие"),
("Вых", "Выходной по графику/календарю"),
("Тех", "Технический сбой"),
("ОТП", "Отпуск"),
@ -745,6 +747,18 @@ def build_attendance_record_defaults(
personnel_state=personnel_state,
)
if (
effective_is_workday
and not has_work
and not is_absent
and _has_absence_issue([*statuses, *employee_issues])
):
is_absent = True
if is_absent and not has_work:
is_underwork = False
underwork_hours = None
if not effective_is_workday:
is_late = False
late_minutes = None
@ -873,6 +887,14 @@ def _normalize_issues_after_manual_payload(
]
def _has_absence_issue(values: list[Any]) -> bool:
return any(
marker in str(value).lower()
for value in values
for marker in ABSENCE_ISSUE_MARKERS
)
def _parse_record_date(value: Any) -> date | None:
if isinstance(value, date):
return value
@ -1155,7 +1177,7 @@ def _monthly_matrix_status(record: AttendanceRecord) -> str:
return "non_working"
if record.technical_issues:
return "technical"
if record.is_absent and not record.arrival_time and not record.departure_time:
if _record_has_absence(record):
return "absent"
if record.is_underwork:
return "underwork"
@ -1163,11 +1185,21 @@ def _monthly_matrix_status(record: AttendanceRecord) -> str:
return "late"
if record.is_overtime:
return "overtime"
if record.is_absent:
return "absent"
return "normal"
def _record_has_absence(record: AttendanceRecord) -> bool:
return bool(
record.is_absent
or _has_absence_issue(
[
*record.statuses,
*record.employee_issues,
]
)
)
def _non_working_reason(
*,
is_workday: bool,
@ -1231,7 +1263,11 @@ def _summary_overtime_days(records: list[AttendanceRecord]) -> int:
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)
return sum(
1
for record in records
if _is_valid_workday_record(record) and _record_has_absence(record)
)
def _employee_display_name(employee: Employee) -> str:

View File

@ -1029,10 +1029,68 @@ def test_monthly_matrix_prioritizes_absence_when_no_times_and_no_personnel_reaso
)
matrix_cell = matrix_row["cells"][str(employee.id)]
assert matrix_cell["status"] == "absent"
assert matrix_cell["display_text"] == "Н"
assert matrix_cell["display_text"] == "Отс."
assert matrix_cell["primary_label"] == "Отсутствие"
def test_monthly_matrix_infers_absence_from_issue_when_logstorm_omits_flag(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory()
client = auth_client_factory(staff)
result = {
"records": [
{
"date": "2026-04-20",
"employee_id": str(employee.id),
"display_name": "Absent User",
"arrival_time": None,
"departure_time": None,
"work_hours": 0,
"expected_hours": 9,
"is_workday": True,
"is_underwork": True,
"underwork_hours": 9,
"employee_issues": ["Отсутствие"],
"technical_issues": [],
}
]
}
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=result,
):
response = client.post(
_analyze_url(),
_payload(employee.id, period_start="2026-04-20", period_end="2026-04-20"),
format="json",
)
assert response.status_code == 200
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
assert record.is_absent is True
assert record.is_underwork is False
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"] == "absent"
assert matrix_cell["display_text"] == "Отс."
assert matrix_cell["primary_label"] == "Отсутствие"
assert "Недоработка" not in " ".join(matrix_cell["detail_lines"])
def test_non_staff_cannot_export_monthly_matrix_xlsx(
auth_client_factory,
user_factory,
@ -1127,7 +1185,7 @@ def test_monthly_matrix_xlsx_export_keeps_full_month_but_limits_records_to_perio
assert april["A21"].value == "2026-04-20"
assert april["A22"].value == "2026-04-21"
assert april["C21"].value is None
assert april["C22"].value == "Н"
assert april["C22"].value == "Отс."
assert april["C33"].value == 1
assert april["C35"].value == 0

View File

@ -228,6 +228,25 @@ function formatAttendanceIssueLabel(label: string) {
return attendanceIssueLabelMap[normalized] || label;
}
function hasAbsenceIssue(values: Array<unknown> | undefined) {
return Boolean(values?.some((value) => {
const normalized = String(value).trim().toLowerCase();
return (
normalized.includes("absence")
|| normalized.includes("absent")
|| normalized.includes("отсутств")
);
}));
}
function attendanceRecordHasAbsence(record: AttendanceRecord) {
return Boolean(
record.is_absent
|| hasAbsenceIssue(record.employee_issues)
|| hasAbsenceIssue(record.statuses),
);
}
function matrixCellIssueLabels(cell: MonthlyAttendanceMatrixCell) {
if (cell.status === "absent") return ["absence"];
const result = [...cell.issues.map(String)];
@ -246,10 +265,10 @@ function attendanceRecordIssueLabels(record: AttendanceRecord) {
...(record.employee_issues || []),
...(record.technical_issues || []),
].map(String);
if (record.is_absent) result.push("absence");
if (attendanceRecordHasAbsence(record)) result.push("absence");
if (record.is_late) result.push("late");
if (record.is_early_leave) result.push("early_leave");
if (record.is_underwork) result.push("underwork");
if (record.is_underwork && !attendanceRecordHasAbsence(record)) result.push("underwork");
if (record.is_overtime) result.push("overtime");
return Array.from(new Set(result)).map(formatAttendanceIssueLabel);
}
@ -264,7 +283,7 @@ function matrixCellDisplay(cell: MonthlyAttendanceMatrixCell) {
function attendanceRecordStatusLabel(record: AttendanceRecord) {
if (record.technical_issues?.length) return "Техсбой";
if (record.personnel_status_label) return record.personnel_status_label;
if (record.is_absent) return "Отсутствие";
if (attendanceRecordHasAbsence(record)) return "Отсутствие";
if (record.is_underwork) return "Недоработка";
if (record.is_late) return "Опоздание";
if (record.is_overtime) return "Переработка";
@ -278,7 +297,7 @@ function attendanceRecordDisplay(record: AttendanceRecord) {
const arrival = formatTime(record.arrival_time);
const departure = formatTime(record.departure_time);
if (arrival === "-" && departure === "-") {
if (record.is_absent) return "Отсутствие";
if (attendanceRecordHasAbsence(record)) return "Отсутствие";
return record.non_working_reason || "Нет проходов";
}
return `${arrival}/${departure} · ${formatHours(record.work_hours)}ч`;
@ -295,7 +314,7 @@ function attendanceRecordDetails(record: AttendanceRecord) {
if (record.early_leave_minutes) {
lines.push(`Ранний уход: ${record.early_leave_minutes} мин.`);
}
if (record.underwork_hours) {
if (record.underwork_hours && !attendanceRecordHasAbsence(record)) {
lines.push(`Недоработка: ${formatHours(record.underwork_hours)} ч.`);
}
if (record.overtime_hours) {