648 lines
31 KiB
Python
648 lines
31 KiB
Python
"""Сводная ОСВ версии (Срез 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)
|