From d3d2bb44627fdcfb6b7e26c1c19aff9dc86fb762 Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 21 Jul 2026 17:44:20 +0300 Subject: [PATCH] feat(finance): import payroll work from Excel --- backend/api/v1/finance/admin_serializers.py | 28 +- backend/api/v1/finance/admin_views.py | 54 +- backend/api/v1/finance/urls.py | 12 + backend/finance/payroll/services.py | 101 +-- backend/finance/payroll/workbook_import.py | 698 ++++++++++++++++++ .../finance/test_payroll_workbook_import.py | 216 ++++++ frontend/next-env.d.ts | 2 +- .../finance/admin/PayrollAdminWorkspace.tsx | 69 +- .../admin/PayrollWorkbookImportModal.tsx | 238 ++++++ frontend/src/lib/api/finance.ts | 83 +++ 10 files changed, 1422 insertions(+), 79 deletions(-) create mode 100644 backend/finance/payroll/workbook_import.py create mode 100644 backend/tests/finance/test_payroll_workbook_import.py create mode 100644 frontend/src/components/finance/admin/PayrollWorkbookImportModal.tsx diff --git a/backend/api/v1/finance/admin_serializers.py b/backend/api/v1/finance/admin_serializers.py index f68fd7fc..0659ff60 100644 --- a/backend/api/v1/finance/admin_serializers.py +++ b/backend/api/v1/finance/admin_serializers.py @@ -318,7 +318,9 @@ class BulkPointRateCommandSerializer(serializers.Serializer): attrs["mode"] = mode if mode == self.MODE_FIXED and attrs.get("point_rate") is None: raise serializers.ValidationError( - {"point_rate": "Укажите фиксированную цену или выберите автоматический расчёт."} + { + "point_rate": "Укажите фиксированную цену или выберите автоматический расчёт." + } ) if mode == self.MODE_IN_NORM: attrs["point_rate"] = None @@ -459,9 +461,9 @@ class PayrollWorkRecordWriteSerializer( def validate(self, attrs): attrs = super().validate(attrs) target_was_sent = "target_points" in attrs - should_calculate = ( - self.instance is None and not target_was_sent - ) or attrs.get("target_points") is None + should_calculate = (self.instance is None and not target_was_sent) or attrs.get( + "target_points" + ) is None if should_calculate: period = attrs.get("period") or self.instance.period employee = attrs.get("employee") or self.instance.employee @@ -636,6 +638,24 @@ class AttendanceWorkImportCommandSerializer(serializers.Serializer): return attrs +class WorkbookWorkPreviewSerializer(serializers.Serializer): + file = serializers.FileField() + + def validate_file(self, value): + if not value.name.lower().endswith(".xlsx"): + raise serializers.ValidationError("Выберите файл Excel в формате .xlsx.") + if value.size > 10 * 1024 * 1024: + raise serializers.ValidationError("Excel-файл должен быть не больше 10 МБ.") + return value + + +class WorkbookWorkImportSerializer(WorkbookWorkPreviewSerializer): + mode = serializers.ChoiceField(choices=("skip_existing", "replace_existing")) + mappings = serializers.CharField(required=False, default="{}") + expected_file_hash = serializers.RegexField(regex=r"^[0-9a-f]{64}$") + expected_period_lock_version = serializers.IntegerField(min_value=0) + + class ReturnPayrollCommandSerializer(serializers.Serializer): reason = serializers.CharField(allow_blank=False, trim_whitespace=True) diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py index 38685628..37116535 100644 --- a/backend/api/v1/finance/admin_views.py +++ b/backend/api/v1/finance/admin_views.py @@ -62,6 +62,11 @@ from finance.payroll.attendance import ( apply_attendance_work_preview, build_attendance_work_preview, ) +from finance.payroll.workbook_import import ( + apply_workbook_import, + build_workbook_preview, + parse_mappings, +) from .admin_serializers import ( AttendanceWorkImportCommandSerializer, @@ -85,6 +90,8 @@ from .admin_serializers import ( PayrollWorkRecordSerializer, PayrollWorkRecordWriteSerializer, ReturnPayrollCommandSerializer, + WorkbookWorkImportSerializer, + WorkbookWorkPreviewSerializer, _validation_detail, ) from .views import NoStorePayrollResponseMixin, PayrollConflict @@ -740,9 +747,7 @@ class DraftDetailView(PayrollAdminAPIView): # Read serializers join nullable audit/revision relations. PostgreSQL # cannot apply FOR UPDATE to the nullable side of those outer joins, # and only the draft itself needs to be locked here. - instance = get_object_or_404( - scoped_queryset.select_for_update(of=("self",)) - ) + instance = get_object_or_404(scoped_queryset.select_for_update(of=("self",))) if period is not None: instance._state.fields_cache["period"] = period self.validate_period_update(instance, period) @@ -1251,6 +1256,49 @@ class PayrollAttendanceWorkRecordsView(PayrollAdminAPIView): ) +class PayrollWorkbookWorkPreviewView(PayrollAdminAPIView): + """Inspect a legacy schedule workbook without changing payroll data.""" + + allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) + + def post(self, request, pk): + command = WorkbookWorkPreviewSerializer(data=request.data) + command.is_valid(raise_exception=True) + period = get_object_or_404(PayrollPeriod, pk=pk) + uploaded = command.validated_data["file"] + return Response( + build_workbook_preview( + period, + payload=uploaded.read(), + filename=uploaded.name, + ) + ) + + +class PayrollWorkbookWorkImportView(PayrollAdminAPIView): + """Apply a previously inspected schedule workbook atomically.""" + + allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],) + + def post(self, request, pk): + command = WorkbookWorkImportSerializer(data=request.data) + command.is_valid(raise_exception=True) + uploaded = command.validated_data["file"] + result = apply_workbook_import( + pk, + actor=request.user, + payload=uploaded.read(), + filename=uploaded.name, + mappings=parse_mappings(command.validated_data["mappings"]), + mode=command.validated_data["mode"], + expected_file_hash=command.validated_data["expected_file_hash"], + expected_period_lock_version=command.validated_data[ + "expected_period_lock_version" + ], + ) + return Response(result) + + class PayrollPeriodCalculateView(PayrollAdminAPIView): allowed_permissions = (PAYROLL_PERMISSIONS["calculate"],) diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index 94b34c19..3ddc6ea4 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -38,6 +38,8 @@ from .admin_views import ( PayrollWorkRecordDetailView, PayrollWorkRecordListCreateView, PayrollWorkRecordReviseView, + PayrollWorkbookWorkImportView, + PayrollWorkbookWorkPreviewView, ) app_name = "finance-payroll" @@ -93,6 +95,16 @@ urlpatterns = [ PayrollAttendanceWorkRecordsView.as_view(), name="admin-period-attendance-work-records", ), + path( + "payroll/admin/periods//workbook-import/preview/", + PayrollWorkbookWorkPreviewView.as_view(), + name="admin-period-workbook-import-preview", + ), + path( + "payroll/admin/periods//workbook-import/apply/", + PayrollWorkbookWorkImportView.as_view(), + name="admin-period-workbook-import-apply", + ), path( "payroll/admin/periods//bulk-pay-rate/", PayrollBulkPayRateView.as_view(), diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index 453291b1..ba80081c 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -932,10 +932,7 @@ def bulk_set_point_rate( except IntegrityError: _operation_error( "RATE_REVISION_CONFLICT", - ( - "Для одной из ставок уже создана новая версия; " - "обновите данные." - ), + ("Для одной из ставок уже создана новая версия; обновите данные."), rate_id=current.pk, ) _audit( @@ -1033,9 +1030,7 @@ def bulk_set_pay_rate( employee_rates = rates_by_employee.get(employee_id, []) exact_rates = [ - rate - for rate in employee_rates - if rate.effective_from == effective_from + rate for rate in employee_rates if rate.effective_from == effective_from ] current_rates = [ rate for rate in exact_rates if rate.status != ApprovalStatus.VOIDED @@ -1051,10 +1046,7 @@ def bulk_set_pay_rate( continue if current is not None and current.status == ApprovalStatus.DRAFT: - if ( - not has_simple_admin_access(actor) - and current.created_by_id != actor.pk - ): + if not has_simple_admin_access(actor) and current.created_by_id != actor.pk: summary["skipped"] += 1 continue previous_amount = current.amount @@ -1161,9 +1153,7 @@ def bulk_set_pay_rate( "INPUT_VALIDATION_FAILED", "Новая ставка не прошла проверку.", errors=( - exc.message_dict - if hasattr(exc, "message_dict") - else exc.messages + exc.message_dict if hasattr(exc, "message_dict") else exc.messages ), ) except IntegrityError: @@ -1277,10 +1267,7 @@ def bulk_set_target_points( continue if current is not None and current.status == ApprovalStatus.DRAFT: - if ( - not has_simple_admin_access(actor) - and current.created_by_id != actor.pk - ): + if not has_simple_admin_access(actor) and current.created_by_id != actor.pk: summary["skipped"] += 1 continue previous_target = current.target_points @@ -1387,9 +1374,7 @@ def bulk_set_target_points( "INPUT_VALIDATION_FAILED", "Норма баллов не прошла проверку.", errors=( - exc.message_dict - if hasattr(exc, "message_dict") - else exc.messages + exc.message_dict if hasattr(exc, "message_dict") else exc.messages ), ) except IntegrityError: @@ -1416,14 +1401,16 @@ def bulk_set_target_points( } -def _sync_daily_work_record( +def sync_daily_work_record( *, period: PayrollPeriod, + employee, actor, + channel: str = "employee_api", ) -> PayrollWorkRecord: totals = PayrollDailyWorkEntry.objects.filter( period=period, - employee=actor, + employee=employee, ).aggregate( actual_points=Sum("actual_points"), ) @@ -1431,7 +1418,7 @@ def _sync_daily_work_record( records = list( PayrollWorkRecord.objects.select_for_update() - .filter(period=period, employee=actor) + .filter(period=period, employee=employee) .order_by("revision", "pk") ) active_records = [ @@ -1442,12 +1429,10 @@ def _sync_daily_work_record( if active_records else None ) - calculated_target, workdays_count, target_source = ( - calculate_period_target_points(period, employee=actor) - ) - uses_saved_target = ( - current is not None and current.target_points_overridden + calculated_target, workdays_count, target_source = calculate_period_target_points( + period, employee=employee ) + uses_saved_target = current is not None and current.target_points_overridden target_points = current.target_points if uses_saved_target else calculated_target if target_points <= 0: _operation_error( @@ -1475,7 +1460,7 @@ def _sync_daily_work_record( current.reason = reason current.source = InputSource.API current.source_ref = ( - f"daily-work:{period.pk}:{actor.pk}:r{current.revision}" + f"daily-work:{period.pk}:{employee.pk}:r{current.revision}" ) current.target_points_overridden = False current._expected_lock_version = current.lock_version @@ -1487,9 +1472,7 @@ def _sync_daily_work_record( "INPUT_VALIDATION_FAILED", "Итоговая выработка не прошла проверку.", errors=( - exc.message_dict - if hasattr(exc, "message_dict") - else exc.messages + exc.message_dict if hasattr(exc, "message_dict") else exc.messages ), ) _audit( @@ -1498,7 +1481,7 @@ def _sync_daily_work_record( instance=current, period=period, metadata={ - "channel": "employee_api", + "channel": channel, "previous_target_points": str(previous_target), "previous_actual_points": str(previous_actual), "target_points": str(target_points), @@ -1521,7 +1504,7 @@ def _sync_daily_work_record( revision = current.revision + 1 record = PayrollWorkRecord( period=period, - employee=actor, + employee=employee, target_points=target_points, target_points_overridden=uses_saved_target, actual_points=actual_points, @@ -1530,25 +1513,23 @@ def _sync_daily_work_record( reason=reason, status=ApprovalStatus.DRAFT, source=(current.source if uses_saved_target else InputSource.API), - source_ref=f"daily-work:{period.pk}:{actor.pk}:r{revision}", + source_ref=f"daily-work:{period.pk}:{employee.pk}:r{revision}", created_by=actor, ) action = "payroll.daily_work_aggregate_revision_created" metadata = { - "channel": "employee_api", + "channel": channel, "replaces_id": current.pk, "target_points": str(target_points), "actual_points": str(actual_points), - "target_source": ( - "saved_record" if uses_saved_target else target_source - ), + "target_source": ("saved_record" if uses_saved_target else target_source), "workdays_count": workdays_count, } elif current is None: revision = max((record.revision for record in records), default=0) + 1 record = PayrollWorkRecord( period=period, - employee=actor, + employee=employee, target_points=target_points, target_points_overridden=False, actual_points=actual_points, @@ -1556,12 +1537,12 @@ def _sync_daily_work_record( reason=reason, status=ApprovalStatus.DRAFT, source=InputSource.API, - source_ref=f"daily-work:{period.pk}:{actor.pk}:r{revision}", + source_ref=f"daily-work:{period.pk}:{employee.pk}:r{revision}", created_by=actor, ) action = "payroll.daily_work_aggregate_created" metadata = { - "channel": "employee_api", + "channel": channel, "target_points": str(target_points), "actual_points": str(actual_points), "target_source": target_source, @@ -1581,11 +1562,7 @@ def _sync_daily_work_record( _operation_error( "INPUT_VALIDATION_FAILED", "Итоговая выработка не прошла проверку.", - errors=( - exc.message_dict - if hasattr(exc, "message_dict") - else exc.messages - ), + errors=(exc.message_dict if hasattr(exc, "message_dict") else exc.messages), ) except IntegrityError: _operation_error( @@ -1663,7 +1640,11 @@ def save_own_daily_work_entry( actual_lock_version=entry.lock_version, ) if entry.actual_points == actual_points and entry.note == note: - aggregate = _sync_daily_work_record(period=period, actor=actor) + aggregate = sync_daily_work_record( + period=period, + employee=actor, + actor=actor, + ) return entry, aggregate, "unchanged" previous_actual = entry.actual_points @@ -1705,11 +1686,7 @@ def save_own_daily_work_entry( _operation_error( "INPUT_VALIDATION_FAILED", "Ежедневная выработка не прошла проверку.", - errors=( - exc.message_dict - if hasattr(exc, "message_dict") - else exc.messages - ), + errors=(exc.message_dict if hasattr(exc, "message_dict") else exc.messages), ) except IntegrityError: _operation_error( @@ -1723,7 +1700,11 @@ def save_own_daily_work_entry( period=period, metadata=metadata, ) - aggregate = _sync_daily_work_record(period=period, actor=actor) + aggregate = sync_daily_work_record( + period=period, + employee=actor, + actor=actor, + ) return entry, aggregate, operation @@ -1860,11 +1841,7 @@ def clear_component_input_lines( """Clear one spreadsheet-like component cell without losing its audit trail.""" _require_permission(actor, "finance.manage_payroll_inputs") - period = ( - PayrollPeriod.objects.select_for_update() - .filter(pk=period_id) - .first() - ) + period = PayrollPeriod.objects.select_for_update().filter(pk=period_id).first() if period is None: _operation_error("PERIOD_NOT_FOUND", "Расчётный период не найден.") _ensure_period_accepts_approvals(period) @@ -1927,9 +1904,7 @@ def approve_period_drafts(period_id: int, *, actor) -> dict[str, int]: item for item in periods if item.status != PayrollPeriodStatus.CLOSED ] current_run_ids = [ - item.current_run_id - for item in open_periods - if item.current_run_id is not None + item.current_run_id for item in open_periods if item.current_run_id is not None ] current_runs = list( PayrollRun.objects.select_for_update() diff --git a/backend/finance/payroll/workbook_import.py b/backend/finance/payroll/workbook_import.py new file mode 100644 index 00000000..c82a4f03 --- /dev/null +++ b/backend/finance/payroll/workbook_import.py @@ -0,0 +1,698 @@ +"""Import daily payroll points from the legacy schedule workbook.""" + +from __future__ import annotations + +import hashlib +import io +import json +import re +import zipfile +from collections import defaultdict +from datetime import date, datetime, time +from decimal import Decimal, InvalidOperation +from difflib import SequenceMatcher +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import transaction +from openpyxl import load_workbook +from openpyxl.utils.datetime import to_excel + +from finance.enums import ApprovalStatus, PayrollPeriodStatus, PayrollRunStatus +from finance.models import ( + PayrollAuditEvent, + PayrollDailyWorkEntry, + PayrollPeriod, + PayrollWorkSettings, + PayrollWorkRecord, +) + +from .access import has_payroll_permission +from .exceptions import PayrollOperationError, PayrollPermissionDenied +from .services import sync_daily_work_record + +Employee = get_user_model() + +MAX_WORKBOOK_SIZE = 10 * 1024 * 1024 +MAX_UNCOMPRESSED_SIZE = 80 * 1024 * 1024 +IMPORT_MODES = {"skip_existing", "replace_existing"} +QUANTUM = Decimal("0.0001") +MONTHS = { + "январь": 1, + "февраль": 2, + "март": 3, + "апрель": 4, + "май": 5, + "июнь": 6, + "июль": 7, + "август": 8, + "сентябрь": 9, + "октябрь": 10, + "ноябрь": 11, + "декабрь": 12, +} + + +def _error(code: str, message: str, **details): + raise PayrollOperationError(code, message, details=details) + + +def _normalize_name(value: str) -> str: + value = value.casefold().replace("ё", "е") + value = re.sub(r"[^a-zа-я0-9]+", " ", value) + return " ".join(value.split()) + + +def _employee_display_name(employee) -> str: + parts = [employee.last_name, employee.first_name, employee.patronymic] + return ( + " ".join(part.strip() for part in parts if part and part.strip()) + or f"Сотрудник #{employee.pk}" + ) + + +def _employee_variants(employee) -> set[str]: + first = employee.first_name or "" + last = employee.last_name or "" + patronymic = employee.patronymic or "" + candidates = { + _employee_display_name(employee), + employee.get_full_name(), + f"{first} {last}", + f"{last} {first}", + first, + last, + } + if patronymic: + candidates.update( + { + f"{last} {first} {patronymic}", + f"{first} {patronymic} {last}", + } + ) + aliases = ( + employee.attendance_aliases + if isinstance(employee.attendance_aliases, list) + else [] + ) + candidates.update(str(item) for item in aliases) + return { + normalized for item in candidates if (normalized := _normalize_name(str(item))) + } + + +def _employee_payload(employee) -> dict: + position = getattr(employee, "position", None) + return { + "id": employee.pk, + "display_name": _employee_display_name(employee), + "position": position.name if position is not None else None, + "is_active": employee.is_active, + } + + +def _sanitize_styles(payload: bytes) -> bytes: + """Repair invalid third-party colour values without touching the source file.""" + + source = io.BytesIO(payload) + target = io.BytesIO() + try: + with zipfile.ZipFile(source, "r") as archive: + total = sum(item.file_size for item in archive.infolist()) + if total > MAX_UNCOMPRESSED_SIZE: + _error( + "WORKBOOK_TOO_LARGE", "Распакованный Excel-файл слишком большой." + ) + with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as repaired: + for item in archive.infolist(): + content = archive.read(item.filename) + if item.filename == "xl/styles.xml": + content = re.sub( + rb'rgb="(?![0-9A-Fa-f]{8}")[^"]*"', + b'rgb="00000000"', + content, + ) + # Several generators write the unsupported border value + # ``style="none"`` or CSS-like ``solid``. + content = content.replace(b' style="none"', b"") + content = content.replace(b'style="solid"', b'style="thin"') + repaired.writestr(item, content) + except zipfile.BadZipFile: + _error("INVALID_WORKBOOK", "Файл не является корректной книгой Excel.") + return target.getvalue() + + +def _load_workbook(payload: bytes): + try: + return load_workbook(io.BytesIO(payload), data_only=False, read_only=False) + except (TypeError, ValueError): + try: + repaired = _sanitize_styles(payload) + return load_workbook(io.BytesIO(repaired), data_only=False, read_only=False) + except PayrollOperationError: + raise + except Exception as exc: # openpyxl exposes several format exceptions + _error( + "INVALID_WORKBOOK", + "Не удалось прочитать структуру Excel-файла.", + error=str(exc), + ) + except (OSError, zipfile.BadZipFile) as exc: + _error("INVALID_WORKBOOK", "Не удалось открыть Excel-файл.", error=str(exc)) + + +def _cell_day(value) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int) and 1 <= value <= 31: + return value + if isinstance(value, float) and value.is_integer() and 1 <= value <= 31: + return int(value) + return None + + +def _month_number(value) -> int | None: + if not isinstance(value, str): + return None + return MONTHS.get(_normalize_name(value)) + + +def _block_year( + sheet_title: str, worksheet, day_row: int, month_row: int +) -> tuple[int | None, int]: + for row in range(day_row, month_row + 1): + value = worksheet.cell(row, 1).value + if ( + isinstance(value, (int, float)) + and int(value) == value + and 2000 <= int(value) <= 2200 + ): + return int(value), 2 + match = re.search(r"(20\d{2})", sheet_title) + return (int(match.group(1)), 1) if match else (None, 0) + + +def _point_value(value) -> tuple[Decimal | None, str | None]: + if value is None or isinstance(value, bool): + return None, None + if isinstance(value, str): + stripped = value.replace("\xa0", " ").strip() + if not stripped or stripped.startswith("="): + return None, None + try: + number = Decimal(stripped.replace(",", ".")) + except InvalidOperation: + return None, None + elif isinstance(value, (datetime, date, time)): + # Some cells containing 5.5 points are incorrectly formatted as dates. + number = Decimal(str(to_excel(value))) + else: + try: + number = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return None, None + if not number.is_finite(): + return None, "Значение не является конечным числом" + if number < 0: + return None, "Отрицательные баллы не поддерживаются" + return number.quantize(QUANTUM), None + + +def _find_period_blocks(workbook, period: PayrollPeriod) -> list[dict]: + wanted_months: set[tuple[int, int]] = set() + cursor = date(period.date_from.year, period.date_from.month, 1) + end = date(period.date_to.year, period.date_to.month, 1) + while cursor <= end: + wanted_months.add((cursor.year, cursor.month)) + cursor = date(cursor.year + (cursor.month == 12), cursor.month % 12 + 1, 1) + + candidates: dict[tuple[int, int], list[dict]] = defaultdict(list) + for worksheet in workbook.worksheets: + for month_row in range(1, worksheet.max_row + 1): + month = _month_number(worksheet.cell(month_row, 1).value) + if month is None: + continue + day_row = None + day_columns = None + for possible in range(month_row - 1, max(0, month_row - 4), -1): + columns = { + column: day + for column in range(2, worksheet.max_column + 1) + if (day := _cell_day(worksheet.cell(possible, column).value)) + is not None + } + if len(columns) >= 7: + day_row, day_columns = possible, columns + break + if day_row is None or day_columns is None: + continue + year, confidence = _block_year( + worksheet.title, worksheet, day_row, month_row + ) + if year is None or (year, month) not in wanted_months: + continue + candidates[(year, month)].append( + { + "worksheet": worksheet, + "sheet_name": worksheet.title, + "month_row": month_row, + "day_row": day_row, + "day_columns": day_columns, + "year": year, + "month": month, + "confidence": confidence, + } + ) + blocks = [] + missing = [] + for year_month in sorted(wanted_months): + options = candidates.get(year_month, []) + if not options: + missing.append(f"{year_month[1]:02d}.{year_month[0]}") + continue + blocks.append( + max( + options, key=lambda item: (item["confidence"], len(item["day_columns"])) + ) + ) + if missing: + _error( + "PERIOD_NOT_FOUND_IN_WORKBOOK", + "В файле не найден график для выбранного периода.", + missing_months=missing, + ) + return blocks + + +def parse_workbook(payload: bytes, period: PayrollPeriod) -> dict: + if not payload: + _error("EMPTY_WORKBOOK", "Выберите непустой Excel-файл.") + if len(payload) > MAX_WORKBOOK_SIZE: + _error("WORKBOOK_TOO_LARGE", "Excel-файл должен быть не больше 10 МБ.") + workbook = _load_workbook(payload) + blocks = _find_period_blocks(workbook, period) + rows: list[dict] = [] + for block in blocks: + worksheet = block["worksheet"] + later_month_rows = [ + row + for row in range(block["month_row"] + 1, worksheet.max_row + 1) + if _month_number(worksheet.cell(row, 1).value) is not None + ] + end_row = (min(later_month_rows) - 1) if later_month_rows else worksheet.max_row + for row_number in range(block["month_row"] + 1, end_row + 1): + raw_name = worksheet.cell(row_number, 1).value + if ( + not isinstance(raw_name, str) + or not raw_name.replace("\xa0", " ").strip() + ): + continue + source_name = " ".join(raw_name.replace("\xa0", " ").split()) + entries = [] + invalid = [] + for column, day in block["day_columns"].items(): + try: + work_date = date(block["year"], block["month"], day) + except ValueError: + continue + if not period.date_from <= work_date <= period.date_to: + continue + points, issue = _point_value(worksheet.cell(row_number, column).value) + if issue: + invalid.append({"date": work_date.isoformat(), "message": issue}) + elif points is not None: + entries.append({"date": work_date, "points": points}) + if not entries and not invalid: + continue + rows.append( + { + "row_key": f"{block['sheet_name']}:{row_number}", + "sheet_name": block["sheet_name"], + "row_number": row_number, + "source_name": source_name, + "entries": entries, + "invalid_cells": invalid, + } + ) + if not rows: + _error( + "NO_WORK_POINTS_FOUND", + "В графике за выбранный период не найдено числовых значений выработки.", + ) + return { + "blocks": [ + { + "sheet_name": item["sheet_name"], + "year": item["year"], + "month": item["month"], + } + for item in blocks + ], + "rows": rows, + } + + +def _employees_with_variants(): + employees = list( + Employee.objects.select_related("position").order_by( + "last_name", "first_name", "id" + ) + ) + return employees, { + employee.pk: _employee_variants(employee) for employee in employees + } + + +def _resolve_name( + source_name: str, employees, variants_by_id +) -> tuple[int | None, list[int], str]: + normalized = _normalize_name(source_name) + exact = [ + employee.pk + for employee in employees + if normalized in variants_by_id[employee.pk] + ] + if len(exact) == 1: + return exact[0], exact, "matched" + if len(exact) > 1: + return None, exact, "ambiguous" + scored = [] + source_tokens = set(normalized.split()) + for employee in employees: + variants = variants_by_id[employee.pk] + ratio = max( + (SequenceMatcher(None, normalized, item).ratio() for item in variants), + default=0, + ) + if source_tokens and any( + source_tokens & set(item.split()) for item in variants + ): + ratio = max(ratio, 0.61) + if ratio >= 0.5: + scored.append((ratio, employee.pk)) + candidates = [employee_id for _, employee_id in sorted(scored, reverse=True)[:7]] + return None, candidates, "unmatched" + + +def build_workbook_preview( + period: PayrollPeriod, *, payload: bytes, filename: str +) -> dict: + parsed = parse_workbook(payload, period) + employees, variants_by_id = _employees_with_variants() + existing = set( + PayrollDailyWorkEntry.objects.filter(period=period).values_list( + "employee_id", "work_date" + ) + ) + employees_with_daily_entries = {employee_id for employee_id, _ in existing} + employees_with_monthly_records = set( + PayrollWorkRecord.objects.filter(period=period) + .exclude(status=ApprovalStatus.VOIDED) + .values_list("employee_id", flat=True) + ) + public_rows = [] + matched_count = 0 + conflict_count = 0 + for row in parsed["rows"]: + employee_id, candidates, status = _resolve_name( + row["source_name"], employees, variants_by_id + ) + if employee_id is not None: + matched_count += 1 + date_conflicts = sum( + (employee_id, item["date"]) in existing + for item in row["entries"] + if employee_id + ) + monthly_only_conflict = bool( + employee_id + and employee_id in employees_with_monthly_records + and employee_id not in employees_with_daily_entries + ) + row_conflicts = len(row["entries"]) if monthly_only_conflict else date_conflicts + conflict_count += row_conflicts + public_rows.append( + { + "row_key": row["row_key"], + "sheet_name": row["sheet_name"], + "row_number": row["row_number"], + "source_name": row["source_name"], + "match_status": status, + "matched_employee_id": employee_id, + "candidate_employee_ids": candidates, + "entry_count": len(row["entries"]), + "points_total": str( + sum( + (item["points"] for item in row["entries"]), Decimal("0") + ).quantize(QUANTUM) + ), + "existing_count": row_conflicts, + "existing_period_record": monthly_only_conflict, + "invalid_cells": row["invalid_cells"], + "entries": [ + {"date": item["date"].isoformat(), "points": str(item["points"])} + for item in row["entries"] + ], + } + ) + return { + "file_name": Path(filename).name, + "file_hash": hashlib.sha256(payload).hexdigest(), + "period_id": period.pk, + "period_lock_version": period.lock_version, + "blocks": parsed["blocks"], + "employees": [_employee_payload(employee) for employee in employees], + "rows": public_rows, + "summary": { + "rows": len(public_rows), + "matched": matched_count, + "needs_mapping": len(public_rows) - matched_count, + "entries": sum(item["entry_count"] for item in public_rows), + "existing": conflict_count, + "invalid": sum(len(item["invalid_cells"]) for item in public_rows), + }, + } + + +def _ensure_import_allowed(period: PayrollPeriod): + if period.status == PayrollPeriodStatus.CLOSED: + _error("PERIOD_INPUTS_LOCKED", "Данные закрытого периода заблокированы.") + if period.current_run is not None and period.current_run.status in { + PayrollRunStatus.CALCULATED, + PayrollRunStatus.REVIEW, + PayrollRunStatus.APPROVED, + }: + _error("PERIOD_INPUTS_LOCKED", "Сначала верните текущий расчёт на исправление.") + + +@transaction.atomic +def apply_workbook_import( + period_id: int, + *, + actor, + payload: bytes, + filename: str, + mappings: dict[str, int | None], + mode: str, + expected_file_hash: str, + expected_period_lock_version: int, +) -> dict: + if ( + not actor + or not actor.is_authenticated + or not has_payroll_permission(actor, "finance.manage_payroll_inputs") + ): + raise PayrollPermissionDenied("finance.manage_payroll_inputs") + if mode not in IMPORT_MODES: + _error( + "INVALID_WORKBOOK_IMPORT_MODE", + "Неизвестный режим обработки существующих записей.", + ) + actual_hash = hashlib.sha256(payload).hexdigest() + if actual_hash != expected_file_hash: + _error( + "WORKBOOK_CHANGED", + "После предпросмотра выбран другой файл. Повторите проверку.", + ) + period = ( + PayrollPeriod.objects.select_for_update() + .select_related("current_run") + .get(pk=period_id) + ) + _ensure_import_allowed(period) + if period.lock_version != expected_period_lock_version: + _error("STALE_PERIOD", "Период уже изменён; повторите предпросмотр.") + + parsed = parse_workbook(payload, period) + employees, variants_by_id = _employees_with_variants() + employees_by_id = {employee.pk: employee for employee in employees} + resolved_rows = [] + unmapped_entry_count = 0 + for row in parsed["rows"]: + automatic_id, _, _ = _resolve_name( + row["source_name"], employees, variants_by_id + ) + employee_id = mappings.get(row["row_key"], automatic_id) + if employee_id is None: + unmapped_entry_count += len(row["entries"]) + continue + employee = employees_by_id.get(int(employee_id)) + if employee is None: + _error( + "EMPLOYEE_NOT_FOUND", + "Выбранный сотрудник не найден.", + employee_id=employee_id, + ) + resolved_rows.append((row, employee)) + + imported_keys: set[tuple[int, date]] = set() + for row, employee in resolved_rows: + for item in row["entries"]: + key = (employee.pk, item["date"]) + if key in imported_keys: + _error( + "DUPLICATE_WORKBOOK_MAPPING", + "Две строки файла сопоставлены одному сотруднику и одной дате.", + employee_id=employee.pk, + work_date=item["date"].isoformat(), + ) + imported_keys.add(key) + + existing_by_key = { + (entry.employee_id, entry.work_date): entry + for entry in PayrollDailyWorkEntry.objects.select_for_update().filter( + period=period, + employee_id__in={employee.pk for _, employee in resolved_rows}, + ) + } + employees_with_daily_entries = {employee_id for employee_id, _ in existing_by_key} + employees_with_monthly_records = set( + PayrollWorkRecord.objects.select_for_update() + .filter( + period=period, + employee_id__in={employee.pk for _, employee in resolved_rows}, + ) + .exclude(status=ApprovalStatus.VOIDED) + .values_list("employee_id", flat=True) + ) + monthly_only_conflicts = ( + employees_with_monthly_records - employees_with_daily_entries + ) + daily_target = PayrollWorkSettings.get_daily_target_points() + summary = { + "created": 0, + "replaced": 0, + "unchanged": 0, + "skipped": unmapped_entry_count, + } + affected_employee_ids = set() + safe_filename = Path(filename).name + for row, employee in resolved_rows: + for item in row["entries"]: + if mode == "skip_existing" and employee.pk in monthly_only_conflicts: + summary["skipped"] += 1 + continue + key = (employee.pk, item["date"]) + entry = existing_by_key.get(key) + if entry is not None and mode == "skip_existing": + summary["skipped"] += 1 + continue + note = f"Импорт из {safe_filename}; строка «{row['source_name']}»" + if entry is not None: + if entry.actual_points == item["points"]: + summary["unchanged"] += 1 + continue + previous = str(entry.actual_points) + entry.actual_points = item["points"] + entry.note = note + entry.lock_version += 1 + action = "payroll.daily_work_entry_import_replaced" + metadata = {"previous_actual_points": previous} + summary["replaced"] += 1 + else: + entry = PayrollDailyWorkEntry( + period=period, + employee=employee, + work_date=item["date"], + target_points=daily_target, + actual_points=item["points"], + note=note, + ) + action = "payroll.daily_work_entry_import_created" + metadata = {} + summary["created"] += 1 + try: + entry.full_clean() + entry.save() + except ValidationError as exc: + _error( + "INPUT_VALIDATION_FAILED", + "Импортируемая выработка не прошла проверку.", + errors=exc.message_dict, + ) + affected_employee_ids.add(employee.pk) + PayrollAuditEvent.objects.create( + actor=actor, + action=action, + object_type=entry._meta.label_lower, + object_id=str(entry.pk), + period=period, + metadata={ + "channel": "workbook_import", + "file_hash": actual_hash, + "file_name": safe_filename, + "source_name": row["source_name"], + "work_date": item["date"].isoformat(), + "actual_points": str(item["points"]), + **metadata, + }, + ) + + records = [] + for employee_id in sorted(affected_employee_ids): + records.append( + sync_daily_work_record( + period=period, + employee=employees_by_id[employee_id], + actor=actor, + channel="workbook_import", + ) + ) + return { + "mode": mode, + "summary": summary, + "record_ids": [item.pk for item in records], + } + + +def parse_mappings(value) -> dict[str, int | None]: + if isinstance(value, dict): + raw = value + else: + try: + raw = json.loads(value or "{}") + except (TypeError, ValueError, json.JSONDecodeError): + _error( + "INVALID_EMPLOYEE_MAPPINGS", + "Не удалось прочитать сопоставление сотрудников.", + ) + if not isinstance(raw, dict): + _error( + "INVALID_EMPLOYEE_MAPPINGS", + "Сопоставление сотрудников должно быть объектом.", + ) + parsed = {} + for key, employee_id in raw.items(): + if not isinstance(key, str) or employee_id is None: + parsed[str(key)] = None + continue + try: + parsed[key] = int(employee_id) + except (TypeError, ValueError): + _error( + "INVALID_EMPLOYEE_MAPPINGS", + "Некорректный идентификатор сотрудника.", + row_key=key, + ) + return parsed diff --git a/backend/tests/finance/test_payroll_workbook_import.py b/backend/tests/finance/test_payroll_workbook_import.py new file mode 100644 index 00000000..9751bb3a --- /dev/null +++ b/backend/tests/finance/test_payroll_workbook_import.py @@ -0,0 +1,216 @@ +import io +import json +import zipfile +from datetime import date + +import pytest +from django.contrib.auth.models import Permission +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from openpyxl import Workbook + +from finance.models import PayrollDailyWorkEntry, PayrollPeriod, PayrollWorkRecord +from finance.payroll.workbook_import import _sanitize_styles, parse_workbook + +pytestmark = pytest.mark.django_db + + +def grant(user, codename): + permission = Permission.objects.get( + content_type__app_label="finance", + codename=codename, + ) + user.user_permissions.add(permission) + for cache_name in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"): + if hasattr(user, cache_name): + delattr(user, cache_name) + + +def workbook_bytes(*, ivan_first_day=5): + workbook = Workbook() + sheet = workbook.active + sheet.title = "2026год" + for offset in range(7): + sheet.cell(1, offset + 2, offset + 1) + sheet.cell(2, offset + 2, "пн") + sheet.cell(2, 1, "Июль") + sheet.cell(3, 1, "Иван Иванов") + sheet.cell(4, 1, "Алексей") + for offset in range(7): + sheet.cell(3, offset + 2, ivan_first_day if offset == 0 else 5) + sheet.cell(4, offset + 2, 4) + target = io.BytesIO() + workbook.save(target) + return target.getvalue() + + +def upload(payload): + return SimpleUploadedFile( + "График.xlsx", + payload, + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + +def make_period(creator): + return PayrollPeriod.objects.create( + code="2026-07", + name="Июль 2026", + date_from="2026-07-01", + date_to="2026-07-31", + pay_date="2026-08-05", + currency="RUB", + created_by=creator, + ) + + +def test_invalid_legacy_colour_can_be_repaired_before_parsing(): + payload = workbook_bytes() + source = zipfile.ZipFile(io.BytesIO(payload)) + target = io.BytesIO() + with source, zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as repaired: + for item in source.infolist(): + content = source.read(item.filename) + if item.filename == "xl/styles.xml": + content = content.replace(b'theme="1"', b'rgb="none"', 1) + repaired.writestr(item, content) + period = type( + "Period", + (), + {"date_from": date(2026, 7, 1), "date_to": date(2026, 7, 31)}, + )() + + repaired_payload = _sanitize_styles(target.getvalue()) + with zipfile.ZipFile(io.BytesIO(repaired_payload)) as repaired: + assert b'rgb="none"' not in repaired.read("xl/styles.xml") + parsed = parse_workbook(repaired_payload, period) + + assert len(parsed["rows"]) == 2 + assert parsed["rows"][0]["entries"][0]["points"] == 5 + + +def test_api_previews_manual_mapping_and_skips_or_replaces_existing( + user_factory, + auth_client_factory, +): + manager = user_factory(email="workbook.manager@example.test") + ivan = user_factory( + email="ivan.workbook@example.test", + first_name="Иван", + last_name="Иванов", + ) + alexey = user_factory( + email="alexey.one.workbook@example.test", + first_name="Алексей", + last_name="Первый", + ) + alexey_two = user_factory( + email="alexey.two.workbook@example.test", + first_name="Алексей", + last_name="Второй", + ) + grant(manager, "manage_payroll_inputs") + period = make_period(manager) + existing = PayrollDailyWorkEntry.objects.create( + period=period, + employee=ivan, + work_date="2026-07-01", + target_points="5", + actual_points="9", + ) + manual_monthly = PayrollWorkRecord.objects.create( + period=period, + employee=alexey, + target_points="110", + actual_points="99", + created_by=manager, + ) + client = auth_client_factory(manager) + preview_url = reverse( + "api:v1:finance-payroll:admin-period-workbook-import-preview", + kwargs={"pk": period.pk}, + ) + apply_url = reverse( + "api:v1:finance-payroll:admin-period-workbook-import-apply", + kwargs={"pk": period.pk}, + ) + payload = workbook_bytes() + + preview_response = client.post( + preview_url, {"file": upload(payload)}, format="multipart" + ) + + assert preview_response.status_code == 200, preview_response.content + preview = preview_response.json() + rows = {row["source_name"]: row for row in preview["rows"]} + assert rows["Иван Иванов"]["match_status"] == "matched" + assert rows["Иван Иванов"]["matched_employee_id"] == ivan.pk + assert rows["Алексей"]["match_status"] == "ambiguous" + assert set(rows["Алексей"]["candidate_employee_ids"]) == { + alexey.pk, + alexey_two.pk, + } + # The ambiguous row is counted as a conflict only after the user maps it. + assert rows["Алексей"]["existing_period_record"] is False + assert preview["summary"]["existing"] == 1 + + mappings = { + rows["Иван Иванов"]["row_key"]: ivan.pk, + rows["Алексей"]["row_key"]: alexey.pk, + } + skip_mappings = { + rows["Иван Иванов"]["row_key"]: ivan.pk, + rows["Алексей"]["row_key"]: None, + } + skipped_response = client.post( + apply_url, + { + "file": upload(payload), + "mode": "skip_existing", + "mappings": json.dumps(skip_mappings), + "expected_file_hash": preview["file_hash"], + "expected_period_lock_version": preview["period_lock_version"], + }, + format="multipart", + ) + + assert skipped_response.status_code == 200, skipped_response.content + assert skipped_response.json()["summary"] == { + "created": 6, + "replaced": 0, + "unchanged": 0, + "skipped": 8, + } + existing.refresh_from_db() + assert existing.actual_points == 9 + assert PayrollWorkRecord.objects.filter(period=period, employee=ivan).exists() + assert PayrollWorkRecord.objects.filter(period=period, employee=alexey).exists() + manual_monthly.refresh_from_db() + assert manual_monthly.actual_points == 99 + + second_preview = client.post( + preview_url, {"file": upload(payload)}, format="multipart" + ).json() + replaced_response = client.post( + apply_url, + { + "file": upload(payload), + "mode": "replace_existing", + "mappings": json.dumps(mappings), + "expected_file_hash": second_preview["file_hash"], + "expected_period_lock_version": second_preview["period_lock_version"], + }, + format="multipart", + ) + + assert replaced_response.status_code == 200, replaced_response.content + assert replaced_response.json()["summary"] == { + "created": 7, + "replaced": 1, + "unchanged": 6, + "skipped": 0, + } + existing.refresh_from_db() + assert existing.actual_points == 5 + manual_monthly.refresh_from_db() + assert manual_monthly.actual_points == 28 diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index af8f8923..4c661d94 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -11,6 +11,7 @@ import { ChevronRight, Clock3, FileCheck2, + FileSpreadsheet, Loader2, Maximize2, Minimize2, @@ -36,6 +37,7 @@ import { PayrollWorkRecordFormModal, } from "@/components/finance/admin/PayrollAdminModals"; import { PayrollAttendanceWorkModal } from "@/components/finance/admin/PayrollAttendanceWorkModal"; +import { PayrollWorkbookImportModal } from "@/components/finance/admin/PayrollWorkbookImportModal"; import { PayrollApprovalQueue, PayrollInputLinesTable, @@ -63,6 +65,7 @@ import type { PayrollPeriodTableRow, PayrollPeriodWrite, PayrollWorkRecordWrite, + PayrollWorkbookImportResult, } from "@/lib/api/finance"; import { getPayrollAdminError, @@ -122,6 +125,7 @@ function TableToolbar({ onStatus, onAdd, onFillFromAttendance, + onImportWorkbook, onBulkPayRate, onBulkPointRate, addLabel, @@ -132,12 +136,19 @@ function TableToolbar({ onStatus: (value: "" | PayrollApprovalStatus) => void; onAdd?: () => void; onFillFromAttendance?: () => void; + onImportWorkbook?: () => void; onBulkPayRate?: () => void; onBulkPointRate?: () => void; addLabel: string; }) { const [addMenuOpen, setAddMenuOpen] = useState(false); const addMenuRef = useRef(null); + const hasAdditionalActions = Boolean( + onFillFromAttendance + || onImportWorkbook + || onBulkPayRate + || onBulkPointRate, + ); useEffect(() => { if (!addMenuOpen) return; @@ -166,22 +177,17 @@ function TableToolbar({ - {onFillFromAttendance ? ( - - ) : null} {onAdd ? (
- {onBulkPayRate || onBulkPointRate ? ( + {hasAdditionalActions ? ( ) : null} - {addMenuOpen && (onBulkPayRate || onBulkPointRate) ? ( + {addMenuOpen && hasAdditionalActions ? (
+ {onFillFromAttendance ? ( + + ) : null} + {onImportWorkbook ? ( + + ) : null} {onBulkPayRate ? (
diff --git a/frontend/src/components/finance/admin/PayrollWorkbookImportModal.tsx b/frontend/src/components/finance/admin/PayrollWorkbookImportModal.tsx new file mode 100644 index 00000000..73a847a4 --- /dev/null +++ b/frontend/src/components/finance/admin/PayrollWorkbookImportModal.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { AlertTriangle, Check, FileSpreadsheet, Loader2, Upload } from "lucide-react"; +import { useEffect, useId, useMemo, useState, type FormEvent } from "react"; + +import { Modal } from "@/components/ui"; +import { apiClient } from "@/lib/api"; +import type { + PayrollAdminPeriod, + PayrollWorkbookImportMode, + PayrollWorkbookImportPreview, + PayrollWorkbookImportResult, +} from "@/lib/api/finance"; +import { getPayrollAdminError, isPayrollAdminStaleConflict } from "@/lib/payroll-admin"; +import { getPayrollPeriodRange } from "@/lib/payroll"; + +type Props = { + isOpen: boolean; + period: PayrollAdminPeriod; + onClose: () => void; + onApplied: (result: PayrollWorkbookImportResult) => void | Promise; + onStale?: () => void | Promise; +}; + +export function PayrollWorkbookImportModal({ isOpen, period, onClose, onApplied, onStale }: Props) { + const formId = useId(); + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [mappings, setMappings] = useState>({}); + const [mode, setMode] = useState("skip_existing"); + const [loading, setLoading] = useState(false); + const [applying, setApplying] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen) return; + setFile(null); + setPreview(null); + setMappings({}); + setMode("skip_existing"); + setError(null); + }, [isOpen, period.id]); + + const selectedSummary = useMemo( + () => preview?.rows.reduce( + (summary, row) => mappings[row.row_key] + ? { rows: summary.rows + 1, entries: summary.entries + row.entry_count } + : summary, + { rows: 0, entries: 0 }, + ) ?? { rows: 0, entries: 0 }, + [mappings, preview], + ); + + const inspect = async () => { + if (!file) return; + setLoading(true); + setError(null); + try { + const result = await apiClient.previewPayrollAdminWorkbookImport(period.id, file); + setPreview(result); + setMappings(Object.fromEntries( + result.rows + .filter((row) => row.matched_employee_id != null) + .map((row) => [row.row_key, row.matched_employee_id as number]), + )); + setMode("skip_existing"); + } catch (loadError) { + setPreview(null); + setError(getPayrollAdminError(loadError, "Не удалось прочитать график из Excel.")); + } finally { + setLoading(false); + } + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!file || !preview || !selectedSummary.rows) return; + setApplying(true); + setError(null); + try { + const result = await apiClient.applyPayrollAdminWorkbookImport(period.id, { + file, + mode, + mappings, + expected_file_hash: preview.file_hash, + expected_period_lock_version: preview.period_lock_version, + }); + await onApplied(result); + } catch (applyError) { + if (isPayrollAdminStaleConflict(applyError) && onStale) { + await onStale(); + return; + } + setError(getPayrollAdminError(applyError, "Не удалось импортировать выработку.")); + } finally { + setApplying(false); + } + }; + + const footer = ( +
+ + {preview ? ( + + ) : ( + + )} +
+ ); + + return ( + undefined : onClose} title="Импорт выработки из Excel" size="xl" showCloseButton={!applying} closeOnEsc={!applying} footer={footer}> +
void submit(event)} className="space-y-4"> +
+

{period.name || period.code}

+

{getPayrollPeriodRange(period)}. Из файла будет взят только этот период.

+
+ + + + {preview ? ( + <> +
+ {[ + ["Строк сотрудников", preview.summary.rows], + ["Дневных записей", preview.summary.entries], + ["Найдено автоматически", preview.summary.matched], + ["Уже существует", preview.summary.existing], + ].map(([label, value]) => ( +

{label}

{value}

+ ))} +
+ + {preview.summary.needs_mapping ? ( +
+ + Не удалось однозначно определить {preview.summary.needs_mapping} строк. Выберите нужных сотрудников; остальные строки будут пропущены. +
+ ) : null} + +
+
+ Имя в Excel + Сотрудник в системе + Данные +
+
+ {preview.rows.map((row) => { + const candidateIds = new Set(row.candidate_employee_ids); + const selectedEmployeeId = mappings[row.row_key]; + const matchedAutomatically = Boolean( + selectedEmployeeId + && row.match_status === "matched" + && selectedEmployeeId === row.matched_employee_id, + ); + const orderedEmployees = [...preview.employees].sort((left, right) => { + const candidateDelta = Number(candidateIds.has(right.id)) - Number(candidateIds.has(left.id)); + return candidateDelta || left.display_name.localeCompare(right.display_name, "ru"); + }); + return ( +
+
+

{row.source_name}

+

{row.sheet_name}, строка {row.row_number}

+ + {matchedAutomatically ? "Найден автоматически" : selectedEmployeeId ? "Выбран вручную" : "Будет пропущен"} + +
+ +
+

{row.entry_count} дней · {row.points_total} балл.

+ {row.existing_count ?

{row.existing_period_record ? "Есть итог за период" : `Совпадений по датам: ${row.existing_count}`}

: null} +
+
+ ); + })} +
+
+ +
+ Если запись сотрудника за дату уже существует +
+ + +
+
+ + {selectedSummary.rows < preview.summary.rows ?

Будет импортировано строк: {selectedSummary.rows} из {preview.summary.rows}. Строки без выбранного сотрудника будут пропущены.

: null} + {preview.summary.invalid ?

Пропущено некорректных числовых ячеек: {preview.summary.invalid}.

: null} + + ) : null} + + {error ?
{error}
: null} +
+
+ ); +} diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts index cdd3cb16..ade4132a 100644 --- a/frontend/src/lib/api/finance.ts +++ b/frontend/src/lib/api/finance.ts @@ -505,6 +505,57 @@ export type PayrollAttendanceWorkApplyResult = { records: PayrollAdminWorkRecord[]; }; +export type PayrollWorkbookImportMode = "skip_existing" | "replace_existing"; + +export type PayrollWorkbookImportEmployee = PayrollAdminEmployee & { + is_active: boolean; +}; + +export type PayrollWorkbookImportRow = { + row_key: string; + sheet_name: string; + row_number: number; + source_name: string; + match_status: "matched" | "ambiguous" | "unmatched"; + matched_employee_id: number | null; + candidate_employee_ids: number[]; + entry_count: number; + points_total: string; + existing_count: number; + existing_period_record: boolean; + invalid_cells: Array<{ date: string; message: string }>; + entries: Array<{ date: string; points: string }>; +}; + +export type PayrollWorkbookImportPreview = { + file_name: string; + file_hash: string; + period_id: number; + period_lock_version: number; + blocks: Array<{ sheet_name: string; year: number; month: number }>; + employees: PayrollWorkbookImportEmployee[]; + rows: PayrollWorkbookImportRow[]; + summary: { + rows: number; + matched: number; + needs_mapping: number; + entries: number; + existing: number; + invalid: number; + }; +}; + +export type PayrollWorkbookImportResult = { + mode: PayrollWorkbookImportMode; + summary: { + created: number; + replaced: number; + unchanged: number; + skipped: number; + }; + record_ids: number[]; +}; + export type PayrollAdminInputLine = { id: number; period_id: number; @@ -757,6 +808,38 @@ export function createFinanceApi(request: RequestFn) { `${adminPrefix}/periods/${periodId}/attendance-work-records/`, jsonOptions("POST", payload), ), + previewPayrollAdminWorkbookImport: ( + periodId: number, + file: File, + ): Promise => { + const body = new FormData(); + body.append("file", file); + return request(`${adminPrefix}/periods/${periodId}/workbook-import/preview/`, { + method: "POST", + body, + }); + }, + applyPayrollAdminWorkbookImport: ( + periodId: number, + payload: { + file: File; + mode: PayrollWorkbookImportMode; + mappings: Record; + expected_file_hash: string; + expected_period_lock_version: number; + }, + ): Promise => { + const body = new FormData(); + body.append("file", payload.file); + body.append("mode", payload.mode); + body.append("mappings", JSON.stringify(payload.mappings)); + body.append("expected_file_hash", payload.expected_file_hash); + body.append("expected_period_lock_version", String(payload.expected_period_lock_version)); + return request(`${adminPrefix}/periods/${periodId}/workbook-import/apply/`, { + method: "POST", + body, + }); + }, getPayrollAdminInputLines: (params: PayrollAdminListParams = {}): Promise> => request(`${adminPrefix}/input-lines/${buildQuery({ ...params, page_size: 200 })}`), createPayrollAdminInputLine: (payload: PayrollInputLineWrite): Promise =>