1-74 rule

This commit is contained in:
Raykov-MS 2026-07-30 12:37:18 +03:00
parent fe37cf3660
commit 25f96438b1
4 changed files with 248 additions and 43 deletions

View File

@ -204,7 +204,7 @@ def _build_structured_group_rules(
one_line_names: list[str] = [] one_line_names: list[str] = []
component_names: list[str] = [] component_names: list[str] = []
# Left-side "one line" can be immediately before structured column. # Поле "в одной строке" может располагаться слева от структурного.
left = by_index.get(index - 1) left = by_index.get(index - 1)
if left: if left:
left_name = str(left.get("column_name", "")).strip() left_name = str(left.get("column_name", "")).strip()
@ -290,6 +290,7 @@ REPORT_MAPPING_RULES: tuple[MappingRule, ...] = _build_rules(_MAPPING_DATA)
STRUCTURED_GROUP_RULES: tuple[StructuredGroupRule, ...] = _build_structured_group_rules( STRUCTURED_GROUP_RULES: tuple[StructuredGroupRule, ...] = _build_structured_group_rules(
_MAPPING_DATA _MAPPING_DATA
) )
_ACCOUNT_PLACEHOLDER = "00000000000000000000"
def build_fixed_row( def build_fixed_row(
@ -332,6 +333,7 @@ def build_fixed_row(
source_payload, suffix_index, rule.candidate_keys source_payload, suffix_index, rule.candidate_keys
) )
_apply_structured_group_rules(row) _apply_structured_group_rules(row)
_apply_conditional_rules_1_74(row)
for column in FIXED_REPORT_COLUMNS: for column in FIXED_REPORT_COLUMNS:
row.setdefault(column, "") row.setdefault(column, "")
return row return row
@ -414,3 +416,118 @@ def _pick_structured_value(
return "" return ""
separator = " " if join_with_space else ", " separator = " " if join_with_space else ", "
return separator.join(values) return separator.join(values)
def _column(index: int) -> str:
return FIXED_REPORT_COLUMNS[index - 1]
def _row_value(row: dict[str, str], index: int) -> str:
return str(row.get(_column(index), "")).strip()
def _set_row_value(row: dict[str, str], index: int, value: str) -> None:
row[_column(index)] = value
def _extract_numeric_codes(value: str) -> set[str]:
return {match for match in re.findall(r"\d+", value)}
def _operation_and_extra_codes(row: dict[str, str]) -> set[str]:
op_code = _row_value(row, 19)
extra_codes = _row_value(row, 20)
return _extract_numeric_codes(op_code) | _extract_numeric_codes(extra_codes)
def _apply_conditional_rules_1_74(row: dict[str, str]) -> None:
codes = _operation_and_extra_codes(row)
op_type_code = _row_value(row, 14)
transfer_type = _row_value(row, 35)
operator_type = _row_value(row, 37)
# 10. Допустимые значения: 0..5, иначе очищаем.
if _row_value(row, 10) not in {"0", "1", "2", "3", "4", "5"}:
_set_row_value(row, 10, "")
# 11. Заполняется только для приостановлений и валидных кодов основания.
if _row_value(row, 11) not in {"1", "2", "3"}:
_set_row_value(row, 11, "")
# 15. Заполняется только для допустимых значений признака ЭСП.
if _row_value(row, 15) not in {"1", "2", "3", "4"}:
_set_row_value(row, 15, "")
# 21. Коды необычной операции заполняются только при признаке 6001.
if "6001" not in codes:
_set_row_value(row, 21, "")
# 22-24. Для операций с цифровыми правами (код 8) не заполняем.
if op_type_code == "8":
_set_row_value(row, 22, "")
_set_row_value(row, 23, "")
_set_row_value(row, 24, "")
# 28. Идентификатор подозрительной деятельности только для 6001.
if "6001" not in codes:
_set_row_value(row, 28, "")
# 30. Наименование драгметалла только для кода C99.
if _row_value(row, 29).upper() != "C99":
_set_row_value(row, 30, "")
# 31. Предмет операции только для кодов 5020..5023.
if not {"5020", "5021", "5022", "5023"} & codes:
_set_row_value(row, 31, "")
# 36. Код территории заполняется только для признака 5016.
if "5016" not in codes:
_set_row_value(row, 36, "")
# 38. Счет плательщика обязателен для видов перевода 1/2/3/10 с заглушкой.
if transfer_type in {"1", "2", "3", "10"}:
if not _row_value(row, 38):
_set_row_value(row, 38, _ACCOUNT_PLACEHOLDER)
elif transfer_type in {"12", "13", "14"}:
pass
else:
_set_row_value(row, 38, "")
# 39 / 47. Идентификаторы ЭСП отсутствуют для признака операции 9.
if op_type_code == "9":
_set_row_value(row, 39, "")
_set_row_value(row, 47, "")
# 40. Банк плательщика заполняется только при типе оператора 2 или 4.
if operator_type not in {"2", "4"}:
_set_row_value(row, 40, "")
# 42/43. Реквизиты банка получателя только при типе оператора 1 или 4.
if operator_type not in {"1", "4"}:
_set_row_value(row, 42, "")
_set_row_value(row, 43, "")
# 44. Корсчет банка плательщика с заглушкой там, где это применимо.
if operator_type in {"3", "5"} or transfer_type in {"12", "13"}:
_set_row_value(row, 44, "")
elif not _row_value(row, 44):
_set_row_value(row, 44, _ACCOUNT_PLACEHOLDER)
# 45. Корсчет банка получателя с заглушкой там, где это применимо.
if operator_type in {"3", "5"} or transfer_type in {"10", "11"}:
_set_row_value(row, 45, "")
elif not _row_value(row, 45):
_set_row_value(row, 45, _ACCOUNT_PLACEHOLDER)
# 46. Счет получателя с заглушкой для обязательных видов перевода.
if transfer_type in {"1", "4", "7", "12"}:
if not _row_value(row, 46):
_set_row_value(row, 46, _ACCOUNT_PLACEHOLDER)
elif transfer_type in {"10", "11", "14"}:
pass
else:
_set_row_value(row, 46, "")
# 62. Статус перевода заполняется только для видов 2/5/8.
if transfer_type not in {"2", "5", "8"}:
_set_row_value(row, 62, "")

View File

@ -27,61 +27,63 @@ class GroupHeader:
title: str title: str
# Grouped upper header from the template report for readability. # Верхняя группирующая шапка согласно согласованным логическим категориям.
TOP_GROUP_HEADERS: tuple[GroupHeader, ...] = ( 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=1, start_col=7,
end_col=4,
title="Информация о КО, передающей сведения в уполномоченный орган",
),
GroupHeader(start_col=5, end_col=8, title="Сведения об уполномоченном лице"),
GroupHeader(
start_col=11,
end_col=13, end_col=13,
title="Информация о филиале КО, передающем сведения в уполномоченный орган", title="Идентификация записи и контрольные признаки",
), ),
GroupHeader(start_col=14, end_col=36, title="Информация об операции"), GroupHeader(start_col=14, end_col=36, title="Параметры операции"),
GroupHeader( GroupHeader(
start_col=37, start_col=37,
end_col=48, end_col=48,
title="Сведения о переводах денежных средств, в том числе электронных денежных средств", title="Перевод ДС и реквизиты расчетов",
), ),
GroupHeader( GroupHeader(
start_col=49, start_col=49,
end_col=53, end_col=66,
title="Сведения о месте приема наличных денежных средств", title="Места приема/выдачи наличных и авторизация ЭСП",
),
GroupHeader(
start_col=55,
end_col=59,
title="Сведения о месте выдачи наличных денежных средств",
),
GroupHeader(
start_col=60,
end_col=64,
title="Сведения о месте авторизации ЭСП",
), ),
GroupHeader( GroupHeader(
start_col=67, start_col=67,
end_col=69, end_col=75,
title=( title="Внесение/выдача у одного оператора (место операции)",
"Сведения о внесении наличных денежных средств на свой банковский счет "
"или о получении наличных денежных средств со своего банковского счета "
"у одного оператора по переводу денежных средств"
),
),
GroupHeader(
start_col=70,
end_col=74,
title="Сведения о месте приема (выдачи) наличных денежных средств",
), ),
GroupHeader( GroupHeader(
start_col=76, start_col=76,
end_col=80, end_col=86,
title="Сведения о месте совершения операции с денежными средствами в наличной форме", title="Наличная форма и основание операции",
),
GroupHeader(
start_col=87,
end_col=121,
title="Участник операции (базовые сведения)",
),
GroupHeader(
start_col=122,
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=(
"Колонки вне согласованной группировки (если количество полей расширится)"
),
), ),
GroupHeader(start_col=87, end_col=104, title="Сведения об участниках операции"),
GroupHeader(start_col=105, end_col=118, title="Сведения ЕИО"),
) )
@ -120,7 +122,7 @@ class StreamingReportWriter:
self._column_formats = [self._resolve_format(column) for column in self.columns] self._column_formats = [self._resolve_format(column) for column in self.columns]
self._build_group_header_row() self._build_group_header_row()
self._build_header_row() self._build_header_row()
# Freeze both header rows. # Фиксируем обе строки шапки.
self.sheet.freeze_panes(2, 0) self.sheet.freeze_panes(2, 0)
self.sheet.autofilter(1, 0, 1, len(self.columns) - 1) self.sheet.autofilter(1, 0, 1, len(self.columns) - 1)
self._next_data_row = 2 self._next_data_row = 2
@ -161,6 +163,9 @@ class StreamingReportWriter:
continue continue
first_col = header.start_col - 1 first_col = header.start_col - 1
last_col = min(header.end_col, len(self.columns)) - 1 last_col = min(header.end_col, len(self.columns)) - 1
if first_col == last_col:
self.sheet.write(0, first_col, header.title, self._group_format)
continue
self.sheet.merge_range( self.sheet.merge_range(
0, 0,
first_col, first_col,

View File

@ -294,3 +294,86 @@ def test_structured_column_uses_non_one_line_candidate_when_mixed_tags() -> None
] ]
== "г. Москва, ул. Пушкина, д. 1" == "г. Москва, ул. Пушкина, д. 1"
) )
def test_digital_rights_operation_clears_currency_amount_fields() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодПризнОперации": "8",
"КодВал": "643",
"СумОперации": "100.00",
"СумРуб": "100.00",
},
participant_fields={},
)
assert row["Валюта"] == ""
assert row["Сумма в валюте проведения"] == ""
assert row["Сумма в рубл. эквиваленте"] == ""
def test_abnormal_operation_codes_only_for_6001() -> None:
row_without_6001 = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "5007",
"ПризнНеобОперации": "1101",
},
participant_fields={},
)
assert row_without_6001["Коды призн. необычной операции"] == ""
row_with_6001 = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"КодОперации": "6001",
"ПризнНеобОперации": "1101",
},
participant_fields={},
)
assert row_with_6001["Коды призн. необычной операции"] == "1101"
def test_transfer_accounts_get_placeholder_when_required() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ВидПереводДС": "1",
"ТипОператорДС": "4",
},
participant_fields={},
)
assert row["Номер счета плательщика"] == "00000000000000000000"
assert row["Номер счета банка плательщика"] == "00000000000000000000"
assert row["Номер счета банка получателя"] == "00000000000000000000"
assert row["Номер счета получателя"] == "00000000000000000000"
def test_operator_type_conditions_clear_irrelevant_bank_fields() -> None:
row = build_fixed_row(
file_name="f.xml",
record_id="R1",
operation_index=1,
operation_fields={
"ТипОператорДС": "3",
"НаимКО": "Банк плательщика",
"СведБанкПолучатель.НаимКО": "Банк получателя",
"СведБанкПолучатель.БИККО": "044525225",
"СчетБанкПлательщик": "30101810000000000001",
"СчетБанкПолучатель": "30101810000000000002",
},
participant_fields={},
)
assert row["Наименование банка плательщика"] == ""
assert row["Наименование банка получателя"] == ""
assert row["БИК банка получателя"] == ""
assert row["Номер счета банка плательщика"] == ""
assert row["Номер счета банка получателя"] == ""

View File

@ -62,15 +62,15 @@ def test_write_report_contains_top_group_header_row(tmp_path: Path) -> None:
workbook = load_workbook(destination) workbook = load_workbook(destination)
sheet = workbook["Отчет"] sheet = workbook["Отчет"]
assert sheet.cell(row=1, column=1).value == ( assert sheet.cell(row=1, column=1).value == "Служебные поля"
"Информация о КО, передающей сведения в уполномоченный орган"
)
merged_ranges = {str(rng) for rng in sheet.merged_cells.ranges} merged_ranges = {str(rng) for rng in sheet.merged_cells.ranges}
expected_ranges = set() expected_ranges = set()
for header in TOP_GROUP_HEADERS: for header in TOP_GROUP_HEADERS:
if header.start_col > len(FIXED_REPORT_COLUMNS): if header.start_col > len(FIXED_REPORT_COLUMNS):
continue continue
end_col = min(header.end_col, len(FIXED_REPORT_COLUMNS)) end_col = min(header.end_col, len(FIXED_REPORT_COLUMNS))
if header.start_col == end_col:
continue
expected_ranges.add( expected_ranges.add(
f"{_column_letter(header.start_col)}1:{_column_letter(end_col)}1" f"{_column_letter(header.start_col)}1:{_column_letter(end_col)}1"
) )