diff --git a/api/src/domain/consolidated_osv.py b/api/src/domain/consolidated_osv.py index abb5ae7..e87a0ad 100644 --- a/api/src/domain/consolidated_osv.py +++ b/api/src/domain/consolidated_osv.py @@ -34,6 +34,7 @@ __all__ = [ "AUTO_MATCH_KINDS", "Consolidated", "ConsolidatedRow", + "CounterPair", "CounterpartyType", "Entity", "GapItem", @@ -44,6 +45,7 @@ __all__ = [ "apply_filter", "build", "classify", + "counter_pairs", "glue_key", "summary", ] @@ -170,6 +172,7 @@ class OsvFilter: account_root: str | None = None counterparty_type: CounterpartyType | None = None unmapped: bool | None = None + counterparty_id: uuid.UUID | None = None match_kinds: frozenset[CounterpartyMatchKind] = frozenset() q: str | None = None @@ -498,6 +501,8 @@ def _matches(row: ConsolidatedRow, f: OsvFilter) -> bool: # noqa: PLR0911 - п return False if f.unmapped is not None and needs_mapping(row) != f.unmapped: return False + if f.counterparty_id is not None and row.counterparty_id != f.counterparty_id: + return False if f.match_kinds and row.match_kind not in f.match_kinds: return False return _matches_query(row, f.q) @@ -508,6 +513,50 @@ def apply_filter(rows: Iterable[ConsolidatedRow], f: OsvFilter) -> list[Consolid return [row for row in rows if _matches(row, f)] +@dataclass(frozen=True) +class CounterPair: + """Пара «держатель видит, встречной записи нет» (§3 п. 9 05-domain-rules, AURORA-1746): у `holder` есть строки с + контрагентом `missing`, а у `missing` нет ни одной строки с контрагентом `holder`.""" + + holder_id: uuid.UUID + holder_name: str + missing_id: uuid.UUID + missing_name: str + holder_rows: int # строки держателя с контрагентом `missing` + missing_unmapped_rows: int # строки `missing`, требующие сопоставления (`needs_mapping`) + missing_rows_total: int # все строки `missing` в своде + + +def counter_pairs(rows: Sequence[ConsolidatedRow], members: Mapping[uuid.UUID, Entity]) -> list[CounterPair]: + """Пары членов периметра без встречных записей (§3 п. 9 05-domain-rules, AURORA-1746). Видимая пара — + `(legal_entity_id, counterparty_id)` по строкам свода (с дорезолвом на чтении), оба в `members` и различны. + Порядок: `missing_name`, затем `holder_name` (затем id для устойчивости).""" + seen: dict[tuple[uuid.UUID, uuid.UUID], int] = defaultdict(int) + totals: dict[uuid.UUID, int] = defaultdict(int) + unmapped: dict[uuid.UUID, int] = defaultdict(int) + for row in rows: + totals[row.legal_entity_id] += 1 + if needs_mapping(row): + unmapped[row.legal_entity_id] += 1 + other = row.counterparty_id + if other is not None and other != row.legal_entity_id and other in members and row.legal_entity_id in members: + seen[(row.legal_entity_id, other)] += 1 + pairs = [ + CounterPair( + holder_id=holder, + holder_name=members[holder].name, + missing_id=missing, + missing_name=members[missing].name, + holder_rows=count, + missing_unmapped_rows=unmapped[missing], + missing_rows_total=totals[missing], + ) + for (holder, missing), count in seen.items() + if (missing, holder) not in seen + ] + return sorted(pairs, key=lambda p: (p.missing_name, p.holder_name, str(p.missing_id), str(p.holder_id))) + + def summary(c: Consolidated) -> tuple[int, int, int]: """Сводка свода: `rows_total`, `unmapped_total` (`needs_mapping`: нет ЮЛ и нет ИНН из файла), `auto_matched_total` (`match_kind` ∈ {`NAME`, `ALIAS`} независимо от `match_stored`).""" diff --git a/api/tests/test_domain_consolidated_osv.py b/api/tests/test_domain_consolidated_osv.py index a66adf1..f385cbf 100644 --- a/api/tests/test_domain_consolidated_osv.py +++ b/api/tests/test_domain_consolidated_osv.py @@ -11,6 +11,7 @@ import pytest from src.domain.consolidated_osv import ( Consolidated, ConsolidatedRow, + CounterPair, CounterpartyType, Entity, GapItem, @@ -20,6 +21,7 @@ from src.domain.consolidated_osv import ( apply_filter, build, classify, + counter_pairs, needs_mapping, summary, ) @@ -552,3 +554,94 @@ def test_needs_mapping_is_no_counterparty_and_no_inn( 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)