feat(consolidation): экспорт сводной ОСВ в xlsx с листом пробелов

GET /consolidations/versions/{id}/osv/export: лист «Сводная ОСВ» (15 колонок D-14, суммы — числовые
ячейки #,##0.00 из Decimal) и лист «Пробелы» по своду целиком; фильтры как у GET …/osv без пагинации;
Content-Disposition с filename* (чистый ASCII); audit_event(EXPORT) с filters и rows.

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 07:04:53 +04:00
parent e246531c70
commit 8e82c2c19f
9 changed files with 529 additions and 6 deletions

View File

@ -2,7 +2,7 @@
from fastapi import APIRouter
from src.api.endpoints.consolidations import checklist, locks, mapping, members, osv, periods, status, versions
from src.api.endpoints.consolidations import checklist, export, locks, mapping, members, osv, periods, status, versions
router = APIRouter(tags=["consolidations"])
router.include_router(periods.router)
@ -12,4 +12,5 @@ router.include_router(status.router)
router.include_router(members.router)
router.include_router(checklist.router)
router.include_router(osv.router)
router.include_router(export.router)
router.include_router(mapping.router)

View File

@ -0,0 +1,55 @@
"""Экспорт сводной ОСВ версии в xlsx (spec consolidated-osv «Экспорт xlsx», audit-trail `EXPORT`; design D-14).
Право проверяется до видимости; версия ГК вне видимости — тот же 404, что у несуществующей; версия читается в любом
статусе. Файл строится в памяти; единственная запись в БД — `audit_event(EXPORT)` в транзакции запроса."""
import uuid
from typing import Annotated, Any
from urllib.parse import quote
from fastapi import APIRouter, Depends, Query, Response, status
from src.api.dependencies.audit import RequestAudit, get_request_audit
from src.api.dependencies.auth import require
from src.api.dependencies.consolidations import ConsolidatedOsvServiceDep
from src.schemas.common import ErrorDetail
from src.schemas.consolidations import OsvFilters
router = APIRouter(prefix="/consolidations/versions")
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
VIEW = [Depends(require("CONSOLIDATION_VIEW"))]
RESPONSES: dict[int | str, dict[str, Any]] = {
status.HTTP_200_OK: {
"description": "файл xlsx: листы «Сводная ОСВ» и «Пробелы»",
"content": {XLSX_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}},
},
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"},
}
def content_disposition(filename: str) -> str:
"""Заголовок вложения: ASCII-имя по умолчанию и `filename*` (RFC 5987) с полным percent-кодированием имени —
пробел, кавычки, `;`, `/`, переводы строк и кириллица экранируются, заголовок остаётся чистым ASCII."""
return f"attachment; filename=\"svod-osv.xlsx\"; filename*=UTF-8''{quote(filename, safe='')}"
@router.get(
"/{version_id}/osv/export",
response_class=Response,
dependencies=VIEW,
responses=RESPONSES,
summary="Экспорт сводной ОСВ версии в xlsx",
)
async def export_osv(
version_id: uuid.UUID,
service: ConsolidatedOsvServiceDep,
audit: Annotated[RequestAudit, Depends(get_request_audit)],
filters: Annotated[OsvFilters, Query()],
) -> Response:
"""Файл `svod-osv_<ГК>_<год>-<квартал>_v<n>.xlsx`: строки свода после фильтров (как у `GET …/osv`, без
пагинации, в том же порядке) и лист пробелов по своду целиком. Пишет `audit_event(EXPORT)`."""
content, filename = await service.export(version_id, filters, audit)
return Response(
content=content, media_type=XLSX_MEDIA_TYPE, headers={"Content-Disposition": content_disposition(filename)}
)

View File

@ -18,13 +18,14 @@ from typing import Literal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.common.exceptions import DomainValidationError, NotFoundError
from src.domain import consolidated_osv as domain
from src.domain.aliases import normalize_alias
from src.domain.policy import VisibilityScope
from src.models.consolidation import Consolidation, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity, RelatedGroup
from src.models.enums import DocumentType, RelatedGroupKind
from src.models.enums import AuditAction, AuditSection, DocumentType, RelatedGroupKind
from src.models.files import Document
from src.models.osv import OsvEntry
from src.repositories import consolidations as repo
@ -40,6 +41,7 @@ from src.schemas.consolidations import (
RefOut,
)
from src.services.consolidation_guard import VERSION_NOT_FOUND
from src.services.osv_export import build_workbook
__all__ = ["MAX_CANDIDATES", "MIN_QUERY_ALL", "CandidateScope", "ConsolidatedOsvService", "domain_filter", "row_out"]
@ -168,6 +170,35 @@ class ConsolidatedOsvService:
items=[row_out(row) for row in window], total=len(rows), limit=page.limit, offset=page.offset
)
async def export(self, version_id: uuid.UUID, f: OsvFilters, audit: RequestAudit) -> tuple[bytes, str]:
"""xlsx сводной ОСВ (spec «Экспорт xlsx», D-14): строки после тех же фильтров, что у `page`, без пагинации, в
порядке D-16; лист пробелов — по своду целиком. Имя файла — `svod-osv_<ГК>_<год>-<квартал>_v<n>.xlsx` (без
percent-кодирования: оно в маршруте). `audit_event(EXPORT)` пишется после сборки книги, в транзакции запроса."""
consolidated, version, consolidation = await self._build(version_id)
rows = domain.apply_filter(consolidated.rows, domain_filter(f))
content = build_workbook(consolidated, rows, entity_names={})
group_name = (
await self._session.execute(
select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id)
)
).scalar_one()
await audit.record(
action=AuditAction.EXPORT,
section=AuditSection.EXPORT,
object_type="consolidation_version",
object_id=version.id,
consolidation_version_id=version.id,
after_value={
"report": "consolidated_osv",
"filters": f.model_dump(mode="json", exclude_none=True),
"rows": len(rows),
},
)
filename = (
f"svod-osv_{group_name}_{consolidation.year}-{consolidation.quarter.value}_v{version.version_no}.xlsx"
)
return content, filename
async def summary(self, version_id: uuid.UUID) -> OsvSummaryOut:
"""Сводка: строки, несопоставленные, автосопоставленные по имени/алиасу, ОСВ-документы версии и пробелы."""
consolidated, _, _ = await self._build(version_id)

View File

@ -0,0 +1,95 @@
"""Книга xlsx сводной ОСВ (spec consolidated-osv «Экспорт xlsx»; design D-14): чистая функция без БД.
Лист «Сводная ОСВ» — строки свода в переданном порядке (порядок API, D-16), шесть сумм — числовые ячейки
`#,##0.00` в рублях без деления. В ячейки пишется `Decimal` как есть: `float` в нашем коде не используется;
`openpyxl` сам сериализует число в XML (формат xlsx хранит числа как IEEE-754 double, поэтому значения свыше
~10^15 теряют младшие разряды — для сумм ОСВ в рублях запас на три порядка). Лист «Пробелы» — пробелы свода
целиком (`Consolidated.gaps`), фильтры строк на него не влияют; без пробелов — одна ячейка «пробелов нет».
"""
import uuid
from collections.abc import Mapping, Sequence
from io import BytesIO
from openpyxl import Workbook
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.worksheet import Worksheet
from src.domain.consolidated_osv import Consolidated, ConsolidatedRow
__all__ = ["GAPS_SHEET", "MAIN_SHEET", "MONEY_FORMAT", "NO_GAPS", "build_workbook"]
MAIN_SHEET = "Сводная ОСВ"
GAPS_SHEET = "Пробелы"
MONEY_FORMAT = "#,##0.00"
NO_GAPS = "пробелов нет"
MAIN_HEADER = (
"Компания ГК", "ИНН компании", "Счёт", "Период", "Контрагент", "ИНН контрагента", "Сальдо начало Дт",
"Сальдо начало Кт", "Обороты Дт", "Обороты Кт", "Сальдо конец Дт", "Сальдо конец Кт", "Тип контрагента",
"Сопоставлено с", "Способ сопоставления",
) # fmt: skip
GAPS_HEADER = ("Компания ГК", "Счёт", "Нет ОСВ за кварталы")
FIRST_MONEY_COLUMN, LAST_MONEY_COLUMN = 7, 12
def _widths(sheet: Worksheet, widths: Sequence[int]) -> None:
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[get_column_letter(index)].width = width
def _main_sheet(sheet: Worksheet, rows: Sequence[ConsolidatedRow], entity_names: Mapping[uuid.UUID, str]) -> None:
sheet.title = MAIN_SHEET
sheet.append(MAIN_HEADER)
for row in rows:
sheet.append(
[
entity_names.get(row.legal_entity_id, row.legal_entity_name),
row.legal_entity_inn,
row.account_code,
row.period,
row.counterparty_name,
row.counterparty_inn,
row.opening_debit,
row.opening_credit,
row.turnover_debit,
row.turnover_credit,
row.closing_debit,
row.closing_credit,
row.counterparty_type.value,
row.counterparty_entity_name,
None if row.match_kind is None else row.match_kind.value,
]
)
for cell in sheet[sheet.max_row][FIRST_MONEY_COLUMN - 1 : LAST_MONEY_COLUMN]:
cell.number_format = MONEY_FORMAT
sheet.freeze_panes = "A2"
_widths(sheet, (32, 14, 8, 10, 36, 14, 16, 16, 16, 16, 16, 16, 12, 32, 14))
def _gaps_sheet(sheet: Worksheet, consolidated: Consolidated, entity_names: Mapping[uuid.UUID, str]) -> None:
sheet.title = GAPS_SHEET
if not consolidated.gaps:
sheet.append([NO_GAPS])
return
sheet.append(GAPS_HEADER)
for gap in consolidated.gaps:
quarters = ", ".join(quarter.value for quarter in gap.missing_quarters)
sheet.append([entity_names.get(gap.legal_entity_id, gap.legal_entity_name), gap.account_code, quarters])
_widths(sheet, (32, 8, 24))
def build_workbook(
consolidated: Consolidated, rows: Sequence[ConsolidatedRow], *, entity_names: Mapping[uuid.UUID, str]
) -> bytes:
"""Книга из двух листов: `rows` (уже отфильтрованные строки свода) и пробелы `consolidated.gaps`.
`entity_names` — отображаемые имена ЮЛ по идентификатору (переопределяют имена из строк и пробелов; нет в
словаре — имя из самой строки/пробела). Только память: `BytesIO`, S3 и БД не используются."""
workbook = Workbook()
main = workbook.active
assert main is not None # новая книга всегда с активным листом
_main_sheet(main, rows, entity_names)
_gaps_sheet(workbook.create_sheet(), consolidated, entity_names)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()

View File

@ -0,0 +1,323 @@
"""Экспорт сводной ОСВ в xlsx (spec consolidated-osv «Экспорт xlsx», audit-trail EXPORT; design D-14): файл читается
обратно `openpyxl`, суммы — числа `#,##0.00` без `float` в нашем коде, лист «Пробелы», фильтры как у `GET …/osv`,
заголовок `Content-Disposition`, аудит, права и видимость, никаких записей кроме `audit_event`."""
import io
import uuid
from decimal import Decimal
from typing import Any
from urllib.parse import unquote
import pytest
from openpyxl import load_workbook
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, CounterpartyMatchKind, PeriodQuarter
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
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
KA_RF = "ka_rf@armka.local"
ROSTOV = "Ростовский региональный филиал"
YEAR = 2024
Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3
D = Decimal
MAIN, GAPS = "Сводная ОСВ", "Пробелы"
HEADER = [
"Компания ГК", "ИНН компании", "Счёт", "Период", "Контрагент", "ИНН контрагента", "Сальдо начало Дт",
"Сальдо начало Кт", "Обороты Дт", "Обороты Кт", "Сальдо конец Дт", "Сальдо конец Кт", "Тип контрагента",
"Сопоставлено с", "Способ сопоставления",
] # fmt: skip
GAPS_HEADER = ["Компания ГК", "Счёт", "Нет ОСВ за кварталы"]
ROWS_TOTAL = 3
class World:
"""Версия Q3 2024, периметр {А, Б}. «А» по счёту 60 — Q1..Q3 (3 строки свода: «ООО Б» ГСК, «Неизвестный»
без сопоставления, «ООО Внешний» — имя найдётся дорезолвом); «Б» по счёту 60 — Q1 и Q3 (пробел Q2)."""
version: ConsolidationVersion
group: CompanyGroup
a: LegalEntity
b: LegalEntity
external: LegalEntity
async def _world(
session: AsyncSession, *, group_name: str = "ГК Русский бисквит", gap: bool = True,
status: ConsolidationStatus = ConsolidationStatus.DATA_CORRECTION,
) -> World: # fmt: skip
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
world = World()
world.group = await make_company_group(session, name=group_name, office=office)
world.a = await make_legal_entity(session, name="ООО А", group=world.group)
world.b = await make_legal_entity(session, name="ООО Б", group=world.group)
world.external = await make_legal_entity(session, name="ООО Внешний")
user = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
_, world.version = await make_consolidation(
session, group=world.group, year=YEAR, quarter=Q3, members=[world.a, world.b], initiator=user, status=status
)
documents = []
for quarter in (Q1, Q2, Q3):
step = {Q1: 0, Q2: 10, Q3: 20}[quarter]
amounts = {
"opening_debit": D(100 + step) + D("0.05"),
"turnover_debit": D("10.10"),
"closing_credit": D("7.07"),
}
documents.append(
await make_confirmed_osv(
session, entity=world.a, account="60", year=YEAR, quarter=quarter, is_cumulative=False,
rows=[
{"name": "ООО Б", "inn": world.b.inn, "counterparty_id": world.b.id,
"counterparty_match_kind": CounterpartyMatchKind.INN, **amounts},
{"name": "Неизвестный контрагент", **amounts},
{"name": "ООО Внешний", **amounts},
],
)
) # fmt: skip
for quarter in (Q1, Q3) if gap else (Q1, Q2, Q3):
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")},
],
)
) # fmt: skip
for document in documents:
session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id))
await session.flush()
return world
def _url(world: World, query: str = "") -> str:
return f"/consolidations/versions/{world.version.id}/osv/export?{query}"
def _book(content: bytes) -> Any:
return load_workbook(io.BytesIO(content))
async def _export_events(session: AsyncSession) -> list[AuditEvent]:
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT).order_by(AuditEvent.occurred_at)
return list((await session.execute(stmt)).scalars())
@pytest.mark.integration
async def test_export_reads_back_with_amounts_equal_to_api(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
world = await _world(seeded)
client = await auth_client(KA_RF)
response = await client.get(_url(world))
assert response.status_code == HTTP_OK, response.text
assert response.headers["content-type"] == XLSX
book = _book(response.content)
assert book.sheetnames == [MAIN, GAPS]
sheet = book[MAIN]
rows = list(sheet.iter_rows(values_only=True))
assert list(rows[0]) == HEADER
assert len(rows) == 1 + ROWS_TOTAL # заголовок и 3 строки: «Б» без строк (пробел)
api_items = (await client.get(f"/consolidations/versions/{world.version.id}/osv")).json()["items"]
assert len(api_items) == ROWS_TOTAL
money = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit")
for item, row in zip(api_items, rows[1:], strict=True):
assert row[:6] == (item["legal_entity_name"], item["legal_entity_inn"], item["account_code"], item["period"],
item["counterparty_name"], item["counterparty_inn"]) # fmt: skip
assert [Decimal(str(value)) for value in row[6:12]] == [Decimal(item[key]) for key in money]
assert row[12:] == (item["counterparty_type"], item["counterparty_entity_name"], item["match_kind"])
assert sheet.cell(2, 7).value == pytest.approx(100.05) # 100 + 0.05 (Q1): число, не строка
for cell in sheet[2][6:12]:
assert cell.number_format == "#,##0.00" and cell.data_type == "n"
@pytest.mark.integration
async def test_gaps_sheet_lists_missing_quarters(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)
client = await auth_client(KA_RF)
book = _book((await client.get(_url(world))).content)
assert [list(row) for row in book[GAPS].iter_rows(values_only=True)] == [GAPS_HEADER, ["ООО Б", "60", "Q2"]]
events = await _export_events(seeded)
assert len(events) == 1
event = events[0]
assert (event.section, event.object_type, event.object_id) == (AuditSection.EXPORT, "consolidation_version",
world.version.id) # fmt: skip
assert event.consolidation_version_id == world.version.id
assert event.after_value == {"report": "consolidated_osv", "filters": {}, "rows": ROWS_TOTAL}
assert event.before_value is None and event.user_id is not None
@pytest.mark.integration
async def test_gap_quarters_joined_and_no_gaps_cell(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded, gap=False)
client = await auth_client(KA_RF)
book = _book((await client.get(_url(world))).content)
assert [list(row) for row in book[GAPS].iter_rows(values_only=True)] == [["пробелов нет"]]
assert len(list(book[MAIN].iter_rows())) == 1 + ROWS_TOTAL + 1 # «Б» теперь даёт строку
@pytest.mark.integration
async def test_unmapped_filter_after_reresolve_gives_header_only_and_filled_gaps(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Review Focus 5: у всех строк с `counterparty_id IS NULL` имя находится дорезолвом — несопоставленных нет."""
world = await _world(seeded)
for entry in (
await seeded.execute(select(OsvEntry).where(OsvEntry.counterparty_name == "Неизвестный контрагент"))
).scalars():
entry.counterparty_name = "ООО Внешний"
await seeded.flush()
client = await auth_client(KA_RF)
assert (await client.get(f"/consolidations/versions/{world.version.id}/osv?unmapped=true")).json()["total"] == 0
response = await client.get(_url(world, "unmapped=true"))
assert response.status_code == HTTP_OK
book = _book(response.content)
assert [list(row) for row in book[MAIN].iter_rows(values_only=True)] == [HEADER]
assert [list(row) for row in book[GAPS].iter_rows(values_only=True)] == [GAPS_HEADER, ["ООО Б", "60", "Q2"]]
(event,) = await _export_events(seeded)
assert event.after_value == {"report": "consolidated_osv", "filters": {"unmapped": True}, "rows": 0}
FILTERS = [
"",
"unmapped=true",
"unmapped=false",
"counterparty_type=GSK",
"counterparty_type=EXTERNAL&account_root=60",
"match_kind=INN&match_kind=MANUAL",
"match_kind=NAME",
"q=%D0%B2%D0%BD%D0%B5%D1%88",
"account_root=62",
]
@pytest.mark.integration
@pytest.mark.parametrize("query", FILTERS)
async def test_filters_match_page_endpoint_rows_and_order(
auth_client: AuthClientFactory, seeded: AsyncSession, query: str
) -> None:
world = await _world(seeded, gap=False)
client = await auth_client(KA_RF)
page = (await client.get(f"/consolidations/versions/{world.version.id}/osv?{query}&limit=2000")).json()
book = _book((await client.get(_url(world, query))).content)
exported = list(book[MAIN].iter_rows(min_row=2, values_only=True))
assert [(row[0], row[2], row[4], row[12]) for row in exported] == [
(item["legal_entity_name"], item["account_code"], item["counterparty_name"], item["counterparty_type"])
for item in page["items"]
]
(*_, event) = await _export_events(seeded)
assert event.after_value["rows"] == page["total"] == len(exported)
@pytest.mark.integration
async def test_filters_are_audited_as_json(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)
client = await auth_client(KA_RF)
query = f"legal_entity_id={world.a.id}&counterparty_type=GSK&match_kind=INN&match_kind=NAME&q=%D0%B1"
assert (await client.get(_url(world, query))).status_code == HTTP_OK
(event,) = await _export_events(seeded)
assert event.after_value["filters"] == {
"legal_entity_id": str(world.a.id), "counterparty_type": "GSK", "match_kind": ["INN", "NAME"], "q": "б",
} # fmt: skip
@pytest.mark.integration
@pytest.mark.parametrize("group_name", ["ГК Русский бисквит", 'ГК "Кавычки" / слэш; a=b\r\nX-Inj: 1', "ГК Ёлка ё'ё"])
async def test_content_disposition_is_ascii_and_cannot_be_broken(
auth_client: AuthClientFactory, seeded: AsyncSession, group_name: str
) -> None:
world = await _world(seeded, group_name=group_name)
client = await auth_client(KA_RF)
response = await client.get(_url(world))
assert response.status_code == HTTP_OK
header = response.headers["content-disposition"]
header.encode("ascii")
assert header.startswith("attachment; filename=\"svod-osv.xlsx\"; filename*=UTF-8''svod-osv_")
encoded = header.split("filename*=UTF-8''", 1)[1]
assert encoded.endswith("_2024-Q3_v1.xlsx")
assert all(ch.isalnum() or ch in "%_.-~" for ch in encoded), encoded # пробел, кавычки, `;`, `/`, `'` экранированы
assert len(response.headers.get_list("content-disposition")) == 1 and "x-inj" not in response.headers
@pytest.mark.integration
async def test_filename_decodes_to_group_year_quarter_version(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
world = await _world(seeded)
client = await auth_client(KA_RF)
header = (await client.get(_url(world))).headers["content-disposition"]
assert unquote(header.split("filename*=UTF-8''", 1)[1]) == "svod-osv_ГК Русский бисквит_2024-Q3_v1.xlsx"
@pytest.mark.integration
async def test_cancelled_version_is_exportable(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded, status=ConsolidationStatus.CANCELLED)
client = await auth_client(KA_RF)
response = await client.get(_url(world))
assert response.status_code == HTTP_OK
assert len(list(_book(response.content)[MAIN].iter_rows())) == 1 + ROWS_TOTAL
@pytest.mark.integration
async def test_rights_and_visibility(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)
curator = await auth_client("curator_go@armka.local")
expert = await auth_client("expert@armka.local")
foreign = await auth_client("client_manager@armka.local") # Краснодарский филиал
admin = await auth_client("admin@armka.local") # права CONSOLIDATION_VIEW у роли нет
assert (await curator.get(_url(world))).status_code == HTTP_OK
assert (await expert.get(_url(world))).status_code == HTTP_OK
hidden = await foreign.get(_url(world))
unknown = await foreign.get(f"/consolidations/versions/{uuid.uuid4()}/osv/export")
assert hidden.status_code == unknown.status_code == HTTP_NOT_FOUND
assert hidden.json() == unknown.json()
assert (await admin.get(_url(world))).status_code == HTTP_FORBIDDEN
events = await _export_events(seeded)
assert len(events) == 2 # noqa: PLR2004 - curator и expert; отказы и 404 не аудируются
@pytest.mark.integration
async def test_export_writes_only_the_audit_event(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Дорезолв не пишется в `osv_entry`, свод не хранится: меняется только число `audit_event`."""
world = await _world(seeded)
client = await auth_client(KA_RF)
before = (await seeded.execute(select(OsvEntry.id, OsvEntry.counterparty_id).order_by(OsvEntry.id))).all()
others = select(func.count()).select_from(AuditEvent).where(AuditEvent.action != AuditAction.LOGIN)
events_before = (await seeded.execute(others)).scalar_one() # вход (LOGIN) пишет клиент теста, не экспорт
assert (await client.get(_url(world))).status_code == HTTP_OK
after = (await seeded.execute(select(OsvEntry.id, OsvEntry.counterparty_id).order_by(OsvEntry.id))).all()
assert after == before
assert (await seeded.execute(others)).scalar_one() == events_before + 1
assert len(await _export_events(seeded)) == 1

View File

@ -61,6 +61,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
"/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"},
"/api/v1/consolidations/versions/{version_id}/osv/export": {"get"},
"/api/v1/consolidations/versions/{version_id}/counterparty-candidates": {"get"},
"/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty": {"post"},
}
@ -161,6 +162,17 @@ def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any
assert summary["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/OsvSummaryOut")
def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) -> None:
"""Экспорт: 200 — файл xlsx (бинарный контент), 404/422; фильтры те же, что у страницы, но без пагинации."""
export = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv/export"]["get"]
assert {"200", "404", "422"} <= set(export["responses"])
xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert export["responses"]["200"]["content"][xlsx]["schema"] == {"type": "string", "format": "binary"}
names = {parameter["name"] for parameter in export["parameters"]}
assert {"legal_entity_id", "account_root", "counterparty_type", "unmapped", "match_kind", "q"} <= names
assert not {"limit", "offset"} & names
def test_lock_and_status_routes_declare_documented_responses(schema: dict[str, Any]) -> None:
"""Взятие — 201/200 с `LockOut`, 404/409/422; снятие — 204/404/409; смена статуса — карточка, 404/409/422."""
lock = schema["paths"]["/api/v1/consolidations/versions/{version_id}/lock"]

View File

@ -236,9 +236,9 @@
- `ConsolidatedOsvService.export(version_id, f: OsvFilters, audit: RequestAudit) -> tuple[bytes, str filename]`; аудит `EXPORT` (`section = EXPORT`, `after = {report: "consolidated_osv", filters: f.model_dump(exclude_none=True), rows: len(rows)}`).
- Маршрут `GET …/osv/export` → `Response(content, media_type=…, headers={"Content-Disposition": f'attachment; filename="svod-osv.xlsx"; filename*=UTF-8\'\'{quote(name)}'})`.
- [ ] **Step 1: Тесты.** Сценарий «Экспорт читается обратно» (`openpyxl.load_workbook(BytesIO(resp.content))`: заголовки, 3 строки, `Decimal(str(cell.value))` равен API, лист «Пробелы» с `Q2`, аудит `rows = 3`); Review Focus 5 — пустой лист при `unmapped=true`; `Content-Disposition` содержит `filename*=UTF-8''`; `expert` (VIEW) → 200, без права → 403.
- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_osv_export.py -q` → PASS.
- [ ] **Step 5: Commit** `feat(consolidation): экспорт сводной ОСВ в xlsx с листом пробелов`.
- [x] **Step 1: Тесты.** Сценарий «Экспорт читается обратно» (`openpyxl.load_workbook(BytesIO(resp.content))`: заголовки, 3 строки, `Decimal(str(cell.value))` равен API, лист «Пробелы» с `Q2`, аудит `rows = 3`); Review Focus 5 — пустой лист при `unmapped=true`; `Content-Disposition` содержит `filename*=UTF-8''`; `expert` (VIEW) → 200, без права → 403.
- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_osv_export.py -q` → PASS.
- [x] **Step 5: Commit** `feat(consolidation): экспорт сводной ОСВ в xlsx с листом пробелов`.
---

View File

@ -255,3 +255,9 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/
| 68 | 2026-10-06 | `alias_moved_from` (ответ и `after_value` `OSV_COUNTERPARTY_MAP`) — один `legal_entity_id`: первый владелец по `(created_at, id)`; при нескольких владельцах (возможны только из данных до общей блокировки) удаляются все их алиасы, каждый со своим `LEGAL_ENTITY_CHANGE`; `ALIAS_TAKEN.owners` — все владельцы в том же порядке | схема `MapCounterpartyOut.alias_moved_from: UUID \| None` (Task 3) | остальные прежние владельцы видны только в `LEGAL_ENTITY_CHANGE` |
| 69 | 2026-10-06 | `MapCounterpartyOut.row` — `ConsolidatedRowOut \| null`: строка привязанного документа, которая в свод не попадает (пара с пробелом, квартальный документ при нарастающем), сопоставляется, но строки свода для ответа нет — `row = null` | спека разрешает сопоставлять любую строку привязанного ОСВ-документа; выдумывать строку свода из одной исходной нельзя | фронт обрабатывает `null` (из UI такая строка не выбирается — её нет в своде) |
| 70 | 2026-10-06 | Кандидаты: `entry_id` не строки свода версии (чужой, непривязанный, несуществующий) или имя с пустой нормализацией — рекомендаций нет, не 404; `recommended` ищется по `CounterpartyIndex.by_normalized` (имена и алиасы всего справочника; загружается только при `entry_id`); `q` обрезается по краям (`scope=all`: пустой или короче 3 — 422 `QUERY_TOO_SHORT`; `max_length` 500); ИНН — префикс без пробелов внутри `q`; алиас — подстрока `legal_entity_alias.alias`; `scope=group` = ГК консолидации ∪ периметр версии; признак — `domain.classify` | D-11 говорит «если передан»; нормализация — функция Python, в SQL её нет, а индекс — тот же, что у резолва свода | лишняя загрузка справочника на запрос с `entry_id` (как у `GET …/osv`) |
| 71 | 2026-10-06 | Экспорт: лист «Пробелы» строится по своду целиком (`Consolidated.gaps`) и от фильтров строк не зависит; при `unmapped=true` без совпадений лист «Сводная ОСВ» — только заголовок, «Пробелы» заполнен, `audit.rows = 0` (Review Focus 5) | D-14 и спека описывают лист пробелов отдельно от фильтров; пробел — свойство версии, а не строк, и фильтр не должен прятать неполноту данных | файл с фильтром показывает пробелы, не относящиеся к выбранным строкам; читателю это видно по листу |
| 72 | 2026-10-06 | Колонки листа: «Контрагент»/«ИНН контрагента» — как в файле ОСВ (`counterparty_name`/`counterparty_inn`), «Сопоставлено с» — имя ЮЛ справочника (`counterparty_entity_name`, пусто без сопоставления), «Тип контрагента» — код `GSK`/`GK`/`EXTERNAL`, «Способ сопоставления» — код `match_kind` (пусто без него); пустые значения — пустые ячейки | D-14 перечисляет колонки без значений; коды совпадают с API, и выгрузка сверяется со страницей свода построчно | если банк захочет русские подписи типа — правка одного словаря в `osv_export` |
| 73 | 2026-10-06 | Суммы пишутся `Decimal` напрямую; `openpyxl` сериализует число в XML через `float`-представление (формат xlsx — IEEE-754 double), значения свыше ~10^15 теряют младшие разряды; защит в коде нет | суммы ОСВ в рублях на три порядка меньше предела (`numeric(18,2)` — запас, не потребность); тест сверяет `Decimal(str(cell.value))` с API | сумма от 10^15 руб. в выгрузке будет округлена, в БД и API — точная |
| 74 | 2026-10-06 | Имя файла: `svod-osv_<ГК>_<год>-<Qn>_v<n>.xlsx`, имя ГК берётся как есть и целиком кодируется `quote(safe="")` (пробел, кавычки, `;`, `/`, `'`, переводы строк, кириллица) — заголовок `Content-Disposition` чистый ASCII, `filename="svod-osv.xlsx"` фиксирован; квартал — `Q1..Q4` | D-14: `<год>-<квартал>`; фиксированное ASCII-имя защищает от инъекции в заголовок | клиент без поддержки `filename*` сохранит файл как `svod-osv.xlsx` |
| 75 | 2026-10-06 | `build_workbook(c, rows, *, entity_names)`: параметр `entity_names` сохранён по плану как переопределение отображаемых имён ЮЛ по id, без записи в словаре — имя из строки/пробела; сервис передаёт `{}`, так как строки и пробелы уже несут имена | интерфейс зафиксирован планом; убирать параметр — менять контракт задачи | параметр сейчас не используется сервисом; кандидат на удаление при ревью |
| 76 | 2026-10-06 | Аудит `EXPORT` пишется после сборки книги в той же транзакции запроса: `filters = f.model_dump(mode="json", exclude_none=True)` (enum → значение, UUID → строка; пустые фильтры — `{}`), `rows` — число строк листа после фильтров; ошибка сборки откатывает и аудит; отказы (403/404) не аудируются | spec audit-trail: EXPORT — действие над версией; запись без выгруженного файла была бы ложной | при сбое после коммита (обрыв соединения при отдаче) событие остаётся, а файл клиент мог не получить |

View File

@ -51,7 +51,7 @@
- [x] 4.3 **Кандидаты и сопоставление.** `GET …/counterparty-candidates`, `POST …/osv/entries/{id}/counterparty`
(`services/counterparty_mapping.py`: алиас, `ALIAS_TAKEN`, перенос, `MANUAL`/`PROPAGATED`, ИНН-противоречие, аудит).
Тесты: все сценарии «Кандидаты», «Ручное сопоставление», блокировка/статус.
- [ ] 4.4 **Экспорт.** `services/osv_export.py` (openpyxl, два листа, `BytesIO`), `GET …/osv/export`, `Content-Disposition`,
- [x] 4.4 **Экспорт.** `services/osv_export.py` (openpyxl, два листа, `BytesIO`), `GET …/osv/export`, `Content-Disposition`,
аудит `EXPORT`. Тест: файл читается `openpyxl`, суммы равны API, лист «Пробелы».
## 5. Сквозной тест и документы