This commit is contained in:
Raykov-MS 2026-08-24 11:44:32 +03:00
parent 5935eceadf
commit 07056e6266
6 changed files with 256 additions and 24 deletions

View File

@ -62,6 +62,7 @@ COL_BIRTH_REG_DATE = _index(123)
COL_BRANCH_SIGN = _index(124)
COL_INSURANCE_NUMBER = _index(125)
COL_COUNTRY_CODE = _index(126)
COL_STAY_DOCUMENT_TYPE = _index(136)
COL_ADDRESS_ONE_LINE = _index(141)
COL_ADDRESS_STRUCTURED = _index(142)
COL_REG_PLACE_ONE_LINE = _index(152)
@ -76,6 +77,11 @@ COL_CP_ISSUER_INN = _index(202)
# Блоки схемы.
BLOCK_EIO = COLUMNS.block("eio")
BLOCK_CP = COLUMNS.block("cp")
PARTICIPANT_ADDRESS_COMPONENT_COLUMNS = tuple(
column.index
for column in COLUMNS.ordered_columns()
if column.structured_group == "legacy_142" and column.structured_role == "component"
)
# Точные поля наличных, которые исторически очищаются для безналичных переводов.
CASHLESS_CLEARED_COLUMNS = tuple(

View File

@ -10,6 +10,7 @@ from .column_constants import (
CASHLESS_CLEARED_COLUMNS,
CASHLESS_TRANSFER_TYPES,
COL_ADDRESS_ONE_LINE,
COL_ADDRESS_STRUCTURED,
COL_AMOUNT_CURRENCY,
COL_AMOUNT_RUB,
COL_BIRTH_REG_DATE,
@ -56,6 +57,7 @@ from .column_constants import (
COL_RESIDENT_SIGN,
COL_SALE_AMOUNT,
COL_SALE_CURRENCY,
COL_STAY_DOCUMENT_TYPE,
COL_SUSPENSION_BASIS,
COL_SUSPICIOUS_ID,
COL_TERRITORY_CODE,
@ -66,6 +68,7 @@ from .column_constants import (
EMPLOYEE_ABSENT_CODE,
EXPORT_SUBSIDIARY_COMMENT_CODES,
OKATO_COUNTRY_PAIRS,
PARTICIPANT_ADDRESS_COMPONENT_COLUMNS,
PARTICIPANT_TYPE_FL,
PARTICIPANT_TYPE_FLCHP,
PARTICIPANT_TYPE_IP,
@ -87,6 +90,7 @@ class _CompiledCandidate:
raw: str
normalized: str
short: str
strict_scoped: bool
@dataclass(frozen=True)
@ -134,6 +138,11 @@ _EIO_BLOCK_NAMES = (
"БенефициарФЛИП",
"БенефициарИНБОЮЛ",
)
_PARTICIPANT_ADDRESS_BASE_PATHS = (
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ",
"УчастникФЛИП.СведФЛИП.АдрРег",
"УчастникИНБОЮЛ.СведИНБОЮЛ.МестоДеятельностьИНБОЮЛ",
)
def _bank_bik_candidate_keys(bank_block: str) -> tuple[str, ...]:
@ -153,13 +162,21 @@ def _bank_bik_candidate_keys(bank_block: str) -> tuple[str, ...]:
def _compile_candidates(candidates: tuple[str, ...]) -> tuple[_CompiledCandidate, ...]:
normalized_candidates = tuple(
_INDEXED_PATH_SEGMENT.sub("", candidate) for candidate in candidates
)
return tuple(
_CompiledCandidate(
raw=candidate,
normalized=_INDEXED_PATH_SEGMENT.sub("", candidate),
normalized=normalized,
short=candidate.rsplit(".", maxsplit=1)[-1],
strict_scoped="." in normalized
and not any(
other != normalized and other.endswith(f".{normalized}")
for other in normalized_candidates
),
)
for candidate in candidates
for candidate, normalized in zip(candidates, normalized_candidates)
)
@ -207,6 +224,21 @@ def _build_rules(mapping_data: tuple[ColumnMeta, ...]) -> tuple[MappingRule, ...
for candidate in [*path_candidates, *candidates]
if candidate.endswith(("ДатаРегЮЛ", "ДатаРождения"))
]
elif index == COL_STAY_DOCUMENT_TYPE:
candidates = [
candidate
for candidate in path_candidates
if candidate.endswith("УчастникФЛИП.СведФЛИП.СведДокПреб.ВидДокКод")
]
elif (
index == COL_ADDRESS_STRUCTURED
or index in PARTICIPANT_ADDRESS_COMPONENT_COLUMNS
):
candidates = [
f"{base_path}.{address_tag}"
for base_path in _PARTICIPANT_ADDRESS_BASE_PATHS
for address_tag in _extract_tag_candidate_keys(tag)
]
elif index == COL_REG_PLACE_ONE_LINE:
candidates = [
"УчастникЮЛ.СведЮЛ.АдрРегЮЛ.АдресСтрока",
@ -402,18 +434,19 @@ def build_fixed_row_by_index(
source_lookup = operation_lookup
else:
source_lookup = merged_lookup
allow_short_lookup = rule.allow_short_lookup or rule.column_index in BLOCK_EIO
if rule.structured_value:
row[rule.column_index] = _pick_structured_value(
lookup=source_lookup,
candidates=rule.compiled_candidates,
join_with_space="фио" in rule.column_name.lower(),
allow_short_lookup=rule.allow_short_lookup,
allow_short_lookup=allow_short_lookup,
)
else:
row[rule.column_index] = _pick_value(
source_lookup,
rule.compiled_candidates,
allow_short_lookup=rule.allow_short_lookup,
allow_short_lookup=allow_short_lookup,
)
if not is_continuation:
_apply_participant_identity_rules(row, participant_lookup)
@ -633,11 +666,20 @@ def _pick_value(
allow_short_lookup: bool = True,
) -> str:
for candidate in candidates:
if not allow_short_lookup and not candidate.strict_scoped:
continue
if value := lookup.values.get(candidate.raw):
return value
if value := lookup.normalized_suffix_first.get(candidate.normalized):
return value
if not allow_short_lookup:
for entry in lookup.entries:
if (
entry.value
and "." in entry.normalized_path
and candidate.normalized.endswith(f".{entry.normalized_path}")
):
return entry.value
continue
if value := lookup.values.get(candidate.short):
return value
@ -877,6 +919,11 @@ def _rule_correspondent_accounts(
row: dict[int, str], operator_type: str, transfer_type: str
) -> None:
"""Правила 44-45: корсчета зависят от оператора и вида перевода."""
if not operator_type and not transfer_type:
_set(row, COL_PAYER_BANK_ACCOUNT, "")
_set(row, COL_RECIPIENT_BANK_ACCOUNT, "")
return
if operator_type in {"3", "5"} or transfer_type in {"12", "13"}:
_set(row, COL_PAYER_BANK_ACCOUNT, "")
elif not _get(row, COL_PAYER_BANK_ACCOUNT):

View File

@ -2706,9 +2706,9 @@
"block": "participant_documents",
"report_group": "participant_identity",
"xml_tag": "ВидДокКод",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/СведДокУдЛичн/ВидДокКод",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФЛИП/СведФЛИП/СведДокПреб/ВидДокКод",
"source_scope": "participant",
"allow_short_lookup": true,
"allow_short_lookup": false,
"allow_direct_mapping": true,
"structured_value": false,
"structured_group": "",
@ -2828,7 +2828,7 @@
"xml_tag": "Индекс|КодОКСМ|КодСубъектаПоОКАТО|Район|Пункт|Улица|Дом|Корп|Оф",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/",
"source_scope": "any",
"allow_short_lookup": true,
"allow_short_lookup": false,
"allow_direct_mapping": true,
"structured_value": true,
"structured_group": "legacy_142",
@ -2848,7 +2848,7 @@
"xml_tag": "Индекс",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Индекс | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Индекс",
"source_scope": "any",
"allow_short_lookup": true,
"allow_short_lookup": false,
"allow_direct_mapping": true,
"structured_value": false,
"structured_group": "legacy_142",
@ -2868,7 +2868,7 @@
"xml_tag": "КодОКСМ",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/КодОКСМ | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/КодОКСМ",
"source_scope": "any",
"allow_short_lookup": true,
"allow_short_lookup": false,
"allow_direct_mapping": true,
"structured_value": false,
"structured_group": "legacy_142",
@ -2888,7 +2888,7 @@
"xml_tag": "КодСубъектаПоОКАТО",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/КодСубъектаПоОКАТО | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/КодСубъектаПоОКАТО",
"source_scope": "any",
"allow_short_lookup": true,
"allow_short_lookup": false,
"allow_direct_mapping": true,
"structured_value": false,
"structured_group": "legacy_142",
@ -2908,7 +2908,7 @@
"xml_tag": "Район",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Район | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Район",
"source_scope": "any",
"allow_short_lookup": true,
"allow_short_lookup": false,
"allow_direct_mapping": true,
"structured_value": false,
"structured_group": "legacy_142",
@ -2928,7 +2928,7 @@
"xml_tag": "Пункт",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Пункт | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Пункт",
"source_scope": "any",
"allow_short_lookup": true,
"allow_short_lookup": false,
"allow_direct_mapping": true,
"structured_value": false,
"structured_group": "legacy_142",
@ -2948,7 +2948,7 @@
"xml_tag": "Улица",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Улица | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Улица",
"source_scope": "any",
"allow_short_lookup": true,
"allow_short_lookup": false,
"allow_direct_mapping": true,
"structured_value": false,
"structured_group": "legacy_142",
@ -2968,7 +2968,7 @@
"xml_tag": "Дом",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Дом | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Дом",
"source_scope": "any",
"allow_short_lookup": true,
"allow_short_lookup": false,
"allow_direct_mapping": true,
"structured_value": false,
"structured_group": "legacy_142",
@ -2988,7 +2988,7 @@
"xml_tag": "Корп",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Корп | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Корп",
"source_scope": "any",
"allow_short_lookup": true,
"allow_short_lookup": false,
"allow_direct_mapping": true,
"structured_value": false,
"structured_group": "legacy_142",
@ -3008,7 +3008,7 @@
"xml_tag": "Оф",
"xml_path": "/СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникЮЛ/СведЮЛ/АдрРегЮЛ/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникФлип/СведФЛИП/АдрРег/Оф | /СообщОперКО/ИнформЧасть/СведКО/Операция/УчастникОП/УчастникИНБОЮЛ/СведИНБОЮЛ/МестоДеятельностьИНБОЮЛ/Оф",
"source_scope": "any",
"allow_short_lookup": true,
"allow_short_lookup": false,
"allow_direct_mapping": true,
"structured_value": false,
"structured_group": "legacy_142",

View File

@ -82,6 +82,9 @@ class StreamingReportWriter:
self._column_formats = [self._resolve_format(column) for column in self.columns]
self._build_group_header_row()
self._build_header_row()
for column_index, column_format in enumerate(self._column_formats):
if column_format is self._date_format:
self.sheet.set_column(column_index, column_index, 12)
# Фиксируем обе строки шапки.
self.sheet.freeze_panes(2, 0)
self.sheet.autofilter(1, 0, 1, len(self.columns) - 1)

View File

@ -112,10 +112,10 @@ def test_build_fixed_row_splits_pipe_separated_xml_tag_values() -> None:
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"Индекс": "674500",
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.АдрРег.Индекс": "674500",
},
participant_fields={},
)
assert row["Адрес по структуре (целый)"] == "674500"
@ -194,11 +194,11 @@ def test_structured_address_is_built_from_right_columns() -> None:
operation_index=1,
operation_fields={},
participant_fields={
"Индекс": "674500",
"КодОКСМ": "643",
"Пункт": "Москва",
"Улица": "Тверская",
"Дом": "1",
"УчастникФЛИП.СведФЛИП.АдрРег.Индекс": "674500",
"УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643",
"УчастникФЛИП.СведФЛИП.АдрРег.Пункт": "Москва",
"УчастникФЛИП.СведФЛИП.АдрРег.Улица": "Тверская",
"УчастникФЛИП.СведФЛИП.АдрРег.Дом": "1",
},
)
assert row["Адрес по структуре (целый)"] == "674500, 643, Москва, Тверская, 1"
@ -398,6 +398,25 @@ def test_transfer_accounts_get_placeholder_when_required() -> None:
assert row["Номер счета получателя"] == "00000000000000000000"
def test_transfer_accounts_stay_empty_without_transfer_block() -> None:
row = build_fixed_row_by_index(
file_name="SKO115FZ_01_044525111_20260810_000001.xml",
record_id="R1",
operation_index=1,
operation_fields={
"НомерОперация": "ЦФТ-БАНК_1361233603059",
"ПризнакОперации": "5",
"СведенияВнесениеПолучениеНалДС.СуммаНал": "1000.00",
},
participant_fields={},
)
assert row[38] == ""
assert row[44] == ""
assert row[45] == ""
assert row[46] == ""
def test_operator_type_conditions_clear_irrelevant_bank_fields() -> None:
row = build_fixed_row(
file_name="f.xml",
@ -655,6 +674,49 @@ def test_dul_columns_128_134_keep_expected_positions() -> None:
assert row["КП"] == "222-022"
def test_col136_does_not_use_identity_document_type_without_stay_document() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.СведДокУдЛичн.ВидДокКод": "21",
},
)
assert row[136] == ""
def test_col136_uses_stay_document_type_instead_of_identity_document_type() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"УчастникФЛИП.СведФЛИП.СведДокУдЛичн.ВидДокКод": "21",
"УчастникФЛИП.СведФЛИП.СведДокПреб.ВидДокКод": "10",
},
)
assert row[136] == "10"
def test_col136_rejects_stay_document_type_from_unrelated_participant_path() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ДругойБлок.СведДокПреб.ВидДокКод": "99",
},
)
assert row[136] == ""
def test_bank_bik_columns_use_transfer_bank_paths_instead_of_common_bikko() -> None:
row = build_fixed_row(
file_name="f.xml",
@ -672,6 +734,45 @@ def test_bank_bik_columns_use_transfer_bank_paths_instead_of_common_bikko() -> N
assert row["БИК банка получателя"] == "022202220"
def test_transfer_bank_fields_do_not_leak_into_unrelated_bank_blocks() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ТипОператорДС": "2",
"ВидПереводДС": "1",
"СведенияПереводыДС.СведБанкПлательщик.НаимКО": "ПАО Сбербанк",
"СведенияПереводыДС.СведБанкПлательщик.БИККО": "044525225",
},
participant_fields={},
)
assert row[40] == "ПАО Сбербанк"
assert row[41] == "044525225"
for column_index in (60, 61, 75, 76, 94, 95, 100, 101):
assert row[column_index] == ""
def test_cash_bank_fields_fill_only_their_own_block() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"СведенияВнесениеПолучениеНалДС.СведПриемВыдача.НаимКО": "АО Наличный банк",
"СведенияВнесениеПолучениеНалДС.СведПриемВыдача.БИККО": "040407777",
"СведенияВнесениеПолучениеНалДС.СведПриемВыдача.КодОКСМ": "643",
},
participant_fields={},
)
assert row[94] == "АО Наличный банк"
assert row[95] == "040407777"
for column_index in (40, 41, 42, 43, 60, 61, 75, 76, 98, 100, 101):
assert row[column_index] == ""
def test_citizenship_country_code_uses_only_direct_svedflip_value() -> None:
row = build_fixed_row(
file_name="f.xml",
@ -767,6 +868,66 @@ def test_participant_structured_address_stays_in_142_and_components_143_plus() -
assert row[144] == "643"
def test_participant_address_does_not_use_citizenship_without_address_block() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ТипУчастника": "3",
"УчастникФЛИП.СведФЛИП.КодОКСМ": "643",
},
)
assert row[126] == "643"
assert row[142] == ""
assert row[144] == ""
def test_participant_address_country_is_isolated_from_citizenship() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={},
participant_fields={
"ТипУчастника": "2",
"УчастникФЛИП.СведФЛИП.КодОКСМ": "762",
"УчастникФЛИП.СведФЛИП.АдрРег.Индекс": "123456",
"УчастникФЛИП.СведФЛИП.АдрРег.КодОКСМ": "643",
"УчастникФЛИП.СведФЛИП.АдрРег.Пункт": "Москва",
},
)
assert row[126] == "762"
assert row[142] == "123456, 643, Москва"
assert row[144] == "643"
def test_participant_address_components_reject_unrelated_address_fields() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ДругойАдрес.Индекс": "999999",
"ДругойАдрес.Район": "Чужой район",
"ДругойАдрес.Дом": "9",
},
participant_fields={
"ТипУчастника": "3",
"УчастникФЛИП.СведФЛИП.КодОКСМ": "643",
},
)
assert row[142] == ""
assert row[143] == ""
assert row[144] == ""
assert row[146] == ""
assert row[149] == ""
def test_col153_uses_only_legal_entity_registration_address_components() -> None:
row = build_fixed_row_by_index(
file_name="f.xml",

View File

@ -5,6 +5,7 @@ from pathlib import Path
from unittest.mock import patch
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
from app.pipeline.column_registry import COLUMNS
from app.pipeline.mapping import FIXED_REPORT_COLUMNS
@ -44,6 +45,20 @@ def test_write_report_streaming_preserves_header_and_formats(tmp_path: Path) ->
assert sheet.cell(row=3, column=sum_col_idx).number_format == "#,##0.00"
def test_write_report_expands_only_date_columns(tmp_path: Path) -> None:
destination = tmp_path / "report.xlsx"
write_report([], destination)
workbook = load_workbook(destination)
sheet = workbook["Отчет"]
date_col_idx = FIXED_REPORT_COLUMNS.index("Дата сообщения") + 1
date_column_letter = get_column_letter(date_col_idx)
assert date_column_letter in sheet.column_dimensions
assert sheet.column_dimensions[date_column_letter].width >= 12
assert "A" not in sheet.column_dimensions
def test_write_report_contains_top_group_header_row(tmp_path: Path) -> None:
destination = tmp_path / "report.xlsx"
write_report([], destination)