diff --git a/api/src/api/endpoints/consolidations/osv.py b/api/src/api/endpoints/consolidations/osv.py index 22a9034..2d89505 100644 --- a/api/src/api/endpoints/consolidations/osv.py +++ b/api/src/api/endpoints/consolidations/osv.py @@ -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) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 0215801..ca4b727 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -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) diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index 3dd9166..b548d42 100644 --- a/api/src/services/consolidated_osv.py +++ b/api/src/services/consolidated_osv.py @@ -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: diff --git a/api/tests/test_api_consolidated_osv.py b/api/tests/test_api_consolidated_osv.py index 5300f21..eff6eeb 100644 --- a/api/tests/test_api_consolidated_osv.py +++ b/api/tests/test_api_consolidated_osv.py @@ -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) diff --git a/api/tests/test_api_counterparty_mapping.py b/api/tests/test_api_counterparty_mapping.py index 31ad5f9..a46ba15 100644 --- a/api/tests/test_api_counterparty_mapping.py +++ b/api/tests/test_api_counterparty_mapping.py @@ -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"] == []