"""Сводная ОСВ версии (Срез 4, задача 4.1; spec consolidated-osv; D-1, D-2, D-3, D-16): чистая функция без БД.""" import dataclasses import random import uuid from collections.abc import Sequence from decimal import Decimal import pytest from src.domain.consolidated_osv import ( Consolidated, ConsolidatedRow, CounterpartyType, Entity, GapItem, OsvFilter, SourceDocument, SourceRow, apply_filter, build, classify, summary, ) from src.domain.osv_mapping import Resolution from src.models.enums import CounterpartyMatchKind, PeriodQuarter Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3 INN, NAME, ALIAS, MANUAL, PROPAGATED = ( CounterpartyMatchKind.INN, CounterpartyMatchKind.NAME, CounterpartyMatchKind.ALIAS, CounterpartyMatchKind.MANUAL, CounterpartyMatchKind.PROPAGATED, ) GSK, GK, EXTERNAL = CounterpartyType.GSK, CounterpartyType.GK, CounterpartyType.EXTERNAL ZERO = Decimal("0") GROUP = uuid.uuid4() RB = Entity(uuid.uuid4(), "АО Русский бисквит", "3528052718", GROUP) SCH = Entity(uuid.uuid4(), "ООО Пекарня Счастливочная", "3528000001", GROUP) CHKF = Entity(uuid.uuid4(), "ООО ЧКФ", "3528000002", GROUP) # в ГК, не в периметре версии OUTSIDER = Entity(uuid.uuid4(), "ООО Внешний поставщик", "7700000003", uuid.uuid4()) LONE = Entity(uuid.uuid4(), "ИП Монозаёмщик", "770000000004", None) MEMBERS = {RB.id: RB, SCH.id: SCH} ENTITIES = {entity.id: entity for entity in (RB, SCH, CHKF, OUTSIDER, LONE)} class _Resolver: """Справочник для дорезолва: ответы по (ИНН, имя), журнал вызовов.""" def __init__(self, answers: dict[tuple[str | None, str | None], Resolution] | None = None) -> None: self.answers = answers or {} self.calls: list[tuple[str | None, str | None]] = [] def resolve(self, inn: str | None, name: str | None) -> Resolution | None: self.calls.append((inn, name)) return self.answers.get((inn, name)) def _doc( entity: Entity = RB, account: str = "60", quarter: PeriodQuarter = Q3, *, cumulative: bool | None = False ) -> SourceDocument: return SourceDocument(uuid.uuid4(), entity.id, account, quarter, cumulative) _AMOUNT_FIELDS = { "od": "opening_debit", "oc": "opening_credit", "td": "turnover_debit", "tc": "turnover_credit", "cd": "closing_debit", "cc": "closing_credit", } def _amounts(**short: str | None) -> dict[str, Decimal | None]: """Суммы строки по коротким именам (`od`, `oc`, `td`, `tc`, `cd`, `cc`): не заданные — ноль, `None` — пусто.""" assert set(short) <= set(_AMOUNT_FIELDS), set(short) - set(_AMOUNT_FIELDS) return { field: None if (value := short.get(key, "0")) is None else Decimal(value) for key, field in _AMOUNT_FIELDS.items() } def _row( # noqa: PLR0913 - тестовая фабрика строки osv_entry document: SourceDocument, name: str | None = "А", *, inn: str | None = None, row_number: int | None = 1, account_code: str | None = None, counterparty_id: uuid.UUID | None = None, match_kind: CounterpartyMatchKind | None = None, **amounts: str | None, ) -> SourceRow: code = account_code if account_code is not None else document.osv_account_code return SourceRow( id=uuid.uuid4(), document_id=document.id, row_number=row_number, account_code=code, account_root=(code or "").split(".")[0] or None, counterparty_name=name, counterparty_inn=inn, counterparty_id=counterparty_id, match_kind=match_kind, **_amounts(**amounts), ) def _build( documents: Sequence[SourceDocument], rows: Sequence[SourceRow], *, quarter: PeriodQuarter = Q3, resolver: _Resolver | None = None, ) -> Consolidated: return build( year=2024, quarter=quarter, group_id=GROUP, members=MEMBERS, entities=ENTITIES, documents=documents, rows=rows, resolver=resolver or _Resolver(), ) def _sums(row: ConsolidatedRow) -> tuple[Decimal | None, ...]: return ( row.opening_debit, row.opening_credit, row.turnover_debit, row.turnover_credit, row.closing_debit, row.closing_credit, ) # --------------------------------------------------------------------------- приведение периодов def test_three_quarters_are_glued_into_cumulative_row() -> None: """Сценарий «Три квартала»: opening из Q1, turnover = Σ, closing из Q3; entry_ids — три источника.""" q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) r1 = _row(q1, od="100", td="200", cd="300") r2 = _row(q2, od="300", td="150", cd="450") r3 = _row(q3, od="450", td="50", cd="500") result = _build([q3, q1, q2], [r3, r2, r1]) assert len(result.rows) == 1 row = result.rows[0] assert (row.opening_debit, row.turnover_debit, row.closing_debit) == (Decimal(100), Decimal(400), Decimal(500)) assert (row.opening_credit, row.turnover_credit, row.closing_credit) == (ZERO, ZERO, ZERO) assert row.period == "2024 Q3" assert row.is_cumulative is False assert row.entry_ids == (r1.id, r2.id, r3.id) assert (row.legal_entity_id, row.legal_entity_name, row.legal_entity_inn) == (RB.id, RB.name, RB.inn) assert (row.account_code, row.account_root, row.counterparty_name) == ("60", "60", "А") assert result.gaps == () assert result.documents_total == 3 # noqa: PLR2004 def test_counterparty_only_in_middle_quarter() -> None: """Сценарий «Контрагент только во втором квартале»: opening/closing — нули, turnover — из Q2.""" q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) only = _row(q2, "Б", row_number=4, td="70", cd="70") result = _build([q1, q2, q3], [_row(q1), only, _row(q3)]) row = next(r for r in result.rows if r.counterparty_name == "Б") assert _sums(row) == (ZERO, ZERO, Decimal(70), ZERO, ZERO, ZERO) assert row.entry_ids == (only.id,) assert row.row_number == 4 # noqa: PLR2004 - ключа нет в последнем квартале: номер из последнего, где он есть def test_gap_skips_pair_and_lists_missing_quarters() -> None: """Сценарий «Пробел»: Q1 и Q3 есть, Q2 нет → строк по 60 нет, пробел с `Q2`; соседний счёт строится.""" q1, q3 = _doc(quarter=Q1), _doc(quarter=Q3) cumulative_62 = _doc(account="62", cumulative=True) result = _build([q1, q3, cumulative_62], [_row(q1), _row(q3), _row(cumulative_62, "В")]) assert [r.account_code for r in result.rows] == ["62"] assert result.gaps == (GapItem(RB.id, RB.name, "60", (Q2,)),) assert result.documents_total == 3 # noqa: PLR2004 def test_gap_of_cumulative_from_earlier_quarter() -> None: """Review Focus 2 (защитно): нарастающий Q2 при версии Q3 — не источник; пара — пробел Q1–Q3.""" stale = _doc(account="62", quarter=Q2, cumulative=True) result = _build([stale], [_row(stale)]) assert result.rows == () assert result.gaps == (GapItem(RB.id, RB.name, "62", (Q1, Q2, Q3)),) def test_cumulative_next_to_quarterly_rows_as_is() -> None: """Сценарий «Нарастающий рядом с квартальными»: строки нарастающего один в один (суммы `None` — как есть).""" quarterly = [_doc(account="62", quarter=q) for q in (Q1, Q2, Q3)] cumulative = _doc(account="62", cumulative=True) source = _row(cumulative, row_number=9, od="1.50", oc=None, td="2", tc=None, cd="3.50", cc=None) result = _build([*quarterly, cumulative], [*(_row(d, td="999") for d in quarterly), source]) assert len(result.rows) == 1 row = result.rows[0] assert row.is_cumulative is True assert row.entry_ids == (source.id,) assert row.row_number == 9 # noqa: PLR2004 assert _sums(row) == (Decimal("1.50"), None, Decimal(2), None, Decimal("3.50"), None) assert result.gaps == () def test_cumulative_q1_with_quarterly_q2_q3_is_glued() -> None: """R-14, форма фикстур РБ: ОСВ Q1 помечена нарастающей, Q2/Q3 — квартальные; версия Q3 — склейка: opening из Q1, turnover = Σ, closing из Q3, строка квартальная; пробела нет.""" q1, q2, q3 = _doc(quarter=Q1, cumulative=True), _doc(quarter=Q2), _doc(quarter=Q3) r1 = _row(q1, od="100", oc="5", td="200", tc="7", cd="300", cc="12") r2 = _row(q2, od="300", oc="12", td="150", tc=None, cd="450", cc="12") r3 = _row(q3, od="450", oc="12", td="50", tc="3", cd="500", cc="15") result = _build([q1, q2, q3], [r3, r1, r2]) assert result.gaps == () assert len(result.rows) == 1 row = result.rows[0] assert _sums(row) == (Decimal(100), Decimal(5), Decimal(400), Decimal(10), Decimal(500), Decimal(15)) assert row.is_cumulative is False assert row.entry_ids == (r1.id, r2.id, r3.id) def test_cumulative_q1_at_version_q1_is_taken_as_is() -> None: q1 = _doc(quarter=Q1, cumulative=True) source = _row(q1, od="1", oc=None, td="2", tc=None, cd="3", cc=None) row = _build([q1], [source], quarter=Q1).rows[0] assert row.is_cumulative is True assert _sums(row) == (Decimal(1), None, Decimal(2), None, Decimal(3), None) def test_cumulative_document_keeps_duplicate_keys_separate() -> None: """Решение №1: дубли ключа в нарастающем документе не суммируются — строки свода = строки файла (§3 п. 6).""" cumulative = _doc(cumulative=True) first, second = _row(cumulative, row_number=1, td="10"), _row(cumulative, row_number=2, td="20") result = _build([cumulative], [second, first]) assert [r.entry_ids for r in result.rows] == [(first.id,), (second.id,)] def test_q1_version_quarterly_document_is_single_quarter_glue() -> None: q1 = _doc(quarter=Q1) source = _row(q1, od="5", td="6", cd="11") result = _build([q1], [source], quarter=Q1) assert len(result.rows) == 1 assert result.rows[0].period == "2024 Q1" assert result.rows[0].is_cumulative is False assert _sums(result.rows[0]) == (Decimal(5), ZERO, Decimal(6), ZERO, Decimal(11), ZERO) def test_duplicate_key_within_quarter_is_summed() -> None: """Решение №1: две строки одного ключа в квартале суммируются, `entry_ids` — обе; row_number — минимальный.""" q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) a = _row(q1, row_number=3, od="10", td="1") b = _row(q1, row_number=8, od="20", td="2") c = _row(q2, td="4") d = _row(q3, row_number=7, td="8", cd="100") e = _row(q3, row_number=5, td="16", cd="200") result = _build([q1, q2, q3], [b, a, c, d, e]) assert len(result.rows) == 1 row = result.rows[0] assert (row.opening_debit, row.turnover_debit, row.closing_debit) == (Decimal(30), Decimal(31), Decimal(300)) assert set(row.entry_ids) == {a.id, b.id, c.id, d.id, e.id} assert row.entry_ids == (a.id, b.id, c.id, e.id, d.id) # по кварталам, внутри — по номеру строки assert row.row_number == 5 # noqa: PLR2004 - минимум по последнему кварталу, не по Q1 def test_none_amounts_count_as_zero_when_glued() -> None: q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) rows = [ _row(q1, od=None, oc="7", td=None, tc="1", cd=None, cc=None), _row(q2, td="2", tc=None), _row(q3, td=None, tc=None, cd="2", cc=None), ] row = _build([q1, q2, q3], rows).rows[0] assert _sums(row) == (ZERO, Decimal(7), Decimal(2), Decimal(1), Decimal(2), ZERO) assert all(isinstance(value, Decimal) for value in _sums(row)) def test_glue_key_strips_name_and_inn_whitespace() -> None: """Ключ — (счёт строки, имя.strip(), ИНН без пробелов); «как в файле» — из последнего квартала.""" q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) rows = [ _row(q1, " ООО Ромашка ", inn="7701 000 001", td="1"), _row(q2, "ООО Ромашка", inn=" 7701000001", td="2"), _row(q3, "ООО Ромашка ", inn="7701000001 ", td="4"), ] result = _build([q1, q2, q3], rows) assert len(result.rows) == 1 assert result.rows[0].turnover_debit == Decimal(7) assert (result.rows[0].counterparty_name, result.rows[0].counterparty_inn) == ("ООО Ромашка ", "7701000001 ") def test_glue_key_separates_inn_and_subaccount() -> None: q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) rows = [ *(_row(d, "А", inn="7701000001") for d in (q1, q2, q3)), *(_row(d, "А", inn="7701000002") for d in (q1, q2, q3)), *(_row(d, "А", inn="7701000001", account_code="60.02") for d in (q1, q2, q3)), *(_row(d, "А", inn=None) for d in (q1, q2, q3)), ] result = _build([q1, q2, q3], rows) assert len(result.rows) == 4 # noqa: PLR2004 assert all(len(r.entry_ids) == 3 for r in result.rows) # noqa: PLR2004 def test_rows_of_unknown_documents_and_non_members_are_ignored() -> None: q3 = _doc(cumulative=True) foreign = _doc(CHKF, cumulative=True) # защитно: документ не члена периметра в свод не попадает orphan = _row(_doc(cumulative=True)) # строка документа, которого нет во входе result = _build([q3, foreign], [_row(q3), _row(foreign), orphan]) assert [r.legal_entity_id for r in result.rows] == [RB.id] assert result.gaps == () def test_empty_input() -> None: assert _build([], []) == Consolidated(rows=(), gaps=(), documents_total=0) assert summary(_build([], [])) == (0, 0, 0) # --------------------------------------------------------------------------- контрагент: id, признак, дорезолв def test_counterparty_id_from_last_quarter_with_warning_on_divergence() -> None: """Решение №8: расхождение `counterparty_id` между кварталами — значение последнего квартала + `warnings`.""" q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) early = _row(q1, counterparty_id=CHKF.id, match_kind=INN) unmapped = _row(q2) late = _row(q3, counterparty_id=SCH.id, match_kind=MANUAL) resolver = _Resolver() row = _build([q1, q2, q3], [early, unmapped, late], resolver=resolver).rows[0] assert (row.counterparty_id, row.match_kind, row.match_stored) == (SCH.id, MANUAL, True) assert (row.counterparty_type, row.counterparty_entity_name) == (GSK, SCH.name) assert len(row.warnings) == 1 assert str(early.id) in row.warnings[0] assert str(CHKF.id) in row.warnings[0] assert resolver.calls == [] def test_counterparty_id_from_earlier_quarter_when_last_is_empty() -> None: q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) rows = [_row(q1, counterparty_id=SCH.id, match_kind=NAME), _row(q2), _row(q3)] row = _build([q1, q2, q3], rows).rows[0] assert (row.counterparty_id, row.match_kind, row.match_stored, row.warnings) == (SCH.id, NAME, True, ()) def test_same_counterparty_in_all_quarters_has_no_warning() -> None: q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) rows = [_row(d, counterparty_id=SCH.id, match_kind=PROPAGATED) for d in (q1, q2, q3)] row = _build([q1, q2, q3], rows).rows[0] assert (row.counterparty_id, row.match_kind, row.warnings) == (SCH.id, PROPAGATED, ()) def test_counterparty_types_on_rb_fixture_shape() -> None: """Сценарий «Признаки на фикстурах РБ»: Счастливочная → РБ по ИНН — GSK; РБ → «Пекарня Счастливочная ООО» без ИНН не сопоставлена — EXTERNAL; РБ → ЧКФ (в ГК, не в ГСК) — GK.""" sch_60 = _doc(SCH, cumulative=True) rb_60 = _doc(RB, cumulative=True) to_rb = _row(sch_60, "Русский бисквит", inn="3528052718", counterparty_id=RB.id, match_kind=INN) to_sch = _row(rb_60, "Пекарня Счастливочная ООО", row_number=1) to_chkf = _row(rb_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id, match_kind=NAME) result = _build([sch_60, rb_60], [to_rb, to_sch, to_chkf]) by_entry = {r.entry_ids[0]: r for r in result.rows} assert (by_entry[to_rb.id].counterparty_type, by_entry[to_rb.id].match_kind) == (GSK, INN) assert by_entry[to_rb.id].match_stored is True assert by_entry[to_sch.id].counterparty_type == EXTERNAL assert (by_entry[to_sch.id].counterparty_id, by_entry[to_sch.id].match_kind) == (None, None) assert by_entry[to_sch.id].match_stored is False assert by_entry[to_sch.id].counterparty_entity_name is None assert by_entry[to_chkf.id].counterparty_type == GK assert by_entry[to_chkf.id].counterparty_entity_name == CHKF.name def test_classify_counterparty_type() -> None: def kind(counterparty_id: uuid.UUID | None) -> CounterpartyType: return classify(counterparty_id, members=MEMBERS, entities=ENTITIES, group_id=GROUP) assert (kind(SCH.id), kind(RB.id), kind(CHKF.id)) == (GSK, GSK, GK) assert (kind(OUTSIDER.id), kind(LONE.id), kind(None), kind(uuid.uuid4())) == (EXTERNAL,) * 4 def test_resolve_on_read_is_not_stored() -> None: """Сценарий «Дорезолв на чтение»: пустой `counterparty_id` резолвится на лету, `match_stored = false`, входная строка не меняется.""" cumulative = _doc(cumulative=True) source = _row(cumulative, "Пекарня Счастливочная ООО", inn=" ") resolver = _Resolver({(" ", "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)}) row = _build([cumulative], [source], resolver=resolver).rows[0] assert (row.counterparty_id, row.match_kind, row.match_stored) == (SCH.id, ALIAS, False) assert (row.counterparty_type, row.counterparty_entity_name) == (GSK, SCH.name) assert resolver.calls == [(" ", "Пекарня Счастливочная ООО")] assert source.counterparty_id is None assert source.match_kind is None def test_resolve_on_read_for_glued_row_uses_last_quarter_values() -> None: q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) rows = [_row(q1, " ЧКФ", inn="3528 000002"), _row(q2, "ЧКФ", inn="3528000002"), _row(q3, "ЧКФ ", inn="3528000002")] resolver = _Resolver({("3528000002", "ЧКФ "): Resolution(CHKF.id, INN)}) result = _build([q1, q2, q3], rows, resolver=resolver) assert len(result.rows) == 1 row = result.rows[0] assert resolver.calls == [("3528000002", "ЧКФ ")] assert (row.counterparty_id, row.match_kind, row.match_stored, row.counterparty_type) == (CHKF.id, INN, False, GK) def test_rows_are_frozen() -> None: row = _build([cumulative := _doc(cumulative=True)], [_row(cumulative)]).rows[0] with pytest.raises(dataclasses.FrozenInstanceError): row.counterparty_id = SCH.id # type: ignore[misc] # --------------------------------------------------------------------------- порядок def test_rows_order_entity_name_account_row_number_entry_id() -> None: """Порядок: имя ЮЛ, счёт (натурально: 9 < 60 < 60.2 < 60.10), номер строки (пустой — в конце), первый entry_id.""" rb_9, rb_60, sch_60 = _doc(RB, "9", cumulative=True), _doc(RB, "60", cumulative=True), _doc(SCH, cumulative=True) rows = [ _row(sch_60, "С1", row_number=1), _row(rb_60, "Р3", row_number=None, account_code="60.10"), _row(rb_60, "Р2", row_number=2, account_code="60.10"), _row(rb_60, "Р1", row_number=5, account_code="60.2"), _row(rb_60, "Р0", row_number=7, account_code="60"), _row(rb_9, "Р9", row_number=100), ] tied = sorted( [_row(rb_60, "Т1", row_number=3, account_code="60.10"), _row(rb_60, "Т2", row_number=3, account_code="60.10")], key=lambda r: r.id, ) result = _build([rb_9, rb_60, sch_60], [*rows, *reversed(tied)]) names = [r.counterparty_name for r in result.rows] assert names == ["Р9", "Р0", "Р1", "Р2", tied[0].counterparty_name, tied[1].counterparty_name, "Р3", "С1"] def test_build_is_deterministic_for_shuffled_input() -> None: documents = [ _doc(entity, account, quarter) for entity in (RB, SCH) for account in ("60", "62") for quarter in (Q1, Q2, Q3) ] rows = [_row(d, name, row_number=n, td=str(n)) for d in documents for n, name in enumerate(("А", "Б", "В"))] expected = _build(documents, rows) shuffled_docs, shuffled_rows = documents[:], rows[:] random.Random(7).shuffle(shuffled_docs) random.Random(11).shuffle(shuffled_rows) assert _build(shuffled_docs, shuffled_rows) == expected assert len(expected.rows) == 12 # noqa: PLR2004 - 2 ЮЛ × 2 счёта × 3 контрагента def test_gaps_are_ordered_by_entity_name_and_account() -> None: documents = [_doc(SCH, "62", Q1), _doc(RB, "62", Q1), _doc(RB, "60", Q2)] result = _build(documents, []) assert [(g.legal_entity_name, g.account_code, g.missing_quarters) for g in result.gaps] == [ (RB.name, "60", (Q1, Q3)), (RB.name, "62", (Q2, Q3)), (SCH.name, "62", (Q2, Q3)), ] # --------------------------------------------------------------------------- фильтры и сводка def _filter_fixture() -> tuple[ConsolidatedRow, ...]: rb, sch = _doc(RB, "60", cumulative=True), _doc(SCH, "62", cumulative=True) rows = [ _row(rb, "ООО Ромашка", inn="7701000001", row_number=1), # EXTERNAL, не сопоставлена _row(rb, "Пекарня Счастливочная ООО", row_number=2), # ALIAS дорезолвом _row(rb, "ЧКФ", row_number=3, account_code="60.01", counterparty_id=CHKF.id, match_kind=NAME), # GK, NAME _row(sch, "Русский бисквит", inn="3528052718", counterparty_id=RB.id, match_kind=INN), # GSK, INN _row(sch, "Внешний", row_number=2, counterparty_id=OUTSIDER.id, match_kind=MANUAL), # EXTERNAL ] resolver = _Resolver({(None, "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)}) return _build([rb, sch], rows, resolver=resolver).rows def _names(rows: Sequence[ConsolidatedRow]) -> list[str | None]: return [r.counterparty_name for r in rows] def test_empty_filter_returns_all_rows() -> None: rows = _filter_fixture() assert apply_filter(rows, OsvFilter()) == list(rows) @pytest.mark.parametrize( ("osv_filter", "expected"), [ (OsvFilter(legal_entity_id=SCH.id), ["Русский бисквит", "Внешний"]), (OsvFilter(account_root="62"), ["Русский бисквит", "Внешний"]), (OsvFilter(account_root="6"), []), (OsvFilter(counterparty_type=GSK), ["Пекарня Счастливочная ООО", "Русский бисквит"]), (OsvFilter(counterparty_type=GK), ["ЧКФ"]), (OsvFilter(counterparty_type=EXTERNAL), ["ООО Ромашка", "Внешний"]), (OsvFilter(unmapped=True), ["ООО Ромашка"]), (OsvFilter(unmapped=False), ["Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"]), (OsvFilter(match_kinds=frozenset({NAME, ALIAS})), ["Пекарня Счастливочная ООО", "ЧКФ"]), (OsvFilter(match_kinds=frozenset({MANUAL})), ["Внешний"]), (OsvFilter(q="рОмАш"), ["ООО Ромашка"]), (OsvFilter(q=" бисквит "), ["Русский бисквит"]), (OsvFilter(q="7701"), ["ООО Ромашка"]), (OsvFilter(q="1000001"), []), # ИНН — только префикс (OsvFilter(q=" "), ["ООО Ромашка", "Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"]), (OsvFilter(legal_entity_id=RB.id, counterparty_type=EXTERNAL), ["ООО Ромашка"]), (OsvFilter(legal_entity_id=RB.id, match_kinds=frozenset({ALIAS}), q="пекарня"), ["Пекарня Счастливочная ООО"]), (OsvFilter(account_root="60", unmapped=True, q="ЧКФ"), []), ], ) def test_apply_filter(osv_filter: OsvFilter, expected: list[str]) -> None: assert _names(apply_filter(_filter_fixture(), osv_filter)) == expected def test_summary_counts_and_matches_filters() -> None: """Сценарии «Только несопоставленные» и «Фильтр по способу»: total фильтра = счётчик сводки.""" rows = _filter_fixture() result = Consolidated(rows=rows, gaps=(), documents_total=2) rows_total, unmapped_total, auto_matched_total = summary(result) assert (rows_total, unmapped_total, auto_matched_total) == (5, 1, 2) assert len(apply_filter(rows, OsvFilter(unmapped=True))) == unmapped_total assert len(apply_filter(rows, OsvFilter(match_kinds=frozenset({NAME, ALIAS})))) == auto_matched_total assert all(r.counterparty_id is None for r in apply_filter(rows, OsvFilter(unmapped=True))) def test_counterparty_type_is_str_enum() -> None: assert [t.value for t in CounterpartyType] == ["GSK", "GK", "EXTERNAL"]