feat(#195): доменная функция пар без встречных записей и фильтр counterparty_id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e1299fab45
commit
40c1bca6bc
@ -34,6 +34,7 @@ __all__ = [
|
|||||||
"AUTO_MATCH_KINDS",
|
"AUTO_MATCH_KINDS",
|
||||||
"Consolidated",
|
"Consolidated",
|
||||||
"ConsolidatedRow",
|
"ConsolidatedRow",
|
||||||
|
"CounterPair",
|
||||||
"CounterpartyType",
|
"CounterpartyType",
|
||||||
"Entity",
|
"Entity",
|
||||||
"GapItem",
|
"GapItem",
|
||||||
@ -44,6 +45,7 @@ __all__ = [
|
|||||||
"apply_filter",
|
"apply_filter",
|
||||||
"build",
|
"build",
|
||||||
"classify",
|
"classify",
|
||||||
|
"counter_pairs",
|
||||||
"glue_key",
|
"glue_key",
|
||||||
"summary",
|
"summary",
|
||||||
]
|
]
|
||||||
@ -170,6 +172,7 @@ class OsvFilter:
|
|||||||
account_root: str | None = None
|
account_root: str | None = None
|
||||||
counterparty_type: CounterpartyType | None = None
|
counterparty_type: CounterpartyType | None = None
|
||||||
unmapped: bool | None = None
|
unmapped: bool | None = None
|
||||||
|
counterparty_id: uuid.UUID | None = None
|
||||||
match_kinds: frozenset[CounterpartyMatchKind] = frozenset()
|
match_kinds: frozenset[CounterpartyMatchKind] = frozenset()
|
||||||
q: str | None = None
|
q: str | None = None
|
||||||
|
|
||||||
@ -498,6 +501,8 @@ def _matches(row: ConsolidatedRow, f: OsvFilter) -> bool: # noqa: PLR0911 - п
|
|||||||
return False
|
return False
|
||||||
if f.unmapped is not None and needs_mapping(row) != f.unmapped:
|
if f.unmapped is not None and needs_mapping(row) != f.unmapped:
|
||||||
return False
|
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:
|
if f.match_kinds and row.match_kind not in f.match_kinds:
|
||||||
return False
|
return False
|
||||||
return _matches_query(row, f.q)
|
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)]
|
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]:
|
def summary(c: Consolidated) -> tuple[int, int, int]:
|
||||||
"""Сводка свода: `rows_total`, `unmapped_total` (`needs_mapping`: нет ЮЛ и нет ИНН из файла), `auto_matched_total`
|
"""Сводка свода: `rows_total`, `unmapped_total` (`needs_mapping`: нет ЮЛ и нет ИНН из файла), `auto_matched_total`
|
||||||
(`match_kind` ∈ {`NAME`, `ALIAS`} независимо от `match_stored`)."""
|
(`match_kind` ∈ {`NAME`, `ALIAS`} независимо от `match_stored`)."""
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import pytest
|
|||||||
from src.domain.consolidated_osv import (
|
from src.domain.consolidated_osv import (
|
||||||
Consolidated,
|
Consolidated,
|
||||||
ConsolidatedRow,
|
ConsolidatedRow,
|
||||||
|
CounterPair,
|
||||||
CounterpartyType,
|
CounterpartyType,
|
||||||
Entity,
|
Entity,
|
||||||
GapItem,
|
GapItem,
|
||||||
@ -20,6 +21,7 @@ from src.domain.consolidated_osv import (
|
|||||||
apply_filter,
|
apply_filter,
|
||||||
build,
|
build,
|
||||||
classify,
|
classify,
|
||||||
|
counter_pairs,
|
||||||
needs_mapping,
|
needs_mapping,
|
||||||
summary,
|
summary,
|
||||||
)
|
)
|
||||||
@ -552,3 +554,94 @@ def test_needs_mapping_is_no_counterparty_and_no_inn(
|
|||||||
|
|
||||||
def test_counterparty_type_is_str_enum() -> None:
|
def test_counterparty_type_is_str_enum() -> None:
|
||||||
assert [t.value for t in CounterpartyType] == ["GSK", "GK", "EXTERNAL"]
|
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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user