Merge branch 'develop'

This commit is contained in:
lizerk 2026-07-21 17:45:07 +03:00
commit 3b3e7b3e87
10 changed files with 1422 additions and 79 deletions

View File

@ -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)

View File

@ -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"],)

View File

@ -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/<int:pk>/workbook-import/preview/",
PayrollWorkbookWorkPreviewView.as_view(),
name="admin-period-workbook-import-preview",
),
path(
"payroll/admin/periods/<int:pk>/workbook-import/apply/",
PayrollWorkbookWorkImportView.as_view(),
name="admin-period-workbook-import-apply",
),
path(
"payroll/admin/periods/<int:pk>/bulk-pay-rate/",
PayrollBulkPayRateView.as_view(),

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

View File

@ -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<HTMLDivElement | null>(null);
const hasAdditionalActions = Boolean(
onFillFromAttendance
|| onImportWorkbook
|| onBulkPayRate
|| onBulkPointRate,
);
useEffect(() => {
if (!addMenuOpen) return;
@ -166,22 +177,17 @@ function TableToolbar({
<select className="app-select rounded-lg px-3 py-2.5 text-sm sm:w-44" value={status} onChange={(event) => onStatus(event.target.value as "" | PayrollApprovalStatus)}>
<option value="">Все статусы</option><option value="draft">Черновики</option><option value="approved">Утверждённые</option><option value="voided">Аннулированные</option>
</select>
{onFillFromAttendance ? (
<button type="button" className="app-action-secondary inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-medium" onClick={onFillFromAttendance}>
<CalendarCheck2 size={16} /> Заполнить из посещаемости
</button>
) : null}
{onAdd ? (
<div ref={addMenuRef} className="relative flex shrink-0 items-center gap-1">
<button type="button" className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold" onClick={onAdd}>
<Plus size={16} /> {addLabel}
</button>
{onBulkPayRate || onBulkPointRate ? (
{hasAdditionalActions ? (
<button
type="button"
className="app-action-ghost flex h-10 w-8 items-center justify-center rounded-md"
onClick={() => setAddMenuOpen((current) => !current)}
aria-label="Дополнительные действия со ставками"
aria-label="Дополнительные действия"
aria-expanded={addMenuOpen}
aria-haspopup="menu"
title="Дополнительные действия"
@ -189,8 +195,34 @@ function TableToolbar({
<ChevronRight size={15} className={`transition-transform duration-200 ${addMenuOpen ? "rotate-90" : ""}`} />
</button>
) : null}
{addMenuOpen && (onBulkPayRate || onBulkPointRate) ? (
{addMenuOpen && hasAdditionalActions ? (
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-64 rounded-xl py-1.5" role="menu">
{onFillFromAttendance ? (
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
onClick={() => {
setAddMenuOpen(false);
onFillFromAttendance();
}}
>
<CalendarCheck2 className="app-text-muted" size={15} />
Заполнить из посещаемости
</button>
) : null}
{onImportWorkbook ? (
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
onClick={() => {
setAddMenuOpen(false);
onImportWorkbook();
}}
>
<FileSpreadsheet className="app-text-muted" size={15} />
Импорт из Excel
</button>
) : null}
{onBulkPayRate ? (
<button
type="button"
@ -289,6 +321,7 @@ export function PayrollAdminWorkspace({
const [workModalOpen, setWorkModalOpen] = useState(false);
const [editingWork, setEditingWork] = useState<PayrollAdminWorkRecord | null>(null);
const [attendanceWorkModalOpen, setAttendanceWorkModalOpen] = useState(false);
const [workbookImportModalOpen, setWorkbookImportModalOpen] = useState(false);
const [inputModalOpen, setInputModalOpen] = useState(false);
const [editingInput, setEditingInput] = useState<PayrollAdminInputLine | null>(null);
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
@ -802,6 +835,7 @@ export function PayrollAdminWorkspace({
onSearch={setSearch}
onStatus={setStatus}
onFillFromAttendance={canManageSourceData && selectedPeriod.status !== "published" ? () => setAttendanceWorkModalOpen(true) : undefined}
onImportWorkbook={canManageSourceData && selectedPeriod.status !== "published" ? () => setWorkbookImportModalOpen(true) : undefined}
onAdd={canManageSourceData && selectedPeriod.status !== "published" ? () => { setEditingWork(null); setWorkModalOpen(true); } : undefined}
addLabel="Добавить выработку"
/>
@ -883,6 +917,25 @@ export function PayrollAdminWorkspace({
onStale={handleStale}
/>
) : null}
{selectedPeriod && workbookImportModalOpen ? (
<PayrollWorkbookImportModal
isOpen
period={selectedPeriod}
onClose={() => setWorkbookImportModalOpen(false)}
onApplied={async (result: PayrollWorkbookImportResult) => {
setWorkbookImportModalOpen(false);
setSearch("");
setStatus("draft");
const changed = result.summary.created + result.summary.replaced;
const details = [
result.summary.skipped ? `пропущено: ${result.summary.skipped}` : "",
result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "",
].filter(Boolean).join(", ");
await refreshView(`Из Excel импортировано ${changed} дневных записей${details ? ` (${details})` : ""}.`);
}}
onStale={handleStale}
/>
) : null}
{selectedPeriod && inputModalOpen ? <PayrollInputLineFormModal isOpen period={selectedPeriod} periods={workspace.periods} employees={workspace.employees} components={workspace.components} line={editingInput} onClose={() => setInputModalOpen(false)} onSubmit={saveInput} onSaved={async () => { setInputModalOpen(false); await refreshView("Черновик операции сохранён."); }} onStale={handleStale} /> : null}
{pendingAction ? <PayrollConfirmModal isOpen title={pendingAction.title} description={pendingAction.description} confirmLabel={pendingAction.confirmLabel} reasonLabel={pendingAction.reasonLabel} reasonRequired={pendingAction.reasonRequired} warning={pendingAction.warning} onClose={() => setPendingAction(null)} onConfirm={pendingAction.execute} onDone={async () => { const message = pendingAction.successMessage; setPendingAction(null); await refreshView(message); }} onStale={handleStale} /> : null}
</div>

View File

@ -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<void>;
onStale?: () => void | Promise<void>;
};
export function PayrollWorkbookImportModal({ isOpen, period, onClose, onApplied, onStale }: Props) {
const formId = useId();
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<PayrollWorkbookImportPreview | null>(null);
const [mappings, setMappings] = useState<Record<string, number | null>>({});
const [mode, setMode] = useState<PayrollWorkbookImportMode>("skip_existing");
const [loading, setLoading] = useState(false);
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(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 = (
<div className="flex flex-col-reverse gap-2 border-t border-[var(--border-subtle)] pb-1 pt-4 sm:flex-row sm:justify-end">
<button type="button" className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium" disabled={applying} onClick={onClose}>Отмена</button>
{preview ? (
<button type="submit" form={formId} className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60" disabled={applying || !selectedSummary.rows}>
{applying ? <Loader2 className="animate-spin" size={16} /> : <Check size={16} />}
Импортировать {selectedSummary.entries} записей
</button>
) : (
<button type="button" className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60" disabled={!file || loading} onClick={() => void inspect()}>
{loading ? <Loader2 className="animate-spin" size={16} /> : <Upload size={16} />}
Проверить файл
</button>
)}
</div>
);
return (
<Modal isOpen={isOpen} onClose={applying ? () => undefined : onClose} title="Импорт выработки из Excel" size="xl" showCloseButton={!applying} closeOnEsc={!applying} footer={footer}>
<form id={formId} onSubmit={(event) => void submit(event)} className="space-y-4">
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">{period.name || period.code}</p>
<p className="app-text-muted mt-1 text-xs">{getPayrollPeriodRange(period)}. Из файла будет взят только этот период.</p>
</div>
<label className="app-surface-muted flex cursor-pointer items-center gap-3 rounded-xl border border-dashed border-[var(--border-strong)] p-4">
<FileSpreadsheet className="app-accent-text shrink-0" size={24} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">{file?.name || "Выберите файл .xlsx"}</span>
<span className="app-text-muted mt-1 block text-xs">Сотрудники — по строкам, дни — по столбцам, в ячейках — дневные баллы.</span>
</span>
<input
className="sr-only"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
disabled={loading || applying}
onChange={(event) => {
setFile(event.target.files?.[0] || null);
setPreview(null);
setMappings({});
setError(null);
}}
/>
</label>
{preview ? (
<>
<div className="grid gap-2 sm:grid-cols-4">
{[
["Строк сотрудников", preview.summary.rows],
["Дневных записей", preview.summary.entries],
["Найдено автоматически", preview.summary.matched],
["Уже существует", preview.summary.existing],
].map(([label, value]) => (
<div key={label} className="app-surface-muted rounded-xl p-3"><p className="app-text-muted text-xs">{label}</p><p className="mt-1 text-lg font-semibold text-[var(--foreground)]">{value}</p></div>
))}
</div>
{preview.summary.needs_mapping ? (
<div className="app-feedback-warning flex items-start gap-2 rounded-xl px-3 py-2.5 text-sm">
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
<span>Не удалось однозначно определить {preview.summary.needs_mapping} строк. Выберите нужных сотрудников; остальные строки будут пропущены.</span>
</div>
) : null}
<div className="max-h-[42vh] overflow-y-auto rounded-xl border border-[var(--border-subtle)]">
<div className="app-surface-muted sticky top-0 z-10 hidden grid-cols-[minmax(180px,1fr)_minmax(250px,1.4fr)_150px] gap-3 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)] md:grid">
<span>Имя в Excel</span>
<span>Сотрудник в системе</span>
<span className="text-right">Данные</span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{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 (
<div key={row.row_key} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(180px,1fr)_minmax(250px,1.4fr)_150px] md:items-center">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--foreground)]" title={row.source_name}>{row.source_name}</p>
<p className="app-text-muted mt-1 text-[11px]">{row.sheet_name}, строка {row.row_number}</p>
<span className={`${matchedAutomatically ? "app-feedback-success" : selectedEmployeeId ? "app-selected" : "app-surface-muted"} mt-1.5 inline-flex rounded-full px-2 py-1 text-[10px] font-semibold leading-none`}>
{matchedAutomatically ? "Найден автоматически" : selectedEmployeeId ? "Выбран вручную" : "Будет пропущен"}
</span>
</div>
<select
className={`app-select w-full rounded-lg px-3 py-2 text-sm ${!mappings[row.row_key] ? "border-[var(--warning-border)]" : ""}`}
value={selectedEmployeeId || ""}
onChange={(event) => setMappings((current) => ({
...current,
[row.row_key]: event.target.value ? Number(event.target.value) : null,
}))}
aria-label={`Сотрудник для строки ${row.source_name}`}
>
<option value="">Выберите сотрудника…</option>
{orderedEmployees.map((employee) => <option key={employee.id} value={employee.id}>{candidateIds.has(employee.id) ? "★ " : ""}{employee.display_name}{employee.is_active ? "" : " (неактивен)"}</option>)}
</select>
<div className="text-xs text-[var(--muted-foreground)] md:text-right">
<p><b className="text-[var(--foreground)]">{row.entry_count}</b> дней · <b className="text-[var(--foreground)]">{row.points_total}</b> балл.</p>
{row.existing_count ? <p className="mt-1 text-[var(--warning-foreground)]">{row.existing_period_record ? "Есть итог за период" : `Совпадений по датам: ${row.existing_count}`}</p> : null}
</div>
</div>
);
})}
</div>
</div>
<fieldset>
<legend className="app-field-label">Если запись сотрудника за дату уже существует</legend>
<div className="grid gap-2 sm:grid-cols-2">
<label className={`${mode === "skip_existing" ? "app-selected" : "app-surface-muted"} flex cursor-pointer items-start gap-3 rounded-xl p-3.5`}>
<input type="radio" className="app-radio mt-0.5" checked={mode === "skip_existing"} onChange={() => setMode("skip_existing")} />
<span><span className="block text-sm font-semibold text-[var(--foreground)]">Пропустить существующие</span><span className="app-text-muted mt-1 block text-xs">Безопасный режим: имеющиеся дневные записи не изменятся.</span></span>
</label>
<label className={`${mode === "replace_existing" ? "app-selected" : "app-surface-muted"} flex cursor-pointer items-start gap-3 rounded-xl p-3.5`}>
<input type="radio" className="app-radio mt-0.5" checked={mode === "replace_existing"} onChange={() => setMode("replace_existing")} />
<span><span className="block text-sm font-semibold text-[var(--foreground)]">Заменить существующие</span><span className="app-text-muted mt-1 block text-xs">Баллы за совпадающие даты будут заменены значениями из файла.</span></span>
</label>
</div>
</fieldset>
{selectedSummary.rows < preview.summary.rows ? <p className="app-surface-muted rounded-xl px-3 py-2.5 text-sm text-[var(--muted-foreground)]">Будет импортировано строк: {selectedSummary.rows} из {preview.summary.rows}. Строки без выбранного сотрудника будут пропущены.</p> : null}
{preview.summary.invalid ? <p className="app-feedback-warning rounded-xl px-3 py-2.5 text-sm">Пропущено некорректных числовых ячеек: {preview.summary.invalid}.</p> : null}
</>
) : null}
{error ? <div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{error}</div> : null}
</form>
</Modal>
);
}

View File

@ -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<PayrollWorkbookImportPreview> => {
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<string, number | null>;
expected_file_hash: string;
expected_period_lock_version: number;
},
): Promise<PayrollWorkbookImportResult> => {
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<PayrollAdminListPayload<PayrollAdminInputLine>> =>
request(`${adminPrefix}/input-lines/${buildQuery({ ...params, page_size: 200 })}`),
createPayrollAdminInputLine: (payload: PayrollInputLineWrite): Promise<PayrollAdminInputLine> =>