arm-core/api/tests/test_domain_consolidated_osv.py
2026-10-27 18:44:17 +04:00

648 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сводная ОСВ версии (Срез 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,
CounterPair,
CounterpartyType,
Entity,
GapItem,
OsvFilter,
SourceDocument,
SourceRow,
apply_filter,
build,
classify,
counter_pairs,
needs_mapping,
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 row.counterparty_entity_inn == SCH.inn
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, by_entry[to_sch.id].counterparty_entity_inn) == (None, None)
assert by_entry[to_chkf.id].counterparty_type == GK
assert by_entry[to_chkf.id].counterparty_entity_name == CHKF.name
assert by_entry[to_chkf.id].counterparty_entity_inn == CHKF.inn # ИНН ЮЛ справочника, в файле ИНН нет
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 row.counterparty_entity_inn == SCH.inn
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
_row(sch, "Ведомость", inn=" ", row_number=3), # 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), ["ООО Ромашка", "Внешний", "Ведомость"]),
# отмена П-1 (08.10.2026): строка с ИНН из файла не требует сопоставления, даже если ИНН нет в справочнике
(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) == (6, 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(needs_mapping(r) for r in apply_filter(rows, OsvFilter(unmapped=True)))
@pytest.mark.parametrize(
("inn", "counterparty_id", "expected"),
[
(None, None, True),
("", None, True),
(" ", None, True),
("7701000001", None, False), # ИНН из файла, в справочнике нет — отмена П-1
(None, uuid.uuid4(), False),
("7701000001", uuid.uuid4(), False),
],
)
def test_needs_mapping_is_no_counterparty_and_no_inn(
inn: str | None, counterparty_id: uuid.UUID | None, *, expected: bool
) -> None:
"""Строка требует сопоставления («Указать», `unmapped`), только если нет ни ЮЛ, ни ИНН из файла."""
row = dataclasses.replace(_filter_fixture()[0], counterparty_inn=inn, counterparty_id=counterparty_id)
assert needs_mapping(row) is expected
def test_counterparty_type_is_str_enum() -> None:
assert [t.value for t in CounterpartyType] == ["GSK", "GK", "EXTERNAL"]
def _rb_shape(*, rb_to_sch: uuid.UUID | None = None) -> Consolidated:
sch_60 = _doc(SCH, cumulative=True)
rb_60 = _doc(RB, cumulative=True)
rows = [
_row(sch_60, "Русский бисквит", counterparty_id=RB.id, match_kind=NAME),
_row(
rb_60,
"Пекарня Счастливочная ООО",
row_number=1,
counterparty_id=rb_to_sch,
match_kind=NAME if rb_to_sch else None,
),
_row(rb_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id, match_kind=NAME),
]
return _build([sch_60, rb_60], rows)
def test_counter_pairs_rb_shape() -> None:
result = _rb_shape()
assert counter_pairs(result.rows, MEMBERS) == [
CounterPair(SCH.id, SCH.name, RB.id, RB.name, holder_rows=1, missing_unmapped_rows=1, missing_rows_total=2)
]
def test_counter_pairs_closed_when_both_see_each_other() -> None:
assert counter_pairs(_rb_shape(rb_to_sch=SCH.id).rows, MEMBERS) == []
def test_counter_pairs_unrelated_members_absent() -> None:
sch_60 = _doc(SCH, cumulative=True)
rb_60 = _doc(RB, cumulative=True)
rows = [_row(sch_60, "РБ", counterparty_id=RB.id), _row(rb_60, "Сч", counterparty_id=SCH.id)]
result = _build([sch_60, rb_60], rows)
assert counter_pairs(result.rows, {**MEMBERS, CHKF.id: CHKF}) == []
def test_counter_pairs_missing_side_without_rows() -> None:
sch_60 = _doc(SCH, cumulative=True)
result = _build([sch_60], [_row(sch_60, "РБ", counterparty_id=RB.id)])
pairs = counter_pairs(result.rows, MEMBERS)
assert len(pairs) == 1
assert (pairs[0].missing_rows_total, pairs[0].missing_unmapped_rows) == (0, 0)
def test_counter_pairs_ignore_self_and_non_members() -> None:
rb_60 = _doc(RB, cumulative=True)
rows = [
_row(rb_60, "РБ", row_number=1, counterparty_id=RB.id),
_row(rb_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id),
]
assert counter_pairs(_build([rb_60], rows).rows, MEMBERS) == []
def test_counter_pairs_use_read_time_resolution() -> None:
sch_60 = _doc(SCH, cumulative=True)
rb_60 = _doc(RB, cumulative=True)
rows = [_row(sch_60, "РБ", counterparty_id=RB.id), _row(rb_60, "Пекарня Счастливочная ООО")]
resolver = _Resolver({(None, "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)})
assert counter_pairs(_build([sch_60, rb_60], rows, resolver=resolver).rows, MEMBERS) == []
def test_counter_pairs_order() -> None:
members = {RB.id: RB, SCH.id: SCH, CHKF.id: CHKF}
sch_60 = _doc(SCH, cumulative=True)
chkf_60 = _doc(CHKF, cumulative=True)
rows = [
_row(sch_60, "РБ", row_number=1, counterparty_id=RB.id),
_row(sch_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id),
_row(chkf_60, "РБ", counterparty_id=RB.id),
]
result = build(
year=2024,
quarter=Q3,
group_id=GROUP,
members=members,
entities=ENTITIES,
documents=[sch_60, chkf_60],
rows=rows,
resolver=_Resolver(),
)
got = [(p.missing_id, p.holder_id) for p in counter_pairs(result.rows, members)]
assert got == [(RB.id, SCH.id), (RB.id, CHKF.id), (CHKF.id, SCH.id)]
def test_filter_counterparty_id() -> None:
result = _rb_shape()
kept = apply_filter(result.rows, OsvFilter(counterparty_id=RB.id))
assert kept and all(r.counterparty_id == RB.id for r in kept)
assert len(kept) < len(result.rows)