diff --git a/README.md b/README.md index 4867f66..a2a9eaa 100644 --- a/README.md +++ b/README.md @@ -871,8 +871,16 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица `GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица `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; требования — в существующих спецификациях +(аудит `EXPORT`, `report = "intercompany_result_table"`). Режим PRESET (счета и показатель из правила элиминации): +`GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`. +`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной +версии правил — открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ сообщает этот номер. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`, +`ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. Имя XLSX пресета оканчивается на +`_rule-balance-.xlsx`; в шапке — номер, статьи и версия правила, строка «Показатель», +audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `rule_id`, `rule_no` и фактический +`rule_version_no`. Открытые вопросы по пресетам — +`openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта — +`../docs/dev/frontend-api-guide.md` §5.7.1 (docs #146); требования — в существующих спецификациях `openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`. Расчёт читает сводную ОСВ версии, результат не хранится. diff --git a/api/src/api/endpoints/consolidations/elimination_rule.py b/api/src/api/endpoints/consolidations/elimination_rule.py index 66af95f..60f93b1 100644 --- a/api/src/api/endpoints/consolidations/elimination_rule.py +++ b/api/src/api/endpoints/consolidations/elimination_rule.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, status from src.api.dependencies.auth import require from src.api.dependencies.consolidations import EliminationRuleServiceDep from src.schemas.common import ErrorDetail -from src.schemas.consolidations import EliminationRuleOut +from src.schemas.consolidations import EliminationRulesOut router = APIRouter(prefix="/consolidations/versions") @@ -20,7 +20,7 @@ NOT_FOUND: dict[int | str, dict[str, Any]] = { @router.get( "/{version_id}/elimination-rules", - response_model=list[EliminationRuleOut], + response_model=EliminationRulesOut, dependencies=VIEW, responses=NOT_FOUND, summary="Правила для шахматки", @@ -28,6 +28,6 @@ NOT_FOUND: dict[int | str, dict[str, Any]] = { async def list_rules( version_id: uuid.UUID, service: EliminationRuleServiceDep, -) -> list[EliminationRuleOut]: - """Все правила выбранной версии со счетами и исключениями обеих сторон.""" +) -> EliminationRulesOut: + """Правила §4 зафиксированной либо последней открытой версии с основанием выбора.""" return await service.list_rules(version_id) diff --git a/api/src/api/endpoints/consolidations/intercompany_matrix.py b/api/src/api/endpoints/consolidations/intercompany_matrix.py index be82051..5f41642 100644 --- a/api/src/api/endpoints/consolidations/intercompany_matrix.py +++ b/api/src/api/endpoints/consolidations/intercompany_matrix.py @@ -12,7 +12,11 @@ from src.api.dependencies.consolidations import IntercompanyMatrixServiceDep from src.common.filenames import content_disposition from src.models.enums import AuditAction, AuditSection 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_export import build_matrix_workbook, build_result_table_workbook @@ -55,6 +59,8 @@ async def _export_response( selection: IntercompanyMatrixManualIn | None = None, ) -> Response: content = await run_in_threadpool(build_matrix_workbook, matrix, info) + if info.mode == "PRESET" and (info.rule_id is None or info.rule_no is None or info.rule_version_no is None): + raise ValueError("Для аудита PRESET нужны ID, номер и версия правила") response = _xlsx_response(content, info.filename, ascii_fallback="intercompany-matrix.xlsx") await audit.record( action=AuditAction.EXPORT, @@ -67,6 +73,15 @@ async def _export_response( "mode": info.mode, "debit_account_ids": [str(account_id) for account_id in selection.debit_account_ids] if selection else [], "credit_account_ids": [str(account_id) for account_id in selection.credit_account_ids] if selection else [], + **( + { + "rule_id": str(info.rule_id), + "rule_no": info.rule_no, + "rule_version_no": info.rule_version_no, + } + if info.mode == "PRESET" + else {} + ), "cells": len(matrix.cells), }, ) @@ -153,6 +168,40 @@ async def export_intercompany_matrix_manual( ) +@router.get( + "/{version_id}/intercompany-matrix/preset/{rule_id}", + response_model=IntercompanyMatrixOut, + dependencies=VIEW, + responses=ERRORS, + summary="Рассчитать шахматку по пресету", +) +async def build_intercompany_matrix_preset( + version_id: uuid.UUID, + rule_id: uuid.UUID, + service: IntercompanyMatrixServiceDep, +) -> IntercompanyMatrixOut: + """Шахматка §6 по счетам сторон правила §4.""" + return await service.build_accounts(version_id, mode="PRESET", rule_id=rule_id) + + +@router.get( + "/{version_id}/intercompany-matrix/preset/{rule_id}/export", + response_class=Response, + dependencies=VIEW, + responses=EXPORT_RESPONSES, + summary="Скачать шахматку по пресету в XLSX", +) +async def export_intercompany_matrix_preset( + version_id: uuid.UUID, + rule_id: uuid.UUID, + service: IntercompanyMatrixServiceDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], +) -> Response: + """XLSX шахматки §6 по правилу §4 с аудитом выгрузки.""" + matrix, info = await service.build_export(version_id, mode="PRESET", rule_id=rule_id) + return await _export_response(version_id, matrix, info, audit) + + @router.get( "/{version_id}/intercompany-matrix/result-table", response_model=IntercompanyResultTableOut, diff --git a/api/src/repositories/elimination_rules.py b/api/src/repositories/elimination_rules.py index aa0389a..d059395 100644 --- a/api/src/repositories/elimination_rules.py +++ b/api/src/repositories/elimination_rules.py @@ -3,22 +3,28 @@ import uuid from collections.abc import Collection -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount -from src.models.consolidation import ConsolidationVersion async def list_rules_with_accounts( - session: AsyncSession, consolidation_version_id: uuid.UUID + session: AsyncSession, version_no: int | None ) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]: - """Правила версии консолидации по `elimination_rules_version` и счета их сторон.""" + """Правила §4 заданной либо последней открытой версии.""" + condition = ( + EliminationRule.version_no == version_no + if version_no is not None + else ( + EliminationRule.valid_to.is_(None) + & (EliminationRule.version_no == _current_open_version_stmt().scalar_subquery()) + ) + ) stmt = ( select(EliminationRule, EliminationRuleAccount) - .join(ConsolidationVersion, ConsolidationVersion.elimination_rules_version == EliminationRule.version_no) .outerjoin(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id) - .where(ConsolidationVersion.id == consolidation_version_id) + .where(condition) .order_by( EliminationRule.statement_type, EliminationRule.rule_no, @@ -28,6 +34,21 @@ async def list_rules_with_accounts( return [(rule, account) for rule, account in (await session.execute(stmt)).all()] +def _current_open_version_stmt(): + """Запрос номера последней открытой версии правил §4.""" + return select(func.max(EliminationRule.version_no)).where(EliminationRule.valid_to.is_(None)) + + +async def current_open_version_no(session: AsyncSession) -> int | None: + """Номер последней версии с открытыми правилами §4.""" + return await session.scalar(_current_open_version_stmt()) + + +def is_valid_for_version(rule: EliminationRule | None, version_no: int | None) -> bool: + """Проверить версию правила §4 либо отсутствие `valid_to`.""" + return rule is not None and (rule.version_no == version_no if version_no is not None else rule.valid_to is None) + + async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]: """Все правила заданной версии, включая ББ и ОФР, в порядке типа отчётности и номера.""" stmt = ( @@ -51,3 +72,19 @@ async def list_rule_accounts( .order_by(EliminationRuleAccount.rule_id, EliminationRuleAccount.company_no, ChartOfAccount.code) ) return [(account, code) for account, code in (await session.execute(stmt)).all()] + + +async def rule_with_account( + session: AsyncSession, rule_id: uuid.UUID +) -> tuple[EliminationRule | None, list[EliminationRuleAccount]]: + """Правило §4 и его счета; `company_no` задаёт сторону, `is_excluded` — исключение.""" + rule = await session.get(EliminationRule, rule_id) + if rule is None: + return None, [] + stmt = ( + select(EliminationRuleAccount) + .join(ChartOfAccount, ChartOfAccount.id == EliminationRuleAccount.account_id) + .where(EliminationRuleAccount.rule_id == rule_id) + .order_by(EliminationRuleAccount.company_no, ChartOfAccount.code) + ) + return rule, list((await session.scalars(stmt)).all()) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index adde35c..d3863d7 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -318,6 +318,7 @@ class IntercompanyMatrixRuleSideOut(BaseModel): class EliminationRuleOut(BaseModel): """Правило элиминации из БД; ключ — тип отчётности, версия и номер.""" + id: uuid.UUID rule_no: int version_no: int statement_type: StatementType @@ -327,6 +328,14 @@ class EliminationRuleOut(BaseModel): company_2: IntercompanyMatrixRuleSideOut +class EliminationRulesOut(BaseModel): + """Правила и основание выбора их версии.""" + + version_no: int | None + is_version_fixed: bool + rules: list[EliminationRuleOut] + + class IntercompanyMatrixManualIn(BaseModel): """Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки.""" diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index 4e6542b..3dd9166 100644 --- a/api/src/services/consolidated_osv.py +++ b/api/src/services/consolidated_osv.py @@ -13,6 +13,7 @@ import uuid from collections.abc import Collection, Sequence +from dataclasses import dataclass from typing import Literal from fastapi.concurrency import run_in_threadpool @@ -109,7 +110,7 @@ def _source_row(entry: OsvEntry) -> domain.SourceRow: def _candidate_ids(rows: Sequence[domain.SourceRow], index: CounterpartyIndex) -> set[uuid.UUID]: - """Идентификаторы контрагентов, имена которых понадобятся `build`: хранимые `counterparty_id` и результат дорезолва + """Идентификаторы контрагентов для `build_with_members`: хранимые `counterparty_id` и результат дорезолва для строк без него (`resolve` — по словарям индекса, без обращений к БД; ключ склейки включает имя и ИНН, поэтому результат для представителя группы равен результату для любой её строки).""" ids: set[uuid.UUID] = set() @@ -129,6 +130,16 @@ async def _entities(session: AsyncSession, ids: Collection[uuid.UUID]) -> dict[u return {entity.id: _entity(entity) for entity in found.scalars()} +@dataclass(frozen=True) +class ConsolidatedOsvBuildResult: + """Свод и данные версии, загруженные для его расчёта.""" + + consolidated: domain.Consolidated + version: ConsolidationVersion + consolidation: Consolidation + members: dict[uuid.UUID, domain.Entity] + + class ConsolidatedOsvService: """Чтение сводной ОСВ версии: страница с фильтрами и сводка с пробелами (без блокировок и аудита).""" @@ -136,15 +147,8 @@ class ConsolidatedOsvService: self._session = session self._scope = scope - async def build(self, version_id: uuid.UUID) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation]: - """Свод версии (`domain.build`) вместе с версией и консолидацией; версия вне видимости — 404.""" - consolidated, version, consolidation, _ = await self.build_with_members(version_id) - return consolidated, version, consolidation - - async def build_with_members( - self, version_id: uuid.UUID - ) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation, dict[uuid.UUID, domain.Entity]]: - """Свод и участники периметра, загруженные при его расчёте.""" + async def build_with_members(self, version_id: uuid.UUID) -> ConsolidatedOsvBuildResult: + """Свод и участники периметра, загруженные при его расчёте; версия вне видимости — 404.""" found = await repo.get_version(self._session, self._scope, version_id) if found is None: raise NotFoundError(VERSION_NOT_FOUND) @@ -174,12 +178,17 @@ class ConsolidatedOsvService: rows=rows, resolver=index, ) - return consolidated, version, consolidation, members + return ConsolidatedOsvBuildResult( + consolidated=consolidated, + version=version, + consolidation=consolidation, + members=members, + ) async def page(self, version_id: uuid.UUID, f: OsvFilters, page: OsvPageParams) -> Page[ConsolidatedRowOut]: """Страница свода после фильтров: `total` — число строк после фильтра, срез — в памяти (порядок D-16).""" - consolidated, _, _ = await self.build(version_id) - rows = domain.apply_filter(consolidated.rows, domain_filter(f)) + result = await self.build_with_members(version_id) + rows = domain.apply_filter(result.consolidated.rows, domain_filter(f)) window = rows[page.offset : page.offset + page.limit] return Page[ConsolidatedRowOut]( items=[row_out(row) for row in window], total=len(rows), limit=page.limit, offset=page.offset @@ -189,48 +198,47 @@ class ConsolidatedOsvService: """xlsx сводной ОСВ (spec «Экспорт xlsx», D-14): строки после тех же фильтров, что у `page`, без пагинации, в порядке D-16; лист пробелов — по своду целиком. Имя файла — `svod-osv_<ГК>_<год>-<квартал>_v.xlsx` (без percent-кодирования: оно в маршруте). `audit_event(EXPORT)` пишется после сборки книги, в транзакции запроса.""" - consolidated, version, consolidation = await self.build(version_id) - rows = domain.apply_filter(consolidated.rows, domain_filter(f)) - content = await run_in_threadpool(build_workbook, consolidated, rows) + result = await self.build_with_members(version_id) + rows = domain.apply_filter(result.consolidated.rows, domain_filter(f)) + content = await run_in_threadpool(build_workbook, result.consolidated, rows) group_name = ( await self._session.execute( - select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id) + select(CompanyGroup.name).where(CompanyGroup.id == result.consolidation.company_group_id) ) ).scalar_one() await audit.record( action=AuditAction.EXPORT, section=AuditSection.EXPORT, object_type="consolidation_version", - object_id=version.id, - consolidation_version_id=version.id, + object_id=result.version.id, + consolidation_version_id=result.version.id, after_value={ "report": "consolidated_osv", "filters": f.model_dump(mode="json", exclude_none=True), "rows": len(rows), }, ) - filename = ( - f"svod-osv_{group_name}_{consolidation.year}-{consolidation.quarter.value}_v{version.version_no}.xlsx" - ) + period = f"{result.consolidation.year}-{result.consolidation.quarter.value}" + filename = f"svod-osv_{group_name}_{period}_v{result.version.version_no}.xlsx" return content, filename async def summary(self, version_id: uuid.UUID) -> OsvSummaryOut: """Сводка: строки, несопоставленные, автосопоставленные по имени/алиасу, ОСВ-документы версии и пробелы.""" - consolidated, _, _ = await self.build(version_id) - rows_total, unmapped_total, auto_matched_total = domain.summary(consolidated) + result = await self.build_with_members(version_id) + rows_total, unmapped_total, auto_matched_total = domain.summary(result.consolidated) return OsvSummaryOut( rows_total=rows_total, unmapped_total=unmapped_total, auto_matched_total=auto_matched_total, - documents_total=consolidated.documents_total, - gaps=[GapOut.model_validate(gap, from_attributes=True) for gap in consolidated.gaps], + documents_total=result.consolidated.documents_total, + gaps=[GapOut.model_validate(gap, from_attributes=True) for gap in result.consolidated.gaps], ) async def row_for_entry(self, version_id: uuid.UUID, entry_id: uuid.UUID) -> ConsolidatedRowOut | None: """Строка свода, в `entry_ids` которой есть `entry_id`; `None` — строка в своде не участвует (пара с пробелом или документ не выбран источником при наличии нарастающего).""" - consolidated, _, _ = await self.build(version_id) - row = next((row for row in consolidated.rows if entry_id in row.entry_ids), None) + result = await self.build_with_members(version_id) + row = next((row for row in result.consolidated.rows if entry_id in row.entry_ids), None) return None if row is None else row_out(row) async def candidates( diff --git a/api/src/services/elimination_rules.py b/api/src/services/elimination_rules.py index 5e7191d..a67171e 100644 --- a/api/src/services/elimination_rules.py +++ b/api/src/services/elimination_rules.py @@ -8,7 +8,7 @@ from src.common.exceptions import NotFoundError from src.domain.policy import VisibilityScope from src.repositories import consolidations as consolidations_repo from src.repositories import elimination_rules as rules_repo -from src.schemas.consolidations import EliminationRuleOut, IntercompanyMatrixRuleSideOut +from src.schemas.consolidations import EliminationRuleOut, EliminationRulesOut, IntercompanyMatrixRuleSideOut VERSION_NOT_FOUND = "версия консолидации не найдена" @@ -20,18 +20,24 @@ class EliminationRuleService: self._session = session self._scope = scope - async def list_rules(self, consolidation_version_id: uuid.UUID) -> list[EliminationRuleOut]: + async def list_rules(self, consolidation_version_id: uuid.UUID) -> EliminationRulesOut: + """Правила §4 версии консолидации с проверкой видимости и основанием выбора.""" found = await consolidations_repo.get_version(self._session, self._scope, consolidation_version_id) if found is None: raise NotFoundError(VERSION_NOT_FOUND) - - rows = await rules_repo.list_rules_with_accounts(self._session, consolidation_version_id) + version, _ = found + is_version_fixed = version.elimination_rules_version is not None + version_no = version.elimination_rules_version + rows = await rules_repo.list_rules_with_accounts(self._session, version_no) + if not is_version_fixed and rows: + version_no = rows[0][0].version_no by_id: dict[uuid.UUID, EliminationRuleOut] = {} for rule, account in rows: result = by_id.get(rule.id) if result is None: result = EliminationRuleOut.model_validate( { + "id": rule.id, "rule_no": rule.rule_no, "version_no": rule.version_no, "statement_type": rule.statement_type, @@ -48,4 +54,8 @@ class EliminationRuleService: side = result.company_1 if account.company_no == 1 else result.company_2 (side.excluded if account.is_excluded else side.accounts).append(account.account_id) - return list(by_id.values()) + return EliminationRulesOut( + version_no=version_no, + is_version_fixed=is_version_fixed, + rules=list(by_id.values()), + ) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index bbc0014..1ce8fe0 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -10,14 +10,15 @@ from typing import Literal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.common.exceptions import DomainValidationError +from src.common.exceptions import DomainValidationError, NotFoundError from src.domain.consolidated_osv import ConsolidatedRow, Entity from src.domain.policy import VisibilityScope -from src.models.accounting import ChartOfAccount +from src.models.accounting import ChartOfAccount, EliminationRule from src.models.consolidation import Consolidation, ConsolidationVersion from src.models.directories import CompanyGroup -from src.models.enums import AccountKind, PeriodQuarter +from src.models.enums import AccountKind, PeriodQuarter, StatementType from src.repositories import directories as directory_repo +from src.repositories import elimination_rules as elimination_rules_repo from src.schemas.consolidations import ( IntercompanyMatrixCellOut, IntercompanyMatrixCompanyOut, @@ -75,6 +76,18 @@ class MatrixSelection: measure: Measure +@dataclass(frozen=True) +class MatrixBuildResult: + """Матрица и данные свода, использованные при её построении.""" + + matrix: IntercompanyMatrixOut + version: ConsolidationVersion + consolidation: Consolidation + accounts: list[ChartOfAccount] + selection: MatrixSelection + rule: EliminationRule | None + + @dataclass(frozen=True) class VersionExportInfo: """Реквизиты версии в шапке и имени книги.""" @@ -90,13 +103,41 @@ class VersionExportInfo: @dataclass(frozen=True) class MatrixExportInfo(VersionExportInfo): - mode: Literal["ALL", "MANUAL"] + mode: Literal["ALL", "MANUAL", "PRESET"] debit_account_codes: tuple[str, ...] credit_account_codes: tuple[str, ...] + measure: Measure = "closing" + rule_id: uuid.UUID | None = None + rule_no: int | None = None + rule_version_no: int | None = None + rule_statement_type: StatementType | None = None + article_1_line: str | None = None + article_2_line: str | None = None + + @property + def preset_details(self) -> tuple[StatementType, int, int, str, str]: + """Реквизиты правила §4 для имени и шапки XLSX шахматки §6.""" + details = ( + self.rule_statement_type, + self.rule_no, + self.rule_version_no, + self.article_1_line, + self.article_2_line, + ) + if any(value is None for value in details): + raise ValueError("Для выгрузки PRESET нужны тип, номер, версия и статьи правила") + statement_type, rule_no, rule_version_no, article_1_line, article_2_line = details + assert statement_type is not None and rule_no is not None and rule_version_no is not None + assert article_1_line is not None and article_2_line is not None + return statement_type, rule_no, rule_version_no, article_1_line, article_2_line @property def filename(self) -> str: - return self._filename("intercompany-matrix") + preset = "" + if self.mode == "PRESET": + statement_type, rule_no, _, _, _ = self.preset_details + preset = f"_rule-{statement_type.value}-{rule_no}" + return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}{preset}.xlsx" @dataclass(frozen=True) @@ -113,7 +154,7 @@ class ResultTableBuild: consolidation: Consolidation -def _amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal: +def _get_amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal: if measure == "closing": value = row.closing_debit if side == "D" else row.closing_credit else: @@ -152,11 +193,11 @@ def calculate_matrix( continue if row.account_id in selection.company_1_account_ids: item = amounts_1[pair] - item["amount"] += _amount(row, selection.measure, "D") + item["amount"] += _get_amount(row, selection.measure, "D") item["entry_osv_ids"].update(row.entry_ids) if row.account_id in selection.company_2_account_ids: item = amounts_2[pair] - item["amount"] += _amount(row, selection.measure, "K") + item["amount"] += _get_amount(row, selection.measure, "K") item["entry_osv_ids"].update(row.entry_ids) cells: list[IntercompanyMatrixCellOut] = [] @@ -195,7 +236,7 @@ def calculate_matrix( return IntercompanyMatrixOut(companies=list(company_info), cells=cells) -def _account_tree( +def _make_account_tree( accounts: Sequence[ChartOfAccount], ) -> tuple[dict[uuid.UUID, ChartOfAccount], dict[uuid.UUID, list[uuid.UUID]]]: by_id = {account.id: account for account in accounts} @@ -218,11 +259,11 @@ def _expand(roots: Iterable[uuid.UUID], children: dict[uuid.UUID, list[uuid.UUID return result -def _kinds(side: Side) -> frozenset[AccountKind]: +def _get_kinds(side: Side) -> frozenset[AccountKind]: return DEBIT_KINDS if side == "D" else CREDIT_KINDS -def _selected_accounts( +def _select_accounts( included: Iterable[uuid.UUID], excluded: Iterable[uuid.UUID], side: Side, @@ -234,10 +275,10 @@ def _selected_accounts( if missing: missing_ids = ", ".join(map(str, sorted(missing))) raise DomainValidationError("ACCOUNT_NOT_FOUND", f"Выбранного счёта нет ({missing_ids})") - if any(by_id[account_id].kind not in _kinds(side) for account_id in roots): + if any(by_id[account_id].kind not in _get_kinds(side) for account_id in roots): raise DomainValidationError("ACCOUNT_KIND_MISMATCH", "Сторона счёта не соответствует направлению расчёта") selected = _expand(roots, children) - _expand(excluded, children) - return frozenset(account_id for account_id in selected if by_id[account_id].kind in _kinds(side)) + return frozenset(account_id for account_id in selected if by_id[account_id].kind in _get_kinds(side)) def calculate_result_table( @@ -246,7 +287,7 @@ def calculate_result_table( accounts: Sequence[ChartOfAccount], ) -> IntercompanyResultTableOut: """Фиксированные статьи ВГО: сальдо по всем контрагентам периметра для каждого ЮЛ.""" - by_id, children = _account_tree(accounts) + by_id, children = _make_account_tree(accounts) by_code = {account.code: account.id for account in accounts} required_codes = {code for article in RESULT_ARTICLES for code in (*article.account_codes, *article.excluded_codes)} missing = sorted(required_codes - by_code.keys()) @@ -254,7 +295,7 @@ def calculate_result_table( raise DomainValidationError("RESULT_ACCOUNTS_MISSING", f"В плане счетов нет счетов: {', '.join(missing)}") selected = { - article.code: _selected_accounts( + article.code: _select_accounts( (by_code[code] for code in article.account_codes), (by_code[code] for code in article.excluded_codes), article.side, @@ -278,7 +319,7 @@ def calculate_result_table( continue for article in RESULT_ARTICLES: if row.account_id in selected[article.code]: - amounts[article.code][row.legal_entity_id] += _amount(row, "closing", article.side) + amounts[article.code][row.legal_entity_id] += _get_amount(row, "closing", article.side) articles: list[IntercompanyResultArticleOut] = [] for article in RESULT_ARTICLES: @@ -311,8 +352,8 @@ class IntercompanyMatrixService: selection: MatrixSelection, ) -> IntercompanyMatrixOut: """Счета принимает готовыми; выбор режима ALL/MANUAL выполняется вызывающим кодом.""" - consolidated, _, _, members = await self._osv_service.build_with_members(version_id) - return self._calculate(consolidated.rows, members, selection) + result = await self._osv_service.build_with_members(version_id) + return self._calculate(result.consolidated.rows, result.members, selection) def _calculate( self, rows: Iterable[ConsolidatedRow], members: dict[uuid.UUID, Entity], selection: MatrixSelection @@ -346,73 +387,157 @@ class IntercompanyMatrixService: ).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) + result = 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()] - result = calculate_result_table(consolidated.rows, companies, accounts) - return ResultTableBuild(result, version, consolidation) + companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in result.members.values()] + table = calculate_result_table(result.consolidated.rows, companies, accounts) + return ResultTableBuild(table, result.version, result.consolidation) async def build_accounts( self, version_id: uuid.UUID, *, - mode: Literal["ALL", "MANUAL"], + mode: Literal["ALL", "MANUAL", "PRESET"], debit_account_ids: Sequence[uuid.UUID] = (), credit_account_ids: Sequence[uuid.UUID] = (), + rule_id: uuid.UUID | None = None, ) -> IntercompanyMatrixOut: - """ALL использует все счета нужного вида; MANUAL раскрывает выбранные счета и потомков.""" - matrix, _, _, _ = await self._build_accounts( - version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids + """Построить шахматку по выбранному режиму. + + ALL включает все счета соответствующей стороны; MANUAL — выбранные счета и их потомков; + PRESET берёт счета и показатель из правила с идентификатором `rule_id`. + """ + result = await self._build_accounts( + version_id, + mode=mode, + debit_account_ids=debit_account_ids, + credit_account_ids=credit_account_ids, + rule_id=rule_id, ) - return matrix + return result.matrix async def build_export( self, version_id: uuid.UUID, *, - mode: Literal["ALL", "MANUAL"], + mode: Literal["ALL", "MANUAL", "PRESET"], debit_account_ids: Sequence[uuid.UUID] = (), credit_account_ids: Sequence[uuid.UUID] = (), + rule_id: uuid.UUID | None = None, ) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]: """Матрица и реквизиты книги из одного расчёта свода.""" - matrix, version, consolidation, accounts = await self._build_accounts( - version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids - ) - by_id = {account.id: account.code for account in accounts} - info = MatrixExportInfo( - group_name=await self._group_name(consolidation), - year=consolidation.year, - quarter=consolidation.quarter, - version_no=version.version_no, + result = await self._build_accounts( + version_id, mode=mode, - debit_account_codes=tuple(by_id[account_id] for account_id in debit_account_ids), - credit_account_codes=tuple(by_id[account_id] for account_id in credit_account_ids), + debit_account_ids=debit_account_ids, + credit_account_ids=credit_account_ids, + rule_id=rule_id, ) - return matrix, info + group_name = await self._group_name(result.consolidation) + by_id = {account.id: account.code for account in result.accounts} + if mode == "PRESET": + debit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_1_account_ids)) + credit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_2_account_ids)) + else: + debit_codes = tuple(by_id[account_id] for account_id in debit_account_ids) + credit_codes = tuple(by_id[account_id] for account_id in credit_account_ids) + info = MatrixExportInfo( + group_name=group_name, + year=result.consolidation.year, + quarter=result.consolidation.quarter, + version_no=result.version.version_no, + mode=mode, + debit_account_codes=debit_codes, + credit_account_codes=credit_codes, + measure=result.selection.measure, + rule_id=result.rule.id if result.rule is not None else None, + rule_no=result.rule.rule_no if result.rule is not None else None, + rule_version_no=result.rule.version_no if result.rule is not None else None, + rule_statement_type=result.rule.statement_type if result.rule is not None else None, + article_1_line=result.rule.article_1_line if result.rule is not None else None, + article_2_line=result.rule.article_2_line if result.rule is not None else None, + ) + return result.matrix, info + + async def _resolve_preset_selection( + self, + rule_id: uuid.UUID | None, + version: ConsolidationVersion, + by_id: dict[uuid.UUID, ChartOfAccount], + children: dict[uuid.UUID, list[uuid.UUID]], + ) -> tuple[MatrixSelection, EliminationRule]: + """Выбрать стороны счетов и показатель правила §4 для шахматки §6.""" + if rule_id is None: + raise DomainValidationError("RULE_REQUIRED", "Выберите правило-пресет") + rule, rule_accounts = await elimination_rules_repo.rule_with_account(self._session, rule_id) + if rule is None or not elimination_rules_repo.is_valid_for_version(rule, version.elimination_rules_version): + raise NotFoundError("правило-пресет не найдено для версии консолидации") + if version.elimination_rules_version is None: + open_version_no = await elimination_rules_repo.current_open_version_no(self._session) + if rule.version_no != open_version_no: + raise NotFoundError("правило-пресет не найдено для версии консолидации") + if rule.statement_type != StatementType.balance: + raise DomainValidationError("INVALID_RULE", "Шахматка PRESET поддерживает только правила ББ") + if rule.side_1 not in ("D", "K") or rule.measure not in ("closing", "turnover"): + raise DomainValidationError("INVALID_RULE", "Неверное направление или показатель правила-пресета") + + sides: dict[int, tuple[list[uuid.UUID], list[uuid.UUID]]] = { + 1: ([], []), + 2: ([], []), + } + for rule_account in rule_accounts: + if rule_account.company_no not in sides: + raise DomainValidationError("INVALID_RULE", "Неверная сторона счёта в правиле-пресете") + included, excluded = sides[rule_account.company_no] + (excluded if rule_account.is_excluded else included).append(rule_account.account_id) + debit_side = 1 if rule.side_1 == "D" else 2 + credit_side = 2 if debit_side == 1 else 1 + debit_ids, debit_excluded = sides[debit_side] + credit_ids, credit_excluded = sides[credit_side] + if not debit_ids or not credit_ids: + raise DomainValidationError("ACCOUNTS_REQUIRED", "В правиле-пресете нужны счета обеих сторон") + debit = _select_accounts(debit_ids, debit_excluded, "D", by_id, children) + credit = _select_accounts(credit_ids, credit_excluded, "K", by_id, children) + measure: Measure = "closing" if rule.measure == "closing" else "turnover" + + return MatrixSelection(debit, credit, measure), rule async def _build_accounts( self, version_id: uuid.UUID, *, - mode: Literal["ALL", "MANUAL"], + mode: Literal["ALL", "MANUAL", "PRESET"], debit_account_ids: Sequence[uuid.UUID], credit_account_ids: Sequence[uuid.UUID], - ) -> tuple[IntercompanyMatrixOut, ConsolidationVersion, Consolidation, list[ChartOfAccount]]: - consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id) + rule_id: uuid.UUID | None = None, + ) -> MatrixBuildResult: + osv_result = await self._osv_service.build_with_members(version_id) + rows = osv_result.consolidated.rows accounts = await directory_repo.list_accounts(self._session) - by_id, children = _account_tree(accounts) + by_id, children = _make_account_tree(accounts) + rule: EliminationRule | None = None if mode == "ALL": if debit_account_ids or credit_account_ids: raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для режима ALL не нужно указывать счета") debit = frozenset(account.id for account in accounts if account.kind in DEBIT_KINDS) credit = frozenset(account.id for account in accounts if account.kind in CREDIT_KINDS) + selection = MatrixSelection(debit, credit, "closing") elif mode == "MANUAL": if not debit_account_ids or not credit_account_ids: raise DomainValidationError("ACCOUNTS_REQUIRED", "Выберите дебетовые и кредитовые счета") - debit = _selected_accounts(debit_account_ids, (), "D", by_id, children) - credit = _selected_accounts(credit_account_ids, (), "K", by_id, children) + debit = _select_accounts(debit_account_ids, (), "D", by_id, children) + credit = _select_accounts(credit_account_ids, (), "K", by_id, children) + selection = MatrixSelection(debit, credit, "closing") + elif mode == "PRESET": + selection, rule = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children) else: raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов") - selection = MatrixSelection(debit, credit, "closing") - matrix = self._calculate(consolidated.rows, members, selection) - return matrix, version, consolidation, accounts + matrix = self._calculate(rows, osv_result.members, selection) + return MatrixBuildResult( + matrix=matrix, + version=osv_result.version, + consolidation=osv_result.consolidation, + accounts=accounts, + selection=selection, + rule=rule, + ) diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index 5e91e55..00b6242 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -12,8 +12,6 @@ from src.services.intercompany_matrix import MatrixExportInfo, ResultTableExport from src.services.osv_export import MONEY_FORMAT METRICS = ("Разница", "Дебет", "Кредит", "Расхождение, %") -TABLE_HEADER_ROW = 10 -DATA_START_ROW = TABLE_HEADER_ROW + 1 PERCENT_FORMAT = '0.00"%"' THRESHOLD_FILL = PatternFill("solid", fgColor="FCE8E6") THRESHOLD_FONT = Font(color="9C0006", bold=True) @@ -35,7 +33,7 @@ def _skipped_comment(sheet: Worksheet, row: int, column: int, count: int) -> Non def _write_details( sheet: Worksheet, title: str, info: VersionExportInfo, units: str, extra: tuple[tuple[str, str], ...] = () -) -> None: +) -> int: """Заголовок и строки «реквизит — значение» над таблицей книги.""" _text(sheet, 1, 1, title) details = ( @@ -49,19 +47,34 @@ def _write_details( _text(sheet, row, 1, label) _text(sheet, row, 2, value) sheet.cell(row, 1).font = Font(bold=True) + return len(details) + 2 -def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None: - _write_details( +def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> int: + mode_name = {"ALL": "Все счета", "MANUAL": "Выбранные счета", "PRESET": "Пресет"}[info.mode] + extra = [ + ("Режим", mode_name), + ("Счета дебета", ", ".join(info.debit_account_codes) if info.mode != "ALL" else "Все счета"), + ("Счета кредита", ", ".join(info.credit_account_codes) if info.mode != "ALL" else "Все счета"), + ("Показатель", "Сальдо" if info.measure == "closing" else "Оборот"), + ] + if info.mode == "PRESET": + statement_type, rule_no, rule_version_no, article_1_line, article_2_line = info.preset_details + rule_type = "ББ" if statement_type.value == "balance" else "ОФР" + extra.extend( + ( + ("Правило", f"{rule_type} № {rule_no}"), + ("Статья А", article_1_line), + ("Статья Б", article_2_line), + ("Версия правил", f"v{rule_version_no}"), + ) + ) + return _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 "Все счета"), - ), + tuple(extra), ) @@ -78,24 +91,25 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) sheet = workbook.active assert sheet is not None sheet.title = "Шахматка" - _write_context(sheet, info) - _text(sheet, TABLE_HEADER_ROW, 1, "Дебет ↓ / Кредит →") - _text(sheet, TABLE_HEADER_ROW, 2, "Показатель") + table_header_row = _write_context(sheet, info) + data_start_row = table_header_row + 1 + _text(sheet, table_header_row, 1, "Дебет ↓ / Кредит →") + _text(sheet, table_header_row, 2, "Показатель") sheet.column_dimensions["A"].width = 32 sheet.column_dimensions["B"].width = 40 for column, company in enumerate(matrix.companies, start=3): - _text(sheet, TABLE_HEADER_ROW, column, company.name) + _text(sheet, table_header_row, column, company.name) sheet.column_dimensions[get_column_letter(column)].width = 26 - sheet.row_dimensions[TABLE_HEADER_ROW].height = 36 - for header in sheet[TABLE_HEADER_ROW]: + sheet.row_dimensions[table_header_row].height = 36 + for header in sheet[table_header_row]: header.font = Font(bold=True) header.fill = HEADER_FILL header.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) cells = {(cell.company_a_id, cell.company_b_id): cell for cell in matrix.cells} for company_index, debit_company in enumerate(matrix.companies): - start_row = DATA_START_ROW + company_index * len(METRICS) + start_row = data_start_row + company_index * len(METRICS) end_row = start_row + len(METRICS) - 1 sheet.merge_cells(start_row=start_row, start_column=1, end_row=end_row, end_column=1) _text(sheet, start_row, 1, debit_company.name) @@ -126,7 +140,7 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) marked.fill = THRESHOLD_FILL marked.font = THRESHOLD_FONT - sheet.freeze_panes = f"C{DATA_START_ROW}" + sheet.freeze_panes = f"C{data_start_row}" buffer = BytesIO() workbook.save(buffer) return buffer.getvalue() diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 950e7a7..df81f5e 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -13,11 +13,11 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.models.access import AppUser -from src.models.accounting import ChartOfAccount +from src.models.accounting import ChartOfAccount, EliminationRule from src.models.audit_ex import AuditEvent from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice -from src.models.enums import AuditAction, AuditSection, CounterpartyMatchKind, PeriodQuarter +from src.models.enums import AuditAction, AuditSection, CounterpartyMatchKind, PeriodQuarter, StatementType from src.models.osv import OsvEntry from src.repositories import consolidations as consolidation_repo from src.repositories import directories as directory_repo @@ -27,6 +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" +RECEIVABLES_PAYABLES_RULE_NO = 6 ROUTES = ("all", "manual", "result-table", "all/export", "manual/export", "result-table/export") @@ -129,7 +130,11 @@ async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seed assert response.status_code == OK, response.text assert len(response.json()["cells"]) == len(world.members) for cell in response.json()["cells"]: - assert {"osv_entry_ids_debit", "osv_entry_ids_credit", "exceeds_threshold"} <= cell.keys() + assert { + "osv_entry_ids_debit", + "osv_entry_ids_credit", + "exceeds_threshold", + } <= cell.keys() assert {"entry_osv_ids_debit", "entry_osv_ids_credit", "is_more_5_percent"}.isdisjoint(cell) assert cell["amount_a"] == cell["amount_b"] == cell["variance_amount"] == "0.00" assert cell["variance_percent"] is None @@ -138,6 +143,127 @@ async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seed assert cell["skipped_osv_entry_count"] == 0 +async def test_preset_uses_rule_sides_and_exclusions(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + rule = ( + await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO)) + ).one() + world.version.elimination_rules_version = rule.version_no + await seeded.flush() + await _source(seeded, world, credit=False, amount="100", account="62.01") + await _source(seeded, world, credit=True, amount="90", account="60.01") + await _source(seeded, world, credit=False, amount="500", account="76.07.5") + await _source(seeded, world, credit=True, amount="400", account="76.07.1") + + client = await auth_client(KA_RF) + response = await client.get(f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}") + assert response.status_code == OK, response.text + cell = next(item for item in response.json()["cells"] if item["company_a_id"] == str(world.members[0].id)) + assert cell["amount_a"] == "100.00" + assert cell["amount_b"] == "90.00" + + exported = await client.get( + f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}/export" + ) + assert exported.status_code == OK, exported.text + assert f"_rule-balance-{rule.rule_no}.xlsx" in unquote(exported.headers["content-disposition"]) + sheet = load_workbook(BytesIO(exported.content))["Шахматка"] + assert sheet["B6"].value == "Пресет" + assert "76.07.5" not in sheet["B7"].value + assert "76.07.1" not in sheet["B8"].value + assert sheet["B9"].value == "Сальдо" + assert sheet["B10"].value == f"ББ № {rule.rule_no}" + assert sheet["B11"].value == rule.article_1_line + assert sheet["B12"].value == rule.article_2_line + assert sheet["B13"].value == f"v{rule.version_no}" + assert Decimal(str(sheet["D16"].value)) == Decimal("100") + assert Decimal(str(sheet["D17"].value)) == Decimal("90") + audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() + assert audit.after_value["mode"] == "PRESET" + assert audit.after_value["rule_id"] == str(rule.id) + assert audit.after_value["rule_no"] == rule.rule_no + assert audit.after_value["rule_version_no"] == rule.version_no + + +@pytest.mark.parametrize("suffix", ["", "/export"]) +async def test_preset_rejects_income_rule(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None: + world = await _world(seeded) + rule = ( + await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO)) + ).one() + rule.statement_type = StatementType.income + await seeded.flush() + + client = await auth_client(KA_RF) + response = await client.get( + f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}{suffix}" + ) + assert response.status_code == UNPROCESSABLE, response.text + assert response.json()["detail"]["code"] == "INVALID_RULE" + + +async def test_preset_uses_current_rules_when_version_unfixed( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + world = await _world(seeded) + rule = ( + await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO)) + ).one() + client = await auth_client(KA_RF) + url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}" + listed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules") + assert listed.status_code == OK, listed.text + assert listed.json()["is_version_fixed"] is False + assert listed.json()["version_no"] == rule.version_no + assert "as_of" not in listed.json() + assert str(rule.id) in {item["id"] for item in listed.json()["rules"]} + response = await client.get(url) + assert response.status_code == OK, response.text + assert (await client.get(f"{url}/export")).status_code == OK + audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() + assert audit.after_value["rule_no"] == rule.rule_no + assert audit.after_value["rule_version_no"] == rule.version_no + assert world.version.elimination_rules_version is None + world.version.elimination_rules_version = rule.version_no + 1 + await seeded.flush() + assert (await client.get(url)).status_code == NOT_FOUND + assert (await client.get(f"{url}/export")).status_code == NOT_FOUND + world.version.elimination_rules_version = rule.version_no + await seeded.flush() + fixed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules") + assert fixed.status_code == OK, fixed.text + assert fixed.json()["is_version_fixed"] is True + assert fixed.json()["version_no"] == rule.version_no + missing_url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{uuid.uuid4()}" + missing = await client.get(missing_url) + assert missing.status_code == NOT_FOUND + assert (await client.get(f"{missing_url}/export")).status_code == NOT_FOUND + + +async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + old_rule, new_rule = ( + await seeded.scalars(select(EliminationRule).order_by(EliminationRule.rule_no).limit(2)) + ).all() + assert old_rule.valid_to is None and new_rule.valid_to is None + new_rule.rule_no = old_rule.rule_no + new_rule.version_no = old_rule.version_no + 1 + await seeded.flush() + + client = await auth_client(KA_RF) + base = f"/consolidations/versions/{world.version.id}" + response = await client.get(f"{base}/elimination-rules") + assert response.status_code == OK, response.text + payload = response.json() + assert payload["is_version_fixed"] is False + assert payload["version_no"] == new_rule.version_no + assert [item["id"] for item in payload["rules"]] == [str(new_rule.id)] + assert payload["rules"][0]["rule_no"] == old_rule.rule_no + + old_preset = await client.get(f"{base}/intercompany-matrix/preset/{old_rule.id}") + assert old_preset.status_code == NOT_FOUND + + async def test_unmapped_account_source_is_visible_in_cell_count( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: diff --git a/api/tests/test_elimination_rules.py b/api/tests/test_elimination_rules.py new file mode 100644 index 0000000..fd5615e --- /dev/null +++ b/api/tests/test_elimination_rules.py @@ -0,0 +1,113 @@ +"""Выбор версии правил для шахматки без обращения к БД.""" + +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.models.enums import StatementType +from src.repositories import elimination_rules as rules_repo +from src.services import elimination_rules as service_module + + +def _rule() -> SimpleNamespace: + return SimpleNamespace( + id=uuid.uuid4(), + statement_type=StatementType.balance, + rule_no=2, + version_no=3, + side_1="D", + measure="closing", + ) + + +@pytest.mark.parametrize( + ("rule", "version_no", "expected"), + [ + (None, 3, False), + (SimpleNamespace(version_no=3, valid_to=None), None, True), + (SimpleNamespace(version_no=3, valid_to="2026-12-31"), None, False), + (SimpleNamespace(version_no=3), 2, False), + (SimpleNamespace(version_no=3), 3, True), + (SimpleNamespace(version_no=3, valid_to="2026-12-31"), 3, True), + ], +) +def test_rule_is_valid_for_version(rule: SimpleNamespace | None, version_no: int | None, expected: bool) -> None: + assert rules_repo.is_valid_for_version(rule, version_no) is expected + + +async def test_rules_filter_by_version_or_open_valid_to() -> None: + version_no = 3 + session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(all=lambda: []))) + + await rules_repo.list_rules_with_accounts(session, None) + query = str(session.execute.await_args.args[0]) + assert "valid_to IS NULL" in query + assert "max(" in query + assert "version_no = (SELECT" in query + + await rules_repo.list_rules_with_accounts(session, version_no) + assert "valid_to IS NULL" not in str(session.execute.await_args.args[0]) + assert "version_no =" in str(session.execute.await_args.args[0]) + + +async def test_fixed_rules_use_version_number(monkeypatch: pytest.MonkeyPatch) -> None: + session, scope = object(), object() + version_id = uuid.uuid4() + rule = _rule() + account_id = uuid.uuid4() + account = SimpleNamespace(company_no=1, account_id=account_id, is_excluded=False) + monkeypatch.setattr( + service_module.consolidations_repo, + "get_version", + AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=rule.version_no), None)), + ) + listed = AsyncMock(return_value=[(rule, account)]) + monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed) + + result = await service_module.EliminationRuleService(session, scope).list_rules(version_id) + + assert result.is_version_fixed is True + assert result.version_no == rule.version_no + assert [item.id for item in result.rules] == [rule.id] + assert result.rules[0].company_1.accounts == [account_id] + listed.assert_awaited_once_with(session, rule.version_no) + + +async def test_unfixed_rules_use_open_valid_to(monkeypatch: pytest.MonkeyPatch) -> None: + session, scope = object(), object() + version_id = uuid.uuid4() + rule = _rule() + monkeypatch.setattr( + service_module.consolidations_repo, + "get_version", + AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)), + ) + listed = AsyncMock(return_value=[(rule, None)]) + monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed) + + result = await service_module.EliminationRuleService(session, scope).list_rules(version_id) + + assert result.is_version_fixed is False + assert result.version_no == rule.version_no + assert [item.id for item in result.rules] == [rule.id] + listed.assert_awaited_once_with(session, None) + + +async def test_no_current_rules_returns_empty_with_unfixed_flag(monkeypatch: pytest.MonkeyPatch) -> None: + session, scope = object(), object() + monkeypatch.setattr( + service_module.consolidations_repo, + "get_version", + AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)), + ) + listed = AsyncMock(return_value=[]) + monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed) + + result = await service_module.EliminationRuleService(session, scope).list_rules(uuid.uuid4()) + + assert result.is_version_fixed is False + assert result.version_no is None + assert result.rules == [] + listed.assert_awaited_once_with(session, None) diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 971a135..7ffa900 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -1,13 +1,16 @@ """Выбор счетов и расчёт шахматки без обращения к БД.""" +import datetime import uuid from decimal import Decimal from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest +from fastapi import status as http_status from src.api.endpoints.consolidations.intercompany_matrix import router -from src.models.enums import AccountKind +from src.models.enums import AccountKind, StatementType from src.schemas.consolidations import ( IntercompanyMatrixCellOut, IntercompanyMatrixCompanyOut, @@ -45,15 +48,17 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes} assert ("/consolidations/versions/{version_id}/intercompany-matrix/all", ("GET",)) in paths assert ("/consolidations/versions/{version_id}/intercompany-matrix/manual", ("POST",)) in paths + assert ("/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}", ("GET",)) in paths + assert not any(path.endswith("/unpaired-osv") for path, _ in paths) assert ("/consolidations/versions/{version_id}/intercompany-matrix/result-table", ("GET",)) in paths debit_parent = _account(AccountKind.A) debit_child = _account(AccountKind.A, debit_parent.id) credit_parent = _account(AccountKind.P) credit_child = _account(AccountKind.P, credit_parent.id) - by_id, children = matrix._account_tree([debit_parent, debit_child, credit_parent, credit_child]) - debit = matrix._selected_accounts([debit_parent.id], [], "D", by_id, children) - credit = matrix._selected_accounts([credit_parent.id], [], "K", by_id, children) + by_id, children = matrix._make_account_tree([debit_parent, debit_child, credit_parent, credit_child]) + debit = matrix._select_accounts([debit_parent.id], [], "D", by_id, children) + credit = matrix._select_accounts([credit_parent.id], [], "K", by_id, children) assert debit == {debit_parent.id, debit_child.id} assert credit == {credit_parent.id, credit_child.id} @@ -84,6 +89,118 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: ] +async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkeypatch: pytest.MonkeyPatch) -> None: + parent = _account(AccountKind.AP) + child = _account(AccountKind.AP, parent.id) + parent.code = "76" + child.code = "76.01" + version = SimpleNamespace(elimination_rules_version=1, version_no=2) + consolidation = SimpleNamespace(company_group_id=uuid.uuid4(), year=2024, quarter=matrix.PeriodQuarter.Q3) + osv_service = SimpleNamespace( + build_with_members=AsyncMock( + return_value=SimpleNamespace( + consolidated=SimpleNamespace(rows=[]), version=version, consolidation=consolidation, members={} + ) + ) + ) + monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=[parent, child])) + rule = SimpleNamespace( + id=uuid.uuid4(), + rule_no=6, + statement_type=StatementType.balance, + article_1_line="1230", + article_2_line="1520", + version_no=1, + side_1="D", + measure="turnover", + valid_from=datetime.date(2024, 1, 1), + valid_to=None, + ) + rule_accounts = [ + SimpleNamespace(company_no=1, account_id=parent.id, is_excluded=False), + SimpleNamespace(company_no=1, account_id=child.id, is_excluded=True), + SimpleNamespace(company_no=2, account_id=parent.id, is_excluded=False), + ] + monkeypatch.setattr( + matrix.elimination_rules_repo, "rule_with_account", AsyncMock(return_value=(rule, rule_accounts)) + ) + session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(scalar_one=lambda: "ГК"))) + service = matrix.IntercompanyMatrixService(session, None, osv_service) + captured = [] + monkeypatch.setattr(service, "_calculate", lambda rows, members, selection: captured.append(selection)) + + await service.build_accounts(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4()) + + assert len(captured) == 1 + assert captured[0].company_1_account_ids == {parent.id} + assert captured[0].company_2_account_ids == {parent.id, child.id} + assert captured[0].measure == "turnover" + + version.elimination_rules_version = None + monkeypatch.setattr( + matrix.elimination_rules_repo, "current_open_version_no", AsyncMock(return_value=rule.version_no) + ) + _, info = await service.build_export(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4()) + assert info.mode == "PRESET" + assert info.debit_account_codes == ("76",) + assert info.credit_account_codes == ("76", "76.01") + assert info.measure == "turnover" + assert info.rule_no == rule.rule_no + assert info.rule_version_no == rule.version_no + assert info.article_1_line == rule.article_1_line + assert info.article_2_line == rule.article_2_line + + +async def test_preset_credit_first_uses_second_side_for_debit(monkeypatch: pytest.MonkeyPatch) -> None: + credit_parent = _account(AccountKind.P) + credit_child = _account(AccountKind.P, credit_parent.id) + debit_parent = _account(AccountKind.A) + debit_child = _account(AccountKind.A, debit_parent.id) + accounts = [credit_parent, credit_child, debit_parent, debit_child] + by_id, children = matrix._make_account_tree(accounts) + rule = SimpleNamespace( + version_no=1, + valid_to=None, + statement_type=StatementType.balance, + side_1="K", + measure="turnover", + ) + rule_accounts = [ + SimpleNamespace(company_no=1, account_id=credit_parent.id, is_excluded=False), + SimpleNamespace(company_no=1, account_id=credit_child.id, is_excluded=True), + SimpleNamespace(company_no=2, account_id=debit_parent.id, is_excluded=False), + ] + monkeypatch.setattr( + matrix.elimination_rules_repo, "rule_with_account", AsyncMock(return_value=(rule, rule_accounts)) + ) + service = matrix.IntercompanyMatrixService(object(), None, object()) + + selection, selected_rule = await service._resolve_preset_selection( + uuid.uuid4(), SimpleNamespace(elimination_rules_version=1), by_id, children + ) + + assert selected_rule is rule + assert selection.company_1_account_ids == {debit_parent.id, debit_child.id} + assert selection.company_2_account_ids == {credit_parent.id} + assert selection.measure == "turnover" + + +async def test_preset_rejects_income_rule_before_selecting_accounts(monkeypatch: pytest.MonkeyPatch) -> None: + rule = SimpleNamespace( + statement_type=StatementType.income, + version_no=1, + valid_to=None, + ) + monkeypatch.setattr(matrix.elimination_rules_repo, "rule_with_account", AsyncMock(return_value=(rule, []))) + service = matrix.IntercompanyMatrixService(object(), None, object()) + + with pytest.raises(matrix.DomainValidationError) as error: + await service._resolve_preset_selection(uuid.uuid4(), SimpleNamespace(elimination_rules_version=1), {}, {}) + + assert error.value.status == http_status.HTTP_422_UNPROCESSABLE_CONTENT + assert error.value.code == "INVALID_RULE" + + def test_one_sided_reflects_osv_sources_even_when_amount_is_zero() -> None: company_a, company_b = uuid.uuid4(), uuid.uuid4() debit_account, credit_account = uuid.uuid4(), uuid.uuid4() diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index 9b43dbd..48556a1 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -12,10 +12,11 @@ from openpyxl import load_workbook from src.api.endpoints.consolidations.intercompany_matrix import ( export_intercompany_matrix_all, export_intercompany_matrix_manual, + export_intercompany_matrix_preset, export_intercompany_result_table, router, ) -from src.models.enums import AuditAction, AuditSection, PeriodQuarter +from src.models.enums import AuditAction, AuditSection, PeriodQuarter, StatementType from src.schemas.consolidations import ( IntercompanyMatrixCellOut, IntercompanyMatrixCompanyOut, @@ -35,15 +36,22 @@ from src.services.intercompany_matrix_export import ( ) -def _info(*, mode: str = "MANUAL") -> MatrixExportInfo: +def _info(*, mode: str = "MANUAL", rule_id: UUID | None = None) -> MatrixExportInfo: return MatrixExportInfo( group_name='ГК "Кавычки" / Бисквит', year=2024, quarter=PeriodQuarter.Q3, version_no=2, mode=mode, - debit_account_codes=("62.01",) if mode == "MANUAL" else (), - credit_account_codes=("60.01",) if mode == "MANUAL" else (), + debit_account_codes=("62.01",) if mode != "ALL" else (), + credit_account_codes=("60.01",) if mode != "ALL" else (), + measure="turnover" if mode == "PRESET" else "closing", + rule_id=rule_id, + rule_no=6 if mode == "PRESET" else None, + rule_version_no=3 if mode == "PRESET" else None, + rule_statement_type=StatementType.balance if mode == "PRESET" else None, + article_1_line="1230" if mode == "PRESET" else None, + article_2_line="1520" if mode == "PRESET" else None, ) @@ -142,7 +150,7 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold ) -async def test_both_export_routes_use_matrix_calculation() -> None: +async def test_export_routes_use_matrix_calculation() -> None: version_id = UUID("11111111-1111-1111-1111-111111111111") account_id = UUID("22222222-2222-2222-2222-222222222222") company_a = IntercompanyMatrixCompanyOut(id=version_id, name="Компания А") @@ -169,7 +177,7 @@ async def test_both_export_routes_use_matrix_calculation() -> None: async def build_export(*args: object, **kwargs: object) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]: calls.append((args, kwargs)) - return matrix, _info(mode=kwargs["mode"]) + return matrix, _info(mode=kwargs["mode"], rule_id=kwargs.get("rule_id")) service = SimpleNamespace(build_export=build_export) audit = SimpleNamespace(record=AsyncMock()) @@ -177,18 +185,37 @@ async def test_both_export_routes_use_matrix_calculation() -> None: all_response = await export_intercompany_matrix_all(version_id, service, audit) manual_response = await export_intercompany_matrix_manual(version_id, selection, service, audit) + preset_response = await export_intercompany_matrix_preset(version_id, account_id, service, audit) assert calls == [ ((version_id,), {"mode": "ALL"}), ((version_id,), {"mode": "MANUAL", "debit_account_ids": [account_id], "credit_account_ids": [account_id]}), + ((version_id,), {"mode": "PRESET", "rule_id": account_id}), ] - for response in (all_response, manual_response): + for response, expected_info in ( + (all_response, _info(mode="ALL")), + (manual_response, _info(mode="MANUAL")), + (preset_response, _info(mode="PRESET", rule_id=account_id)), + ): assert response.media_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" disposition = response.headers["content-disposition"] assert disposition.startswith("attachment; filename=\"intercompany-matrix.xlsx\"; filename*=UTF-8''") - assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == _info().filename + assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == expected_info.filename book = load_workbook(BytesIO(response.body)) - assert book.active["B6"].value in {"Все счета", "Выбранные счета"} + assert book.active["B6"].value in {"Все счета", "Выбранные счета", "Пресет"} + preset_sheet = load_workbook(BytesIO(preset_response.body)).active + assert preset_sheet["B7"].value == "62.01" + assert preset_sheet["B8"].value == "60.01" + assert preset_sheet["B9"].value == "Оборот" + assert [(preset_sheet.cell(row, 1).value, preset_sheet.cell(row, 2).value) for row in range(10, 14)] == [ + ("Правило", "ББ № 6"), + ("Статья А", "1230"), + ("Статья Б", "1520"), + ("Версия правил", "v3"), + ] + assert preset_sheet["A14"].value == "Дебет ↓ / Кредит →" + assert preset_sheet.freeze_panes == "C15" + assert _info(mode="PRESET", rule_id=account_id).filename.endswith("_rule-balance-6.xlsx") assert [call.kwargs for call in audit.record.await_args_list] == [ { "action": AuditAction.EXPORT, @@ -218,11 +245,29 @@ async def test_both_export_routes_use_matrix_calculation() -> None: "cells": 1, }, }, + { + "action": AuditAction.EXPORT, + "section": AuditSection.EXPORT, + "object_type": "consolidation_version", + "object_id": version_id, + "consolidation_version_id": version_id, + "after_value": { + "report": "intercompany_matrix", + "mode": "PRESET", + "debit_account_ids": [], + "credit_account_ids": [], + "rule_id": str(account_id), + "rule_no": 6, + "rule_version_no": 3, + "cells": 1, + }, + }, ] paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes} assert ("/consolidations/versions/{version_id}/intercompany-matrix/all/export", ("GET",)) in paths assert ("/consolidations/versions/{version_id}/intercompany-matrix/manual/export", ("POST",)) in paths + assert ("/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/export", ("GET",)) in paths async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/api/tests/test_intercompany_matrix_golden.py b/api/tests/test_intercompany_matrix_golden.py index 5e7d819..4669544 100644 --- a/api/tests/test_intercompany_matrix_golden.py +++ b/api/tests/test_intercompany_matrix_golden.py @@ -2,9 +2,11 @@ Решение владельца 08.10.2026: общий итог элиминации с книгой не сравниваем — аналитик снимает полную сумму каждой стороны, система использует минимум пары. -Здесь явно выбраны только точные встречные строки. Для 62.01 это производство -молочной булочки (строка 21); аренда (строка 17) в эту пару не входит. -Тест проверяет расчёт выбранных пар, а не автоматический подбор строк из всей ОСВ. +С листом «01.10.2024» сверяются только точные встречные строки; расхождения по +остальным строкам ожидаемы. Для правила № 6: 60.01 АО РБ ↔ 62.01 +Счастливочной — производство молочной булочки (строка 21), не аренда (строка 17). +По займу 42 млн есть 66.03 АО РБ, но в фикстурах нет встречной ОСВ 58.03 +Счастливочной; её присутствие проверяется отдельно на модельной строке. """ import json @@ -58,6 +60,8 @@ def test_exact_pairs_against_russian_biscuit_golden( # noqa: PLR0913 - опис ) -> None: debit = _source(fixtures_dir, "schastl", debit_root, debit_row) credit = _source(fixtures_dir, "rb", credit_root, credit_row) + if amount == "106311116.46": + assert (debit["account_code_raw"], credit["account_code_raw"]) == ("62.01", "60.01") # правило № 6 assert debit["counterparty_inn"] == "3528052718" assert "СЧАСТЛИВОЧНАЯ" in credit["counterparty_name"] debit_account, credit_account = uuid.uuid4(), uuid.uuid4() @@ -98,3 +102,44 @@ def test_exact_pairs_against_russian_biscuit_golden( # noqa: PLR0913 - опис workbook.close() # В книге тысячи рублей с округлением до целого; входные ОСВ содержат рубли и копейки. assert (cell.amount_a / 1000).quantize(Decimal("1"), rounding=ROUND_HALF_UP) == expected + + +def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) -> None: + """42 млн на 66.03 РБ: без ОСВ 58.03 пара односторонняя, со встречной строкой — точная.""" + credit = _source(fixtures_dir, "rb", "66", 25) + assert credit["account_code_raw"] == "66.03" + amount = Decimal("42000000.00") + assert Decimal(credit["closing_credit"]) == amount + + debit_account, credit_account = uuid.uuid4(), uuid.uuid4() + credit_row = SimpleNamespace( + legal_entity_id=RB, + counterparty_id=BAKERY, + account_id=credit_account, + closing_debit=Decimal(0), + closing_credit=amount, + entry_ids=(uuid.uuid4(),), + ) + companies = [ + IntercompanyMatrixCompanyOut(id=BAKERY, name="Пекарня Счастливочная"), + IntercompanyMatrixCompanyOut(id=RB, name="Русский бисквит"), + ] + selection = MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing") # правило № 4 + one_sided = calculate_matrix([credit_row], companies, selection).cells[0] + assert one_sided.amount_a == 0 + assert one_sided.amount_b == amount + assert one_sided.one_sided is True + + # В наборе нет ОСВ 58.03 Счастливочной; модельная строка проверяет встречную сторону правила № 4. + debit_row = SimpleNamespace( + legal_entity_id=BAKERY, + counterparty_id=RB, + account_id=debit_account, + closing_debit=amount, + closing_credit=Decimal(0), + entry_ids=(uuid.uuid4(),), + ) + paired = calculate_matrix([debit_row, credit_row], companies, selection).cells[0] + assert paired.amount_a == paired.amount_b == amount + assert paired.variance_amount == 0 + assert paired.one_sided is False diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index c9cc80c..2346e9a 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -79,6 +79,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"}, "/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/preset/{rule_id}": {"get"}, + "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/export": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export": {"get"}, } diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md new file mode 100644 index 0000000..5895ea8 --- /dev/null +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -0,0 +1,74 @@ +## Context + +Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`), задача AURORA-1747 (выделена из AURORA-1536 +решением владельца 21.10.2026). Расчёт элиминации §5 — сумма по тройке с `elimination_mode` каскадом по `rule_no`, +упаковка строк и «без пары», API списка пар, фиксация `elimination_rules_version` — остаётся в AURORA-1536 и в этот +change не входит. Шахматка — чистая функция от сводной ОСВ версии, +периметра и выбранных счетов; в БД не хранится (DECISION-5). Корректировки (`correction`) — хранимые ручные решения, +читаются, но в ядре пока никем не создаются. + +## Decisions + +1. Режим PRESET берёт правило по `rule_id`. Правило должно быть действительно для версии: при зафиксированном + `elimination_rules_version` — совпадать по `version_no`, иначе — иметь `valid_to IS NULL` и максимальный открытый + `version_no`; иначе 404. +2. Сторона дебета — `company_1`, если `side_1 = D`, иначе `company_2`; кредит — вторая сторона. Счета и исключения + раскрываются по иерархии (`_select_accounts`), как в MANUAL. Нет счетов хотя бы одной стороны — 422 + `ACCOUNTS_REQUIRED`; неверные `side_1`/`measure`/`company_no` — 422 `INVALID_RULE`; не передан `rule_id` — 422 + `RULE_REQUIRED`. +3. Показатель (`closing`/`turnover`) берётся из правила; ALL и MANUAL остаются на `closing`. +4. *Отменено решением владельца 21.10.2026 (п. 3 ниже), в реализацию не входит.* Применённые корректировки правила (`status = APPLIED`, тот же `rule_id` и версия консолидации) уменьшают + `amount_a` ячейки `(A, B)` на `debit_adjustment` корректировки (`legal_entity_id = A`, `counterparty_id = B`) и + `amount_b` на `credit_adjustment` корректировки `(B, A)`; расхождение, процент и порог пересчитываются, ID + корректировок попадают в `correction_ids_debit/credit`. +5. Первоначальная ручка `unpaired-osv` отменена решением владельца ниже и в текущую реализацию не входит. +6. `GET …/elimination-rules` отдаёт объект с основанием выбора версии; `as_of` не вводится. +7. xlsx пресета: имя `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.xlsx`, в шапке «Режим = Пресет», + номер правила, статьи сторон, фактическая версия правил, коды счетов сторон (отсортированы) и строка «Показатель» + (Сальдо/Оборот) для всех режимов. + +## Решения владельца (2026-10-21, ревью PR #141) + +1. **Фиксация версии правил — в AURORA-1536 (следующий шаг, не этот change).** Пока `consolidation_version.elimination_rules_version` пуст, шахматка + берёт открытые правила (`valid_to IS NULL`); сейчас версия правил одна (`seed/core/elimination_rules_v1.yaml`, + `valid_to` не задаётся, API управления правилами нет), поэтому результат не плавает. Фиксировать версию — при + создании версии консолидации или при первом расчёте, но не в GET-запросе. +2. **Какая версия правил берётся — действующая на дату составления консолидации**, а не на отчётный период версии. + Реализация — вместе с п. 1 в AURORA-1536. +3. **Корректировки из шахматки убираются** (решение 4 выше и `correction_ids_*` в ячейке). Причины: корректировки в + ядре никто не создаёт (спринт корректировок), семантика ключа `(rule, A, B)` относительно ячейки (A, B) / (B, A) не + утверждена, счета корректировки и `parent_correction_id` не учитывались; §6 описывает шахматку как суммы первички. + Код корректировок сохранён в ветке `wip/shahmatka-corrections-unpaired` (PR #164 не сливается) и возвращается после решения по аналитике + AURORA-1643 «Учитывание корректировок в шахматке». +4. **Ручка `…/preset/{rule_id}/unpaired-osv` убирается** (решение 5 выше). Причины: «без пары» по наличию строк + (дефект AURORA-1651), ложные срабатывания при пересечении счетов сторон (76 в правиле № 6), нет ячейки и стороны + строки, расхождение с «без пары» §5. Сценарий закрывают AURORA-1653 (строки ОСВ ячейки) и AURORA-1536 (строки без + пары по упаковке §5). Код — в той же ветке `wip/shahmatka-corrections-unpaired`. +5. **Пресет = правило Прил. 3 — дополнение к пресетам §6** (наборы счетов), не замена. PRESET ограничивается + правилами ББ (`statement_type = balance`): правил ОФР в seed нет, ОФР в §4 открыт. Правка §6 — в `../docs`. + +После выноса п. 3–4 автором соответствующие требования дельты `consolidated-osv` удаляются из change. + +## Decisions made without discussion + +- 2026-10-21. Change пишет ревьюер в ветке автора по решению владельца. Обоснование: автор не оформила OpenSpec. Цена + ошибки: автор переписывает change. +- 2026-10-21. Спека описывает фактическое поведение PR; спорное вынесено в Known issues (раздел снят в раунде 2 — + все пункты закрыты автором). Обоснование: домен решает + автор и владелец, не ревьюер. Цена ошибки: после решения владельца спека меняется дельтой. +- 2026-10-21. Поле `as_of` удалено из `EliminationRulesOut` как мёртвое: после правок по ревью всегда `None`, в main + его не было, потребителей нет. Цена ошибки: если фронт ждал `as_of`, поле нужно вернуть. +- 2026-10-21. При незаданной версии правил выбирается максимальный открытый `version_no`; он возвращается в ответе + `elimination-rules`, чтобы фронт видел фактически применённую версию. +- 2026-10-21. mypy: `measure` аннотирован `Measure` без `cast`; слишком длинная f-строка имени файла свода ОСВ + (`consolidated_osv.py`) разбита на две без изменения результата (ruff E501). Цена ошибки: нулевая. +- 2026-10-21. В MODIFIED-блоке требования про правила элиминации и аудит экспорта приведён полный текст; требование + «XLSX шахматки» (ALL/MANUAL) не менялось, поведение PRESET описано отдельным ADDED-требованием. +- 2026-10-22. Раунд 2 ревью: в ветку влит `main` (#163), в README возвращена фраза про XLSX результирующей таблицы, + потерянная при merge автора (b7d9794). Показатель пресета в docs (#146, §6) приведён к change: `measure` правила, + у правил ББ — `closing`. Цена ошибки: нулевая. +- 2026-10-22. Спека шапки xlsx пресета оставлена по фактическому поведению (коды счетов раскрыты по иерархии, статьи — + `article_1_line`/`article_2_line`): PR сливается с мелкими замечаниями раунда 2, вынесенными в технический долг + AURORA-1761 по решению владельца; целевая шапка (счета правила и «кроме», статьи по сторонам при `side_1 = K`) — там же, + с MODIFIED-дельтой спеки. Цена ошибки: до AURORA-1761 шапка пресета неудобна для сверки с Прил. 3, расчёт не затронут. + diff --git a/openspec/changes/intercompany-matrix-presets/proposal.md b/openspec/changes/intercompany-matrix-presets/proposal.md new file mode 100644 index 0000000..d697d73 --- /dev/null +++ b/openspec/changes/intercompany-matrix-presets/proposal.md @@ -0,0 +1,33 @@ +## Why + +Шахматка ВГО (ТЗ §6) считалась только по всем счетам (ALL) или по счетам, выбранным вручную (MANUAL). Аналитику нужен +режим «пресет»: счета и показатель берутся из правила элиминации (Прил. 3). +Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`, автор — Потапова). +Требования оформлены как дельты этого change'а при ревью (решение владельца 21.10.2026); решения владельца и ревью — +`design.md`. + +## What Changes + +- Режим PRESET шахматки: `GET …/intercompany-matrix/preset/{rule_id}`, выгрузка `…/preset/{rule_id}/export`. +- Показатель пресета (`closing` / `turnover`) берётся из правила. +- `GET …/elimination-rules` возвращает объект `{version_no, is_version_fixed, rules}` вместо массива; у правила есть + `id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`) + с максимальным `version_no`; ответ содержит этот номер. +- xlsx: для PRESET имя файла с `_rule-balance-`, в шапке номер, статьи, версия правила и «Показатель»; audit `EXPORT` пишет `mode` + `ALL`/`MANUAL`/`PRESET`, а для PRESET также `rule_id`, `rule_no` и фактический `rule_version_no`. + +## Capabilities + +### New Capabilities +Нет. + +### Modified Capabilities +- `consolidations-api`: ответ правил элиминации версии. +- `consolidated-osv`: режим PRESET шахматки и xlsx пресета. +- `audit-trail`: аудит экспорта шахматки (режим PRESET). + +## Impact + +Только `arm-core` (API, сервисы, схемы), таблицы и DBML не меняются. Ломающее изменение контракта +`GET …/elimination-rules` (массив → объект); поле `as_of` в ответ не входит. Потребителей старого формата в этом репозитории не проверялось — проверить при слиянии. Non-goals: фиксация версии правил, расчёт «актуальных» правил на отчётную дату, создание и применение корректировок, +исправление `one_sided` (AURORA-1651). diff --git a/openspec/changes/intercompany-matrix-presets/specs/audit-trail/spec.md b/openspec/changes/intercompany-matrix-presets/specs/audit-trail/spec.md new file mode 100644 index 0000000..a38ff01 --- /dev/null +++ b/openspec/changes/intercompany-matrix-presets/specs/audit-trail/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Аудит экспорта шахматки +Каждый успешный `GET …/intercompany-matrix/all/export`, `POST …/intercompany-matrix/manual/export` и +`GET …/intercompany-matrix/preset/{rule_id}/export` +SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`, +`object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора, +`before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids, +credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и +`rule_version_no` (фактическая версия применённого правила, в том числе при незафиксированной версии правил). `mode` — `ALL`, +`MANUAL` или `PRESET`, массивы счетов — ID из запроса (в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка +сводной ОСВ продолжает писать собственное событие `EXPORT`. + +#### Scenario: Две выгрузки +- **WHEN** пользователь дважды выгружает ту же шахматку +- **THEN** в `audit_event` две разные записи `EXPORT`, каждая со своим payload и числом ячеек + +#### Scenario: Выгрузка пресета +- **WHEN** пользователь выгружает `GET …/intercompany-matrix/preset/{rule_id}/export` +- **THEN** событие `EXPORT` содержит `mode = PRESET`, `rule_id`, `rule_no`, `rule_version_no`, пустые массивы счетов и число ячеек diff --git a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md new file mode 100644 index 0000000..0327c62 --- /dev/null +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: Шахматка по правилу-пресету +`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}` +(`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать шахматку в режиме PRESET с тем же ответом, что ALL и MANUAL. +Счета дебета и кредита SHALL браться из правила элиминации `rule_id`: дебет — сторона `company_1`, если +`side_1 = D`, иначе `company_2`; кредит — вторая сторона; исключения (`excluded`) вычитаются, выбранные счета +раскрываются по иерархии и фильтруются по виду, как в MANUAL. Показатель SHALL браться из `measure` правила: +`closing` — конечное сальдо (`closing_debit`/`closing_credit`), `turnover` — обороты (`turnover_debit`/`turnover_credit`). +Правило SHALL быть действительным для версии консолидации: при заданном `elimination_rules_version` — с тем же +`version_no`, иначе — без `valid_to` и с максимальным открытым `version_no`; несуществующее или недействительное правило SHALL давать 404. Ошибки 422: +`RULE_REQUIRED` — правило не выбрано, `INVALID_RULE` — правило ОФР либо неверные `side_1`, `measure` или сторона счёта правила, +`ACCOUNTS_REQUIRED` — у одной из сторон нет счетов. Невидимая и несуществующая версия SHALL отвечать 404 `NOT_FOUND`. + +#### Scenario: Пресет по сальдо +- **WHEN** правило с `side_1 = D`, `measure = closing` содержит дебетовые счета на стороне 1 и кредитовые на стороне 2 +- **THEN** `amount_a` пары — сумма `closing_debit` по счетам стороны 1, `amount_b` — сумма `closing_credit` по счетам стороны 2 + +#### Scenario: Пресет по оборотам +- **WHEN** у правила `measure = turnover` +- **THEN** суммы считаются по `turnover_debit`/`turnover_credit` + +#### Scenario: Правило другой версии +- **WHEN** у версии `elimination_rules_version = 2`, а `rule_id` принадлежит правилу версии 1 +- **THEN** 404 для расчёта и выгрузки + +#### Scenario: Неизвестное правило +- **WHEN** `rule_id` не существует +- **THEN** 404 + +#### Scenario: Правило ОФР +- **WHEN** у правила `statement_type = income` +- **THEN** расчёт и выгрузка возвращают 422 `INVALID_RULE` + +### Requirement: XLSX шахматки пресета +`GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт +пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.xlsx`. Шапка SHALL +содержать режим «Пресет», коды счетов дебета и кредита (раскрытые по иерархии, с учётом исключений правила), номер +правила, статьи А и Б (`article_1_line` / `article_2_line`), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение +«Сальдо»). + +#### Scenario: Выгрузка по оборотам +- **WHEN** пользователь выгружает пресет с `measure = turnover` +- **THEN** шапка содержит «Режим — Пресет», «Показатель — Оборот», номер, статьи и версию правила; имя файла заканчивается на `_rule-balance-.xlsx` + +#### Scenario: Правило не найдено +- **WHEN** `rule_id` не существует +- **THEN** 404, файл не отдаётся, событие аудита не пишется diff --git a/openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md new file mode 100644 index 0000000..fe54402 --- /dev/null +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Правила элиминации версии +`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК) +SHALL возвращать объект `{version_no, is_version_fixed, rules}`. Если у версии задан +`consolidation_version.elimination_rules_version`, `version_no` SHALL равняться ему, `is_version_fixed = true`, а +`rules` — правилами этой версии. Если он не задан, `is_version_fixed = false`, а `rules` — +открытые правила (`valid_to IS NULL`) только с максимальным `version_no`; ответ SHALL содержать этот фактический +`version_no`. При отсутствии открытых правил `version_no = null`, `rules = []`. Каждое правило SHALL содержать `id`, `rule_no`, +`version_no`, `statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), `company_1`, `company_2`; +каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии счетов. +Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`. + +#### Scenario: Правило с исключением +- **WHEN** у правила первая сторона включает счёт 76 и исключает 76.07.5 +- **THEN** ID 76 находится в `company_1.accounts`, ID 76.07.5 — в `company_1.excluded` + +#### Scenario: Версия правил зафиксирована +- **WHEN** у версии консолидации `elimination_rules_version = 1` +- **THEN** ответ содержит `is_version_fixed = true`, `version_no = 1` и только правила с `version_no = 1` + +#### Scenario: Версия правил не зафиксирована +- **WHEN** у версии консолидации `elimination_rules_version` пуст +- **THEN** ответ содержит `is_version_fixed = false`, фактический `version_no` и открытые правила только этой версии + +#### Scenario: Открыты две версии правил +- **WHEN** открыты правила с `version_no = 1` и `version_no = 2`, в том числе с одинаковым `rule_no` +- **THEN** ответ содержит только правила версии 2 и `version_no = 2` diff --git a/openspec/changes/intercompany-matrix-presets/tasks.md b/openspec/changes/intercompany-matrix-presets/tasks.md new file mode 100644 index 0000000..2a4374d --- /dev/null +++ b/openspec/changes/intercompany-matrix-presets/tasks.md @@ -0,0 +1,23 @@ +## 1. Реализация + +Реализация выполнена автором в PR RSHB/arm-core#141; задач плана нет. Доработки по замечаниям ревью — комментарии PR +(раунды 1–2) и разделы решений в `design.md`. + +## 2. Доработки по решениям владельца 2026-10-21 (автор) + +- [x] 2.1 Отдельным коммитом убрать учёт корректировок (`_apply_corrections`, `correction_for_version_consolidation`, + `correction_ids_*`) вместе с тестами; код сохранён в `wip/shahmatka-corrections-unpaired`. +- [x] 2.1a Убрать ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами; + код сохранён в `wip/shahmatka-corrections-unpaired`. +- [x] 2.2 PRESET — только правила `statement_type = balance`; правило ОФР — 422 `INVALID_RULE`, тест. +- [x] 2.3 Несколько открытых `version_no` — только максимальная версия; вернуть её номер, тест. +- [x] 2.4 Ревьюер: убрать из дельты `consolidated-osv` требования про корректировки и `unpaired-osv`, обновить README. + +## 3. Ревью, раунд 2 (2026-10-22) + +- [x] 3.1 Ревьюер: влить `main`, вернуть фразу README про XLSX результирующей таблицы, поправить design/proposal/tasks. +- [x] 3.2 Ревьюер: показатель пресета в docs #146 §6 — `measure` правила. +- [x] 3.3 Мелкие замечания раунда 2 (шапка xlsx пресета — счета правила и «кроме», подписи статей при `side_1 = K`, + лишняя проверка в `_export_response`, реквизиты правила в `MatrixExportInfo` и `_filename`, описания ошибок preset в + OpenAPI, проверка правила до расчёта свода) вынесены в технический долг AURORA-1761 (решение владельца 22.10.2026) — см. design.md. +- [ ] 3.4 После слияния: архив change.