diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py index 429ca3e..7dcd637 100644 --- a/api/src/api/dependencies/consolidations.py +++ b/api/src/api/dependencies/consolidations.py @@ -9,6 +9,7 @@ from src.api.dependencies.auth import Principal, current_user from src.api.dependencies.db import SessionDep from src.api.dependencies.directories import get_scope from src.domain.policy import VisibilityScope +from src.services.consolidated_osv import ConsolidatedOsvService from src.services.consolidation_locks import ConsolidationLockService from src.services.consolidation_status import ConsolidationStatusService from src.services.consolidations import ConsolidationService @@ -49,3 +50,13 @@ def get_status_service( ConsolidationLockServiceDep = Annotated[ConsolidationLockService, Depends(get_lock_service)] ConsolidationStatusServiceDep = Annotated[ConsolidationStatusService, Depends(get_status_service)] + + +def get_consolidated_osv_service( + session: SessionDep, scope: Annotated[VisibilityScope, Depends(get_scope)] +) -> ConsolidatedOsvService: + """Сервис сводной ОСВ версии: только чтение, без аудита и блокировок.""" + return ConsolidatedOsvService(session, scope) + + +ConsolidatedOsvServiceDep = Annotated[ConsolidatedOsvService, Depends(get_consolidated_osv_service)] diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index b8c3992..34601ef 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from src.api.endpoints.consolidations import checklist, locks, members, periods, status, versions +from src.api.endpoints.consolidations import checklist, locks, members, osv, periods, status, versions router = APIRouter(tags=["consolidations"]) router.include_router(periods.router) @@ -11,3 +11,4 @@ router.include_router(locks.router) router.include_router(status.router) router.include_router(members.router) router.include_router(checklist.router) +router.include_router(osv.router) diff --git a/api/src/api/endpoints/consolidations/osv.py b/api/src/api/endpoints/consolidations/osv.py new file mode 100644 index 0000000..22a9034 --- /dev/null +++ b/api/src/api/endpoints/consolidations/osv.py @@ -0,0 +1,50 @@ +"""Сводная ОСВ версии (spec consolidated-osv «Страница свода и сводка»; design D-1, D-16): страница с фильтрами и +сводка с пробелами. Право проверяется до видимости; версия ГК вне видимости — тот же 404, что у несуществующей; +читается версия в любом статусе.""" + +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Query, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidatedOsvServiceDep +from src.schemas.common import ErrorDetail, Page +from src.schemas.consolidations import ConsolidatedRowOut, OsvQuery, OsvSummaryOut + +router = APIRouter(prefix="/consolidations/versions") + +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} +} + + +@router.get( + "/{version_id}/osv", + response_model=Page[ConsolidatedRowOut], + dependencies=VIEW, + responses=NOT_FOUND, + summary="Сводная ОСВ версии", +) +async def list_osv( + version_id: uuid.UUID, + service: ConsolidatedOsvServiceDep, + query: Annotated[OsvQuery, Query()], +) -> Page[ConsolidatedRowOut]: + """Строки свода в порядке: имя ЮЛ, счёт, номер строки, первый `entry_id`. Фильтры `legal_entity_id`, + `account_root`, `counterparty_type`, `unmapped`, `match_kind` (повторяемый), `q`; `limit` ≤ 2000 (по умолчанию 500). + Суммы — строки с двумя знаками.""" + return await service.page(version_id, query, query) + + +@router.get( + "/{version_id}/osv/summary", + response_model=OsvSummaryOut, + dependencies=VIEW, + responses=NOT_FOUND, + summary="Сводка по своду версии", +) +async def get_osv_summary(version_id: uuid.UUID, service: ConsolidatedOsvServiceDep) -> OsvSummaryOut: + """Число строк, несопоставленных и автосопоставленных, привязанных ОСВ-документов и пробелы по кварталам.""" + return await service.summary(version_id) diff --git a/api/src/domain/osv_mapping.py b/api/src/domain/osv_mapping.py index 7f7e030..71b2597 100644 --- a/api/src/domain/osv_mapping.py +++ b/api/src/domain/osv_mapping.py @@ -130,9 +130,12 @@ def account_root(code: str) -> str | None: def account_sort_key(code: str) -> tuple[tuple[int, int, str], ...]: """Натуральный ключ сортировки кода счёта: числовые сегменты — по значению (`01 < 9 < 58 < 58.03 < 60 < 60.2 < - 60.10`), буквенные — по тексту после числовых. Одно определение порядка счетов для чек-листа и свода (№48).""" + 60.10`), буквенные — по тексту после числовых. Одно определение порядка счетов для чек-листа и свода (№48). + + «Числовой» — только ASCII-цифры (`[0-9]+`): `str.isdigit()` истинно и для `"6²"`/`"①"`, а `int()` на них падает + (код счёта приходит из файла; ошибка роняла бы всю страницу свода). Прочие сегменты — по тексту, после числовых.""" parts = (part for part in _ACCOUNT_SPLIT_RE.split(code) if part) - return tuple((0, int(part), "") if part.isdigit() else (1, 0, part) for part in parts) + return tuple((0, int(part), "") if _ROOT_SEGMENT_RE.fullmatch(part) else (1, 0, part) for part in parts) def _sums(row: OsvRow) -> tuple[Decimal | None, ...]: diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 79a6396..036b36b 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -2,14 +2,15 @@ Суммы — `Decimal` (в JSON — строка, без `float`). `ConsolidatedRowOut` и `OsvSummaryOut` — сводная ОСВ версии (чистая функция, в БД не хранится; DECISION-5). `OsvFilters` — query-модель фильтров `GET …/osv` -(`Annotated[OsvFilters, Query()]`); `limit` ≤ 2000 по умолчанию 500, как у строк документа. +(`Annotated[OsvFilters, Query()]`), `OsvPageParams` — пагинация (`limit` ≤ 2000 по умолчанию 500, как у строк +документа). Суммы свода — всегда с двумя знаками (`"0.00"`, `"30.00"`): деньги `numeric(18,2)`. """ import datetime import uuid from decimal import Decimal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from src.domain.checklist import CellStatus from src.domain.consolidated_osv import CounterpartyType # признак контрагента §3: одно определение (T2.8) @@ -27,6 +28,7 @@ YEAR_MAX = 2100 DEFAULT_OSV_LIMIT = 500 MAX_OSV_LIMIT = 2000 MAX_TAKEOVER_REASON = 1000 +CENT = Decimal("0.01") class RefOut(BaseModel): @@ -196,6 +198,20 @@ class ConsolidatedRowOut(BaseModel): closing_credit: Decimal | None warnings: list[str] + @field_validator( + "opening_debit", + "opening_credit", + "turnover_debit", + "turnover_credit", + "closing_debit", + "closing_credit", + ) + @classmethod + def _two_decimals(cls, value: Decimal | None) -> Decimal | None: + """`numeric(18,2)`: сумма склейки (`Decimal("0")` для отсутствующей строки, сумма кварталов) в JSON — всегда + `"0.00"`/`"30.00"`; значение не меняется (знаков после запятой в данных больше двух не бывает).""" + return None if value is None else value.quantize(CENT) + class GapOut(BaseModel): """Пробел свода: у ЮЛ по счёту нет квартальных ОСВ за перечисленные кварталы (строки по нему не строятся).""" @@ -217,7 +233,7 @@ class OsvSummaryOut(BaseModel): class OsvFilters(BaseModel): - """Фильтры и пагинация `GET …/osv` из query: `Annotated[OsvFilters, Query()]`; `match_kind` повторяемый.""" + """Фильтры `GET …/osv` (и экспорта) из query: `Annotated[OsvFilters, Query()]`; `match_kind` повторяемый.""" legal_entity_id: uuid.UUID | None = None account_root: str | None = None @@ -225,10 +241,21 @@ class OsvFilters(BaseModel): unmapped: bool | None = None match_kind: list[CounterpartyMatchKind] | None = None q: str | None = None + + +class OsvPageParams(BaseModel): + """Пагинация `GET …/osv` из query: `Annotated[OsvPageParams, Query()]` (как `EntriesPageParams`).""" + limit: int = Field(DEFAULT_OSV_LIMIT, ge=1, le=MAX_OSV_LIMIT) offset: int = Field(0, ge=0) +class OsvQuery(OsvFilters, OsvPageParams): + """Query-параметры `GET …/osv` целиком: FastAPI принимает ровно одну query-модель на маршрут, поэтому фильтры и + пагинация сливаются; сервису модель передаётся и как `OsvFilters`, и как `OsvPageParams` (экспорт берёт только + `OsvFilters`).""" + + class CandidateOut(BaseModel): """Кандидат для ручного сопоставления контрагента (`GET …/counterparty-candidates`).""" diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py new file mode 100644 index 0000000..45aec0d --- /dev/null +++ b/api/src/services/consolidated_osv.py @@ -0,0 +1,163 @@ +"""Сводная ОСВ версии: страница и сводка (spec consolidated-osv «Страница свода и сводка»; design D-1, D-16). + +Свод — чистая функция версии (DECISION-5), в БД не хранится: каждый запрос заново собирает его из периметра, +привязанных ОСВ-документов и их строк (`domain.consolidated_osv.build`) и отдаёт срез. Сервис только читает: без +блокировок и аудита, версия в любом статусе (включая `CANCELLED`) читается; видимость — через ГК консолидации +(чужая и несуществующая версии — один 404 `VERSION_NOT_FOUND`). + +Запросов к БД — константа, не зависящая от числа строк и контрагентов (без N+1): `get_version`, периметр, +документы версии, строки, два запроса индекса контрагентов (`CounterpartyIndex.load`) и один `SELECT legal_entity` по +собранным идентификаторам — контрагенты строк и кандидаты дорезолва. Дорезолв на лету кормит `build` тем же +`CounterpartyIndex`, поэтому идентификаторы кандидатов собираются заранее тем же `resolve` по словарям в памяти. +""" + +import uuid +from collections.abc import Collection, Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.common.exceptions import NotFoundError +from src.domain import consolidated_osv as domain +from src.domain.policy import VisibilityScope +from src.models.consolidation import Consolidation, ConsolidationVersion +from src.models.directories import LegalEntity +from src.models.enums import DocumentType +from src.models.files import Document +from src.models.osv import OsvEntry +from src.repositories import consolidations as repo +from src.repositories.counterparties import CounterpartyIndex +from src.schemas.common import Page +from src.schemas.consolidations import ConsolidatedRowOut, GapOut, OsvFilters, OsvPageParams, OsvSummaryOut +from src.services.consolidation_guard import VERSION_NOT_FOUND + +__all__ = ["ConsolidatedOsvService", "domain_filter", "row_out"] + + +def row_out(row: domain.ConsolidatedRow) -> ConsolidatedRowOut: + """Строка свода → схема API: кортежи домена (`entry_ids`, `warnings`) — списки, суммы — `Decimal` (JSON-строка).""" + return ConsolidatedRowOut.model_validate(row, from_attributes=True) + + +def domain_filter(f: OsvFilters) -> domain.OsvFilter: + """Query-модель фильтров → доменный `OsvFilter` (повторяемый `match_kind` → множество).""" + return domain.OsvFilter( + legal_entity_id=f.legal_entity_id, + account_root=f.account_root, + counterparty_type=f.counterparty_type, + unmapped=f.unmapped, + match_kinds=frozenset(f.match_kind or ()), + q=f.q, + ) + + +def _entity(entity: LegalEntity) -> domain.Entity: + return domain.Entity(id=entity.id, name=entity.name, inn=entity.inn, company_group_id=entity.company_group_id) + + +def _source_document(document: Document) -> domain.SourceDocument: + return domain.SourceDocument( + id=document.id, + legal_entity_id=document.legal_entity_id, + osv_account_code=document.osv_account_code, + period_quarter=document.period_quarter, + is_cumulative=document.is_cumulative, + ) + + +def _source_row(entry: OsvEntry) -> domain.SourceRow: + return domain.SourceRow( + id=entry.id, + document_id=entry.document_id, + row_number=entry.row_number, + account_code=entry.account_code, + account_root=entry.account_root, + counterparty_name=entry.counterparty_name, + counterparty_inn=entry.counterparty_inn, + counterparty_id=entry.counterparty_id, + match_kind=entry.counterparty_match_kind, + opening_debit=entry.opening_debit, + opening_credit=entry.opening_credit, + turnover_debit=entry.turnover_debit, + turnover_credit=entry.turnover_credit, + closing_debit=entry.closing_debit, + closing_credit=entry.closing_credit, + ) + + +def _candidate_ids(rows: Sequence[domain.SourceRow], index: CounterpartyIndex) -> set[uuid.UUID]: + """Идентификаторы контрагентов, имена которых понадобятся `build`: хранимые `counterparty_id` и результат дорезолва + для строк без него (`resolve` — по словарям индекса, без обращений к БД; ключ склейки включает имя и ИНН, поэтому + результат для представителя группы равен результату для любой её строки).""" + ids: set[uuid.UUID] = set() + for row in rows: + if row.counterparty_id is not None: + ids.add(row.counterparty_id) + elif (resolution := index.resolve(row.counterparty_inn, row.counterparty_name)) is not None: + ids.add(resolution.entity_id) + return ids + + +async def _entities(session: AsyncSession, ids: Collection[uuid.UUID]) -> dict[uuid.UUID, domain.Entity]: + """ЮЛ справочника по `ids` одним запросом, без policy (контрагент может быть из любого филиала).""" + if not ids: + return {} + found = await session.execute(select(LegalEntity).where(LegalEntity.id.in_(ids))) + return {entity.id: _entity(entity) for entity in found.scalars()} + + +class ConsolidatedOsvService: + """Чтение сводной ОСВ версии: страница с фильтрами и сводка с пробелами (без блокировок и аудита).""" + + def __init__(self, session: AsyncSession, scope: VisibilityScope) -> None: + self._session = session + self._scope = scope + + async def _build(self, version_id: uuid.UUID) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation]: + """Свод версии (`domain.build`) вместе с версией и консолидацией; версия вне видимости — 404.""" + found = await repo.get_version(self._session, self._scope, version_id) + if found is None: + raise NotFoundError(VERSION_NOT_FOUND) + version, consolidation = found + members = {member.id: _entity(member) for _, member in await repo.members(self._session, version.id)} + documents = [ + _source_document(document) + for document in await repo.bound_documents(self._session, version.id) + if document.document_type == DocumentType.OSV + ] + # ровно то, что отдал `load_osv_rows`: он оставляет `COUNTERPARTY`/`BU`+`SUM`, домен их не перефильтровывает + rows = [_source_row(entry) for entry, _, _ in await repo.load_osv_rows(self._session, version.id)] + index = await CounterpartyIndex.load(self._session) + entities = {**await _entities(self._session, _candidate_ids(rows, index) - members.keys()), **members} + consolidated = domain.build( + year=consolidation.year, + quarter=consolidation.quarter, + group_id=consolidation.company_group_id, + members=members, + entities=entities, + documents=documents, + rows=rows, + resolver=index, + ) + return consolidated, version, consolidation + + async def page(self, version_id: uuid.UUID, f: OsvFilters, page: OsvPageParams) -> Page[ConsolidatedRowOut]: + """Страница свода после фильтров: `total` — число строк после фильтра, срез — в памяти (порядок D-16).""" + consolidated, _, _ = await self._build(version_id) + rows = domain.apply_filter(consolidated.rows, domain_filter(f)) + window = rows[page.offset : page.offset + page.limit] + return Page[ConsolidatedRowOut]( + items=[row_out(row) for row in window], total=len(rows), limit=page.limit, offset=page.offset + ) + + async def summary(self, version_id: uuid.UUID) -> OsvSummaryOut: + """Сводка: строки, несопоставленные, автосопоставленные по имени/алиасу, ОСВ-документы версии и пробелы.""" + consolidated, _, _ = await self._build(version_id) + rows_total, unmapped_total, auto_matched_total = domain.summary(consolidated) + return OsvSummaryOut( + rows_total=rows_total, + unmapped_total=unmapped_total, + auto_matched_total=auto_matched_total, + documents_total=consolidated.documents_total, + gaps=[GapOut.model_validate(gap, from_attributes=True) for gap in consolidated.gaps], + ) diff --git a/api/tests/test_api_consolidated_osv.py b/api/tests/test_api_consolidated_osv.py new file mode 100644 index 0000000..afd7fee --- /dev/null +++ b/api/tests/test_api_consolidated_osv.py @@ -0,0 +1,420 @@ +"""API сводной ОСВ версии (spec consolidated-osv «Страница свода и сводка»; design D-1, D-16): страница, фильтры, +сводка с пробелами, суммы-строки, права и видимость, производительность без N+1.""" + +import time +import uuid +from decimal import Decimal +from typing import Any + +import pytest +from sqlalchemy import event, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.aliases import normalize_alias +from src.models.access import AppUser +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.directories import CompanyGroup, LegalEntity, LegalEntityAlias, RegionalOffice +from src.models.enums import ConsolidationStatus, CounterpartyMatchKind, PeriodQuarter +from src.models.files import Document +from src.models.osv import OsvEntry +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_confirmed_osv, make_consolidation, make_legal_entity + +HTTP_OK = 200 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_UNPROCESSABLE = 422 +KA_RF = "ka_rf@armka.local" +CURATOR = "curator_go@armka.local" +FOREIGN = "client_manager@armka.local" # Краснодарский филиал +ROSTOV = "Ростовский региональный филиал" +YEAR = 2024 +Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3 +D = Decimal +ZERO = D("0.00") +ROWS_TOTAL, UNMAPPED_TOTAL, AUTO_MATCHED_TOTAL, DOCUMENTS_TOTAL = 8, 2, 2, 7 +PERF_MAX_SECONDS = 5.0 +PERF_MAX_SELECTS = 10 + + +class World: + """Версия РБ Q3 2024: периметр {А, Б}; вне периметра — ЧКФ (та же ГК), «Внешний» (имя), Ф (алиас).""" + + version: ConsolidationVersion + a: LegalEntity + b: LegalEntity + chkf: LegalEntity + external: LegalEntity + aliased: LegalEntity + + +def _quarter_rows(world: World, quarter: PeriodQuarter) -> list[dict[str, Any]]: + """Строки ОСВ 60 «А»: opening/turnover/closing по кварталам 100/10/110, 110/10/120, 120/10/130.""" + step = {Q1: 0, Q2: 10, Q3: 20}[quarter] + amounts = { + "opening_debit": D(100 + step), + "turnover_debit": D(10), + "closing_debit": D(110 + step), + } + return [ + { + "name": "ООО Б", + "inn": world.b.inn, + "counterparty_id": world.b.id, + "counterparty_match_kind": CounterpartyMatchKind.INN, + **amounts, + }, + { + "name": "ООО ЧКФ", + "counterparty_id": world.chkf.id, + "counterparty_match_kind": CounterpartyMatchKind.MANUAL, + **amounts, + }, + {"name": "Неизвестный контрагент", **amounts}, + {"name": "ООО Внешний", **amounts}, + {"name": "Пекарня Ф", **amounts}, + ] + + +async def _bind(session: AsyncSession, version: ConsolidationVersion, *documents: Any) -> None: + for document in documents: + session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await session.flush() + + +async def _world(session: AsyncSession, *, status: ConsolidationStatus = ConsolidationStatus.DATA_CORRECTION) -> World: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group: CompanyGroup = await make_company_group(session, name="ГК Русский бисквит", office=office) + world = World() + world.a = await make_legal_entity(session, name="ООО А", group=group) + world.b = await make_legal_entity(session, name="ООО Б", group=group) + world.chkf = await make_legal_entity(session, name="ООО ЧКФ", group=group) + world.external = await make_legal_entity(session, name="ООО Внешний") + world.aliased = await make_legal_entity(session, name="Фирма Эф") + session.add( + LegalEntityAlias( + legal_entity_id=world.aliased.id, alias="Пекарня Ф", normalized_alias=normalize_alias("Пекарня Ф") + ) + ) + user = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + _, world.version = await make_consolidation( + session, group=group, year=YEAR, quarter=Q3, members=[world.a, world.b], initiator=user, status=status + ) + documents = [] + for quarter in (Q1, Q2, Q3): + documents.append( + await make_confirmed_osv( + session, + entity=world.a, + account="60", + year=YEAR, + quarter=quarter, + is_cumulative=False, + rows=_quarter_rows(world, quarter), + ) + ) + documents.append( + await make_confirmed_osv( + session, + entity=world.b, + account="60", + year=YEAR, + quarter=quarter, + is_cumulative=False, + rows=[ + { + "name": "ООО А", + "counterparty_id": world.a.id, + "counterparty_match_kind": CounterpartyMatchKind.MANUAL, + "turnover_credit": D("5.00"), + }, + {"name": "Неизвестный Б", "turnover_credit": D("1.00")}, + ], + ) + ) + documents.append( + await make_confirmed_osv( + session, + entity=world.a, + account="62", + year=YEAR, + quarter=Q3, + is_cumulative=True, + rows=[ + { + "name": "ООО Б", + "inn": world.b.inn, + "counterparty_id": world.b.id, + "counterparty_match_kind": CounterpartyMatchKind.INN, + "closing_credit": D("42.50"), + } + ], + ) + ) + await _bind(session, world.version, *documents) + return world + + +def _url(world: World, suffix: str = "osv") -> str: + return f"/consolidations/versions/{world.version.id}/{suffix}" + + +async def _page(client: Any, world: World, query: str = "") -> dict[str, Any]: + response = await client.get(f"{_url(world)}?{query}") + assert response.status_code == HTTP_OK, response.text + body: dict[str, Any] = response.json() + return body + + +def _row(body: dict[str, Any], entity: str, account: str, name: str) -> dict[str, Any]: + return next( + row + for row in body["items"] + if (row["legal_entity_name"], row["account_code"], row["counterparty_name"]) == (entity, account, name) + ) + + +@pytest.mark.integration +async def test_page_rows_order_total_and_money_strings(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + body = await _page(client, world) + + assert (body["total"], body["limit"], body["offset"]) == (ROWS_TOTAL, 500, 0) + assert [(r["legal_entity_name"], r["account_code"], r["row_number"]) for r in body["items"]] == [ + ("ООО А", "60", 1), ("ООО А", "60", 2), ("ООО А", "60", 3), ("ООО А", "60", 4), ("ООО А", "60", 5), + ("ООО А", "62", 1), ("ООО Б", "60", 1), ("ООО Б", "60", 2), + ] # fmt: skip + row = _row(body, "ООО А", "60", "ООО Б") + assert row["period"] == "2024 Q3" and row["is_cumulative"] is False + assert (row["opening_debit"], row["turnover_debit"], row["closing_debit"]) == ("100.00", "30.00", "130.00") + assert (row["opening_credit"], row["turnover_credit"], row["closing_credit"]) == ("0.00", "0.00", "0.00") + assert len(row["entry_ids"]) == 3 # noqa: PLR2004 + assert all(isinstance(entry_id, str) for entry_id in row["entry_ids"]) + assert row["warnings"] == [] + assert row["legal_entity_id"] == str(world.a.id) and row["legal_entity_inn"] == world.a.inn + assert row["account_root"] == "60" and row["counterparty_inn"] == world.b.inn + assert (row["counterparty_id"], row["counterparty_entity_name"]) == (str(world.b.id), "ООО Б") + assert (row["counterparty_type"], row["match_kind"], row["match_stored"]) == ("GSK", "INN", True) + cumulative = _row(body, "ООО А", "62", "ООО Б") + assert cumulative["is_cumulative"] is True and cumulative["closing_credit"] == "42.50" + assert len(cumulative["entry_ids"]) == 1 + + +@pytest.mark.integration +async def test_counterparty_types_and_read_time_resolution( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + body = await _page(client, world) + + chkf = _row(body, "ООО А", "60", "ООО ЧКФ") + assert (chkf["counterparty_type"], chkf["match_kind"], chkf["match_stored"]) == ("GK", "MANUAL", True) + unknown = _row(body, "ООО А", "60", "Неизвестный контрагент") + assert (unknown["counterparty_id"], unknown["counterparty_type"], unknown["match_kind"]) == (None, "EXTERNAL", None) + assert unknown["match_stored"] is False and unknown["counterparty_entity_name"] is None + by_name = _row(body, "ООО А", "60", "ООО Внешний") + assert (by_name["counterparty_id"], by_name["match_kind"], by_name["match_stored"]) == ( + str(world.external.id), "NAME", False) # fmt: skip + assert by_name["counterparty_type"] == "EXTERNAL" + by_alias = _row(body, "ООО А", "60", "Пекарня Ф") + assert (by_alias["counterparty_id"], by_alias["match_kind"], by_alias["match_stored"]) == ( + str(world.aliased.id), "ALIAS", False) # fmt: skip + assert by_alias["counterparty_entity_name"] == "Фирма Эф" + assert _row(body, "ООО Б", "60", "ООО А")["counterparty_type"] == "GSK" + + +@pytest.mark.integration +async def test_summary_counts_and_page_total_agree(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + summary = (await client.get(_url(world, "osv/summary"))).json() + + assert summary == { + "rows_total": ROWS_TOTAL, "unmapped_total": UNMAPPED_TOTAL, "auto_matched_total": AUTO_MATCHED_TOTAL, + "documents_total": DOCUMENTS_TOTAL, "gaps": [], + } # fmt: skip + unmapped = await _page(client, world, "unmapped=true") + assert unmapped["total"] == summary["unmapped_total"] + assert len(unmapped["items"]) == UNMAPPED_TOTAL + assert all(row["counterparty_id"] is None for row in unmapped["items"]) + mapped = await _page(client, world, "unmapped=false") + assert mapped["total"] == ROWS_TOTAL - UNMAPPED_TOTAL + + +@pytest.mark.integration +async def test_filter_by_match_kind_repeated_param(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + body = await _page(client, world, "match_kind=NAME&match_kind=ALIAS") + summary = (await client.get(_url(world, "osv/summary"))).json() + + assert body["total"] == summary["auto_matched_total"] == AUTO_MATCHED_TOTAL + assert {row["match_kind"] for row in body["items"]} == {"NAME", "ALIAS"} + assert (await _page(client, world, "match_kind=MANUAL"))["total"] == 2 # noqa: PLR2004 - ЧКФ и «ООО А» у Б + assert (await _page(client, world, "match_kind=INN"))["total"] == 2 # noqa: PLR2004 - 60 и 62 + + +@pytest.mark.integration +async def test_filters_q_entity_account_root_type(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + by_name = await _page(client, world, "q=%D0%B2%D0%BD%D0%B5%D1%88") # «внеш» — регистр не важен + assert [row["counterparty_name"] for row in by_name["items"]] == ["ООО Внешний"] + by_inn = await _page(client, world, f"q={world.b.inn[:6]}") # префикс ИНН контрагента + assert {row["counterparty_name"] for row in by_inn["items"]} == {"ООО Б"} and by_inn["total"] == 2 # noqa: PLR2004 + assert (await _page(client, world, f"legal_entity_id={world.b.id}"))["total"] == 2 # noqa: PLR2004 + assert (await _page(client, world, "account_root=62"))["total"] == 1 + assert (await _page(client, world, "counterparty_type=GK"))["total"] == 1 + assert (await _page(client, world, "counterparty_type=GSK"))["total"] == 3 # noqa: PLR2004 + both = await _page(client, world, f"legal_entity_id={world.a.id}&account_root=60&counterparty_type=EXTERNAL") + assert both["total"] == 3 # noqa: PLR2004 - неизвестный, «Внешний», «Пекарня Ф» + + +@pytest.mark.integration +async def test_pagination_window_and_limit_bounds(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + body = await _page(client, world, "limit=2&offset=4") + + assert (body["total"], body["limit"], body["offset"], len(body["items"])) == (ROWS_TOTAL, 2, 4, 2) + assert [row["row_number"] for row in body["items"]] == [5, 1] # пятая строка 60 «А», затем 62 «А» + assert (await _page(client, world, "limit=2000"))["limit"] == 2000 # noqa: PLR2004 + assert (await _page(client, world, f"offset={ROWS_TOTAL}"))["items"] == [] + for bad in ("limit=2001", "limit=0", "offset=-1", "counterparty_type=OTHER", "match_kind=BOGUS"): + assert (await client.get(f"{_url(world)}?{bad}")).status_code == HTTP_UNPROCESSABLE, bad + + +@pytest.mark.integration +async def test_gap_is_reported_and_has_no_rows(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + # у «Б» отвязываем ОСВ 60 за Q2: остаются Q1 и Q3 → пробел + link = ( + await seeded.execute( + select(ConsolidationDocument) + .join(Document, Document.id == ConsolidationDocument.document_id) + .where( + ConsolidationDocument.consolidation_version_id == world.version.id, + Document.legal_entity_id == world.b.id, + Document.period_quarter == Q2, + ) + ) + ).scalar_one() + await seeded.delete(link) + await seeded.flush() + client = await auth_client(KA_RF) + + summary = (await client.get(_url(world, "osv/summary"))).json() + body = await _page(client, world, f"legal_entity_id={world.b.id}") + + assert summary["gaps"] == [ + {"legal_entity_id": str(world.b.id), "legal_entity_name": "ООО Б", "account_code": "60", + "missing_quarters": ["Q2"]} + ] # fmt: skip + assert summary["documents_total"] == DOCUMENTS_TOTAL - 1 + assert body["total"] == 0 + assert summary["rows_total"] == ROWS_TOTAL - 2 + + +@pytest.mark.integration +async def test_cancelled_version_is_readable(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded, status=ConsolidationStatus.CANCELLED) + client = await auth_client(KA_RF) + + assert (await _page(client, world))["total"] == ROWS_TOTAL + assert (await client.get(_url(world, "osv/summary"))).status_code == HTTP_OK + + +@pytest.mark.integration +async def test_rights_and_visibility(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + curator = await auth_client(CURATOR) + foreign = await auth_client(FOREIGN) + admin = await auth_client("admin@armka.local") # права CONSOLIDATION_VIEW у роли нет + + assert (await curator.get(_url(world))).status_code == HTTP_OK + assert (await curator.get(_url(world, "osv/summary"))).status_code == HTTP_OK + for suffix in ("osv", "osv/summary"): + hidden = await foreign.get(_url(world, suffix)) + unknown = await foreign.get(f"/consolidations/versions/{uuid.uuid4()}/{suffix}") + assert hidden.status_code == unknown.status_code == HTTP_NOT_FOUND + assert hidden.json() == unknown.json() + assert (await admin.get(_url(world, suffix))).status_code == HTTP_FORBIDDEN + + +@pytest.mark.integration +async def test_read_is_side_effect_free(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Дорезолв не пишется: `osv_entry.counterparty_id` строки «ООО Внешний» остаётся пустым.""" + world = await _world(seeded) + client = await auth_client(KA_RF) + + await _page(client, world) + + resolved = ( + await seeded.execute( + select(OsvEntry.counterparty_id).where(OsvEntry.counterparty_name.in_(["ООО Внешний", "Пекарня Ф"])) + ) + ).scalars() + assert set(resolved) == {None} + + +@pytest.mark.integration +async def test_large_version_is_fast_without_n_plus_one(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Свод из 5 400 исходных строк (2 ЮЛ × 3 счёта × 3 квартала × 300): ответ быстро, SELECT-ов — константа.""" + office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(seeded, name="ГК Большая", office=office) + members = [await make_legal_entity(seeded, name=f"ООО Большая {n}", group=group) for n in range(2)] + counterparties = [await make_legal_entity(seeded, name=f"Контрагент {n:03d}", group=group) for n in range(50)] + user = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + _, version = await make_consolidation( + seeded, group=group, year=YEAR, quarter=Q3, members=members, initiator=user, + status=ConsolidationStatus.DATA_CORRECTION, + ) # fmt: skip + documents = [] + for entity in members: + for account in ("60", "62", "76"): + for quarter in (Q1, Q2, Q3): + rows = [ + { + "name": f"Контрагент {n:03d}" if n % 2 else f"Неизвестный {n}", + "counterparty_id": counterparties[n % 50].id if n % 3 == 0 else None, + "counterparty_match_kind": CounterpartyMatchKind.MANUAL if n % 3 == 0 else None, + "opening_debit": D("100.00"), + "turnover_debit": D("10.00"), + "closing_debit": D("110.00"), + } + for n in range(300) + ] + documents.append( + await make_confirmed_osv( + seeded, entity=entity, account=account, year=YEAR, quarter=quarter, is_cumulative=False, + rows=rows, + ) + ) # fmt: skip + await _bind(seeded, version, *documents) + client = await auth_client(KA_RF) + statements: list[str] = [] + connection = (await seeded.connection()).sync_connection + listener = lambda _c, _cur, statement, *_rest: statements.append(statement) # noqa: E731 + event.listen(connection, "before_cursor_execute", listener) + try: + started = time.perf_counter() + response = await client.get(f"/consolidations/versions/{version.id}/osv?limit=2000") + elapsed = time.perf_counter() - started + finally: + event.remove(connection, "before_cursor_execute", listener) + + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert body["total"] == 2 * 3 * 300 and len(body["items"]) == 1800 # noqa: PLR2004 - 5 400 исходных строк → 1 800 + selects = [s for s in statements if s.lstrip().upper().startswith("SELECT")] + print(f"\nPERF osv: {elapsed:.3f}s, SELECT={len(selects)}, statements={len(statements)}") # noqa: T201 + assert elapsed < PERF_MAX_SECONDS + assert len(selects) <= PERF_MAX_SELECTS, selects diff --git a/api/tests/test_domain_osv_mapping.py b/api/tests/test_domain_osv_mapping.py index 7b5cb6a..bdfd687 100644 --- a/api/tests/test_domain_osv_mapping.py +++ b/api/tests/test_domain_osv_mapping.py @@ -18,6 +18,7 @@ from src.domain.osv_mapping import ( DocumentDraft, EntryDraft, account_root, + account_sort_key, build_entries, counterparty_check, document_fields, @@ -305,3 +306,16 @@ def test_document_status( ) def test_opening_matches(entry: EntryDraft, q1_opening: tuple[Decimal | None, Decimal | None], expected: bool) -> None: assert opening_matches(entry, q1_opening) is expected + + +def test_account_sort_key_is_natural_and_total() -> None: + codes = ["60.10", "60.2", "60", "58.03", "58", "9", "01"] + assert sorted(codes, key=account_sort_key) == ["01", "9", "58", "58.03", "60", "60.2", "60.10"] + + +@pytest.mark.parametrize("code", ["6²", "①", "60.①", "٦٠", "6²⁰.1"]) +def test_account_sort_key_survives_non_ascii_digits(code: str) -> None: + """`"6²".isdigit()` истинно, но `int("6²")` падает: код счёта из файла не должен ронять страницу свода (T9.a).""" + key = account_sort_key(code) + assert key == account_sort_key(code) # детерминирован + assert sorted(["A", code, "60"], key=account_sort_key)[0] == "60" # ASCII-числа — раньше нечисловых сегментов diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index e1a78a0..0a4073f 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -59,6 +59,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}/status": {"post"}, "/api/v1/consolidations/versions/{version_id}/members": {"put"}, "/api/v1/consolidations/versions/{version_id}/checklist": {"get"}, + "/api/v1/consolidations/versions/{version_id}/osv": {"get"}, + "/api/v1/consolidations/versions/{version_id}/osv/summary": {"get"}, } # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно @@ -135,6 +137,26 @@ def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"]["responses"] assert {"200", "404"} <= set(checklist) assert checklist["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/ChecklistOut") + osv = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv"]["get"] + assert {"200", "404", "422"} <= set(osv["responses"]) + assert osv["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith( + "/Page_ConsolidatedRowOut_" + ) + params = {parameter["name"]: parameter for parameter in osv["parameters"]} + assert { + "legal_entity_id", + "account_root", + "counterparty_type", + "unmapped", + "match_kind", + "q", + "limit", + "offset", + } <= set(params) + assert params["match_kind"]["schema"]["anyOf"][0]["type"] == "array" # повторяемый параметр + summary = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv/summary"]["get"]["responses"] + assert {"200", "404"} <= set(summary) + assert summary["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/OsvSummaryOut") def test_lock_and_status_routes_declare_documented_responses(schema: dict[str, Any]) -> None: diff --git a/api/tests/test_schemas_consolidations.py b/api/tests/test_schemas_consolidations.py index 906bc92..c74a3e5 100644 --- a/api/tests/test_schemas_consolidations.py +++ b/api/tests/test_schemas_consolidations.py @@ -3,6 +3,7 @@ import dataclasses import uuid from decimal import Decimal +from typing import Any import pytest from pydantic import ValidationError @@ -17,6 +18,7 @@ from src.schemas.consolidations import ( ConsolidationCreate, MapCounterpartyIn, OsvFilters, + OsvPageParams, StatusIn, ) @@ -31,12 +33,20 @@ def test_consolidation_create_year_bounds() -> None: ConsolidationCreate(year=year, **ids) -def test_osv_filters_limit_bounds_and_repeated_match_kind() -> None: - assert OsvFilters().limit == 500 # noqa: PLR2004 - assert OsvFilters(limit=MAX_LIMIT).limit == MAX_LIMIT - for bad in ({"limit": MAX_LIMIT + 1}, {"limit": 0}, {"offset": -1}, {"counterparty_type": "OTHER"}): +def test_osv_page_params_limit_bounds() -> None: + assert (OsvPageParams().limit, OsvPageParams().offset) == (500, 0) # noqa: PLR2004 + assert OsvPageParams(limit=MAX_LIMIT).limit == MAX_LIMIT + for bad in ({"limit": MAX_LIMIT + 1}, {"limit": 0}, {"offset": -1}): with pytest.raises(ValidationError): - OsvFilters(**bad) + OsvPageParams(**bad) + + +def test_osv_filters_repeated_match_kind_and_no_pagination() -> None: + """Фильтры — отдельная модель от пагинации (T2.7): `limit`/`offset` в ней нет.""" + assert "limit" not in OsvFilters.model_fields + assert "offset" not in OsvFilters.model_fields + with pytest.raises(ValidationError): + OsvFilters(counterparty_type="OTHER") # type: ignore[arg-type] assert OsvFilters(match_kind=["NAME", "ALIAS"]).match_kind == [ CounterpartyMatchKind.NAME, CounterpartyMatchKind.ALIAS, @@ -48,36 +58,48 @@ def test_defaults_of_input_schemas() -> None: assert StatusIn(to_status="CANCELLED").comment is None +def _row(**amounts: Decimal | None) -> ConsolidatedRowOut: + fields: dict[str, Any] = { + "entry_ids": [uuid.uuid4()], + "legal_entity_id": uuid.uuid4(), + "legal_entity_name": "ООО", + "legal_entity_inn": "7701000001", + "account_code": "60", + "account_root": "60", + "period": "2024 Q3", + "is_cumulative": False, + "row_number": 1, + "counterparty_name": "А", + "counterparty_inn": None, + "counterparty_id": None, + "counterparty_entity_name": None, + "counterparty_type": "EXTERNAL", + "match_kind": None, + "match_stored": False, + "opening_debit": None, + "opening_credit": None, + "turnover_debit": None, + "turnover_credit": None, + "closing_debit": None, + "closing_credit": None, + "warnings": [], + } + return ConsolidatedRowOut(**{**fields, **amounts}) + + def test_consolidated_row_serializes_money_as_strings() -> None: - row = ConsolidatedRowOut( - entry_ids=[uuid.uuid4()], - legal_entity_id=uuid.uuid4(), - legal_entity_name="ООО", - legal_entity_inn="7701000001", - account_code="60", - account_root="60", - period="2024 Q3", - is_cumulative=False, - row_number=1, - counterparty_name="А", - counterparty_inn=None, - counterparty_id=None, - counterparty_entity_name=None, - counterparty_type="EXTERNAL", - match_kind=None, - match_stored=False, - opening_debit=Decimal("100.50"), - opening_credit=None, - turnover_debit=Decimal("0.00"), - turnover_credit=None, - closing_debit=None, - closing_credit=None, - warnings=[], - ) + row = _row(opening_debit=Decimal("100.50"), turnover_debit=Decimal("0.00")) dumped = row.model_dump(mode="json") assert (dumped["opening_debit"], dumped["opening_credit"], dumped["turnover_debit"]) == ("100.50", None, "0.00") +def test_consolidated_row_money_has_two_decimals() -> None: + """Суммы свода — всегда с двумя знаками (`"0.00"`, не `"0"`): ноль склейки и сумма Decimal не меняют вид.""" + row = _row(opening_debit=Decimal("0"), turnover_debit=Decimal("30.00"), closing_debit=Decimal("7")) + dumped = row.model_dump(mode="json") + assert (dumped["opening_debit"], dumped["turnover_debit"], dumped["closing_debit"]) == ("0.00", "30.00", "7.00") + + def test_entity_files_state_is_frozen() -> None: state = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.ERROR) with pytest.raises(dataclasses.FrozenInstanceError): diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 494016f..a92e51e 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -202,9 +202,9 @@ - `ConsolidatedOsvService(session, scope)`: `async def _build(version_id) -> tuple[Consolidated, ConsolidationVersion, Consolidation]` (`get_version` → `members` → `bound_documents` (ОСВ) → `load_osv_rows` → `CounterpartyIndex.load` → `entities` для `counterparty_id` и кандидатов дорезолва (один `SELECT legal_entity` по собранным id) → `build`); `page(version_id, f: OsvFilters, page: OsvPageParams) -> Page[ConsolidatedRowOut]`; `summary(version_id) -> OsvSummaryOut`. - `OsvPageParams`: `limit` ≤ 2000, по умолчанию 500 (как `EntriesPageParams`). Маршруты: `GET …/osv`, `GET …/osv/summary` (`CONSOLIDATION_VIEW`). -- [ ] **Step 1: Тесты.** Integration на `make_confirmed_osv` ×3 квартала ×2 ЮЛ: страница, `total`, сценарии «Только несопоставленные», «Фильтр по способу», `q` по имени/ИНН, `legal_entity_id`, `account_root`, чтение версии `CANCELLED` → 200, `curator_go` → 200, чужой филиал → 404, `limit=2001` → 422; `summary.gaps` при пробеле; суммы в JSON — строки `"100.00"`. -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidated_osv.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): API сводной ОСВ — страница с фильтрами и сводка с пробелами`. +- [x] **Step 1: Тесты.** Integration на `make_confirmed_osv` ×3 квартала ×2 ЮЛ: страница, `total`, сценарии «Только несопоставленные», «Фильтр по способу», `q` по имени/ИНН, `legal_entity_id`, `account_root`, чтение версии `CANCELLED` → 200, `curator_go` → 200, чужой филиал → 404, `limit=2001` → 422; `summary.gaps` при пробеле; суммы в JSON — строки `"100.00"`. +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidated_osv.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): API сводной ОСВ — страница с фильтрами и сводка с пробелами`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 6db1dec..bc8bfd4 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -241,3 +241,8 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 54 | 2026-10-06 | `build` защитно игнорирует документы ЮЛ вне `members` и строки документов, которых нет в `documents`; `documents_total` = число переданных документов. `select_sources` при дублях (два нарастающих за квартал версии или два квартальных за квартал) возвращает все — привязка их не допускает (R-1) | вход формирует репозиторий по `consolidation_document`; дубли невозможны по ключу привязки | при нарушении инварианта привязки — двойной счёт строк, а не ошибка | | 55 | 2026-10-06 | `OsvFilter.q` обрезается по краям, пустой не фильтрует; имя — подстрока без регистра (`casefold`), ИНН — префикс после удаления пробелов с обеих сторон. `unmapped = false` — только сопоставленные (после дорезолва); `match_kinds` пустой — без фильтра | спека задаёт только `unmapped = true`; симметричное значение естественно для query-параметра `bool` | фронт получит сопоставленные строки на `unmapped=false` вместо всех | | 56 | 2026-10-06 | Признак `CounterpartyType` (StrEnum) и `counts_as_cumulative`/`QUARTER_ORDER`/`quarters_through` — по одному определению в домене: схема API берёт `CounterpartyType` из `domain.consolidated_osv` (T2.8), `services.consolidation_documents` — `QUARTER_ORDER` и `counts_as_cumulative` (R-1), чек-лист — `select_sources` через адаптер `_osv_coverage` (T8.1) и явную таблицу статусов кандидатов (T8.4) | расходящиеся копии — источник тихого рассинхрона | нулевая: OpenAPI-enum и поведение те же, тесты чек-листа не менялись | +| 57 | 2026-10-06 | `OsvFilters` (фильтры) и `OsvPageParams` (`limit` ≤ 2000, по умолчанию 500; `offset` ≥ 0) — отдельные модели; для маршрута `GET …/osv` они сливаются в `OsvQuery(OsvFilters, OsvPageParams)`, потому что FastAPI принимает одну query-модель на маршрут (две модели дают 422 «Field required» по `filters`/`page`); сервису `OsvQuery` передаётся и как фильтры, и как пагинация, экспорт (Task 12) берёт только `OsvFilters` | закрывает T2.7; проверено падением теста до слияния | при ошибке — query-параметры маршрута придётся перегруппировать; контракт API (плоские параметры) не меняется | +| 58 | 2026-10-06 | Суммы `ConsolidatedRowOut` приводятся валидатором к двум знакам (`quantize(Decimal("0.01"))`): `"0.00"`/`"30.00"`, не `"0"`/`"30"` | склейка даёт `Decimal("0")` для отсутствующей в квартале строки, а спека и макет показывают суммы `numeric(18,2)` с двумя знаками (`"100.00"`); значение не меняется (в данных знаков больше двух нет) | при ошибке — лишние/недостающие нули в JSON, суммы остаются точными | +| 59 | 2026-10-06 | `account_sort_key` считает числовым сегмент только из ASCII-цифр (`[0-9]+`), остальные — текстом после числовых; `isdigit()` пропускал `"6²"`/`"①"`, на которых `int()` падает (T9.a) | код счёта приходит из файла; исключение роняло бы всю страницу свода и чек-лист (500) | при ошибке — экзотический код счёта сортируется как текст, а не по значению; падения нет | +| 60 | 2026-10-06 | Идентификаторы кандидатов дорезолва `_build` собирает заранее: `CounterpartyIndex.resolve` (словари в памяти, без БД) по каждой строке без `counterparty_id`, плюс хранимые `counterparty_id`; один `SELECT legal_entity … WHERE id IN (…)` без policy; члены периметра берутся из `members` и в запрос не входят. Запросов к БД на `GET …/osv` — константа (8 SELECT на 5 400 исходных строк), без N+1; страница режется в памяти после `domain.build` и `apply_filter` | свод — чистая функция версии (D-1), срез и фильтры по `counterparty_type`/`unmapped` зависят от дорезолва, поэтому строить нужно весь свод | при ошибке — `IN` с очень большим числом контрагентов упрётся в лимит параметров asyncpg (32 767); число различных контрагентов версии на порядки меньше | +| 61 | 2026-10-06 | `ConsolidatedOsvService` только читает: без блокировок и аудита, версия любого статуса (включая `CANCELLED`) читается; `documents` для `build` — привязанные документы `document_type = OSV` (`bound_documents`), `rows` — ровно результат `load_osv_rows` (`COUNTERPARTY`, `BU`/`SUM`): домен `group_type`/`data_type` не фильтрует (замечание ревью Task 9) | спека «Страница свода и сводка»; DECISION-5 | при ошибке — в свод попали бы строки `ACCOUNT`/`NU` и задвоили бы суммы | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index 4c2e5c1..9f3e17d 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -46,7 +46,7 @@ - [x] 4.1 **Домен.** `domain/consolidation_periods.py:select_sources` (нарастающий/квартальные/пробел), `domain/consolidated_osv.py: build(...)` (склейка по ключу, нули, дубли, `entry_ids`, `warnings`, дорезолв, признак, фильтры, порядок). Тесты без БД: все сценарии «Выбор источников», «Строки свода», фильтры, версия за Q1, дубль ключа, расхождение `counterparty_id`. -- [ ] 4.2 **API свода.** `services/consolidated_osv.py:page/summary`, `GET …/osv`, `GET …/osv/summary`. Тесты: страница на +- [x] 4.2 **API свода.** `services/consolidated_osv.py:page/summary`, `GET …/osv`, `GET …/osv/summary`. Тесты: страница на PostgreSQL с тремя кварталами, `unmapped`, `match_kind`, `q`, чтение `CANCELLED`, `curator_go` читает. - [ ] 4.3 **Кандидаты и сопоставление.** `GET …/counterparty-candidates`, `POST …/osv/entries/{id}/counterparty` (`services/counterparty_mapping.py`: алиас, `ALIAS_TAKEN`, перенос, `MANUAL`/`PROPAGATED`, ИНН-противоречие, аудит).