feat(#195): counter_pairs в сводке сводной ОСВ и фильтр counterparty_id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 18:48:59 +04:00
parent 40c1bca6bc
commit c24bc3d4c1
5 changed files with 68 additions and 2 deletions

View File

@ -33,7 +33,8 @@ async def list_osv(
query: Annotated[OsvQuery, Query()],
) -> Page[ConsolidatedRowOut]:
"""Строки свода в порядке: имя ЮЛ, счёт, номер строки, первый `entry_id`. Фильтры `legal_entity_id`,
`account_root`, `counterparty_type`, `unmapped`, `match_kind` (повторяемый), `q`; `limit` ≤ 2000 (по умолчанию 500).
`account_root`, `counterparty_type`, `unmapped`, `counterparty_id`, `match_kind` (повторяемый), `q`;
`limit` ≤ 2000 (по умолчанию 500).
Суммы — строки с двумя знаками."""
return await service.page(version_id, query, query)

View File

@ -241,6 +241,18 @@ class GapOut(BaseModel):
missing_quarters: list[PeriodQuarter]
class CounterPairOut(BaseModel):
"""Пара «держатель видит, встречной записи нет» (§3 п. 9, AURORA-1746) в сводке свода."""
holder_id: uuid.UUID
holder_name: str
missing_id: uuid.UUID
missing_name: str
holder_rows: int
missing_unmapped_rows: int
missing_rows_total: int
class OsvSummaryOut(BaseModel):
"""Сводка по своду версии (`GET …/osv/summary`)."""
@ -249,6 +261,7 @@ class OsvSummaryOut(BaseModel):
auto_matched_total: int
documents_total: int
gaps: list[GapOut]
counter_pairs: list[CounterPairOut]
class OsvFilters(BaseModel):
@ -258,6 +271,7 @@ class OsvFilters(BaseModel):
account_root: str | None = None
counterparty_type: CounterpartyType | None = None
unmapped: bool | None = None
counterparty_id: uuid.UUID | None = None
match_kind: list[CounterpartyMatchKind] | None = None
q: str | None = Field(default=None, max_length=MAX_OSV_QUERY)

View File

@ -36,6 +36,7 @@ from src.schemas.common import Page
from src.schemas.consolidations import (
CandidateOut,
ConsolidatedRowOut,
CounterPairOut,
GapOut,
OsvFilters,
OsvPageParams,
@ -65,6 +66,7 @@ def domain_filter(f: OsvFilters) -> domain.OsvFilter:
account_root=f.account_root,
counterparty_type=f.counterparty_type,
unmapped=f.unmapped,
counterparty_id=f.counterparty_id,
match_kinds=frozenset(f.match_kind or ()),
q=f.q,
)
@ -232,6 +234,10 @@ class ConsolidatedOsvService:
auto_matched_total=auto_matched_total,
documents_total=result.consolidated.documents_total,
gaps=[GapOut.model_validate(gap, from_attributes=True) for gap in result.consolidated.gaps],
counter_pairs=[
CounterPairOut.model_validate(pair, from_attributes=True)
for pair in domain.counter_pairs(result.consolidated.rows, result.members)
],
)
async def row_for_entry(self, version_id: uuid.UUID, entry_id: uuid.UUID) -> ConsolidatedRowOut | None:

View File

@ -277,7 +277,7 @@ async def test_summary_counts_and_page_total_agree(auth_client: AuthClientFactor
assert summary == {
"rows_total": ROWS_TOTAL, "unmapped_total": UNMAPPED_TOTAL, "auto_matched_total": AUTO_MATCHED_TOTAL,
"documents_total": DOCUMENTS_TOTAL, "gaps": [],
"documents_total": DOCUMENTS_TOTAL, "gaps": [], "counter_pairs": [],
} # fmt: skip
unmapped = await _page(client, world, "unmapped=true")
assert unmapped["total"] == summary["unmapped_total"]
@ -287,6 +287,22 @@ async def test_summary_counts_and_page_total_agree(auth_client: AuthClientFactor
assert mapped["total"] == ROWS_TOTAL - UNMAPPED_TOTAL
@pytest.mark.integration
async def test_filter_by_counterparty_id(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)
client = await auth_client(KA_RF)
body = await _page(client, world, f"legal_entity_id={world.a.id}&counterparty_id={world.b.id}")
# у «ООО А» контрагент Б — в ОСВ 60 (склейка кварталов) и в ОСВ 62
assert body["total"] == 2 # noqa: PLR2004
assert {(r["legal_entity_name"], r["account_code"], r["counterparty_name"]) for r in body["items"]} == {
("ООО А", "60", "ООО Б"),
("ООО А", "62", "ООО Б"),
}
assert (await client.get(f"{_url(world)}?counterparty_id=not-a-uuid")).status_code == 422 # noqa: PLR2004
@pytest.mark.integration
async def test_filter_by_match_kind_repeated_param(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)

View File

@ -902,3 +902,32 @@ async def test_alias_delete_racing_with_move_alias_is_404_without_phantom_event(
]
assert len(removals) == 1 # только удаление при переносе
assert await session.get(LegalEntityAlias, alias_id) is None
@pytest.mark.integration
async def test_counter_pairs_before_and_after_mapping(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Пара «Счастливочная видит РБ, РБ её — нет»: у РБ без ИНН 60/62/66, 76 с ИНН вне справочника (AURORA-1866)."""
world = await _world(seeded)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{world.version.id}/osv"
summary = await _summary(client, world)
assert summary["counter_pairs"] == [
{
"holder_id": str(world.schastl.id), "holder_name": "ООО «Пекарня Счастливочная»",
"missing_id": str(world.rb.id), "missing_name": "АО Русский бисквит",
"holder_rows": 1, "missing_unmapped_rows": 3, "missing_rows_total": 5,
}
] # fmt: skip
unmapped = await client.get(f"{base}?legal_entity_id={world.rb.id}&unmapped=true")
assert unmapped.json()["total"] == 3 # noqa: PLR2004
held = await client.get(f"{base}?legal_entity_id={world.schastl.id}&counterparty_id={world.rb.id}")
assert held.json()["total"] == 1
mapped = await client.post(
_map_url(world, world.entries[("60", 1)]), json={"legal_entity_id": str(world.schastl.id)}
)
assert mapped.status_code == HTTP_OK, mapped.text
assert (await _summary(client, world))["counter_pairs"] == []