Merge branch 'develop'
This commit is contained in:
commit
3b3e7b3e87
@ -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)
|
||||
|
||||
|
||||
@ -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"],)
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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()
|
||||
|
||||
698
backend/finance/payroll/workbook_import.py
Normal file
698
backend/finance/payroll/workbook_import.py
Normal 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
|
||||
216
backend/tests/finance/test_payroll_workbook_import.py
Normal file
216
backend/tests/finance/test_payroll_workbook_import.py
Normal 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
|
||||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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> =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user