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:
parent
d5a317a040
commit
a2c2135811
@ -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)]
|
||||
|
||||
@ -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)
|
||||
|
||||
50
api/src/api/endpoints/consolidations/osv.py
Normal file
50
api/src/api/endpoints/consolidations/osv.py
Normal 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)
|
||||
@ -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, ...]:
|
||||
|
||||
@ -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`)."""
|
||||
|
||||
|
||||
163
api/src/services/consolidated_osv.py
Normal file
163
api/src/services/consolidated_osv.py
Normal 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],
|
||||
)
|
||||
420
api/tests/test_api_consolidated_osv.py
Normal file
420
api/tests/test_api_consolidated_osv.py
Normal 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
|
||||
@ -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-числа — раньше нечисловых сегментов
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 сводной ОСВ — страница с фильтрами и сводка с пробелами`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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` и задвоили бы суммы |
|
||||
|
||||
@ -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`, ИНН-противоречие, аудит).
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user