diff --git a/app/pipeline/column_constants.py b/app/pipeline/column_constants.py index 6c2f869..690d829 100644 --- a/app/pipeline/column_constants.py +++ b/app/pipeline/column_constants.py @@ -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( diff --git a/app/pipeline/mapping.py b/app/pipeline/mapping.py index 43e71e6..d9c9549 100644 --- a/app/pipeline/mapping.py +++ b/app/pipeline/mapping.py @@ -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): diff --git a/app/pipeline/mapping_table.json b/app/pipeline/mapping_table.json index 9d327b8..514e070 100644 --- a/app/pipeline/mapping_table.json +++ b/app/pipeline/mapping_table.json @@ -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", diff --git a/app/pipeline/report.py b/app/pipeline/report.py index ef8979d..4f3458a 100644 --- a/app/pipeline/report.py +++ b/app/pipeline/report.py @@ -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) diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index aff03f9..3f61f7f 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -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", diff --git a/tests/unit/test_report.py b/tests/unit/test_report.py index 7a1d3e1..7dc2abf 100644 --- a/tests/unit/test_report.py +++ b/tests/unit/test_report.py @@ -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)