Merge branch 'develop'
This commit is contained in:
commit
a3d4e4e693
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user