This commit is contained in:
Raykov-MS 2026-08-01 10:00:29 +03:00
parent d1cb2dda71
commit d6b3e73cb7
7 changed files with 3307 additions and 529 deletions

View File

@ -0,0 +1,120 @@
from __future__ import annotations
from .column_registry import COLUMNS
def _index(legacy_position: int) -> int:
return COLUMNS.index_of(f"legacy_{legacy_position:03d}")
# Служебные поля и операция.
COL_FILE_NAME = _index(1)
COL_RECORD_ID = _index(7)
COL_FTR_SIGN = _index(10)
COL_SUSPENSION_BASIS = _index(11)
COL_OPERATION_SIGN = _index(14)
COL_ESP_SIGN = _index(15)
COL_OPERATION_CODE = _index(19)
COL_EXTRA_CODES = _index(20)
COL_UNUSUAL_CODES = _index(21)
COL_CURRENCY = _index(22)
COL_AMOUNT_CURRENCY = _index(23)
COL_AMOUNT_RUB = _index(24)
COL_SALE_CURRENCY = _index(25)
COL_SALE_AMOUNT = _index(26)
COL_CURRENCY_SIGN = _index(27)
COL_SUSPICIOUS_ID = _index(28)
COL_METAL_CODE = _index(29)
COL_METAL_NAME = _index(30)
COL_ITEM_TYPE = _index(31)
# Перевод.
COL_TRANSFER_TYPE = _index(35)
COL_TERRITORY_CODE = _index(36)
COL_OPERATOR_TYPE = _index(37)
COL_PAYER_ACCOUNT = _index(38)
COL_PAYER_ESP = _index(39)
COL_PAYER_BANK_NAME = _index(40)
COL_PAYER_BANK_BIK = _index(41)
COL_RECIPIENT_BANK_NAME = _index(42)
COL_RECIPIENT_BANK_BIK = _index(43)
COL_PAYER_BANK_ACCOUNT = _index(44)
COL_RECIPIENT_BANK_ACCOUNT = _index(45)
COL_RECIPIENT_ACCOUNT = _index(46)
COL_RECIPIENT_ESP = _index(47)
COL_TRANSFER_STATUS = _index(62)
# Карты и место операции.
COL_TERRITORY_CODE_CARD = _index(96)
COL_CARD_HOLDER_INFO = _index(103)
COL_EMPLOYEE_SIGN = _index(104)
COL_FOREIGN_BANK_NAME = _index(105)
# Участник.
COL_PARTICIPANT_TYPE = _index(109)
COL_RESIDENT_SIGN = _index(110)
COL_CLIENT_SIGN = _index(111)
COL_IDENT_FL = _index(119)
COL_INN = _index(120)
COL_KPP = _index(122)
COL_BIRTH_REG_DATE = _index(123)
COL_BRANCH_SIGN = _index(124)
COL_INSURANCE_NUMBER = _index(125)
COL_COUNTRY_CODE = _index(126)
COL_ADDRESS_ONE_LINE = _index(141)
COL_ADDRESS_STRUCTURED = _index(142)
COL_REG_PLACE_ONE_LINE = _index(152)
COL_REG_PLACE_STRUCTURED = _index(153)
# ЕИО и ценные бумаги.
COL_EIO_INN = _index(163)
COL_EIO_REG_PLACE_ONE_LINE = _index(178)
COL_CP_ISSUER_INN = _index(202)
# Блоки схемы.
BLOCK_EIO = COLUMNS.block("eio")
BLOCK_CP = COLUMNS.block("cp")
# Точные поля наличных, которые исторически очищаются для безналичных переводов.
CASHLESS_CLEARED_COLUMNS = tuple(
_index(position) for position in (50, 61, 65, 76, 84, 95, 99, 101)
)
OKATO_COUNTRY_PAIRS: tuple[tuple[int, int], ...] = tuple(
(_index(country), _index(okato))
for country, okato in (
(52, 53),
(67, 68),
(86, 87),
(144, 145),
(170, 171),
)
)
# Текущая перестановка адресного блока при выгрузке в целевой шаблон.
PARTICIPANT_ADDRESS_OUTPUT_SOURCES: dict[int, int] = {
_index(141): _index(142),
**{_index(target): _index(target + 1) for target in range(142, 151)},
_index(151): _index(141),
}
# Бизнес-константы.
ACCOUNT_PLACEHOLDER = "00000000000000000000"
CURRENCY_OPERATION_CODES = frozenset(str(code) for code in range(6101, 6127))
CASHLESS_TRANSFER_TYPES = frozenset(
{"1", "2", "3", "4", "7", "10", "11", "12", "13", "14"}
)
VALID_FTR_SIGNS = frozenset({"0", "1", "2", "3", "4", "5"})
VALID_SUSPENSION_BASES = frozenset({"1", "2", "3"})
VALID_ESP_SIGNS = frozenset({"1", "2", "3", "4"})
PRECIOUS_ITEM_CODES = frozenset({"5020", "5021", "5022", "5023"})
SUSPICIOUS_CODE = "6001"
TERRITORY_CODE = "5016"
PRECIOUS_METAL_CODE = "C99"
EMPLOYEE_ABSENT_CODE = "0"
RUSSIA_COUNTRY_CODE = "643"
PARTICIPANT_TYPE_UL = "1"
PARTICIPANT_TYPE_FL = "2"
PARTICIPANT_TYPE_IP = "3"
PARTICIPANT_TYPE_FLCHP = "4"

View File

@ -0,0 +1,365 @@
from __future__ import annotations
import json
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
EXPECTED_COLUMN_COUNT = 204
MAPPING_DATA_FILE = Path(__file__).resolve().with_name("mapping_table.json")
REPORT_GROUP_TITLES: dict[str, str] = {
"service": "Служебные поля",
"message_bank": "Реквизиты сообщения/КО/филиала",
"record_control": "Идентификация записи и контрольные признаки",
"operation_parameters": "Параметры операции",
"transfer_settlement": "Перевод ДС и реквизиты расчетов",
"cash_receipt_authorization": ("Места приема/выдачи наличных и авторизация ЭСП"),
"same_operator_place": "Внесение/выдача у одного оператора (место операции)",
"cash_form_basis": "Наличная форма и основание операции",
"participant_base": "Участник операции (базовые сведения)",
"participant_identity": ("Участник операции (идентификация, документы, адреса)"),
"eio": "ЕИО/Бенефициар (идентификация, документы, адреса)",
"cp": "Сведения о ценных бумагах (ЦП)",
}
BUSINESS_BLOCKS = frozenset(
{
"file_metadata",
"message_header",
"operation_base",
"transfer",
"cash_receipt",
"transfer_status",
"cash_payment",
"network",
"cash_operation",
"foreign_card",
"operation_basis",
"participant_base",
"participant_name",
"participant_identity",
"participant_documents",
"participant_address",
"participant_extra",
"eio",
"cp",
}
)
class ColumnSchemaError(ValueError):
"""Ошибка целостности схемы колонок отчёта."""
class AmbiguousColumnNameError(LookupError):
"""Имя колонки соответствует нескольким позициям отчёта."""
@dataclass(frozen=True)
class ColumnMeta:
index: int
column_id: str
name: str
block: str
report_group: str
xml_tag: str
xml_path: str
source_scope: str
allow_short_lookup: bool
allow_direct_mapping: bool
structured_value: bool
structured_group: str
structured_role: str
structured_order: int
join_with_space: bool
@dataclass(frozen=True)
class ReportGroup:
group_id: str
title: str
indexes: tuple[int, ...]
@property
def start_index(self) -> int:
return self.indexes[0]
@property
def end_index(self) -> int:
return self.indexes[-1]
class ColumnRegistry:
def __init__(self, columns: list[ColumnMeta]) -> None:
ordered = tuple(sorted(columns, key=lambda column: column.index))
self._validate_columns(ordered)
self._ordered = ordered
self._by_index = {column.index: column for column in ordered}
self._by_id = {column.column_id: column for column in ordered}
by_name: dict[str, list[int]] = defaultdict(list)
by_block: dict[str, list[int]] = defaultdict(list)
for column in ordered:
by_name[column.name].append(column.index)
by_block[column.block].append(column.index)
self._by_name = {name: tuple(indexes) for name, indexes in by_name.items()}
self._by_block = {block: tuple(indexes) for block, indexes in by_block.items()}
self._report_groups = self._build_report_groups(ordered)
def index_of(self, column_id: str) -> int:
"""Возвращает текущую позицию стабильной колонки."""
try:
return self._by_id[column_id].index
except KeyError as exc:
raise KeyError(f"Неизвестный column_id: {column_id}") from exc
def meta(self, index: int) -> ColumnMeta:
"""Возвращает метаданные колонки по текущей позиции."""
return self._by_index[index]
def by_id(self, column_id: str) -> ColumnMeta:
"""Возвращает метаданные по стабильному идентификатору."""
try:
return self._by_id[column_id]
except KeyError as exc:
raise KeyError(f"Неизвестный column_id: {column_id}") from exc
def block(self, block_name: str) -> tuple[int, ...]:
"""Возвращает позиции всех колонок бизнес-блока."""
return self._by_block.get(block_name, ())
def indices_named(self, name: str) -> tuple[int, ...]:
"""Возвращает все позиции с указанным отображаемым именем."""
return self._by_name.get(name, ())
def index_named(self, name: str, *, block: str | None = None) -> int:
"""Ищет имя без молчаливого выбора среди дубликатов."""
indexes = self.indices_named(name)
if block is not None:
indexes = tuple(
index for index in indexes if self.meta(index).block == block
)
if not indexes:
raise KeyError(f"Колонка не найдена: {name}")
if len(indexes) > 1:
raise AmbiguousColumnNameError(
f"Имя колонки неоднозначно: {name!r}, позиции {indexes}"
)
return indexes[0]
def ordered_columns(self) -> tuple[ColumnMeta, ...]:
"""Возвращает всю схему в порядке вывода."""
return self._ordered
def all_columns(self) -> tuple[str, ...]:
"""Возвращает отображаемые имена в порядке вывода."""
return tuple(column.name for column in self._ordered)
def report_groups(self) -> tuple[ReportGroup, ...]:
"""Возвращает непрерывные группы верхней шапки."""
return self._report_groups
@classmethod
def load(cls, json_path: Path) -> ColumnRegistry:
raw = json.loads(json_path.read_text(encoding="utf-8"))
if not isinstance(raw, list):
raise ColumnSchemaError("Корень mapping_table.json должен быть списком")
columns: list[ColumnMeta] = []
for position, item in enumerate(raw, start=1):
if not isinstance(item, dict):
raise ColumnSchemaError(
f"Запись {position} mapping_table.json должна быть объектом"
)
try:
columns.append(
ColumnMeta(
index=cls._required_int(item, "index"),
column_id=cls._required_str(item, "column_id"),
name=cls._required_str(item, "column_name"),
block=cls._required_str(item, "block"),
report_group=cls._required_str(item, "report_group"),
xml_tag=cls._required_str(item, "xml_tag"),
xml_path=cls._required_str(item, "xml_path"),
source_scope=cls._required_str(item, "source_scope"),
allow_short_lookup=cls._required_bool(
item, "allow_short_lookup"
),
allow_direct_mapping=cls._required_bool(
item, "allow_direct_mapping"
),
structured_value=cls._required_bool(item, "structured_value"),
structured_group=cls._required_str(item, "structured_group"),
structured_role=cls._required_str(item, "structured_role"),
structured_order=cls._required_int(item, "structured_order"),
join_with_space=cls._required_bool(item, "join_with_space"),
)
)
except (KeyError, TypeError, ValueError) as exc:
raise ColumnSchemaError(
f"Некорректная запись схемы в позиции {position}: {exc}"
) from exc
return cls(columns)
@staticmethod
def _required_bool(item: dict[object, object], key: str) -> bool:
value = item[key]
if not isinstance(value, bool):
raise TypeError(f"{key} должен быть bool")
return value
@staticmethod
def _required_str(item: dict[object, object], key: str) -> str:
value = item[key]
if not isinstance(value, str):
raise TypeError(f"{key} должен быть строкой")
return value.strip()
@staticmethod
def _required_int(item: dict[object, object], key: str) -> int:
value = item[key]
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"{key} должен быть целым числом")
return value
@staticmethod
def _validate_columns(columns: tuple[ColumnMeta, ...]) -> None:
if len(columns) != EXPECTED_COLUMN_COUNT:
raise ColumnSchemaError(
f"Ожидалось {EXPECTED_COLUMN_COUNT} колонок, получено {len(columns)}"
)
indexes = tuple(column.index for column in columns)
expected_indexes = tuple(range(1, EXPECTED_COLUMN_COUNT + 1))
if indexes != expected_indexes:
raise ColumnSchemaError(
"Индексы колонок должны непрерывно идти от 1 до 204"
)
column_ids = [column.column_id for column in columns]
if len(column_ids) != len(set(column_ids)):
raise ColumnSchemaError("column_id должны быть уникальными")
valid_sources = {"any", "operation", "participant"}
valid_roles = {"", "aggregate", "one_line", "component"}
for column in columns:
if not column.column_id or not column.name:
raise ColumnSchemaError(
f"Колонка {column.index}: пустой column_id или column_name"
)
if not column.block or not column.report_group:
raise ColumnSchemaError(
f"Колонка {column.index}: block и report_group обязательны"
)
if column.block not in BUSINESS_BLOCKS:
raise ColumnSchemaError(f"Колонка {column.index}: неизвестный block")
if column.report_group not in REPORT_GROUP_TITLES:
raise ColumnSchemaError(
f"Колонка {column.index}: неизвестный report_group"
)
if column.source_scope not in valid_sources:
raise ColumnSchemaError(
f"Колонка {column.index}: неизвестный source_scope"
)
if column.structured_role not in valid_roles:
raise ColumnSchemaError(
f"Колонка {column.index}: неизвестная structured_role"
)
if bool(column.structured_group) != bool(column.structured_role):
raise ColumnSchemaError(
f"Колонка {column.index}: группа и роль структуры задаются вместе"
)
ColumnRegistry._validate_structured_groups(columns)
@staticmethod
def _validate_structured_groups(columns: tuple[ColumnMeta, ...]) -> None:
grouped: dict[str, list[ColumnMeta]] = defaultdict(list)
for column in columns:
if column.structured_group:
grouped[column.structured_group].append(column)
elif column.structured_order != 0 or column.join_with_space:
raise ColumnSchemaError(
f"Колонка {column.index}: настройки структуры заданы без группы"
)
for group_id, members in grouped.items():
aggregates = [
member for member in members if member.structured_role == "aggregate"
]
components = [
member for member in members if member.structured_role == "component"
]
if len(aggregates) != 1:
raise ColumnSchemaError(
f"Группа {group_id!r} должна иметь один aggregate"
)
aggregate = aggregates[0]
if aggregate.column_id != group_id:
raise ColumnSchemaError(
f"Группа {group_id!r} должна ссылаться на column_id aggregate"
)
if not aggregate.structured_value or aggregate.structured_order != 0:
raise ColumnSchemaError(
f"Aggregate группы {group_id!r} настроен некорректно"
)
if not components:
raise ColumnSchemaError(
f"Группа {group_id!r} должна содержать components"
)
for role in ("one_line", "component"):
role_members = [
member for member in members if member.structured_role == role
]
orders = [member.structured_order for member in role_members]
if any(order < 1 for order in orders) or len(orders) != len(
set(orders)
):
raise ColumnSchemaError(
f"Группа {group_id!r}: порядок роли {role} некорректен"
)
if any(member.structured_value for member in role_members):
raise ColumnSchemaError(
f"Группа {group_id!r}: только aggregate может быть structured_value"
)
if any(member.join_with_space for member in role_members):
raise ColumnSchemaError(
f"Группа {group_id!r}: join_with_space допустим только aggregate"
)
@staticmethod
def _build_report_groups(
columns: tuple[ColumnMeta, ...],
) -> tuple[ReportGroup, ...]:
result: list[ReportGroup] = []
seen_groups: set[str] = set()
current_id = ""
current_indexes: list[int] = []
for column in columns:
if column.report_group == current_id:
current_indexes.append(column.index)
continue
if current_indexes:
result.append(
ReportGroup(
current_id,
REPORT_GROUP_TITLES[current_id],
tuple(current_indexes),
)
)
seen_groups.add(current_id)
if column.report_group in seen_groups:
raise ColumnSchemaError(
f"report_group {column.report_group!r} разбит на несколько диапазонов"
)
current_id = column.report_group
current_indexes = [column.index]
if current_indexes:
result.append(
ReportGroup(
current_id,
REPORT_GROUP_TITLES[current_id],
tuple(current_indexes),
)
)
return tuple(result)
COLUMNS = ColumnRegistry.load(MAPPING_DATA_FILE)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -2,15 +2,22 @@ from __future__ import annotations
import re import re
import shutil import shutil
from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import Iterable
import xlsxwriter import xlsxwriter
from .column_constants import (
COL_KPP,
COL_PARTICIPANT_TYPE,
PARTICIPANT_ADDRESS_OUTPUT_SOURCES,
PARTICIPANT_TYPE_UL,
)
from .column_registry import COLUMNS
from .mapping import FIXED_REPORT_COLUMNS, build_fixed_row_by_index from .mapping import FIXED_REPORT_COLUMNS, build_fixed_row_by_index
@ -30,72 +37,21 @@ class GroupHeader:
title: str title: str
# Верхняя группирующая шапка согласно согласованным логическим категориям. TOP_GROUP_HEADERS: tuple[GroupHeader, ...] = tuple(
TOP_GROUP_HEADERS: tuple[GroupHeader, ...] = (
GroupHeader(start_col=1, end_col=1, title="Служебные поля"),
GroupHeader(start_col=2, end_col=6, title="Реквизиты сообщения/КО/филиала"),
GroupHeader( GroupHeader(
start_col=7, start_col=group.start_index,
end_col=13, end_col=group.end_index,
title="Идентификация записи и контрольные признаки", title=group.title,
), )
GroupHeader(start_col=14, end_col=36, title="Параметры операции"), for group in COLUMNS.report_groups()
GroupHeader(
start_col=37,
end_col=48,
title="Перевод ДС и реквизиты расчетов",
),
GroupHeader(
start_col=49,
end_col=66,
title="Места приема/выдачи наличных и авторизация ЭСП",
),
GroupHeader(
start_col=67,
end_col=75,
title="Внесение/выдача у одного оператора (место операции)",
),
GroupHeader(
start_col=76,
end_col=86,
title="Наличная форма и основание операции",
),
GroupHeader(
start_col=87,
end_col=126,
title="Участник операции (базовые сведения)",
),
GroupHeader(
start_col=127,
end_col=154,
title="Участник операции (идентификация, документы, адреса)",
),
GroupHeader(
start_col=155,
end_col=193,
title="ЕИО/Бенефициар (идентификация, документы, адреса)",
),
GroupHeader(
start_col=194,
end_col=204,
title="Сведения о ценных бумагах (ЦП)",
),
GroupHeader(
start_col=205,
end_col=205,
title=(
"Колонки вне согласованной группировки (если количество полей расширится)"
),
),
) )
class StreamingReportWriter: class StreamingReportWriter:
def __init__(self) -> None: def __init__(self) -> None:
self.columns = list(FIXED_REPORT_COLUMNS) self.columns = list(FIXED_REPORT_COLUMNS)
temp_file = NamedTemporaryFile(suffix=".xlsx", delete=False) with NamedTemporaryFile(suffix=".xlsx", delete=False) as temp_file:
self._temp_report_path = Path(temp_file.name) self._temp_report_path = Path(temp_file.name)
temp_file.close()
self.workbook = xlsxwriter.Workbook( self.workbook = xlsxwriter.Workbook(
str(self._temp_report_path), str(self._temp_report_path),
{"constant_memory": True}, {"constant_memory": True},
@ -138,9 +94,10 @@ class StreamingReportWriter:
operation_fields=row.operation_fields, operation_fields=row.operation_fields,
participant_fields=row.participant_fields, participant_fields=row.participant_fields,
) )
for index, column_name in enumerate(self.columns): for output_index, column_name in enumerate(self.columns, start=1):
raw_value = self._resolve_output_value(index + 1, data_by_index) zero_based_index = output_index - 1
default_format = self._column_formats[index] raw_value = self._resolve_output_value(output_index, data_by_index)
default_format = self._column_formats[zero_based_index]
value, cell_format = self._normalize_cell_value( value, cell_format = self._normalize_cell_value(
column_name=column_name, column_name=column_name,
raw_value=raw_value, raw_value=raw_value,
@ -148,23 +105,25 @@ class StreamingReportWriter:
) )
if isinstance(value, datetime) and cell_format is self._date_format: if isinstance(value, datetime) and cell_format is self._date_format:
self.sheet.write_datetime( self.sheet.write_datetime(
self._next_data_row, index, value, cell_format self._next_data_row, zero_based_index, value, cell_format
) )
elif self._is_text_identifier_column(column_name): elif self._is_text_identifier_column(column_name):
self.sheet.write_string( self.sheet.write_string(
self._next_data_row, self._next_data_row,
index, zero_based_index,
self._normalize_identifier_string( self._normalize_identifier_string(
column_name, column_name,
value, value,
column_index=index + 1, column_index=output_index,
row_by_index=data_by_index, row_by_index=data_by_index,
), ),
) )
elif cell_format is not None: elif cell_format is not None:
self.sheet.write(self._next_data_row, index, value, cell_format) self.sheet.write(
self._next_data_row, zero_based_index, value, cell_format
)
else: else:
self.sheet.write(self._next_data_row, index, value) self.sheet.write(self._next_data_row, zero_based_index, value)
self._next_data_row += 1 self._next_data_row += 1
def _resolve_output_value( def _resolve_output_value(
@ -172,15 +131,10 @@ class StreamingReportWriter:
column_index: int, column_index: int,
row_by_index: dict[int, str], row_by_index: dict[int, str],
) -> object: ) -> object:
# В целевом шаблоне адресный блок участника ожидается со структурным source_index = PARTICIPANT_ADDRESS_OUTPUT_SOURCES.get(
# адресом в колонке 141, поэтому на этапе выгрузки делаем сдвиг. column_index, column_index
if column_index == 141: )
return row_by_index.get(142, "") return row_by_index.get(source_index, "")
if 142 <= column_index <= 150:
return row_by_index.get(column_index + 1, "")
if column_index == 151:
return row_by_index.get(141, "")
return row_by_index.get(column_index, "")
def save(self, destination: Path) -> None: def save(self, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
@ -261,9 +215,9 @@ class StreamingReportWriter:
if "БИК" in column_name: if "БИК" in column_name:
return normalized.zfill(9) return normalized.zfill(9)
if "КПП" in column_name: if "КПП" in column_name:
participant_type = str(row_by_index.get(109, "")).strip() participant_type = str(row_by_index.get(COL_PARTICIPANT_TYPE, "")).strip()
normalized_type = participant_type.lstrip("0") or "0" normalized_type = participant_type.lstrip("0") or "0"
if column_index == 122 and normalized_type != "1": if column_index == COL_KPP and normalized_type != PARTICIPANT_TYPE_UL:
return normalized return normalized
return normalized.zfill(9) return normalized.zfill(9)
if "ИНН" in column_name: if "ИНН" in column_name:
@ -300,7 +254,7 @@ class StreamingReportWriter:
date_part = normalized.split()[0] date_part = normalized.split()[0]
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d.%m.%Y", "%Y/%m/%d"): for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d.%m.%Y", "%Y/%m/%d"):
try: try:
return datetime.strptime(date_part, fmt) return datetime.strptime(date_part, fmt) # noqa: DTZ007
except ValueError: except ValueError:
continue continue
return None return None
@ -309,15 +263,16 @@ class StreamingReportWriter:
try: try:
self._temp_report_path.unlink(missing_ok=True) self._temp_report_path.unlink(missing_ok=True)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass return
def __del__(self) -> None: def __del__(self) -> None:
if not getattr(self, "_is_closed", True): if not getattr(self, "_is_closed", True):
try: try:
self.workbook.close() self.workbook.close()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass self._is_closed = True
self._is_closed = True else:
self._is_closed = True
self._cleanup_temp_file() self._cleanup_temp_file()

View File

@ -0,0 +1,135 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from app.pipeline.column_registry import (
COLUMNS,
MAPPING_DATA_FILE,
AmbiguousColumnNameError,
ColumnRegistry,
ColumnSchemaError,
)
def test_registry_keeps_stable_file_name_column_id() -> None:
assert COLUMNS.index_of("legacy_001") == 1
assert COLUMNS.meta(1).name == "Имя XML файла"
def test_registry_covers_all_report_columns_without_gaps() -> None:
indexes = tuple(column.index for column in COLUMNS.ordered_columns())
assert indexes == tuple(range(1, 205))
assert all(column.block for column in COLUMNS.ordered_columns())
assert all(column.report_group for column in COLUMNS.ordered_columns())
def test_registry_assigns_card_columns_missing_from_original_plan() -> None:
assert COLUMNS.block("foreign_card") == tuple(range(97, 107))
def test_registry_explicitly_preserves_structured_lookup_compatibility() -> None:
assert COLUMNS.meta(180).allow_short_lookup is True
assert COLUMNS.meta(153).allow_short_lookup is False
def test_registry_rejects_ambiguous_display_name_lookup() -> None:
assert COLUMNS.indices_named("ОКАТО") == (4, 171)
with pytest.raises(AmbiguousColumnNameError):
COLUMNS.index_named("ОКАТО")
def test_registry_report_groups_are_contiguous_and_cover_schema() -> None:
covered_indexes: list[int] = []
for group in COLUMNS.report_groups():
assert group.indexes == tuple(range(group.start_index, group.end_index + 1))
covered_indexes.extend(group.indexes)
assert covered_indexes == list(range(1, 205))
@pytest.mark.parametrize(
("field", "invalid_value"),
(
("index", True),
("column_id", None),
("column_name", ["Имя"]),
("source_scope", {"scope": "any"}),
("structured_order", False),
),
)
def test_registry_rejects_invalid_scalar_types(
tmp_path: Path, field: str, invalid_value: object
) -> None:
data = _mapping_data()
data[0][field] = invalid_value
with pytest.raises(ColumnSchemaError):
ColumnRegistry.load(_write_mapping(tmp_path, data))
def test_registry_rejects_unknown_report_group(tmp_path: Path) -> None:
data = _mapping_data()
data[0]["report_group"] = "unknown_group"
with pytest.raises(ColumnSchemaError):
ColumnRegistry.load(_write_mapping(tmp_path, data))
def test_registry_rejects_unknown_business_block(tmp_path: Path) -> None:
data = _mapping_data()
data[0]["block"] = "unknown_block"
with pytest.raises(ColumnSchemaError):
ColumnRegistry.load(_write_mapping(tmp_path, data))
def test_registry_rejects_duplicate_column_ids_and_index_gaps(
tmp_path: Path,
) -> None:
data = _mapping_data()
data[1]["column_id"] = data[0]["column_id"]
data[1]["index"] = 3
with pytest.raises(ColumnSchemaError):
ColumnRegistry.load(_write_mapping(tmp_path, data))
def test_registry_rejects_structured_group_without_aggregate(
tmp_path: Path,
) -> None:
data = _mapping_data()
aggregate = next(item for item in data if item["index"] == 50)
aggregate["structured_role"] = "component"
aggregate["structured_order"] = 10
aggregate["structured_value"] = False
with pytest.raises(ColumnSchemaError):
ColumnRegistry.load(_write_mapping(tmp_path, data))
def test_registry_rejects_duplicate_structured_component_order(
tmp_path: Path,
) -> None:
data = _mapping_data()
first = next(item for item in data if item["index"] == 51)
second = next(item for item in data if item["index"] == 52)
second["structured_order"] = first["structured_order"]
with pytest.raises(ColumnSchemaError):
ColumnRegistry.load(_write_mapping(tmp_path, data))
def _mapping_data() -> list[dict[str, object]]:
return json.loads(MAPPING_DATA_FILE.read_text(encoding="utf-8"))
def _write_mapping(tmp_path: Path, data: list[dict[str, object]]) -> Path:
path = tmp_path / "mapping_table.json"
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return path

View File

@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import date, datetime
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from openpyxl import load_workbook from openpyxl import load_workbook
from app.pipeline.column_registry import COLUMNS
from app.pipeline.mapping import FIXED_REPORT_COLUMNS from app.pipeline.mapping import FIXED_REPORT_COLUMNS
from app.pipeline.report import TOP_GROUP_HEADERS, ReportRow, write_report from app.pipeline.report import TOP_GROUP_HEADERS, ReportRow, write_report
@ -64,6 +65,17 @@ def test_write_report_contains_top_group_header_row(tmp_path: Path) -> None:
assert merged_ranges == expected_ranges assert merged_ranges == expected_ranges
def test_top_group_headers_follow_registry_report_groups() -> None:
actual_ranges = tuple(
(header.start_col, header.end_col) for header in TOP_GROUP_HEADERS
)
registry_ranges = tuple(
(group.start_index, group.end_index) for group in COLUMNS.report_groups()
)
assert actual_ranges == registry_ranges
def test_write_report_converts_date_string_to_excel_date(tmp_path: Path) -> None: def test_write_report_converts_date_string_to_excel_date(tmp_path: Path) -> None:
destination = tmp_path / "report.xlsx" destination = tmp_path / "report.xlsx"
rows = [ rows = [
@ -83,7 +95,7 @@ def test_write_report_converts_date_string_to_excel_date(tmp_path: Path) -> None
date_col_idx = FIXED_REPORT_COLUMNS.index("Дата сообщения") + 1 date_col_idx = FIXED_REPORT_COLUMNS.index("Дата сообщения") + 1
cell = sheet.cell(row=3, column=date_col_idx) cell = sheet.cell(row=3, column=date_col_idx)
assert isinstance(cell.value, datetime) assert isinstance(cell.value, datetime)
assert cell.value.date() == datetime(2005, 8, 23).date() assert cell.value.date() == date(2005, 8, 23)
assert cell.number_format == "DD.MM.YYYY" assert cell.number_format == "DD.MM.YYYY"