Compare commits

...

6 Commits

Author SHA1 Message Date
Andrey Epifancev
b8f0672029 docs(openspec): intercompany-matrix-ui — целевое поведение экрана (ревью #158, раунд 3)
Итоги отдаёт бэкенд (AURORA-1756/1757), результирующая таблица и её выгрузка,
«руб.», точное округление, одна сторона Дт/Кт без запроса, правило отметок
дерева по полному плану счетов, отдельные флаги выгрузки, полноэкранный режим.
Known issues — по состоянию 601a125.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:30:01 +04:00
Andrey Epifancev
718d83d6d1 test(web): тесты форматтера сумм шахматки (ревью #158, AURORA-1538)
Сдвиг единиц, точное округление без float, большие суммы, «-0,00»,
пустые значения.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:30:01 +04:00
Andrey Epifancev
0134120905 Merge origin/main в feat/chessTable (ревью #158, PR #159)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:24:11 +04:00
aep
ab49f5e83d Merge pull request 'добавлено выгрузка в xlsx для таблицы ВГО' (#159) from add-export-result-table into main 2026-10-22 11:17:36 +03:00
Andrey Epifancev
63d6c1a017 Замечания ревью #159: проверки, общие реквизиты книги, change OpenSpec
- маршрут result-table/export в EXPECTED_ROUTES, ruff format
- общие VersionExportInfo, _write_details, _group_name, _xlsx_response вместо копий шапки и ответа
- сумма компании в книге без молчаливого 0 (KeyError при пропуске в расчёте)
- своё описание 422 у маршрутов результирующей таблицы в OpenAPI
- тесты: 422 RESULT_ACCOUNTS_MISSING для экспорта без события EXPORT, схема OpenAPI экспорта
- требование перенесено из openspec/specs в change intercompany-result-table-export (+ audit-trail), README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:42:13 +04:00
PotapovaA
ea51cb35c2 добавлено выгрузка в xlsx для таблицы ВГО 2026-10-21 19:26:37 +03:00
17 changed files with 630 additions and 113 deletions

View File

@ -870,7 +870,8 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица
`GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица `GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица
`GET …/intercompany-matrix/result-table`. Контракт для фронта — `GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export`
(аудит `EXPORT`, `report = "intercompany_result_table"`). Контракт для фронта —
`../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях `../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях
`openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`. `openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`.
Расчёт читает сводную ОСВ версии, результат не хранится. Расчёт читает сводную ОСВ версии, результат не хранится.

View File

@ -14,7 +14,7 @@ from src.models.enums import AuditAction, AuditSection
from src.schemas.common import ErrorDetail from src.schemas.common import ErrorDetail
from src.schemas.consolidations import IntercompanyMatrixManualIn, IntercompanyMatrixOut, IntercompanyResultTableOut from src.schemas.consolidations import IntercompanyMatrixManualIn, IntercompanyMatrixOut, IntercompanyResultTableOut
from src.services.intercompany_matrix import MatrixExportInfo from src.services.intercompany_matrix import MatrixExportInfo
from src.services.intercompany_matrix_export import build_matrix_workbook from src.services.intercompany_matrix_export import build_matrix_workbook, build_result_table_workbook
router = APIRouter(prefix="/consolidations/versions") router = APIRouter(prefix="/consolidations/versions")
@ -23,19 +23,26 @@ ERRORS: dict[int | str, dict[str, Any]] = {
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "Версия не найдена либо недоступна"}, status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "Версия не найдена либо недоступна"},
status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": ErrorDetail, "description": "Неверный выбор счетов"}, status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": ErrorDetail, "description": "Неверный выбор счетов"},
} }
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" RESULT_ERRORS: dict[int | str, dict[str, Any]] = {
EXPORT_RESPONSES = {
**ERRORS, **ERRORS,
status.HTTP_200_OK: {"content": {XLSX_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}}, status.HTTP_422_UNPROCESSABLE_CONTENT: {
"model": ErrorDetail,
"description": "В плане счетов нет счетов статей результирующей таблицы",
},
} }
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
XLSX_CONTENT: dict[int | str, dict[str, Any]] = {
status.HTTP_200_OK: {"content": {XLSX_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}}
}
EXPORT_RESPONSES = {**ERRORS, **XLSX_CONTENT}
RESULT_EXPORT_RESPONSES = {**RESULT_ERRORS, **XLSX_CONTENT}
async def _xlsx_response(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) -> Response: def _xlsx_response(content: bytes, filename: str, *, ascii_fallback: str) -> Response:
content = await run_in_threadpool(build_matrix_workbook, matrix, info)
return Response( return Response(
content=content, content=content,
media_type=XLSX_MEDIA_TYPE, media_type=XLSX_MEDIA_TYPE,
headers={"Content-Disposition": content_disposition(info.filename, ascii_fallback="intercompany-matrix.xlsx")}, headers={"Content-Disposition": content_disposition(filename, ascii_fallback=ascii_fallback)},
) )
@ -47,7 +54,8 @@ async def _export_response(
*, *,
selection: IntercompanyMatrixManualIn | None = None, selection: IntercompanyMatrixManualIn | None = None,
) -> Response: ) -> Response:
response = await _xlsx_response(matrix, info) content = await run_in_threadpool(build_matrix_workbook, matrix, info)
response = _xlsx_response(content, info.filename, ascii_fallback="intercompany-matrix.xlsx")
await audit.record( await audit.record(
action=AuditAction.EXPORT, action=AuditAction.EXPORT,
section=AuditSection.EXPORT, section=AuditSection.EXPORT,
@ -149,7 +157,7 @@ async def export_intercompany_matrix_manual(
"/{version_id}/intercompany-matrix/result-table", "/{version_id}/intercompany-matrix/result-table",
response_model=IntercompanyResultTableOut, response_model=IntercompanyResultTableOut,
dependencies=VIEW, dependencies=VIEW,
responses=ERRORS, responses=RESULT_ERRORS,
summary="Результирующая таблица ВГО", summary="Результирующая таблица ВГО",
) )
async def get_intercompany_result_table( async def get_intercompany_result_table(
@ -158,3 +166,29 @@ async def get_intercompany_result_table(
) -> IntercompanyResultTableOut: ) -> IntercompanyResultTableOut:
"""Пять фиксированных статей: суммы по компаниям и итоги по группе.""" """Пять фиксированных статей: суммы по компаниям и итоги по группе."""
return await service.build_result_table(version_id) return await service.build_result_table(version_id)
@router.get(
"/{version_id}/intercompany-matrix/result-table/export",
response_class=Response,
dependencies=VIEW,
responses=RESULT_EXPORT_RESPONSES,
summary="Скачать результирующую таблицу ВГО в XLSX",
)
async def export_intercompany_result_table(
version_id: uuid.UUID,
service: IntercompanyMatrixServiceDep,
audit: Annotated[RequestAudit, Depends(get_request_audit)],
) -> Response:
result, info = await service.build_result_export(version_id)
content = await run_in_threadpool(build_result_table_workbook, result, info)
response = _xlsx_response(content, info.filename, ascii_fallback="intercompany-result-table.xlsx")
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": "intercompany_result_table", "articles": len(result.articles)},
)
return response

View File

@ -76,18 +76,41 @@ class MatrixSelection:
@dataclass(frozen=True) @dataclass(frozen=True)
class MatrixExportInfo: class VersionExportInfo:
"""Реквизиты версии в шапке и имени книги."""
group_name: str group_name: str
year: int year: int
quarter: PeriodQuarter quarter: PeriodQuarter
version_no: int version_no: int
def _filename(self, prefix: str) -> str:
return f"{prefix}_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx"
@dataclass(frozen=True)
class MatrixExportInfo(VersionExportInfo):
mode: Literal["ALL", "MANUAL"] mode: Literal["ALL", "MANUAL"]
debit_account_codes: tuple[str, ...] debit_account_codes: tuple[str, ...]
credit_account_codes: tuple[str, ...] credit_account_codes: tuple[str, ...]
@property @property
def filename(self) -> str: def filename(self) -> str:
return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx" return self._filename("intercompany-matrix")
@dataclass(frozen=True)
class ResultTableExportInfo(VersionExportInfo):
@property
def filename(self) -> str:
return self._filename("intercompany-result-table")
@dataclass(frozen=True)
class ResultTableBuild:
table: IntercompanyResultTableOut
version: ConsolidationVersion
consolidation: Consolidation
def _amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal: def _amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal:
@ -300,10 +323,34 @@ class IntercompanyMatrixService:
async def build_result_table(self, version_id: uuid.UUID) -> IntercompanyResultTableOut: async def build_result_table(self, version_id: uuid.UUID) -> IntercompanyResultTableOut:
"""Статичная таблица по пяти статьям""" """Статичная таблица по пяти статьям"""
consolidated, _, _, members = await self._osv_service.build_with_members(version_id) return (await self._build_result_table(version_id)).table
async def build_result_export(
self, version_id: uuid.UUID
) -> tuple[IntercompanyResultTableOut, ResultTableExportInfo]:
"""Таблица и реквизиты книги из одного расчёта свода."""
built = await self._build_result_table(version_id)
info = ResultTableExportInfo(
group_name=await self._group_name(built.consolidation),
year=built.consolidation.year,
quarter=built.consolidation.quarter,
version_no=built.version.version_no,
)
return built.table, info
async def _group_name(self, consolidation: Consolidation) -> str:
return (
await self._session.execute(
select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id)
)
).scalar_one()
async def _build_result_table(self, version_id: uuid.UUID) -> ResultTableBuild:
consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id)
accounts = await directory_repo.list_accounts(self._session) accounts = await directory_repo.list_accounts(self._session)
companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in members.values()] companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in members.values()]
return calculate_result_table(consolidated.rows, companies, accounts) result = calculate_result_table(consolidated.rows, companies, accounts)
return ResultTableBuild(result, version, consolidation)
async def build_accounts( async def build_accounts(
self, self,
@ -331,14 +378,9 @@ class IntercompanyMatrixService:
matrix, version, consolidation, accounts = await self._build_accounts( matrix, version, consolidation, accounts = await self._build_accounts(
version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids
) )
group_name = (
await self._session.execute(
select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id)
)
).scalar_one()
by_id = {account.id: account.code for account in accounts} by_id = {account.id: account.code for account in accounts}
info = MatrixExportInfo( info = MatrixExportInfo(
group_name=group_name, group_name=await self._group_name(consolidation),
year=consolidation.year, year=consolidation.year,
quarter=consolidation.quarter, quarter=consolidation.quarter,
version_no=version.version_no, version_no=version.version_no,

View File

@ -7,8 +7,8 @@ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
from openpyxl.worksheet.worksheet import Worksheet from openpyxl.worksheet.worksheet import Worksheet
from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut, IntercompanyResultTableOut
from src.services.intercompany_matrix import MatrixExportInfo from src.services.intercompany_matrix import MatrixExportInfo, ResultTableExportInfo, VersionExportInfo
from src.services.osv_export import MONEY_FORMAT from src.services.osv_export import MONEY_FORMAT
METRICS = ("Разница", "Дебет", "Кредит", "Расхождение, %") METRICS = ("Разница", "Дебет", "Кредит", "Расхождение, %")
@ -33,16 +33,17 @@ def _skipped_comment(sheet: Worksheet, row: int, column: int, count: int) -> Non
sheet.cell(row, column).comment = Comment(f"Пропущено строк ОСВ без сопоставленного счёта: {count}", "АРМ КА") sheet.cell(row, column).comment = Comment(f"Пропущено строк ОСВ без сопоставленного счёта: {count}", "АРМ КА")
def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None: def _write_details(
_text(sheet, 1, 1, "Шахматка внутригрупповых операций") sheet: Worksheet, title: str, info: VersionExportInfo, units: str, extra: tuple[tuple[str, str], ...] = ()
) -> None:
"""Заголовок и строки «реквизит — значение» над таблицей книги."""
_text(sheet, 1, 1, title)
details = ( details = (
("ГК", info.group_name), ("ГК", info.group_name),
("Период", f"{info.year}-{info.quarter.value}"), ("Период", f"{info.year}-{info.quarter.value}"),
("Версия", f"v{info.version_no}"), ("Версия", f"v{info.version_no}"),
("Единицы", "руб.; расхождение — %"), ("Единицы", units),
("Режим", "Все счета" if info.mode == "ALL" else "Выбранные счета"), *extra,
("Счета дебета", ", ".join(info.debit_account_codes) if info.mode == "MANUAL" else "Все счета"),
("Счета кредита", ", ".join(info.credit_account_codes) if info.mode == "MANUAL" else "Все счета"),
) )
for row, (label, value) in enumerate(details, start=2): for row, (label, value) in enumerate(details, start=2):
_text(sheet, row, 1, label) _text(sheet, row, 1, label)
@ -50,6 +51,20 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None:
sheet.cell(row, 1).font = Font(bold=True) sheet.cell(row, 1).font = Font(bold=True)
def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None:
_write_details(
sheet,
"Шахматка внутригрупповых операций",
info,
"руб.; расхождение — %",
(
("Режим", "Все счета" if info.mode == "ALL" else "Выбранные счета"),
("Счета дебета", ", ".join(info.debit_account_codes) if info.mode == "MANUAL" else "Все счета"),
("Счета кредита", ", ".join(info.credit_account_codes) if info.mode == "MANUAL" else "Все счета"),
),
)
def _write_values(sheet: Worksheet, row: int, column: int, cell: IntercompanyMatrixCellOut) -> None: def _write_values(sheet: Worksheet, row: int, column: int, cell: IntercompanyMatrixCellOut) -> None:
for offset, amount in enumerate((cell.variance_amount, cell.amount_a, cell.amount_b)): for offset, amount in enumerate((cell.variance_amount, cell.amount_a, cell.amount_b)):
sheet.cell(row + offset, column, amount).number_format = MONEY_FORMAT sheet.cell(row + offset, column, amount).number_format = MONEY_FORMAT
@ -115,3 +130,41 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo)
buffer = BytesIO() buffer = BytesIO()
workbook.save(buffer) workbook.save(buffer)
return buffer.getvalue() return buffer.getvalue()
def build_result_table_workbook(result: IntercompanyResultTableOut, info: ResultTableExportInfo) -> bytes:
workbook = Workbook()
sheet = workbook.active
assert sheet is not None
sheet.title = "Результирующая таблица"
_write_details(sheet, "Результирующая таблица ВГО", info, "руб.")
header_row = 7
_text(sheet, header_row, 1, "Статья ВГО")
sheet.column_dimensions["A"].width = 42
for column, company in enumerate(result.companies, start=2):
_text(sheet, header_row, column, company.name)
sheet.column_dimensions[get_column_letter(column)].width = 26
total_column = len(result.companies) + 2
_text(sheet, header_row, total_column, "Итого по группе")
sheet.column_dimensions[get_column_letter(total_column)].width = 26
sheet.row_dimensions[header_row].height = 36
for column in range(1, total_column + 1):
cell = sheet.cell(header_row, column)
cell.font = Font(bold=True)
cell.fill = HEADER_FILL
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
for row, article in enumerate(result.articles, start=header_row + 1):
_text(sheet, row, 1, article.name)
amounts = {amount.company_id: amount.amount for amount in article.amounts}
for column, company in enumerate(result.companies, start=2):
sheet.cell(row, column, amounts[company.id]).number_format = MONEY_FORMAT
total = sheet.cell(row, total_column, article.group_total)
total.number_format = MONEY_FORMAT
total.font = Font(bold=True)
sheet.freeze_panes = "B8"
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()

View File

@ -27,7 +27,7 @@ from tests.factories import make_company_group, make_confirmed_osv, make_consoli
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422 OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422
KA_RF = "ka_rf@armka.local" KA_RF = "ka_rf@armka.local"
ROUTES = ("all", "manual", "result-table", "all/export", "manual/export") ROUTES = ("all", "manual", "result-table", "all/export", "manual/export", "result-table/export")
@dataclass @dataclass
@ -220,15 +220,39 @@ async def test_invalid_account_selection(
assert response.json()["detail"]["code"] == code assert response.json()["detail"]["code"] == code
async def test_result_accounts_missing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: @pytest.mark.parametrize("suffix", ["result-table", "result-table/export"])
async def test_result_accounts_missing(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None:
world = await _world(seeded) world = await _world(seeded)
# Меняем код в тестовой транзакции, сохраняя FK существующих правил и потомков. # Меняем код в тестовой транзакции, сохраняя FK существующих правил и потомков.
world.accounts["58.03"].code = "58.03-missing" world.accounts["58.03"].code = "58.03-missing"
await seeded.flush() await seeded.flush()
response = await _request(await auth_client(KA_RF), world.version.id, "result-table", world.selection) response = await _request(await auth_client(KA_RF), world.version.id, suffix, world.selection)
assert response.status_code == UNPROCESSABLE, response.text assert response.status_code == UNPROCESSABLE, response.text
assert response.json()["detail"]["code"] == "RESULT_ACCOUNTS_MISSING" assert response.json()["detail"]["code"] == "RESULT_ACCOUNTS_MISSING"
assert "58.03" in response.json()["detail"]["message"] assert "58.03" in response.json()["detail"]["message"]
assert not (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).all()
async def test_result_table_export_matches_json(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)
await _source(seeded, world, credit=False, amount="100.25")
client = await auth_client(KA_RF)
result = await _request(client, world.version.id, "result-table", world.selection)
export = await _request(client, world.version.id, "result-table/export", world.selection)
assert result.status_code == export.status_code == OK
data = result.json()
sheet = load_workbook(BytesIO(export.content))["Результирующая таблица"]
assert [sheet.cell(7, col).value for col in range(2, len(data["companies"]) + 2)] == [
company["name"] for company in data["companies"]
]
for row, article in enumerate(data["articles"], start=8):
assert sheet.cell(row, 1).value == article["name"]
amounts = {item["company_id"]: Decimal(item["amount"]) for item in article["amounts"]}
for col, company in enumerate(data["companies"], start=2):
assert Decimal(str(sheet.cell(row, col).value)) == amounts[company["id"]]
assert Decimal(str(sheet.cell(row, len(data["companies"]) + 2).value)) == Decimal(article["group_total"])
event = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
assert event.after_value == {"report": "intercompany_result_table", "articles": len(data["articles"])}
@pytest.mark.parametrize("mode", ["all", "manual"]) @pytest.mark.parametrize("mode", ["all", "manual"])

View File

@ -12,6 +12,7 @@ from openpyxl import load_workbook
from src.api.endpoints.consolidations.intercompany_matrix import ( from src.api.endpoints.consolidations.intercompany_matrix import (
export_intercompany_matrix_all, export_intercompany_matrix_all,
export_intercompany_matrix_manual, export_intercompany_matrix_manual,
export_intercompany_result_table,
router, router,
) )
from src.models.enums import AuditAction, AuditSection, PeriodQuarter from src.models.enums import AuditAction, AuditSection, PeriodQuarter
@ -20,9 +21,18 @@ from src.schemas.consolidations import (
IntercompanyMatrixCompanyOut, IntercompanyMatrixCompanyOut,
IntercompanyMatrixManualIn, IntercompanyMatrixManualIn,
IntercompanyMatrixOut, IntercompanyMatrixOut,
IntercompanyResultAmountOut,
IntercompanyResultArticleOut,
IntercompanyResultTableOut,
)
from src.services.intercompany_matrix import MatrixExportInfo, ResultTableExportInfo
from src.services.intercompany_matrix_export import (
DIAGONAL_FILL,
METRICS,
THRESHOLD_FILL,
build_matrix_workbook,
build_result_table_workbook,
) )
from src.services.intercompany_matrix import MatrixExportInfo
from src.services.intercompany_matrix_export import DIAGONAL_FILL, METRICS, THRESHOLD_FILL, build_matrix_workbook
def _info(*, mode: str = "MANUAL") -> MatrixExportInfo: def _info(*, mode: str = "MANUAL") -> MatrixExportInfo:
@ -229,3 +239,85 @@ async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.Mon
with pytest.raises(ValueError, match="xlsx failed"): with pytest.raises(ValueError, match="xlsx failed"):
await export_intercompany_matrix_all(version_id, service, audit) await export_intercompany_matrix_all(version_id, service, audit)
audit.record.assert_not_awaited() audit.record.assert_not_awaited()
async def test_result_table_export_matches_result_and_records_audit() -> None:
version_id = UUID("11111111-1111-1111-1111-111111111111")
other_id = UUID("22222222-2222-2222-2222-222222222222")
result = IntercompanyResultTableOut(
companies=[
IntercompanyMatrixCompanyOut(id=version_id, name="=Компания А"),
IntercompanyMatrixCompanyOut(id=other_id, name="Компания Б"),
],
articles=[
IntercompanyResultArticleOut(
code="receivables",
name="Дебиторская задолженность",
amounts=[
IntercompanyResultAmountOut(company_id=version_id, amount="100.25"),
IntercompanyResultAmountOut(company_id=other_id, amount="20.00"),
],
group_total="120.25",
)
],
)
info = ResultTableExportInfo('ГК "Кавычки" / Бисквит', 2024, PeriodQuarter.Q3, 2)
service = SimpleNamespace(build_result_export=AsyncMock(return_value=(result, info)))
audit = SimpleNamespace(record=AsyncMock())
response = await export_intercompany_result_table(version_id, service, audit)
service.build_result_export.assert_awaited_once_with(version_id)
assert response.media_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert unquote(response.headers["content-disposition"].split("filename*=UTF-8''", 1)[1]) == info.filename
sheet = load_workbook(BytesIO(response.body))["Результирующая таблица"]
assert [sheet[f"B{row}"].value for row in range(2, 6)] == [info.group_name, "2024-Q3", "v2", "руб."]
assert [sheet.cell(7, column).value for column in range(1, 5)] == [
"Статья ВГО",
"=Компания А",
"Компания Б",
"Итого по группе",
]
assert sheet["B7"].data_type == "s"
assert [sheet.cell(8, column).value for column in range(1, 5)] == ["Дебиторская задолженность", 100.25, 20, 120.25]
assert all(sheet.cell(8, column).number_format == "#,##0.00" for column in range(2, 5))
assert sheet.freeze_panes == "B8"
assert audit.record.await_args.kwargs == {
"action": AuditAction.EXPORT,
"section": AuditSection.EXPORT,
"object_type": "consolidation_version",
"object_id": version_id,
"consolidation_version_id": version_id,
"after_value": {"report": "intercompany_result_table", "articles": 1},
}
assert ("/consolidations/versions/{version_id}/intercompany-matrix/result-table/export", ("GET",)) in {
(route.path, tuple(sorted(route.methods))) for route in router.routes
}
async def test_failed_result_table_export_does_not_write_audit(monkeypatch: pytest.MonkeyPatch) -> None:
version_id = UUID("11111111-1111-1111-1111-111111111111")
info = ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2)
service = SimpleNamespace(
build_result_export=AsyncMock(return_value=(IntercompanyResultTableOut(companies=[], articles=[]), info))
)
audit = SimpleNamespace(record=AsyncMock())
def fail_workbook(_result: IntercompanyResultTableOut, _info: ResultTableExportInfo) -> bytes:
raise ValueError("xlsx failed")
monkeypatch.setattr(
"src.api.endpoints.consolidations.intercompany_matrix.build_result_table_workbook", fail_workbook
)
with pytest.raises(ValueError, match="xlsx failed"):
await export_intercompany_result_table(version_id, service, audit)
audit.record.assert_not_awaited()
def test_result_table_workbook_rejects_article_without_company_amount() -> None:
company = IntercompanyMatrixCompanyOut(id=UUID("11111111-1111-1111-1111-111111111111"), name="Компания А")
result = IntercompanyResultTableOut(
companies=[company],
articles=[IntercompanyResultArticleOut(code="receivables", name="ДЗ", amounts=[], group_total="0")],
)
with pytest.raises(KeyError):
build_result_table_workbook(result, ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2))

View File

@ -80,6 +80,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual/export": {"post"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual/export": {"post"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export": {"get"},
} }
# Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно
@ -191,6 +192,17 @@ def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any])
assert not {"limit", "offset"} & names assert not {"limit", "offset"} & names
def test_result_table_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) -> None:
"""Экспорт результирующей таблицы: 200 — xlsx, 422 — нет счетов статей, а не «неверный выбор счетов»."""
base = "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table"
export = schema["paths"][f"{base}/export"]["get"]["responses"]
xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert export["200"]["content"][xlsx]["schema"] == {"type": "string", "format": "binary"}
for responses in (export, schema["paths"][base]["get"]["responses"]):
assert responses["404"]["description"] == "Версия не найдена либо недоступна"
assert responses["422"]["description"] == "В плане счетов нет счетов статей результирующей таблицы"
def test_checklist_route_declares_json_and_csv_response(schema: dict[str, Any]) -> None: def test_checklist_route_declares_json_and_csv_response(schema: dict[str, Any]) -> None:
"""T12.2: чек-лист — 200 как JSON (`ChecklistOut`) и как CSV-файл (бинарный текст), 404/422.""" """T12.2: чек-лист — 200 как JSON (`ChecklistOut`) и как CSV-файл (бинарный текст), 404/422."""
checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"] checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"]

View File

@ -1,55 +1,55 @@
## Context ## Context
Экран «Шахматка» [31-01] добавлен в `arm-core/web` PR #158 (ветка `feat/chessTable`) как вторая вкладка страницы отчёта по Экран «Шахматка» [31-01] добавлен в `arm-core/web` PR #158 (ветка `feat/chessTable`, AURORA-1538) как вторая вкладка страницы
версии. Страница `layouts/ConsolidatedOsvPage.tsx` разделена: общую шапку и вкладки держит `layouts/ConsolidatioReportPage.tsx`, отчёта по версии. Страница `layouts/ConsolidatedOsvPage.tsx` разделена: общую шапку и вкладки держит
содержимое вкладок — `widgets/ConsolidatedOsvContent` (прежняя сводная ОСВ без изменений поведения) и `layouts/ConsolidationReportPage.tsx`, содержимое вкладок — `widgets/ConsolidatedOsvContent` (прежняя сводная ОСВ без изменений
`widgets/ConsolidatedChessContent`. Бэкенд шахматки — change `2026-10-16-intercompany-matrix`; расчёт — чистая функция поведения) и `widgets/ConsolidatedChessContent` (матрица `ConsolidationChessTable` и результирующая таблица
версии, ничего не хранится. `ConsolidationChessResTable`). Бэкенд — change `2026-10-16-intercompany-matrix` (матрица, результирующая таблица) и
`2026-10-22-intercompany-result-table-export` (выгрузка результирующей таблицы); расчёт — чистая функция версии, ничего не
хранится.
## Decisions (как устроено в ветке) ## Decisions
- **D-1. Маршрут `:tab`.** `OsvTabs` берёт активную вкладку из `useParams().tab` (по умолчанию `osv`) и по выбору заменяет - **D-1. Маршрут `:tab`.** `OsvTabs` берёт активную вкладку из `useParams().tab` (по умолчанию `osv`) и по выбору заменяет
последний сегмент адреса. Страница выбирает содержимое по таблице `tab → компонент`; неизвестный `tab` даёт пустое содержимое. последний сегмент адреса. Неизвестный `tab` заменяется на `osv` (`navigate(…, {replace: true})`).
- **D-2. Ключ версии.** Страница отчёта разделена на обёртку с `key={version_id}` и содержимое: при смене версии карточка, - **D-2. Ключ версии.** Страница отчёта разделена на обёртку с `key={version_id}` и содержимое: при смене версии карточка,
фильтры и данные создаются заново (как было в `ConsolidatedOsvPage`). фильтры и данные создаются заново (как было в `ConsolidatedOsvPage`).
- **D-3. Режим `all` / `manual` выводится из выбора счетов:** `manual` только при непустых Дт и Кт (иначе бэкенд ответит 422), - **D-3. Режим расчёта из выбора счетов:** `manual` — при непустых Дт и Кт, `all` — при пустых обоих; при одной выбранной
в остальных случаях `all`. Один и тот же признак выбирает маршрут экспорта. стороне запроса нет (бэкенд ответил бы 422 на `manual`, а `all` показал бы не то, что выбрано). Тот же признак выбирает
маршрут экспорта.
- **D-4. Единицы, знаки и фильтр компаний — на клиенте.** Запрос повторяется только при смене выбора счетов или версии. - **D-4. Единицы, знаки и фильтр компаний — на клиенте.** Запрос повторяется только при смене выбора счетов или версии.
Единицы — сдвиг запятой в десятичной строке (`utils/chessTableCellFormatValue.ts`), `Intl.NumberFormat` получает строку и
форматирует её как точное десятичное (без float).
- **D-5. План счетов** — `GET /directories/accounts` целиком, деревья Дт и Кт строятся на клиенте по `parent_id`. - **D-5. План счетов** — `GET /directories/accounts` целиком, деревья Дт и Кт строятся на клиенте по `parent_id`.
- **D-6. `apiFetchBlob(path, init)`** принимает `RequestInit`, чтобы выгружать POST-ом с телом; без `init` поведение прежнее. - **D-6. `apiFetchBlob(path, init)`** принимает `RequestInit`, чтобы выгружать POST-ом с телом; без `init` поведение прежнее.
- **D-7. Полноэкранный режим** — Fullscreen API на контейнере блока шахматки, состояние следует за `fullscreenchange`. - **D-7. Полноэкранный режим** — Fullscreen API на контейнере блока, состояние следует за `fullscreenchange`; выпадающие окна
antd рендерятся в `document.fullscreenElement` (`ConfigProvider getPopupContainer`).
- **D-8. Итоги отдаёт бэкенд.** Решение владельца 22.10.2026: все агрегаты (строки «Итого») считает API, фронт сумм не
складывает. Пока API их не отдаёт, строк «Итого» под матрицей и под результирующей таблицей нет; колонка «Итого» результирующей
таблицы — `group_total` из ответа. Итоги в API и правило суммирования — AURORA-1756 (бэк, ждёт аналитику AURORA-1373), вывод на
экран — AURORA-1757.
## Known issues ## Known issues
Открытые проблемы ветки, которые исправляет автор PR; спека их НЕ закрепляет как требования. Открытые проблемы ветки на 601a125 (ревью, раунд 3), которые исправляет автор PR; спека описывает целевое поведение.
1. Глобальный CSS (`App.css`, `ConsolidatedChessTable.css`, `TreeFilterDropdown.css`) с `!important` на классах antd — влияет на 1. Строки «Итого» под матрицей и под результирующей таблицей считаются на клиенте (`sumAmounts`) — убрать (D-8).
все таблицы и деревья приложения; стили должны быть локальными (CSS-модули или `className` экрана). 2. Поиск в дереве счетов: отметки считаются по отфильтрованному дереву, отметка единственного видимого субсчёта отмечает
2. В полноэкранном режиме выпадающие списки фильтров (Компании, Дт, Кт, Единицы, Знаки) не видны: popup рендерится в `body`, родителя, и на бэк уходит весь родительский счёт (`TreeFilterDropdown.tsx`).
вне полноэкранного элемента; нужен `getPopupContainer` на контейнер шахматки. 3. Выбрана одна сторона Дт/Кт: таблица показывает прежние данные, подсказка — всплывающее уведомление и постоянный текст внутри
3. Суммы форматируются через `number` / `parseFloat` / `toFixed` (float в денежном домене), без разделителей тысяч и без «руб.»; выпадающего окна, «Экспорт» активен и выгружает режим `all`.
`utils/money.ts` работает со строками — использовать его. Деление на единицы делать над десятичной строкой. 4. Стили: правило тегов в `App.css` и класс на форме ЮЛ (`CatalogueJurForm.tsx`) — вне экрана; у селекта Кт класса нет;
4. Строка «Итого» суммирует расхождения (`variance_amount`), а в макете 31-01 в итогах — верхние числа (суммы стороны A); итоги нужны `.resRow`, `.empty-cell` не под `.chessTable`; у `Tree` снят `className='filterTree'`.
по каждой из трёх величин либо по макету. Знак `(…)` разбирается эвристикой `parseAmount`, хотя API отдаёт десятичные строки. 5. Желательно: запасные имена файлов (`exportFilename` добавляет `Сводная_ОСВ_`) и общий `exportLoading` на две кнопки;
5. Подписи дочерних счетов в выборе Дт/Кт — UUID: `Select` получает `options` только корневых узлов, выбранные дочерние ключи нечисловое значение в тыс./млн/млрд искажается (`0.abc`); `sumAmounts(...) ?? '0'`; `as any` в форматтере; мёртвый код
выводятся как идентификаторы; нужны подписи «код - название» для любых узлов. (скобки в форматтере, `formatted === '—'`); ячейки по макету (скобки у расхождения, «—», значок `skipped_osv_entry_count`);
6. Выбор счетов только с одной стороны молча даёт режим `all`: пользователь видит выбранный счёт, но данные по всем счетам; заглушки «Год»/«Квартал»; `services/consodilation`, типы вне `types/api.ts`, `withJsonBody`, `Account.kind`.
нужны подсказка/блокировка либо явное поведение.
7. Гонка ответов: `loadData` не отменяет и не отбрасывает устаревшие ответы (последний пришедший побеждает, не последний
запрошенный); нужен `AbortController` или счётчик запросов. Эффект зависит от `loadData`/`notification` не по правилам хуков.
8. Нет результирующей таблицы ВГО (`GET …/intercompany-matrix/result-table`), пресетов выбора счетов и выбора года/квартала
(заглушки `disabled` в панели фильтров), экран макета 31-01 реализован не полностью.
9. Прочее по ревью: `any` в таблице и дереве, `console.log` в обработчике полноэкранного режима, ошибки не `ApiError` молча
глотаются (`loadData`, `download`), экспорт не передаёт единицы/знаки/компании, пустой `Select` для года/квартала,
опечатки в именах (`ConsolidatioReportPage`, `consodilation`, `expoerLoading`).
После исправления дефектов требования соответствующих разделов спеки уточняются отдельным change.
## Decisions made without discussion ## Decisions made without discussion
- **2026-10-21 (а). Спеку пишет ревьюер в ветке автора** — по решению владельца (разрешено явно); альтернатива — оставить - **2026-10-21 (а). Спеку пишет ревьюер в ветке автора** — по решению владельца (разрешено явно); альтернатива — оставить
автору. Цена ошибки: автор может не согласиться с формулировками; правится одним коммитом в этом же change. автору. Цена ошибки: автор может не согласиться с формулировками; правится одним коммитом в этом же change.
- **2026-10-21 (б). Спека описывает фактическое поведение, дефекты вынесены в «Known issues».** Обоснование: спека — единственный - **2026-10-21 (б). Спека описывает фактическое поведение, дефекты вынесены в «Known issues».** Отменено 22.10.2026 (е). Обоснование: спека — единственный
источник истины при рулингах, а закреплять баги требованиями нельзя; иначе их закрытие превратится в «нарушение спеки». Цена источник истины при рулингах, а закреплять баги требованиями нельзя; иначе их закрытие превратится в «нарушение спеки». Цена
ошибки: отдельные требования (например, режим `all` при выборе одной стороны, «Итого» из расхождений) описаны как есть ошибки: отдельные требования (например, режим `all` при выборе одной стороны, «Итого» из расхождений) описаны как есть
и после исправления потребуют MODIFIED. и после исправления потребуют MODIFIED.
@ -65,3 +65,18 @@
- **2026-10-21 (д). Дельта к `consolidated-osv-ui` не делается:** вкладки описаны в новой спеке, текст - **2026-10-21 (д). Дельта к `consolidated-osv-ui` не делается:** вкладки описаны в новой спеке, текст
`consolidated-osv-ui` не правился, чтобы не смешивать UI-половины. Цена ошибки: при архивации две спеки частично описывают `consolidated-osv-ui` не правился, чтобы не смешивать UI-половины. Цена ошибки: при архивации две спеки частично описывают
один компонент (`OsvTabs`). один компонент (`OsvTabs`).
- **2026-10-22 (е). Спека переписана на целевое поведение** (раунд 3 ревью): владелец решил, что спеки и тесты чужих PR ведём
мы, а автор правит продуктовый код; целевая спека даёт автору однозначный ориентир, расхождения кода — в «Known issues».
Цена ошибки: до исправлений спека и код ветки расходятся; PR не сливается, пока «Known issues» 1–4 не закрыты.
- **2026-10-22 (ж). Выбрана одна сторона Дт/Кт — пустая таблица с текстом** «Для расчёта по счетам выберите счета и в Дт,
и в Кт» у таблицы и у фильтров, «Экспорт» матрицы недоступен. Альтернатива — оставить прежние данные с подсказкой; отвергнута:
на экране были бы данные, не соответствующие фильтру. Цена ошибки: правка текста и одного условия.
- **2026-10-22 (з). Правило отметок дерева по полному плану счетов** (родитель отмечен ⇔ отмечены все потомки), в том числе при
поиске. Обоснование: бэкенд разворачивает выбранный счёт в потомков, лишняя отметка родителя меняет расчёт. Цена ошибки: если
аналитик захочет «родитель + выбранные субсчета» как отдельный режим — MODIFIED требования.
- **2026-10-22 (и). Запасные имена файлов** `Шахматка_<ГК>_<год>_<квартал>.xlsx` и
`Результирующая_таблица_ВГО_<ГК>_<год>_<квартал>.xlsx`. Цена ошибки: низкая, имя из `Content-Disposition` приоритетнее.
- **2026-10-22 (к). Тесты форматтера** `utils/chessTableCellFormatValue.test.ts` написаны ревьюером: проверяют сдвиг единиц,
точное округление, большие суммы, «-0,00» и пустые значения; случай нечислового значения в тыс./млн/млрд не покрыт — он
в Known issues (5) и тест добавляется вместе с исправлением.
- **2026-10-22 (л). В ветку влит `main`** (PR #159), чтобы выгрузка результирующей таблицы работала на стенде из ветки.

View File

@ -2,27 +2,30 @@
В ветке `feat/chessTable` (PR RSHB/arm-core#158, автор — Голов) во фронте появился экран «Шахматка» — вкладка страницы В ветке `feat/chessTable` (PR RSHB/arm-core#158, автор — Голов) во фронте появился экран «Шахматка» — вкладка страницы
отчёта по версии консолидации рядом со «Сводной ОСВ». Бэкенд шахматки (`GET/POST …/intercompany-matrix/*`) уже в main отчёта по версии консолидации рядом со «Сводной ОСВ». Бэкенд шахматки (`GET/POST …/intercompany-matrix/*`) уже в main
(change `2026-10-16-intercompany-matrix`), у экрана спеки нет. Спеку пишет ревьюер в ветке автора по решению владельца (change `2026-10-16-intercompany-matrix`), у экрана спеки нет. Спеку и тесты ведёт ревьюер в ветке автора по решению
(21.10.2026): она фиксирует фактическое поведение экрана и отделяет от него открытые дефекты (`design.md`, «Known issues»), владельца (21–22.10.2026): спека задаёт целевое поведение экрана, расхождения кода ветки с ней — `design.md`, «Known issues».
чтобы дефекты не закрепились требованием.
## What Changes ## What Changes
Репозиторий — только arm-core (`web/`). Экран «Шахматка» [31-01], эндпоинты arm-core: Репозиторий — только arm-core (`web/`). Экран «Шахматка» [31-01], эндпоинты arm-core:
`GET …/intercompany-matrix/all`, `POST …/intercompany-matrix/manual`, `GET …/intercompany-matrix/all/export`, `GET …/intercompany-matrix/all`, `POST …/intercompany-matrix/manual`, `GET …/intercompany-matrix/all/export`,
`POST …/intercompany-matrix/manual/export`, `GET /directories/accounts`. `POST …/intercompany-matrix/manual/export`, `GET …/intercompany-matrix/result-table`,
`GET …/intercompany-matrix/result-table/export`, `GET /directories/accounts`.
- Маршрут `/groups/:group_id/versions/:version_id/:tab`: страница отчёта (`ConsolidatioReportPage`) с общей шапкой и - Маршрут `/groups/:group_id/versions/:version_id/:tab`: страница отчёта (`ConsolidationReportPage`) с общей шапкой и
вкладками (`OsvTabs`); `tab = osv` — сводная ОСВ, `tab = chess` — шахматка. Прежний адрес `…/osv` продолжает работать. вкладками (`OsvTabs`); `tab = osv` — сводная ОСВ, `tab = chess` — шахматка. Прежний адрес `…/osv` продолжает работать.
- Таблица ЮЛ × ЮЛ: в ячейке три числа (сумма A, сумма Б, расхождение), строка «Итого». - Таблица ЮЛ × ЮЛ: в ячейке три числа (сумма A, сумма Б, расхождение); итоги считает бэкенд (пока API их не отдаёт —
строки «Итого» нет).
- Результирующая таблица ВГО по статьям с колонкой «Итого» (`group_total`) и выгрузкой в XLSX.
- Фильтры: компании, счета Дт, счета Кт (деревом), единицы, знаков после запятой; режимы расчёта `all` / `manual`. - Фильтры: компании, счета Дт, счета Кт (деревом), единицы, знаков после запятой; режимы расчёта `all` / `manual`.
- Экспорт в XLSX (`all` / `manual`), полноэкранный режим. - Экспорт в XLSX (`all` / `manual`, результирующая таблица), полноэкранный режим.
- Новая спека `intercompany-matrix-ui` (ADDED Requirements). - Новая спека `intercompany-matrix-ui` (ADDED Requirements).
## Non-goals ## Non-goals
- Исправление дефектов экрана (перечень — `design.md`, «Known issues»): они остаются автору PR. - Исправление продуктового кода по «Known issues» (`design.md`) — за автором PR; спеку и тесты ведёт ревьюер.
- Результирующая таблица ВГО (`GET …/intercompany-matrix/result-table`) и пресеты счетов — во фронте не реализованы. - Пресеты счетов — во фронте не реализованы.
- Итоги «Итого» в API и их вывод — AURORA-1756, AURORA-1757.
- Выбор года и квартала на экране: селекторы заглушены (`disabled`), версия задаётся адресом. - Выбор года и квартала на экране: селекторы заглушены (`disabled`), версия задаётся адресом.
- Изменения бэкенда и DBML: шахматка — чистая функция от версии, ничего не хранится (DECISION-5). - Изменения бэкенда и DBML: шахматка — чистая функция от версии, ничего не хранится (DECISION-5).
- Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий», «Подтверждение КФО клиента» — недоступны. - Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий», «Подтверждение КФО клиента» — недоступны.
@ -38,8 +41,9 @@
## Impact ## Impact
- `web/src/App.tsx`, `web/src/layouts/{ConsolidatioReportPage,index}.ts(x)`, `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx`, - `web/src/App.tsx`, `web/src/layouts/{ConsolidationReportPage,index}.ts(x)`, `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx`,
`web/src/widgets/ConsolidatedOsvContent/`, `web/src/widgets/ConsolidatedChessContent/`, `web/src/widgets/ConsolidatedOsvContent/`, `web/src/widgets/ConsolidatedChessContent/`,
`web/src/widgets/ConsolidationChessTable/`, `web/src/components/TreeFilterDropdown/`, `web/src/widgets/ConsolidationChessTable/`, `web/src/widgets/ConsolidationChessResTable/`,
`web/src/services/consodilation/chess.ts`, `web/src/types/chess/chess.ts`, `web/src/api/client.ts` (`apiFetchBlob` принимает `init`). `web/src/widgets/ConsolidationChessFormatTableFilter/`, `web/src/components/TreeFilterDropdown/`,
`web/src/utils/chessTableCellFormatValue.ts`, `web/src/services/consodilation/intercompanyMatrix.ts`, `web/src/types/chess/chess.ts`, `web/src/api/client.ts` (`apiFetchBlob` принимает `init`).
- Права: экран читает данные под `CONSOLIDATION_VIEW`; меню по правам не скрывается. - Права: экран читает данные под `CONSOLIDATION_VIEW`; меню по правам не скрывается.

View File

@ -4,9 +4,10 @@
Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз
и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`: и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`:
`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка». Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки `osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка». Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки
(навигация, не локальное состояние), активная вкладка подсвечена зелёным и выделена жирным. Вкладки «Таблица корректировки», (навигация, не локальное состояние), активная вкладка подсвечена зелёным и выделена жирным. Неизвестный ключ вкладки SHALL
«Гудвилл», «КФО», «КФО Общий», «Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». заменяться на `osv` без новой записи в истории браузера. Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий»,
При смене `version_id` всё состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново. «Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». При смене `version_id` всё
состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново.
#### Scenario: Переход на шахматку #### Scenario: Переход на шахматку
- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка» - **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка»
@ -16,14 +17,18 @@
- **WHEN** открыт адрес `…/versions/{id}/osv` - **WHEN** открыт адрес `…/versions/{id}/osv`
- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна - **THEN** показана сводная ОСВ, вкладка «ОСВ» активна
#### Scenario: Неизвестная вкладка
- **WHEN** открыт адрес `…/versions/{id}/abc`
- **THEN** адрес заменяется на `…/versions/{id}/osv`, показана сводная ОСВ; кнопка «Назад» браузера не возвращает на `…/abc`
### Requirement: Таблица шахматки ЮЛ × ЮЛ ### Requirement: Таблица шахматки ЮЛ × ЮЛ
Экран SHALL загружать матрицу при открытии вкладки (`GET /consolidations/versions/{id}/intercompany-matrix/all`) и показывать Экран SHALL загружать матрицу при открытии вкладки (`GET /consolidations/versions/{id}/intercompany-matrix/all`) и показывать
таблицу: первая колонка «ДБ / КТ» (закреплена слева) — компании периметра в порядке ответа (`companies[]`), остальные колонки — таблицу: первая колонка «ДБ / КТ» (закреплена слева) — компании периметра в порядке ответа (`companies[]`), остальные колонки —
те же компании. В ячейке пары (строка A, колонка Б) SHALL быть три числа друг под другом: сумма `amount_a`, сумма `amount_b` те же компании. В ячейке пары (строка A, колонка Б) SHALL быть три числа друг под другом: сумма `amount_a`, сумма `amount_b`
и расхождение `variance_amount`; расхождение выделяется красным, если `exceeds_threshold = true`. Если ячейки пары в ответе нет и расхождение `variance_amount`; расхождение выделяется красным, если `exceeds_threshold = true`. Если ячейки пары в ответе нет
(в том числе на диагонали), SHALL показываться прочерк «—» на приглушённом фоне. Ниже таблицы SHALL закрепляться строка «Итого» (в том числе на диагонали), SHALL показываться прочерк «—» на приглушённом фоне. Итоговые суммы (строка «Итого») экран SHALL
с одним числом по каждой колонке. Пока идёт загрузка, таблица показывает индикатор загрузки; ошибка API показывается NOT вычислять сам: агрегаты отдаёт бэкенд, пока API их не возвращает — строки «Итого» под матрицей нет. Пока идёт загрузка,
уведомлением с текстом сервера. таблица показывает индикатор загрузки; ошибка API показывается уведомлением с текстом сервера.
#### Scenario: Ячейка с расхождением выше порога #### Scenario: Ячейка с расхождением выше порога
- **WHEN** для пары `(A, Б)` сервер вернул `exceeds_threshold = true` - **WHEN** для пары `(A, Б)` сервер вернул `exceeds_threshold = true`
@ -33,36 +38,79 @@
- **WHEN** в `cells[]` нет ячейки для пары компаний - **WHEN** в `cells[]` нет ячейки для пары компаний
- **THEN** на их пересечении показан прочерк «—» - **THEN** на их пересечении показан прочерк «—»
#### Scenario: Итоги не считаются на клиенте
- **WHEN** матрица загружена
- **THEN** под таблицей нет строки «Итого»; ни одна сумма на экране не получена сложением сумм ответа на клиенте
### Requirement: Результирующая таблица ВГО
Под матрицей экран SHALL показывать результирующую таблицу ВГО, загруженную при открытии вкладки
(`GET /consolidations/versions/{id}/intercompany-matrix/result-table`): первая колонка «Статья» — статьи ответа (`articles[]`,
`name`) в порядке ответа, далее колонки компаний (`companies[]`) с суммой статьи по компании (`amounts[]` по `company_id`) и
колонка «Итого» со значением `group_total` из ответа. Строки «Итого» под таблицей нет, пока API не отдаёт итог по статьям
(клиент суммы не складывает). У таблицы свои выбор единиц и знаков (по правилам требования «Единицы и знаки», независимо от
матрицы), кнопка «Экспорт» и кнопка «На весь экран». Пока идёт загрузка, таблица показывает индикатор; ошибка API (в том числе
422 `RESULT_ACCOUNTS_MISSING`) показывается уведомлением с текстом сервера.
#### Scenario: Колонка «Итого» из ответа
- **WHEN** сервер вернул статью «Дебиторская задолженность» с `group_total = "1296510.00"`, выбраны «тыс. руб.» и 2 знака
- **THEN** в колонке «Итого» этой статьи показано `1 296,51`
#### Scenario: Нет обязательного счёта
- **WHEN** сервер ответил 422 `RESULT_ACCOUNTS_MISSING`
- **THEN** показано уведомление с текстом сервера, матрица шахматки при этом показывается как обычно
### Requirement: Единицы и знаки ### Requirement: Единицы и знаки
Экран SHALL позволять выбрать единицы суммы — «тыс. руб.» (по умолчанию), «млн. руб.», «млрд руб.» — и число знаков после Экран SHALL позволять выбрать единицы суммы — «руб.», «тыс. руб.» (по умолчанию), «млн. руб.», «млрд руб.» — и число знаков
запятой 0–3 (по умолчанию 2). Выбор применяется на клиенте к уже загруженным значениям (повторного запроса не делает): после запятой 0–3 (по умолчанию 2). Выбор применяется на клиенте к уже загруженным значениям (повторного запроса не делает):
значение делится на 1000 / 1 000 000 / 1 000 000 000 и показывается с выбранным числом знаков, десятичный разделитель — запятая. запятая сдвигается в десятичной строке на 3 / 6 / 9 разрядов, значение округляется до выбранного числа знаков (половина —
Значение, которое не удаётся прочитать как число, SHALL показываться как пришло. от нуля) без перевода в число с плавающей точкой и показывается с разделителем разрядов и десятичной запятой. Значение,
округлённое до нуля, показывается без минуса. Пустое значение — пустая ячейка. Значение, которое не удаётся прочитать как
число, SHALL показываться как пришло — при любых единицах.
#### Scenario: Смена единиц #### Scenario: Смена единиц
- **WHEN** выбраны «млн. руб.» и 1 знак, а сервер вернул `amount_a = "2346459.05"` - **WHEN** выбраны «млн. руб.» и 1 знак, а сервер вернул `amount_a = "2346459.05"`
- **THEN** в ячейке показано `2,3` - **THEN** в ячейке показано `2,3`
#### Scenario: Точное округление
- **WHEN** выбраны «тыс. руб.» и 2 знака, а сервер вернул `"1005"`
- **THEN** показано `1,01` (не `1,00`, как дало бы округление числа с плавающей точкой)
#### Scenario: Маленькая отрицательная сумма
- **WHEN** выбраны «тыс. руб.» и 2 знака, а сервер вернул `"-4.00"`
- **THEN** показано `0,00`, а не `-0,00`
### Requirement: Фильтр компаний ### Requirement: Фильтр компаний
Выбор «Компании» (множественный, пустой выбор — «Все компании») SHALL ограничивать таблицу на клиенте: остаются строки и Выбор «Компании» (множественный, пустой выбор — «Все компании») SHALL ограничивать матрицу на клиенте: остаются строки и
колонки выбранных компаний и ячейки, у которых обе компании выбраны. Запрос на сервер при этом не отправляется. Список колонки выбранных компаний и ячейки, у которых обе компании выбраны. Запрос на сервер при этом не отправляется. Список
вариантов — компании последнего загруженного ответа. Выпадающее окно выбора — дерево с поиском по названию, кнопками вариантов — компании последнего загруженного ответа. Выпадающее окно выбора — дерево с поиском по названию, кнопками
применения и сброса. применения и сброса.
#### Scenario: Две компании из периметра #### Scenario: Две компании из периметра
- **WHEN** выбраны две компании из пяти - **WHEN** выбраны две компании из пяти
- **THEN** таблица содержит две строки и две колонки, и «Итого» считается по оставшимся ячейкам - **THEN** матрица содержит две строки и две колонки
### Requirement: Фильтр счетов Дт и Кт и режимы расчёта ### Requirement: Фильтр счетов Дт и Кт и режимы расчёта
Экран SHALL загружать план счетов `GET /directories/accounts` один раз при открытии и строить из него два дерева по иерархии Экран SHALL загружать план счетов `GET /directories/accounts` один раз при открытии и строить из него два дерева по иерархии
`parent_id`: дебетовые счета (`is_debit`) и кредитовые (`is_credit`); подпись узла — «код - название». Выбор счетов Дт и Кт — `parent_id`: дебетовые счета (`is_debit`) и кредитовые (`is_credit`); подпись узла — «код - название», в том числе у выбранных
множественный, пустой выбор — «Все счета». дочерних счетов в поле выбора. Выбор счетов Дт и Кт — множественный, пустой выбор — «Все счета».
Режим расчёта определяется выбором: если выбраны счета и в Дт, и в Кт, SHALL выполняться Отметки в дереве SHALL считаться по полному плану счетов: родительский счёт отмечен тогда и только тогда, когда отмечены все
`POST …/intercompany-matrix/manual` с `{debit_account_ids, credit_account_ids}` (режим `manual`); в остальных случаях его потомки в полном дереве. Поиск по дереву скрывает не совпавшие узлы, но не меняет этого правила: отметка единственного
(ничего не выбрано либо выбрана только одна сторона) SHALL выполняться `GET …/intercompany-matrix/all` (режим `all`). Каждое видимого при поиске субсчёта не отмечает его родителя, а снятие отметки с субсчёта снимает её и с родителя. Скрытые поиском
изменение выбора счетов перезапрашивает матрицу. Кнопка «Сбросить фильтры» появляется, когда выбрано что-либо в компаниях, Дт отметки при отметке видимых узлов сохраняются.
или Кт, и очищает все три выбора. Выбор единиц и знаков кнопкой не сбрасывается. Ошибка загрузки плана счетов показывается уведомлением.
Режим расчёта определяется выбором:
- ничего не выбрано ни в Дт, ни в Кт — SHALL выполняться `GET …/intercompany-matrix/all` (режим `all`);
- выбраны счета и в Дт, и в Кт — SHALL выполняться `POST …/intercompany-matrix/manual` с `{debit_account_ids,
credit_account_ids}` (режим `manual`);
- выбрана только одна сторона — запрос SHALL NOT отправляться, таблица матрицы SHALL быть пустой и вместо данных показывать
текст «Для расчёта по счетам выберите счета и в Дт, и в Кт»; тот же текст SHALL показываться у фильтров Дт/Кт (не
всплывающим уведомлением) и только в этом состоянии; кнопка «Экспорт» матрицы SHALL быть недоступна.
Каждое изменение выбора счетов, приводящее к режиму `all` или `manual`, перезапрашивает матрицу; на экране остаётся ответ на
последний запрос, индикатор загрузки снимается и при переходе в состояние «выбрана одна сторона». Кнопка «Сбросить фильтры»
появляется, когда выбрано что-либо в компаниях, Дт или Кт, и очищает все три выбора. Выбор единиц и знаков кнопкой не
сбрасывается. Ошибка загрузки плана счетов показывается уведомлением.
#### Scenario: Обе стороны выбраны #### Scenario: Обе стороны выбраны
- **WHEN** выбран счёт 60.01 в Дт и счёт 62.01 в Кт - **WHEN** выбран счёт 60.01 в Дт и счёт 62.01 в Кт
@ -70,28 +118,52 @@
#### Scenario: Выбрана одна сторона #### Scenario: Выбрана одна сторона
- **WHEN** выбран счёт только в Дт - **WHEN** выбран счёт только в Дт
- **THEN** отправляется `GET …/intercompany-matrix/all` (режим `all`); выбор Дт остаётся в фильтре - **THEN** запрос матрицы не отправляется, таблица пуста и показывает «Для расчёта по счетам выберите счета и в Дт, и в Кт»,
тот же текст виден у фильтров, «Экспорт» матрицы недоступен, выбор Дт остаётся в фильтре
#### Scenario: Выбрана одна сторона во время загрузки
- **WHEN** выбраны Дт и Кт, запрос `manual` ещё выполняется, и пользователь очищает Кт
- **THEN** индикатор загрузки снят, ответ прежнего запроса на экран не выводится, таблица в состоянии «выбрана одна сторона»
#### Scenario: Поиск и отметка субсчёта
- **WHEN** в дереве Дт введён поиск, видна ветка 60 с единственным субсчётом 60.02, и отмечен 60.02
- **THEN** выбранным остаётся только 60.02; после сброса поиска счёт 60 и его прочие субсчета не отмечены
#### Scenario: Снятие субсчёта отмеченного родителя
- **WHEN** отмечен счёт 60 (отмечены 60, 60.01, 60.02) и снята отметка с 60.01
- **THEN** выбранным остаётся 60.02, счёт 60 не отмечен
#### Scenario: Сброс #### Scenario: Сброс
- **WHEN** выбраны компании и счета, нажато «Сбросить фильтры» - **WHEN** выбраны компании и счета, нажато «Сбросить фильтры»
- **THEN** все три выбора пусты, матрица перезапрошена в режиме `all`, кнопка исчезла - **THEN** все три выбора пусты, матрица перезапрошена в режиме `all`, кнопка исчезла
### Requirement: Экспорт шахматки в XLSX ### Requirement: Экспорт в XLSX
Кнопка «Экспорт» SHALL скачивать XLSX в том же режиме, что и таблица: при выбранных счетах и в Дт, и в Кт — Кнопка «Экспорт» матрицы SHALL скачивать XLSX в том же режиме, что и таблица: в режиме `manual` —
`POST …/intercompany-matrix/manual/export` с телом `{debit_account_ids, credit_account_ids}`, иначе `POST …/intercompany-matrix/manual/export` с телом `{debit_account_ids, credit_account_ids}`, в режиме `all` —
`GET …/intercompany-matrix/all/export`. Файл сохраняется под именем из `Content-Disposition`, при его отсутствии — «Шахматка». `GET …/intercompany-matrix/all/export`; при выбранной одной стороне кнопка недоступна. Кнопка «Экспорт» результирующей таблицы
Пока идёт выгрузка, кнопка в состоянии загрузки; ошибка API показывается уведомлением. Фильтр компаний, единицы и знаки SHALL скачивать `GET …/intercompany-matrix/result-table/export`. Файл сохраняется под именем из `Content-Disposition`, при его
в запрос экспорта не передаются. отсутствии — `Шахматка_<ГК>_<год>_<квартал>.xlsx` и `Результирующая_таблица_ВГО_<ГК>_<год>_<квартал>.xlsx` соответственно.
Пока идёт выгрузка, в состоянии загрузки только нажатая кнопка; ошибка API показывается уведомлением. Фильтр компаний, единицы и
знаки в запрос экспорта не передаются.
#### Scenario: Экспорт по всем счетам #### Scenario: Экспорт по всем счетам
- **WHEN** счета не выбраны и нажато «Экспорт» - **WHEN** счета не выбраны и нажато «Экспорт» матрицы
- **THEN** отправлен `GET …/intercompany-matrix/all/export`, браузер сохраняет файл `intercompany-matrix_….xlsx` - **THEN** отправлен `GET …/intercompany-matrix/all/export`, браузер сохраняет файл `intercompany-matrix_….xlsx`
#### Scenario: Экспорт результирующей таблицы
- **WHEN** нажато «Экспорт» результирующей таблицы
- **THEN** отправлен `GET …/intercompany-matrix/result-table/export`, кнопка «Экспорт» матрицы не переходит в состояние загрузки
### Requirement: Полноэкранный режим ### Requirement: Полноэкранный режим
Кнопка «На весь экран» SHALL переводить блок шахматки (панель фильтров и таблицу) в полноэкранный режим средствами Fullscreen API Кнопка «На весь экран» матрицы и результирующей таблицы SHALL переводить свой блок (панель фильтров и таблицу) в
с белым фоном и таблицей на высоту окна; в нём кнопка меняется на «Свернуть». Выход из полноэкранного режима клавишей Esc полноэкранный режим средствами Fullscreen API с белым фоном и таблицей на высоту окна; в нём кнопка меняется на «Свернуть».
SHALL возвращать обычный вид и подпись кнопки. Выпадающие списки фильтров и подсказки SHALL оставаться видимыми в полноэкранном режиме. Выход клавишей Esc SHALL возвращать
обычный вид и подпись кнопки. Отказ браузера войти в полноэкранный режим показывается уведомлением.
#### Scenario: Вход и выход #### Scenario: Вход и выход
- **WHEN** нажато «На весь экран», затем Esc - **WHEN** нажато «На весь экран», затем Esc
- **THEN** блок разворачивается на весь экран, затем возвращается в страницу, кнопка снова «На весь экран» - **THEN** блок разворачивается на весь экран, затем возвращается в страницу, кнопка снова «На весь экран»
#### Scenario: Фильтр в полноэкранном режиме
- **WHEN** матрица в полноэкранном режиме и открыт выбор «Компании»
- **THEN** выпадающее окно видно поверх полноэкранного блока

View File

@ -1,6 +1,11 @@
## 1. Спека и ревью (RSHB/arm-core#158) ## 1. Спека и ревью (RSHB/arm-core#158, AURORA-1538)
- [x] 1.1 Спека `intercompany-matrix-ui` по фактическому поведению ветки `feat/chessTable`. - [x] 1.1 Спека `intercompany-matrix-ui` (ревьюер; с раунда 3 — целевое поведение).
- [ ] 1.2 Автор исправляет пункты «Known issues» из `design.md` и обновляет требования спеки отдельным коммитом. - [x] 1.2 Тесты форматтера сумм `utils/chessTableCellFormatValue.test.ts` (ревьюер).
- [ ] 1.3 Перед PR: `make web-check`, `npm test`, `npm run lint` — зелёные, вывод в описание PR. - [ ] 1.3 Автор исправляет «Known issues» 1–4 из `design.md` (желательное — 5 — по возможности).
- [ ] 1.4 Сверка экрана с макетом 31-01 в браузере (`docs/mockup/INDEX.md`). - [ ] 1.4 Перед слиянием: `make web-check`, `npm test`, `npm run lint` — зелёные, вывод в описание PR.
- [ ] 1.5 Сверка экрана с макетом 31-01 / 31-01-01 в браузере (`docs/mockup/INDEX.md`), в том числе полноэкранный режим.
## 2. После слияния
- [ ] 2.1 Итоги «Итого» из API — AURORA-1756 (бэк), AURORA-1757 (фронт).

View File

@ -0,0 +1,32 @@
## Context
Реализация — PR RSHB/arm-core#159. Результирующая таблица ВГО (§6) — чистая функция от сводной ОСВ версии и периметра
(`calculate_result_table`); в БД не хранится (DECISION-5). Выгрузка повторяет устройство выгрузки шахматки
(`intercompany_matrix_export.py`, change `intercompany-matrix`).
## Decisions
1. JSON и xlsx строятся одним методом сервиса `_build_result_table` (`build_with_members` → `calculate_result_table`),
поэтому состав и порядок компаний, статьи и `group_total` совпадают; параметров у таблицы нет.
2. Книга: лист «Результирующая таблица», шапка (ГК, период, версия, единицы «руб.»), строка заголовка «Статья ВГО» /
компании / «Итого по группе», суммы — `Decimal` с форматом `#,##0.00`, закреплены заголовок и первая колонка.
Имя файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v<n>.xlsx` (RFC 5987, запасное ASCII-имя).
3. Аудит `EXPORT` пишется после построения книги: ошибка расчёта (404, 422) или сборки книги события не оставляет.
4. Реквизиты версии в шапке и имени файла общие для шахматки и результирующей таблицы (`VersionExportInfo`,
`_write_details`, `_group_name`, `_xlsx_response`) — без копий кода.
## Decisions made without discussion
- 22.10.2026. Требование автора перенесено из `openspec/specs/consolidations-api` в дельту `ADDED` отдельным
требованием со сценариями (у автора — абзац внутри требования «Результирующая таблица ВГО» без сценария); событие
аудита добавлено в `audit-trail`. Обоснование: владелец поручил закрыть замечания ревью силами ревьюера, прецедент —
change `intercompany-matrix`. Цена ошибки: низкая — текст правится в этом change'е до архивации.
- 22.10.2026. Аудит экспорта результирующей таблицы оформлен отдельным `ADDED`-требованием, а не `MODIFIED`
«Аудит экспорта шахматки». Обоснование: у событий разный payload (`articles` вместо режима и счетов), отдельное
требование не переписывает согласованный текст. Цена ошибки: низкая.
- 22.10.2026. Компания без суммы в статье при сборке книги даёт `KeyError` вместо молчаливого 0. Обоснование:
`calculate_result_table` выдаёт сумму на каждую компанию периметра, пропуск — ошибка расчёта, которую нельзя прятать
в файле. Цена ошибки: при таком дефекте расчёта выгрузка ответит 500 вместо файла с нулём.
- 22.10.2026. Общие реквизиты книги вынесены в базовый `VersionExportInfo`, затронут и код выгрузки шахматки.
Обоснование: замечание ревью о копиях шапки и имени файла. Цена ошибки: конфликт слияния с PR #141 (тот меняет
`MatrixExportInfo`); порядок — сначала #141, затем ребейз #159.

View File

@ -0,0 +1,33 @@
## Why
Фронт шахматки (экран [31-01], результирующая таблица под матрицей) выгружает результирующую таблицу ВГО в xlsx —
запрос фронтенда к API. Реализация — PR RSHB/arm-core#159 (автор — Потапова). Требование автор дописала прямо в
`openspec/specs/`; при подготовке к слиянию оно оформлено дельтами этого change'а, как в change `intercompany-matrix`
(PR #114).
## What Changes
- `GET …/intercompany-matrix/result-table/export` — xlsx результирующей таблицы ВГО: те же статьи, компании и итоги,
что у `GET …/intercompany-matrix/result-table`, считается на лету из той же сводной ОСВ версии (§6).
- Каждая успешная выгрузка пишет `audit_event(EXPORT)` с `report = "intercompany_result_table"`.
- В OpenAPI у 422 маршрутов результирующей таблицы — своё описание («нет счетов статей»), а не «неверный выбор счетов».
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `consolidations-api`: xlsx результирующей таблицы ВГО.
- `audit-trail`: аудит экспорта результирующей таблицы.
## Impact
Только `arm-core` (маршрут, сервис шахматки, построитель книги, тесты). Таблицы DBML v5.21 не меняются: действие
`EXPORT` и раздел `EXPORT` в `audit_action`/`audit_section` уже есть. Срез 5 (`09-delivery-plan.md`).
## Non-goals
- Строка «Итого» по статьям: её нет ни в JSON, ни в xlsx (§6 описывает только колонку «Итого по группе»).
- Пересчёт в тыс. руб. и знаки после запятой: книга в рублях, как выгрузка шахматки.
- Кнопка выгрузки во фронте (`web/`) и строка в `../docs/dev/frontend-api-guide.md` — отдельными изменениями.

View File

@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Аудит экспорта результирующей таблицы ВГО
Каждый успешный `GET …/intercompany-matrix/result-table/export` SHALL добавлять отдельное неизменяемое событие
`audit_event` с `action = EXPORT`, `section = EXPORT`, `object_type = consolidation_version`,
`object_id = consolidation_version_id`, `consolidation_version_id` версии, `user_id` инициатора, `before_value = null`
и `after_value = {report: "intercompany_result_table", articles}`, где `articles` — число статей в книге. Неуспешная
выгрузка (404, 422, ошибка сборки книги) события не пишет.
#### Scenario: Выгрузка таблицы
- **WHEN** пользователь выгружает результирующую таблицу версии
- **THEN** в `audit_event` одна запись `EXPORT` с `after_value = {report: "intercompany_result_table", articles: 5}`
#### Scenario: Нет счёта статьи
- **WHEN** выгрузка отвечает 422 `RESULT_ACCOUNTS_MISSING`
- **THEN** событие `EXPORT` не записано

View File

@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: XLSX результирующей таблицы ВГО
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export` (`CONSOLIDATION_VIEW`,
policy) SHALL возвращать XLSX с теми же статьями, компаниями и итогами, что `GET …/intercompany-matrix/result-table`,
из того же расчёта сводной ОСВ версии. Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы
«руб.») и таблицей «Статья ВГО» × компании периметра + колонка «Итого по группе»; суммы — числа с двумя знаками. Имя
файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v<n>.xlsx`. Ошибки — как у JSON-маршрута: невидимая или
несуществующая версия — 404, отсутствие необходимого кода счёта — 422 `RESULT_ACCOUNTS_MISSING`.
#### Scenario: Совпадение с JSON
- **WHEN** пользователь выгружает результирующую таблицу ГК «Русский бисквит» за 2024 Q1, версия 1
- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, порядок компаний, названия статей, суммы по компаниям и
«Итого по группе» совпадают с ответом `GET …/result-table` той же версии
#### Scenario: Нет счёта статьи
- **WHEN** в плане счетов нет кода 58.03
- **THEN** выгрузка отвечает 422 `RESULT_ACCOUNTS_MISSING` с кодом в сообщении, файл не формируется
#### Scenario: Версия вне видимости
- **WHEN** пользователь филиала выгружает таблицу версии чужой ГК
- **THEN** ответ 404 с тем же телом, что у несуществующей версии

View File

@ -0,0 +1,10 @@
## 1. Реализация
- [x] 1.1 Маршрут `GET …/intercompany-matrix/result-table/export`, построитель книги, аудит `EXPORT` (автор, PR #159)
- [x] 1.2 Замечания ревью: `EXPECTED_ROUTES`, формат ruff, общие реквизиты книги, `KeyError` вместо 0, описание 422,
тесты 422 без события `EXPORT` и OpenAPI-схемы экспорта
## 2. После слияния
- [ ] 2.1 Ребейз на `main` после слияния PR #141 (`build_with_members` возвращает объект), `make check-full`
- [ ] 2.2 Кнопка выгрузки на экране результирующей таблицы (`web/`) и строка в `../docs/dev/frontend-api-guide.md`

View File

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { chessTableCellFormatValue as fmt } from './chessTableCellFormatValue';
const NBSP = ' ';
describe('chessTableCellFormatValue', () => {
it('руб.: разряды, запятая, заданное число знаков', () => {
expect(fmt('4454151.93', 'rub', 2)).toBe(`4${NBSP}454${NBSP}151,93`);
expect(fmt('100', 'rub', 2)).toBe('100,00');
expect(fmt('123.456', 'rub', 0)).toBe('123');
expect(fmt('123.4', 'rub', 3)).toBe('123,400');
});
it('тыс./млн/млрд — сдвиг запятой в строке', () => {
expect(fmt('2346459.05', 'thousand', 2)).toBe(`2${NBSP}346,46`);
expect(fmt('2346459.05', 'million', 1)).toBe('2,3');
expect(fmt('2346459.05', 'billion', 3)).toBe('0,002');
expect(fmt('12', 'thousand', 3)).toBe('0,012');
});
it('округление точное, без двоичной погрешности float', () => {
// 1005 руб. = 1,005 тыс.: через Number(...).toFixed(2) вышло бы «1,00»
expect(fmt('1005', 'thousand', 2)).toBe('1,01');
expect(fmt('0.005', 'rub', 2)).toBe('0,01');
});
it('большие суммы не теряют копейки', () => {
expect(fmt('12345678901234567.89', 'rub', 2)).toBe(`12${NBSP}345${NBSP}678${NBSP}901${NBSP}234${NBSP}567,89`);
});
it('отрицательные', () => {
expect(fmt('-1234567.1', 'rub', 2)).toBe(`-1${NBSP}234${NBSP}567,10`);
expect(fmt('-2346459.05', 'thousand', 2)).toBe(`-2${NBSP}346,46`);
});
it('отрицательная сумма, округлённая до нуля, — без минуса', () => {
expect(fmt('-4.00', 'thousand', 2)).toBe('0,00');
expect(fmt('-0.001', 'rub', 0)).toBe('0');
});
it('пустое значение — пустая строка', () => {
expect(fmt(null, 'rub', 2)).toBe('');
expect(fmt(undefined, 'thousand', 2)).toBe('');
expect(fmt('', 'million', 2)).toBe('');
});
it('нечисловое значение в руб. — как пришло', () => {
expect(fmt('abc', 'rub', 2)).toBe('abc');
});
});