Compare commits
6 Commits
601a125349
...
b8f0672029
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8f0672029 | ||
|
|
718d83d6d1 | ||
|
|
0134120905 | ||
| ab49f5e83d | |||
|
|
63d6c1a017 | ||
|
|
ea51cb35c2 |
@ -870,7 +870,8 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
|
||||
|
||||
API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица
|
||||
`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; требования — в существующих спецификациях
|
||||
`openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`.
|
||||
Расчёт читает сводную ОСВ версии, результат не хранится.
|
||||
|
||||
@ -14,7 +14,7 @@ from src.models.enums import AuditAction, AuditSection
|
||||
from src.schemas.common import ErrorDetail
|
||||
from src.schemas.consolidations import IntercompanyMatrixManualIn, IntercompanyMatrixOut, IntercompanyResultTableOut
|
||||
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")
|
||||
|
||||
@ -23,19 +23,26 @@ ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "Версия не найдена либо недоступна"},
|
||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": ErrorDetail, "description": "Неверный выбор счетов"},
|
||||
}
|
||||
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
EXPORT_RESPONSES = {
|
||||
RESULT_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
**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:
|
||||
content = await run_in_threadpool(build_matrix_workbook, matrix, info)
|
||||
def _xlsx_response(content: bytes, filename: str, *, ascii_fallback: str) -> Response:
|
||||
return Response(
|
||||
content=content,
|
||||
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,
|
||||
) -> 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(
|
||||
action=AuditAction.EXPORT,
|
||||
section=AuditSection.EXPORT,
|
||||
@ -149,7 +157,7 @@ async def export_intercompany_matrix_manual(
|
||||
"/{version_id}/intercompany-matrix/result-table",
|
||||
response_model=IntercompanyResultTableOut,
|
||||
dependencies=VIEW,
|
||||
responses=ERRORS,
|
||||
responses=RESULT_ERRORS,
|
||||
summary="Результирующая таблица ВГО",
|
||||
)
|
||||
async def get_intercompany_result_table(
|
||||
@ -158,3 +166,29 @@ async def get_intercompany_result_table(
|
||||
) -> IntercompanyResultTableOut:
|
||||
"""Пять фиксированных статей: суммы по компаниям и итоги по группе."""
|
||||
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
|
||||
|
||||
@ -76,18 +76,41 @@ class MatrixSelection:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatrixExportInfo:
|
||||
class VersionExportInfo:
|
||||
"""Реквизиты версии в шапке и имени книги."""
|
||||
|
||||
group_name: str
|
||||
year: int
|
||||
quarter: PeriodQuarter
|
||||
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"]
|
||||
debit_account_codes: tuple[str, ...]
|
||||
credit_account_codes: tuple[str, ...]
|
||||
|
||||
@property
|
||||
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:
|
||||
@ -300,10 +323,34 @@ class IntercompanyMatrixService:
|
||||
|
||||
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)
|
||||
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(
|
||||
self,
|
||||
@ -331,14 +378,9 @@ class IntercompanyMatrixService:
|
||||
matrix, version, consolidation, accounts = await self._build_accounts(
|
||||
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}
|
||||
info = MatrixExportInfo(
|
||||
group_name=group_name,
|
||||
group_name=await self._group_name(consolidation),
|
||||
year=consolidation.year,
|
||||
quarter=consolidation.quarter,
|
||||
version_no=version.version_no,
|
||||
|
||||
@ -7,8 +7,8 @@ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
|
||||
from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut
|
||||
from src.services.intercompany_matrix import MatrixExportInfo
|
||||
from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut, IntercompanyResultTableOut
|
||||
from src.services.intercompany_matrix import MatrixExportInfo, ResultTableExportInfo, VersionExportInfo
|
||||
from src.services.osv_export import MONEY_FORMAT
|
||||
|
||||
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}", "АРМ КА")
|
||||
|
||||
|
||||
def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None:
|
||||
_text(sheet, 1, 1, "Шахматка внутригрупповых операций")
|
||||
def _write_details(
|
||||
sheet: Worksheet, title: str, info: VersionExportInfo, units: str, extra: tuple[tuple[str, str], ...] = ()
|
||||
) -> None:
|
||||
"""Заголовок и строки «реквизит — значение» над таблицей книги."""
|
||||
_text(sheet, 1, 1, title)
|
||||
details = (
|
||||
("ГК", info.group_name),
|
||||
("Период", f"{info.year}-{info.quarter.value}"),
|
||||
("Версия", f"v{info.version_no}"),
|
||||
("Единицы", "руб.; расхождение — %"),
|
||||
("Режим", "Все счета" 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 "Все счета"),
|
||||
("Единицы", units),
|
||||
*extra,
|
||||
)
|
||||
for row, (label, value) in enumerate(details, start=2):
|
||||
_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)
|
||||
|
||||
|
||||
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:
|
||||
for offset, amount in enumerate((cell.variance_amount, cell.amount_a, cell.amount_b)):
|
||||
sheet.cell(row + offset, column, amount).number_format = MONEY_FORMAT
|
||||
@ -115,3 +130,41 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo)
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
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()
|
||||
|
||||
@ -27,7 +27,7 @@ from tests.factories import make_company_group, make_confirmed_osv, make_consoli
|
||||
pytestmark = pytest.mark.integration
|
||||
OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422
|
||||
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
|
||||
@ -220,15 +220,39 @@ async def test_invalid_account_selection(
|
||||
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)
|
||||
# Меняем код в тестовой транзакции, сохраняя FK существующих правил и потомков.
|
||||
world.accounts["58.03"].code = "58.03-missing"
|
||||
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.json()["detail"]["code"] == "RESULT_ACCOUNTS_MISSING"
|
||||
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"])
|
||||
|
||||
@ -12,6 +12,7 @@ from openpyxl import load_workbook
|
||||
from src.api.endpoints.consolidations.intercompany_matrix import (
|
||||
export_intercompany_matrix_all,
|
||||
export_intercompany_matrix_manual,
|
||||
export_intercompany_result_table,
|
||||
router,
|
||||
)
|
||||
from src.models.enums import AuditAction, AuditSection, PeriodQuarter
|
||||
@ -20,9 +21,18 @@ from src.schemas.consolidations import (
|
||||
IntercompanyMatrixCompanyOut,
|
||||
IntercompanyMatrixManualIn,
|
||||
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:
|
||||
@ -229,3 +239,85 @@ async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.Mon
|
||||
with pytest.raises(ValueError, match="xlsx failed"):
|
||||
await export_intercompany_matrix_all(version_id, service, audit)
|
||||
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))
|
||||
|
||||
@ -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/export": {"post"},
|
||||
"/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 быть не должно
|
||||
@ -191,6 +192,17 @@ def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any])
|
||||
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:
|
||||
"""T12.2: чек-лист — 200 как JSON (`ChecklistOut`) и как CSV-файл (бинарный текст), 404/422."""
|
||||
checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"]
|
||||
|
||||
@ -1,55 +1,55 @@
|
||||
## Context
|
||||
|
||||
Экран «Шахматка» [31-01] добавлен в `arm-core/web` PR #158 (ветка `feat/chessTable`) как вторая вкладка страницы отчёта по
|
||||
версии. Страница `layouts/ConsolidatedOsvPage.tsx` разделена: общую шапку и вкладки держит `layouts/ConsolidatioReportPage.tsx`,
|
||||
содержимое вкладок — `widgets/ConsolidatedOsvContent` (прежняя сводная ОСВ без изменений поведения) и
|
||||
`widgets/ConsolidatedChessContent`. Бэкенд шахматки — change `2026-10-16-intercompany-matrix`; расчёт — чистая функция
|
||||
версии, ничего не хранится.
|
||||
Экран «Шахматка» [31-01] добавлен в `arm-core/web` PR #158 (ветка `feat/chessTable`, AURORA-1538) как вторая вкладка страницы
|
||||
отчёта по версии. Страница `layouts/ConsolidatedOsvPage.tsx` разделена: общую шапку и вкладки держит
|
||||
`layouts/ConsolidationReportPage.tsx`, содержимое вкладок — `widgets/ConsolidatedOsvContent` (прежняя сводная ОСВ без изменений
|
||||
поведения) и `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`) и по выбору заменяет
|
||||
последний сегмент адреса. Страница выбирает содержимое по таблице `tab → компонент`; неизвестный `tab` даёт пустое содержимое.
|
||||
последний сегмент адреса. Неизвестный `tab` заменяется на `osv` (`navigate(…, {replace: true})`).
|
||||
- **D-2. Ключ версии.** Страница отчёта разделена на обёртку с `key={version_id}` и содержимое: при смене версии карточка,
|
||||
фильтры и данные создаются заново (как было в `ConsolidatedOsvPage`).
|
||||
- **D-3. Режим `all` / `manual` выводится из выбора счетов:** `manual` только при непустых Дт и Кт (иначе бэкенд ответит 422),
|
||||
в остальных случаях `all`. Один и тот же признак выбирает маршрут экспорта.
|
||||
- **D-3. Режим расчёта из выбора счетов:** `manual` — при непустых Дт и Кт, `all` — при пустых обоих; при одной выбранной
|
||||
стороне запроса нет (бэкенд ответил бы 422 на `manual`, а `all` показал бы не то, что выбрано). Тот же признак выбирает
|
||||
маршрут экспорта.
|
||||
- **D-4. Единицы, знаки и фильтр компаний — на клиенте.** Запрос повторяется только при смене выбора счетов или версии.
|
||||
Единицы — сдвиг запятой в десятичной строке (`utils/chessTableCellFormatValue.ts`), `Intl.NumberFormat` получает строку и
|
||||
форматирует её как точное десятичное (без float).
|
||||
- **D-5. План счетов** — `GET /directories/accounts` целиком, деревья Дт и Кт строятся на клиенте по `parent_id`.
|
||||
- **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
|
||||
|
||||
Открытые проблемы ветки, которые исправляет автор PR; спека их НЕ закрепляет как требования.
|
||||
Открытые проблемы ветки на 601a125 (ревью, раунд 3), которые исправляет автор PR; спека описывает целевое поведение.
|
||||
|
||||
1. Глобальный CSS (`App.css`, `ConsolidatedChessTable.css`, `TreeFilterDropdown.css`) с `!important` на классах antd — влияет на
|
||||
все таблицы и деревья приложения; стили должны быть локальными (CSS-модули или `className` экрана).
|
||||
2. В полноэкранном режиме выпадающие списки фильтров (Компании, Дт, Кт, Единицы, Знаки) не видны: popup рендерится в `body`,
|
||||
вне полноэкранного элемента; нужен `getPopupContainer` на контейнер шахматки.
|
||||
3. Суммы форматируются через `number` / `parseFloat` / `toFixed` (float в денежном домене), без разделителей тысяч и без «руб.»;
|
||||
`utils/money.ts` работает со строками — использовать его. Деление на единицы делать над десятичной строкой.
|
||||
4. Строка «Итого» суммирует расхождения (`variance_amount`), а в макете 31-01 в итогах — верхние числа (суммы стороны A); итоги нужны
|
||||
по каждой из трёх величин либо по макету. Знак `(…)` разбирается эвристикой `parseAmount`, хотя API отдаёт десятичные строки.
|
||||
5. Подписи дочерних счетов в выборе Дт/Кт — UUID: `Select` получает `options` только корневых узлов, выбранные дочерние ключи
|
||||
выводятся как идентификаторы; нужны подписи «код - название» для любых узлов.
|
||||
6. Выбор счетов только с одной стороны молча даёт режим `all`: пользователь видит выбранный счёт, но данные по всем счетам;
|
||||
нужны подсказка/блокировка либо явное поведение.
|
||||
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.
|
||||
1. Строки «Итого» под матрицей и под результирующей таблицей считаются на клиенте (`sumAmounts`) — убрать (D-8).
|
||||
2. Поиск в дереве счетов: отметки считаются по отфильтрованному дереву, отметка единственного видимого субсчёта отмечает
|
||||
родителя, и на бэк уходит весь родительский счёт (`TreeFilterDropdown.tsx`).
|
||||
3. Выбрана одна сторона Дт/Кт: таблица показывает прежние данные, подсказка — всплывающее уведомление и постоянный текст внутри
|
||||
выпадающего окна, «Экспорт» активен и выгружает режим `all`.
|
||||
4. Стили: правило тегов в `App.css` и класс на форме ЮЛ (`CatalogueJurForm.tsx`) — вне экрана; у селекта Кт класса нет;
|
||||
`.resRow`, `.empty-cell` не под `.chessTable`; у `Tree` снят `className='filterTree'`.
|
||||
5. Желательно: запасные имена файлов (`exportFilename` добавляет `Сводная_ОСВ_`) и общий `exportLoading` на две кнопки;
|
||||
нечисловое значение в тыс./млн/млрд искажается (`0.abc`); `sumAmounts(...) ?? '0'`; `as any` в форматтере; мёртвый код
|
||||
(скобки в форматтере, `formatted === '—'`); ячейки по макету (скобки у расхождения, «—», значок `skipped_osv_entry_count`);
|
||||
заглушки «Год»/«Квартал»; `services/consodilation`, типы вне `types/api.ts`, `withJsonBody`, `Account.kind`.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
- **2026-10-21 (а). Спеку пишет ревьюер в ветке автора** — по решению владельца (разрешено явно); альтернатива — оставить
|
||||
автору. Цена ошибки: автор может не согласиться с формулировками; правится одним коммитом в этом же change.
|
||||
- **2026-10-21 (б). Спека описывает фактическое поведение, дефекты вынесены в «Known issues».** Обоснование: спека — единственный
|
||||
- **2026-10-21 (б). Спека описывает фактическое поведение, дефекты вынесены в «Known issues».** Отменено 22.10.2026 (е). Обоснование: спека — единственный
|
||||
источник истины при рулингах, а закреплять баги требованиями нельзя; иначе их закрытие превратится в «нарушение спеки». Цена
|
||||
ошибки: отдельные требования (например, режим `all` при выборе одной стороны, «Итого» из расхождений) описаны как есть
|
||||
и после исправления потребуют MODIFIED.
|
||||
@ -65,3 +65,18 @@
|
||||
- **2026-10-21 (д). Дельта к `consolidated-osv-ui` не делается:** вкладки описаны в новой спеке, текст
|
||||
`consolidated-osv-ui` не правился, чтобы не смешивать UI-половины. Цена ошибки: при архивации две спеки частично описывают
|
||||
один компонент (`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), чтобы выгрузка результирующей таблицы работала на стенде из ветки.
|
||||
|
||||
@ -2,27 +2,30 @@
|
||||
|
||||
В ветке `feat/chessTable` (PR RSHB/arm-core#158, автор — Голов) во фронте появился экран «Шахматка» — вкладка страницы
|
||||
отчёта по версии консолидации рядом со «Сводной ОСВ». Бэкенд шахматки (`GET/POST …/intercompany-matrix/*`) уже в main
|
||||
(change `2026-10-16-intercompany-matrix`), у экрана спеки нет. Спеку пишет ревьюер в ветке автора по решению владельца
|
||||
(21.10.2026): она фиксирует фактическое поведение экрана и отделяет от него открытые дефекты (`design.md`, «Known issues»),
|
||||
чтобы дефекты не закрепились требованием.
|
||||
(change `2026-10-16-intercompany-matrix`), у экрана спеки нет. Спеку и тесты ведёт ревьюер в ветке автора по решению
|
||||
владельца (21–22.10.2026): спека задаёт целевое поведение экрана, расхождения кода ветки с ней — `design.md`, «Known issues».
|
||||
|
||||
## What Changes
|
||||
|
||||
Репозиторий — только arm-core (`web/`). Экран «Шахматка» [31-01], эндпоинты arm-core:
|
||||
`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` продолжает работать.
|
||||
- Таблица ЮЛ × ЮЛ: в ячейке три числа (сумма A, сумма Б, расхождение), строка «Итого».
|
||||
- Таблица ЮЛ × ЮЛ: в ячейке три числа (сумма A, сумма Б, расхождение); итоги считает бэкенд (пока API их не отдаёт —
|
||||
строки «Итого» нет).
|
||||
- Результирующая таблица ВГО по статьям с колонкой «Итого» (`group_total`) и выгрузкой в XLSX.
|
||||
- Фильтры: компании, счета Дт, счета Кт (деревом), единицы, знаков после запятой; режимы расчёта `all` / `manual`.
|
||||
- Экспорт в XLSX (`all` / `manual`), полноэкранный режим.
|
||||
- Экспорт в XLSX (`all` / `manual`, результирующая таблица), полноэкранный режим.
|
||||
- Новая спека `intercompany-matrix-ui` (ADDED Requirements).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Исправление дефектов экрана (перечень — `design.md`, «Known issues»): они остаются автору PR.
|
||||
- Результирующая таблица ВГО (`GET …/intercompany-matrix/result-table`) и пресеты счетов — во фронте не реализованы.
|
||||
- Исправление продуктового кода по «Known issues» (`design.md`) — за автором PR; спеку и тесты ведёт ревьюер.
|
||||
- Пресеты счетов — во фронте не реализованы.
|
||||
- Итоги «Итого» в API и их вывод — AURORA-1756, AURORA-1757.
|
||||
- Выбор года и квартала на экране: селекторы заглушены (`disabled`), версия задаётся адресом.
|
||||
- Изменения бэкенда и DBML: шахматка — чистая функция от версии, ничего не хранится (DECISION-5).
|
||||
- Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий», «Подтверждение КФО клиента» — недоступны.
|
||||
@ -38,8 +41,9 @@
|
||||
|
||||
## 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/ConsolidationChessTable/`, `web/src/components/TreeFilterDropdown/`,
|
||||
`web/src/services/consodilation/chess.ts`, `web/src/types/chess/chess.ts`, `web/src/api/client.ts` (`apiFetchBlob` принимает `init`).
|
||||
`web/src/widgets/ConsolidationChessTable/`, `web/src/widgets/ConsolidationChessResTable/`,
|
||||
`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`; меню по правам не скрывается.
|
||||
|
||||
@ -4,9 +4,10 @@
|
||||
Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз
|
||||
и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`:
|
||||
`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка». Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки
|
||||
(навигация, не локальное состояние), активная вкладка подсвечена зелёным и выделена жирным. Вкладки «Таблица корректировки»,
|
||||
«Гудвилл», «КФО», «КФО Общий», «Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах».
|
||||
При смене `version_id` всё состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново.
|
||||
(навигация, не локальное состояние), активная вкладка подсвечена зелёным и выделена жирным. Неизвестный ключ вкладки SHALL
|
||||
заменяться на `osv` без новой записи в истории браузера. Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий»,
|
||||
«Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». При смене `version_id` всё
|
||||
состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново.
|
||||
|
||||
#### Scenario: Переход на шахматку
|
||||
- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка»
|
||||
@ -16,14 +17,18 @@
|
||||
- **WHEN** открыт адрес `…/versions/{id}/osv`
|
||||
- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна
|
||||
|
||||
#### Scenario: Неизвестная вкладка
|
||||
- **WHEN** открыт адрес `…/versions/{id}/abc`
|
||||
- **THEN** адрес заменяется на `…/versions/{id}/osv`, показана сводная ОСВ; кнопка «Назад» браузера не возвращает на `…/abc`
|
||||
|
||||
### Requirement: Таблица шахматки ЮЛ × ЮЛ
|
||||
Экран SHALL загружать матрицу при открытии вкладки (`GET /consolidations/versions/{id}/intercompany-matrix/all`) и показывать
|
||||
таблицу: первая колонка «ДБ / КТ» (закреплена слева) — компании периметра в порядке ответа (`companies[]`), остальные колонки —
|
||||
те же компании. В ячейке пары (строка A, колонка Б) SHALL быть три числа друг под другом: сумма `amount_a`, сумма `amount_b`
|
||||
и расхождение `variance_amount`; расхождение выделяется красным, если `exceeds_threshold = true`. Если ячейки пары в ответе нет
|
||||
(в том числе на диагонали), SHALL показываться прочерк «—» на приглушённом фоне. Ниже таблицы SHALL закрепляться строка «Итого»
|
||||
с одним числом по каждой колонке. Пока идёт загрузка, таблица показывает индикатор загрузки; ошибка API показывается
|
||||
уведомлением с текстом сервера.
|
||||
(в том числе на диагонали), SHALL показываться прочерк «—» на приглушённом фоне. Итоговые суммы (строка «Итого») экран SHALL
|
||||
NOT вычислять сам: агрегаты отдаёт бэкенд, пока API их не возвращает — строки «Итого» под матрицей нет. Пока идёт загрузка,
|
||||
таблица показывает индикатор загрузки; ошибка API показывается уведомлением с текстом сервера.
|
||||
|
||||
#### Scenario: Ячейка с расхождением выше порога
|
||||
- **WHEN** для пары `(A, Б)` сервер вернул `exceeds_threshold = true`
|
||||
@ -33,36 +38,79 @@
|
||||
- **WHEN** в `cells[]` нет ячейки для пары компаний
|
||||
- **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: Единицы и знаки
|
||||
Экран SHALL позволять выбрать единицы суммы — «тыс. руб.» (по умолчанию), «млн. руб.», «млрд руб.» — и число знаков после
|
||||
запятой 0–3 (по умолчанию 2). Выбор применяется на клиенте к уже загруженным значениям (повторного запроса не делает):
|
||||
значение делится на 1000 / 1 000 000 / 1 000 000 000 и показывается с выбранным числом знаков, десятичный разделитель — запятая.
|
||||
Значение, которое не удаётся прочитать как число, SHALL показываться как пришло.
|
||||
Экран SHALL позволять выбрать единицы суммы — «руб.», «тыс. руб.» (по умолчанию), «млн. руб.», «млрд руб.» — и число знаков
|
||||
после запятой 0–3 (по умолчанию 2). Выбор применяется на клиенте к уже загруженным значениям (повторного запроса не делает):
|
||||
запятая сдвигается в десятичной строке на 3 / 6 / 9 разрядов, значение округляется до выбранного числа знаков (половина —
|
||||
от нуля) без перевода в число с плавающей точкой и показывается с разделителем разрядов и десятичной запятой. Значение,
|
||||
округлённое до нуля, показывается без минуса. Пустое значение — пустая ячейка. Значение, которое не удаётся прочитать как
|
||||
число, SHALL показываться как пришло — при любых единицах.
|
||||
|
||||
#### Scenario: Смена единиц
|
||||
- **WHEN** выбраны «млн. руб.» и 1 знак, а сервер вернул `amount_a = "2346459.05"`
|
||||
- **THEN** в ячейке показано `2,3`
|
||||
|
||||
#### Scenario: Точное округление
|
||||
- **WHEN** выбраны «тыс. руб.» и 2 знака, а сервер вернул `"1005"`
|
||||
- **THEN** показано `1,01` (не `1,00`, как дало бы округление числа с плавающей точкой)
|
||||
|
||||
#### Scenario: Маленькая отрицательная сумма
|
||||
- **WHEN** выбраны «тыс. руб.» и 2 знака, а сервер вернул `"-4.00"`
|
||||
- **THEN** показано `0,00`, а не `-0,00`
|
||||
|
||||
### Requirement: Фильтр компаний
|
||||
Выбор «Компании» (множественный, пустой выбор — «Все компании») SHALL ограничивать таблицу на клиенте: остаются строки и
|
||||
Выбор «Компании» (множественный, пустой выбор — «Все компании») SHALL ограничивать матрицу на клиенте: остаются строки и
|
||||
колонки выбранных компаний и ячейки, у которых обе компании выбраны. Запрос на сервер при этом не отправляется. Список
|
||||
вариантов — компании последнего загруженного ответа. Выпадающее окно выбора — дерево с поиском по названию, кнопками
|
||||
применения и сброса.
|
||||
|
||||
#### Scenario: Две компании из периметра
|
||||
- **WHEN** выбраны две компании из пяти
|
||||
- **THEN** таблица содержит две строки и две колонки, и «Итого» считается по оставшимся ячейкам
|
||||
- **THEN** матрица содержит две строки и две колонки
|
||||
|
||||
### Requirement: Фильтр счетов Дт и Кт и режимы расчёта
|
||||
Экран SHALL загружать план счетов `GET /directories/accounts` один раз при открытии и строить из него два дерева по иерархии
|
||||
`parent_id`: дебетовые счета (`is_debit`) и кредитовые (`is_credit`); подпись узла — «код - название». Выбор счетов Дт и Кт —
|
||||
множественный, пустой выбор — «Все счета».
|
||||
`parent_id`: дебетовые счета (`is_debit`) и кредитовые (`is_credit`); подпись узла — «код - название», в том числе у выбранных
|
||||
дочерних счетов в поле выбора. Выбор счетов Дт и Кт — множественный, пустой выбор — «Все счета».
|
||||
|
||||
Режим расчёта определяется выбором: если выбраны счета и в Дт, и в Кт, SHALL выполняться
|
||||
`POST …/intercompany-matrix/manual` с `{debit_account_ids, credit_account_ids}` (режим `manual`); в остальных случаях
|
||||
(ничего не выбрано либо выбрана только одна сторона) SHALL выполняться `GET …/intercompany-matrix/all` (режим `all`). Каждое
|
||||
изменение выбора счетов перезапрашивает матрицу. Кнопка «Сбросить фильтры» появляется, когда выбрано что-либо в компаниях, Дт
|
||||
или Кт, и очищает все три выбора. Выбор единиц и знаков кнопкой не сбрасывается. Ошибка загрузки плана счетов показывается уведомлением.
|
||||
Отметки в дереве SHALL считаться по полному плану счетов: родительский счёт отмечен тогда и только тогда, когда отмечены все
|
||||
его потомки в полном дереве. Поиск по дереву скрывает не совпавшие узлы, но не меняет этого правила: отметка единственного
|
||||
видимого при поиске субсчёта не отмечает его родителя, а снятие отметки с субсчёта снимает её и с родителя. Скрытые поиском
|
||||
отметки при отметке видимых узлов сохраняются.
|
||||
|
||||
Режим расчёта определяется выбором:
|
||||
- ничего не выбрано ни в Дт, ни в Кт — 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: Обе стороны выбраны
|
||||
- **WHEN** выбран счёт 60.01 в Дт и счёт 62.01 в Кт
|
||||
@ -70,28 +118,52 @@
|
||||
|
||||
#### Scenario: Выбрана одна сторона
|
||||
- **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: Сброс
|
||||
- **WHEN** выбраны компании и счета, нажато «Сбросить фильтры»
|
||||
- **THEN** все три выбора пусты, матрица перезапрошена в режиме `all`, кнопка исчезла
|
||||
|
||||
### Requirement: Экспорт шахматки в XLSX
|
||||
Кнопка «Экспорт» SHALL скачивать XLSX в том же режиме, что и таблица: при выбранных счетах и в Дт, и в Кт —
|
||||
`POST …/intercompany-matrix/manual/export` с телом `{debit_account_ids, credit_account_ids}`, иначе
|
||||
`GET …/intercompany-matrix/all/export`. Файл сохраняется под именем из `Content-Disposition`, при его отсутствии — «Шахматка».
|
||||
Пока идёт выгрузка, кнопка в состоянии загрузки; ошибка API показывается уведомлением. Фильтр компаний, единицы и знаки
|
||||
в запрос экспорта не передаются.
|
||||
### Requirement: Экспорт в XLSX
|
||||
Кнопка «Экспорт» матрицы SHALL скачивать XLSX в том же режиме, что и таблица: в режиме `manual` —
|
||||
`POST …/intercompany-matrix/manual/export` с телом `{debit_account_ids, credit_account_ids}`, в режиме `all` —
|
||||
`GET …/intercompany-matrix/all/export`; при выбранной одной стороне кнопка недоступна. Кнопка «Экспорт» результирующей таблицы
|
||||
SHALL скачивать `GET …/intercompany-matrix/result-table/export`. Файл сохраняется под именем из `Content-Disposition`, при его
|
||||
отсутствии — `Шахматка_<ГК>_<год>_<квартал>.xlsx` и `Результирующая_таблица_ВГО_<ГК>_<год>_<квартал>.xlsx` соответственно.
|
||||
Пока идёт выгрузка, в состоянии загрузки только нажатая кнопка; ошибка API показывается уведомлением. Фильтр компаний, единицы и
|
||||
знаки в запрос экспорта не передаются.
|
||||
|
||||
#### Scenario: Экспорт по всем счетам
|
||||
- **WHEN** счета не выбраны и нажато «Экспорт»
|
||||
- **WHEN** счета не выбраны и нажато «Экспорт» матрицы
|
||||
- **THEN** отправлен `GET …/intercompany-matrix/all/export`, браузер сохраняет файл `intercompany-matrix_….xlsx`
|
||||
|
||||
#### Scenario: Экспорт результирующей таблицы
|
||||
- **WHEN** нажато «Экспорт» результирующей таблицы
|
||||
- **THEN** отправлен `GET …/intercompany-matrix/result-table/export`, кнопка «Экспорт» матрицы не переходит в состояние загрузки
|
||||
|
||||
### Requirement: Полноэкранный режим
|
||||
Кнопка «На весь экран» SHALL переводить блок шахматки (панель фильтров и таблицу) в полноэкранный режим средствами Fullscreen API
|
||||
с белым фоном и таблицей на высоту окна; в нём кнопка меняется на «Свернуть». Выход из полноэкранного режима клавишей Esc
|
||||
SHALL возвращать обычный вид и подпись кнопки.
|
||||
Кнопка «На весь экран» матрицы и результирующей таблицы SHALL переводить свой блок (панель фильтров и таблицу) в
|
||||
полноэкранный режим средствами Fullscreen API с белым фоном и таблицей на высоту окна; в нём кнопка меняется на «Свернуть».
|
||||
Выпадающие списки фильтров и подсказки SHALL оставаться видимыми в полноэкранном режиме. Выход клавишей Esc SHALL возвращать
|
||||
обычный вид и подпись кнопки. Отказ браузера войти в полноэкранный режим показывается уведомлением.
|
||||
|
||||
#### Scenario: Вход и выход
|
||||
- **WHEN** нажато «На весь экран», затем Esc
|
||||
- **THEN** блок разворачивается на весь экран, затем возвращается в страницу, кнопка снова «На весь экран»
|
||||
|
||||
#### Scenario: Фильтр в полноэкранном режиме
|
||||
- **WHEN** матрица в полноэкранном режиме и открыт выбор «Компании»
|
||||
- **THEN** выпадающее окно видно поверх полноэкранного блока
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
## 1. Спека и ревью (RSHB/arm-core#158)
|
||||
## 1. Спека и ревью (RSHB/arm-core#158, AURORA-1538)
|
||||
|
||||
- [x] 1.1 Спека `intercompany-matrix-ui` по фактическому поведению ветки `feat/chessTable`.
|
||||
- [ ] 1.2 Автор исправляет пункты «Known issues» из `design.md` и обновляет требования спеки отдельным коммитом.
|
||||
- [ ] 1.3 Перед PR: `make web-check`, `npm test`, `npm run lint` — зелёные, вывод в описание PR.
|
||||
- [ ] 1.4 Сверка экрана с макетом 31-01 в браузере (`docs/mockup/INDEX.md`).
|
||||
- [x] 1.1 Спека `intercompany-matrix-ui` (ревьюер; с раунда 3 — целевое поведение).
|
||||
- [x] 1.2 Тесты форматтера сумм `utils/chessTableCellFormatValue.test.ts` (ревьюер).
|
||||
- [ ] 1.3 Автор исправляет «Known issues» 1–4 из `design.md` (желательное — 5 — по возможности).
|
||||
- [ ] 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 (фронт).
|
||||
|
||||
32
openspec/changes/intercompany-result-table-export/design.md
Normal file
32
openspec/changes/intercompany-result-table-export/design.md
Normal 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.
|
||||
@ -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` — отдельными изменениями.
|
||||
@ -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` не записано
|
||||
@ -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 с тем же телом, что у несуществующей версии
|
||||
10
openspec/changes/intercompany-result-table-export/tasks.md
Normal file
10
openspec/changes/intercompany-result-table-export/tasks.md
Normal 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`
|
||||
50
web/src/utils/chessTableCellFormatValue.test.ts
Normal file
50
web/src/utils/chessTableCellFormatValue.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user