feat(consolidation): API сводной ОСВ — страница с фильтрами и сводка с пробелами

GET /consolidations/versions/{id}/osv и /osv/summary (CONSOLIDATION_VIEW): ConsolidatedOsvService собирает свод
чистой функцией от версии (периметр, привязанные ОСВ, load_osv_rows, один CounterpartyIndex, один SELECT legal_entity)
и режет страницу в памяти; чтение любой версии, включая CANCELLED, без блокировок и аудита.
OsvFilters отделён от OsvPageParams (T2.7), суммы всегда с двумя знаками; account_sort_key устойчив к «6²»/«①» (T9.a).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 06:23:24 +04:00
parent d5a317a040
commit a2c2135811
13 changed files with 778 additions and 40 deletions

View File

@ -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)]

View File

@ -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)

View File

@ -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)

View File

@ -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, ...]:

View File

@ -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`)."""

View File

@ -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],
)

View File

@ -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

View File

@ -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-числа — раньше нечисловых сегментов

View File

@ -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:

View File

@ -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):

View File

@ -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 сводной ОСВ — страница с фильтрами и сводка с пробелами`.
---

View File

@ -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` и задвоили бы суммы |

View File

@ -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`, ИНН-противоречие, аудит).