From 53c2d5c73d898464eec5dc47979208313c193551 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Tue, 20 Oct 2026 14:45:27 +0300 Subject: [PATCH 01/20] =?UTF-8?q?=D0=BF=D1=80=D0=B5=D1=81=D0=B5=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D1=88=D0=B0=D1=85=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consolidations/elimination_rule.py | 8 +- .../consolidations/intercompany_matrix.py | 57 +++++- api/src/repositories/consolidations.py | 21 ++ api/src/repositories/elimination_rules.py | 53 ++++- api/src/schemas/consolidations.py | 19 ++ api/src/services/elimination_rules.py | 28 ++- api/src/services/intercompany_matrix.py | 187 ++++++++++++++++-- .../services/intercompany_matrix_export.py | 13 +- api/tests/test_api_intercompany_matrix.py | 111 ++++++++++- api/tests/test_correction_repository.py | 34 ++++ api/tests/test_elimination_rules.py | 129 ++++++++++++ api/tests/test_intercompany_matrix.py | 152 ++++++++++++++ api/tests/test_intercompany_matrix_export.py | 54 ++++- api/tests/test_intercompany_matrix_golden.py | 51 ++++- api/tests/test_openapi_routes.py | 8 + 15 files changed, 873 insertions(+), 52 deletions(-) create mode 100644 api/tests/test_correction_repository.py create mode 100644 api/tests/test_elimination_rules.py diff --git a/api/src/api/endpoints/consolidations/elimination_rule.py b/api/src/api/endpoints/consolidations/elimination_rule.py index 66af95f..4ab60dd 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: + """Правила зафиксированной версии либо актуальные сегодня, с основанием выбора.""" 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 2fd695a..eb10774 100644 --- a/api/src/api/endpoints/consolidations/intercompany_matrix.py +++ b/api/src/api/endpoints/consolidations/intercompany_matrix.py @@ -12,7 +12,12 @@ 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, + UnpairedOsvOut, +) from src.services.intercompany_matrix import MatrixExportInfo from src.services.intercompany_matrix_export import build_matrix_workbook @@ -59,6 +64,7 @@ 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)} if info.rule_id is not None else {}), "cells": len(matrix.cells), }, ) @@ -145,6 +151,55 @@ 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: + """Cчета пресета с подходящим видом дебета или кредита.""" + return await service.build_accounts(version_id, mode="PRESET", rule_id=rule_id) + + +@router.get( + "/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv", + response_model=UnpairedOsvOut, + dependencies=VIEW, + responses=ERRORS, + summary="Строки сводной ОСВ без пары по правилу-пресету", +) +async def get_unpaired_osv( + version_id: uuid.UUID, + rule_id: uuid.UUID, + service: IntercompanyMatrixServiceDep, +) -> UnpairedOsvOut: + """Все строки односторонних ячеек шахматки и их число.""" + return await service.unpaired_osv(version_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: + 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/consolidations.py b/api/src/repositories/consolidations.py index ac7cdd9..4afee3e 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -31,6 +31,7 @@ from src.models.directories import CompanyGroup, LegalEntity, LegalEntityAlias, from src.models.enums import ( ConsolidationApproach, ConsolidationStatus, + CorrectionStatus, DocumentStatus, DocumentType, FileStatus, @@ -40,6 +41,7 @@ from src.models.enums import ( RelatedGroupKind, ) from src.models.files import Document, StoredFile +from src.models.kfo import Correction from src.models.osv import OsvEntry from src.repositories.directories import LIKE_ESCAPE, escape_like @@ -563,3 +565,22 @@ async def current_related_groups( for entity_id, group in await session.execute(stmt): groups[entity_id][group.kind] = group return dict(groups) + + +async def correction_for_version_consolidation( + session: AsyncSession, version_id: uuid.UUID, rule_id: uuid.UUID +) -> list[Correction]: + """Применённые корректировки правила для версии консолидации.""" + if not version_id or not rule_id: + return [] + + stmt = ( + select(Correction) + .where( + Correction.consolidation_version_id == version_id, + Correction.rule_id == rule_id, + Correction.status == CorrectionStatus.APPLIED, + ) + .order_by(Correction.id) + ) + return list((await session.scalars(stmt)).all()) diff --git a/api/src/repositories/elimination_rules.py b/api/src/repositories/elimination_rules.py index aa0389a..1dc5dbc 100644 --- a/api/src/repositories/elimination_rules.py +++ b/api/src/repositories/elimination_rules.py @@ -1,33 +1,58 @@ """Чтение версионированных правил элиминации и счетов их сторон.""" +import datetime import uuid from collections.abc import Collection -from sqlalchemy import select +from sqlalchemy import func, or_, 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, *, as_of: datetime.date | None = None ) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]: - """Правила версии консолидации по `elimination_rules_version` и счета их сторон.""" + """Правила выбранной версии и счета сторон; для текущей версии — только действующие на дату.""" 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(EliminationRule.version_no == version_no) .order_by( EliminationRule.statement_type, EliminationRule.rule_no, EliminationRuleAccount.company_no, ) ) + if as_of is not None: + stmt = stmt.where( + EliminationRule.valid_from <= as_of, + or_(EliminationRule.valid_to.is_(None), EliminationRule.valid_to >= as_of), + ) return [(rule, account) for rule, account in (await session.execute(stmt)).all()] +async def current_version_no(session: AsyncSession, as_of: datetime.date) -> int | None: + """Старшая версия правил, у которой есть действующие на дату записи.""" + stmt = select(func.max(EliminationRule.version_no)).where( + EliminationRule.valid_from <= as_of, + or_(EliminationRule.valid_to.is_(None), EliminationRule.valid_to >= as_of), + ) + return await session.scalar(stmt) + + +def is_valid_for_version( + rule: EliminationRule | None, version_no: int | None, *, as_of: datetime.date | None = None +) -> bool: + """Проверить принадлежность правила версии и, если нужно, срок его действия.""" + return ( + rule is not None + and version_no is not None + and rule.version_no == version_no + and (as_of is None or (rule.valid_from <= as_of and (rule.valid_to is None or rule.valid_to >= as_of))) + ) + + async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]: """Все правила заданной версии, включая ББ и ОФР, в порядке типа отчётности и номера.""" stmt = ( @@ -51,3 +76,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]]: + """Правило и его счета; `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..8f79240 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -232,6 +232,13 @@ class ConsolidatedRowOut(BaseModel): return None if value is None else value.quantize(CENT) +class UnpairedOsvOut(BaseModel): + """Все строки сводной ОСВ без встречной стороны по правилу и их количество.""" + + items: list[ConsolidatedRowOut] + total: int + + class GapOut(BaseModel): """Пробел свода: у ЮЛ по счёту нет квартальных ОСВ за перечисленные кварталы (строки по нему не строятся).""" @@ -318,6 +325,7 @@ class IntercompanyMatrixRuleSideOut(BaseModel): class EliminationRuleOut(BaseModel): """Правило элиминации из БД; ключ — тип отчётности, версия и номер.""" + id: uuid.UUID rule_no: int version_no: int statement_type: StatementType @@ -327,6 +335,15 @@ class EliminationRuleOut(BaseModel): company_2: IntercompanyMatrixRuleSideOut +class EliminationRulesOut(BaseModel): + """Правила и основание выбора их версии.""" + + version_no: int | None + is_version_fixed: bool + as_of: datetime.date | None + rules: list[EliminationRuleOut] + + class IntercompanyMatrixManualIn(BaseModel): """Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки.""" @@ -351,6 +368,8 @@ class IntercompanyMatrixCellOut(BaseModel): ) osv_entry_ids_debit: list[uuid.UUID] osv_entry_ids_credit: list[uuid.UUID] + correction_ids_debit: list[uuid.UUID] = Field(default_factory=list) + correction_ids_credit: list[uuid.UUID] = Field(default_factory=list) @field_validator("amount_a", "amount_b", "variance_amount", "variance_percent") @classmethod diff --git a/api/src/services/elimination_rules.py b/api/src/services/elimination_rules.py index 5e7191d..3ba8e45 100644 --- a/api/src/services/elimination_rules.py +++ b/api/src/services/elimination_rules.py @@ -4,11 +4,12 @@ import uuid from sqlalchemy.ext.asyncio import AsyncSession +from src.common.clock import moscow_date, utc_now 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 +21,30 @@ 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: 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 + if is_version_fixed: + as_of = None + version_no = version.elimination_rules_version + else: + as_of = moscow_date(utc_now()) + version_no = await rules_repo.current_version_no(self._session, as_of) + rows = ( + await rules_repo.list_rules_with_accounts(self._session, version_no, as_of=as_of) + if version_no is not None + else [] + ) 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 +61,9 @@ 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, + as_of=as_of, + rules=list(by_id.values()), + ) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index f14db8c..181b6c6 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -10,14 +10,17 @@ from typing import Literal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.common.exceptions import DomainValidationError +from src.common.clock import moscow_date, utc_now +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.repositories import consolidations as consolidation_repo 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, @@ -25,8 +28,9 @@ from src.schemas.consolidations import ( IntercompanyResultAmountOut, IntercompanyResultArticleOut, IntercompanyResultTableOut, + UnpairedOsvOut, ) -from src.services.consolidated_osv import ConsolidatedOsvService +from src.services.consolidated_osv import ConsolidatedOsvService, row_out Measure = Literal["closing", "turnover"] Side = Literal["D", "K"] @@ -81,13 +85,16 @@ class MatrixExportInfo: year: int quarter: PeriodQuarter version_no: int - 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 @property def filename(self) -> str: - return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx" + preset = f"_preset-{self.rule_id}" if self.rule_id is not None else "" + return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}{preset}.xlsx" def _amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal: @@ -172,6 +179,17 @@ def calculate_matrix( return IntercompanyMatrixOut(companies=list(company_info), cells=cells) +def unpaired_rows(rows: Iterable[ConsolidatedRow], matrix: IntercompanyMatrixOut) -> list[ConsolidatedRow]: + """Строки свода, попавшие только на одну сторону пары компаний в шахматке.""" + entry_ids = { + entry_id + for cell in matrix.cells + if cell.one_sided + for entry_id in (*cell.osv_entry_ids_debit, *cell.osv_entry_ids_credit) + } + return [row for row in rows if entry_ids.intersection(row.entry_ids)] + + def _account_tree( accounts: Sequence[ChartOfAccount], ) -> tuple[dict[uuid.UUID, ChartOfAccount], dict[uuid.UUID, list[uuid.UUID]]]: @@ -309,13 +327,18 @@ class IntercompanyMatrixService: 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 + """Рассчитать матрицу по всем счетам, ручному выбору или правилу-пресету.""" + matrix, _, _, _, _, _ = 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 @@ -323,13 +346,18 @@ class IntercompanyMatrixService: 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 + matrix, version, consolidation, accounts, selection, _ = await self._build_accounts( + version_id, + mode=mode, + debit_account_ids=debit_account_ids, + credit_account_ids=credit_account_ids, + rule_id=rule_id, ) group_name = ( await self._session.execute( @@ -337,28 +365,112 @@ class IntercompanyMatrixService: ) ).scalar_one() by_id = {account.id: account.code for account in accounts} + if mode == "PRESET": + debit_codes = tuple(sorted(by_id[account_id] for account_id in selection.company_1_account_ids)) + credit_codes = tuple(sorted(by_id[account_id] for account_id in 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=consolidation.year, quarter=consolidation.quarter, version_no=version.version_no, 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_codes=debit_codes, + credit_account_codes=credit_codes, + measure=selection.measure, + rule_id=rule_id, ) return matrix, info - async def _build_accounts( + async def unpaired_osv(self, version_id: uuid.UUID, rule_id: uuid.UUID) -> UnpairedOsvOut: + """Все строки свода без встречной стороны в шахматке заданного пресета.""" + matrix, _, _, _, _, rows = await self._build_accounts( + version_id, mode="PRESET", debit_account_ids=(), credit_account_ids=(), rule_id=rule_id + ) + unmatched = unpaired_rows(rows, matrix) + return UnpairedOsvOut(items=[row_out(row) for row in unmatched], total=len(unmatched)) + + async def _apply_corrections( + self, matrix: IntercompanyMatrixOut, version_id: uuid.UUID, rule_id: uuid.UUID + ) -> IntercompanyMatrixOut: + """Исключение суммы применённых корректировок из сторон пары пресета.""" + corrections = await consolidation_repo.correction_for_version_consolidation( + self._session, version_id=version_id, rule_id=rule_id + ) + if not corrections: + return matrix + + debit_by_pair: dict[tuple[uuid.UUID, uuid.UUID], Decimal] = defaultdict(lambda: ZERO) + credit_by_pair: dict[tuple[uuid.UUID, uuid.UUID], Decimal] = defaultdict(lambda: ZERO) + debit_ids_by_pair: dict[tuple[uuid.UUID, uuid.UUID], set[uuid.UUID]] = defaultdict(set) + credit_ids_by_pair: dict[tuple[uuid.UUID, uuid.UUID], set[uuid.UUID]] = defaultdict(set) + for correction in corrections: + owner, counterparty = correction.legal_entity_id, correction.counterparty_id + if owner is None or counterparty is None: + continue + pair = (owner, counterparty) + if correction.debit_adjustment is not None: + debit_by_pair[pair] += correction.debit_adjustment + debit_ids_by_pair[pair].add(correction.id) + if correction.credit_adjustment is not None: + credit_by_pair[pair] += correction.credit_adjustment + credit_ids_by_pair[pair].add(correction.id) + + cells = [] + for cell in matrix.cells: + debit_pair = (cell.company_a_id, cell.company_b_id) + credit_pair = (cell.company_b_id, cell.company_a_id) + amount_a = cell.amount_a - debit_by_pair.get(debit_pair, ZERO) + amount_b = cell.amount_b - credit_by_pair.get(credit_pair, ZERO) + debit_ids = list(debit_ids_by_pair.get(debit_pair, ())) + credit_ids = list(credit_ids_by_pair.get(credit_pair, ())) + if not debit_ids and not credit_ids: + cells.append(cell) + continue + variance_amount = abs(amount_a - amount_b) + base = min(amount_a, amount_b) + variance_percent = None if base == ZERO else variance_amount / base * 100 + cells.append( + IntercompanyMatrixCellOut.model_validate( + { + **cell.model_dump(), + "amount_a": amount_a, + "amount_b": amount_b, + "correction_ids_debit": debit_ids, + "correction_ids_credit": credit_ids, + "variance_amount": variance_amount, + "variance_percent": variance_percent, + "exceeds_threshold": variance_percent is not None + and variance_percent > VARIANCE_THRESHOLD_PERCENT, + } + ) + ) + return IntercompanyMatrixOut(companies=matrix.companies, cells=cells) + + async def _build_accounts( # noqa: PLR0912 - три режима выбора счетов и проверка пресета 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]]: + rule_id: uuid.UUID | None = None, + ) -> tuple[ + IntercompanyMatrixOut, + ConsolidationVersion, + Consolidation, + list[ChartOfAccount], + MatrixSelection, + tuple[ConsolidatedRow, ...], + ]: consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id) + rows = consolidated.rows accounts = await directory_repo.list_accounts(self._session) by_id, children = _account_tree(accounts) + measure: Measure = "closing" + rule: EliminationRule | None = None if mode == "ALL": if debit_account_ids or credit_account_ids: raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для режима ALL не нужно указывать счета") @@ -369,8 +481,43 @@ class IntercompanyMatrixService: raise DomainValidationError("ACCOUNTS_REQUIRED", "Выберите дебетовые и кредитовые счета") debit = _selected_accounts(debit_account_ids, (), "D", by_id, children) credit = _selected_accounts(credit_account_ids, (), "K", by_id, children) + elif mode == "PRESET": + if rule_id is None: + raise DomainValidationError("RULE_REQUIRED", "Выберите правило-пресет") + rule, rule_accounts = await elimination_rules_repo.rule_with_account(self._session, rule_id) + as_of = None if version.elimination_rules_version is not None else moscow_date(utc_now()) + version_no = ( + version.elimination_rules_version + if as_of is None + else await elimination_rules_repo.current_version_no(self._session, as_of) + ) + if rule is None or not elimination_rules_repo.is_valid_for_version(rule, version_no, as_of=as_of): + raise NotFoundError("правило-пресет не найдено для версии консолидации") + 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 = _selected_accounts(debit_ids, debit_excluded, "D", by_id, children) + credit = _selected_accounts(credit_ids, credit_excluded, "K", by_id, children) + measure = "closing" if rule.measure == "closing" else "turnover" else: raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов") - selection = MatrixSelection(debit, credit, "closing") - matrix = self._calculate(consolidated.rows, members, selection) - return matrix, version, consolidation, accounts + selection = MatrixSelection(debit, credit, measure) + matrix = self._calculate(rows, members, selection) + if rule is not None: + matrix = await self._apply_corrections(matrix, version_id, rule.id) + return matrix, version, consolidation, accounts, selection, rows diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index 081be3f..d26319a 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -35,14 +35,16 @@ def _skipped_comment(sheet: Worksheet, row: int, column: int, count: int) -> Non def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None: _text(sheet, 1, 1, "Шахматка внутригрупповых операций") + mode_name = {"ALL": "Все счета", "MANUAL": "Выбранные счета", "PRESET": "Пресет"}[info.mode] 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 "Все счета"), + ("Режим", 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 "Оборот"), ) for row, (label, value) in enumerate(details, start=2): _text(sheet, row, 1, label) @@ -53,6 +55,11 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None: def _write_values(sheet: Worksheet, row: int, column: int, cell: IntercompanyMatrixCellOut) -> None: for offset, amount in enumerate((cell.variance_amount, cell.amount_a, cell.amount_b)): sheet.cell(row + offset, column, amount).number_format = MONEY_FORMAT + for offset, correction_ids in ((1, cell.correction_ids_debit), (2, cell.correction_ids_credit)): + if correction_ids: + sheet.cell(row + offset, column).comment = Comment( + "ID корректировок: " + ", ".join(map(str, correction_ids)), "АРМ КА" + ) if cell.variance_percent is not None: sheet.cell(row + 3, column, cell.variance_percent).number_format = PERCENT_FORMAT _skipped_comment(sheet, row, column, cell.skipped_osv_entry_count) diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 0017786..1c679d3 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -13,7 +13,7 @@ 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 @@ -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") @@ -129,15 +130,121 @@ 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", + "correction_ids_debit", + "correction_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 assert cell["one_sided"] is False assert cell["osv_entry_ids_debit"] == cell["osv_entry_ids_credit"] == [] + assert cell["correction_ids_debit"] == cell["correction_ids_credit"] == [] 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"preset-{rule.id}" 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 Decimal(str(sheet["D12"].value)) == Decimal("100") + assert Decimal(str(sheet["D13"].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) + + +async def test_preset_unpaired_osv_returns_rows_and_total(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() + debit_document_id = await _source(seeded, world, credit=False, amount="0", account="62.01") + debit_entry = (await seeded.scalars(select(OsvEntry).where(OsvEntry.document_id == debit_document_id))).one() + + client = await auth_client(KA_RF) + url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}/unpaired-osv" + response = await client.get(url) + assert response.status_code == OK, response.text + assert response.json()["total"] == 1 + assert set(response.json()) == {"items", "total"} + assert response.json()["items"][0]["entry_ids"] == [str(debit_entry.id)] + assert response.json()["items"][0]["closing_debit"] == "0.00" + + await _source(seeded, world, credit=True, amount="100", account="60.01") + paired = await client.get(url) + assert paired.status_code == OK, paired.text + assert paired.json()["items"] == [] + assert paired.json()["total"] == 0 + assert (await client.get(url.replace(str(rule.id), str(uuid.uuid4())))).status_code == NOT_FOUND + + +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 listed.json()["as_of"] is not None + 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 + 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 + assert fixed.json()["as_of"] is None + 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_unmapped_account_source_is_visible_in_cell_count( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: diff --git a/api/tests/test_correction_repository.py b/api/tests/test_correction_repository.py new file mode 100644 index 0000000..916cf61 --- /dev/null +++ b/api/tests/test_correction_repository.py @@ -0,0 +1,34 @@ +"""Запрос корректировок к строкам ОСВ версии консолидации.""" + +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from sqlalchemy.dialects import postgresql + +from src.repositories.consolidations import correction_for_version_consolidation + + +async def test_correction_query_returns_all_applied_records_without_matching_osv() -> None: + session = AsyncMock() + corrections = [object(), object()] + session.scalars.return_value = SimpleNamespace(all=lambda: corrections) + version_id, rule_id = uuid.uuid4(), uuid.uuid4() + + assert await correction_for_version_consolidation(session, version_id, rule_id) == corrections + + stmt = session.scalars.await_args.args[0] + sql = str(stmt.compile(dialect=postgresql.dialect())) + assert "FROM dkb.correction" in sql + assert "osv_entry" not in sql + assert "correction.consolidation_version_id" in sql + assert "correction.rule_id" in sql + assert "correction.status" in sql + + +async def test_correction_query_requires_version_and_rule() -> None: + session = AsyncMock() + + assert await correction_for_version_consolidation(session, None, uuid.uuid4()) == [] + assert await correction_for_version_consolidation(session, uuid.uuid4(), None) == [] + session.scalars.assert_not_awaited() diff --git a/api/tests/test_elimination_rules.py b/api/tests/test_elimination_rules.py new file mode 100644 index 0000000..6ac4755 --- /dev/null +++ b/api/tests/test_elimination_rules.py @@ -0,0 +1,129 @@ +"""Выбор версии правил для шахматки без обращения к БД.""" + +import datetime +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", "as_of", "expected"), + [ + (None, 3, None, False), + (SimpleNamespace(version_no=3), None, None, False), + (SimpleNamespace(version_no=3), 2, None, False), + (SimpleNamespace(version_no=3), 3, None, True), + ( + SimpleNamespace(version_no=3, valid_from=datetime.date(2024, 1, 1), valid_to=datetime.date(2024, 12, 31)), + 3, + datetime.date(2023, 12, 31), + False, + ), + ( + SimpleNamespace(version_no=3, valid_from=datetime.date(2024, 1, 1), valid_to=datetime.date(2024, 12, 31)), + 3, + datetime.date(2024, 12, 31), + True, + ), + ( + SimpleNamespace(version_no=3, valid_from=datetime.date(2024, 1, 1), valid_to=datetime.date(2024, 12, 31)), + 3, + datetime.date(2025, 1, 1), + False, + ), + ], +) +def test_rule_is_valid_for_version( + rule: SimpleNamespace | None, version_no: int | None, as_of: datetime.date | None, expected: bool +) -> None: + assert rules_repo.is_valid_for_version(rule, version_no, as_of=as_of) is expected + + +async def test_fixed_rules_version_ignores_current_date(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)), + ) + current = AsyncMock() + listed = AsyncMock(return_value=[(rule, account)]) + monkeypatch.setattr(service_module.rules_repo, "current_version_no", current) + 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 result.as_of is None + assert [item.id for item in result.rules] == [rule.id] + assert result.rules[0].company_1.accounts == [account_id] + current.assert_not_awaited() + listed.assert_awaited_once_with(session, rule.version_no, as_of=None) + + +async def test_unfixed_rules_use_latest_active_version_on_moscow_date(monkeypatch: pytest.MonkeyPatch) -> None: + session, scope = object(), object() + version_id = uuid.uuid4() + rule = _rule() + expected_date = datetime.date(2026, 10, 19) + monkeypatch.setattr(service_module, "utc_now", lambda: datetime.datetime(2026, 10, 18, 22, tzinfo=datetime.UTC)) + monkeypatch.setattr( + service_module.consolidations_repo, + "get_version", + AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)), + ) + current = AsyncMock(return_value=rule.version_no) + listed = AsyncMock(return_value=[(rule, None)]) + monkeypatch.setattr(service_module.rules_repo, "current_version_no", current) + 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 result.as_of == expected_date + assert [item.id for item in result.rules] == [rule.id] + current.assert_awaited_once_with(session, expected_date) + listed.assert_awaited_once_with(session, rule.version_no, as_of=expected_date) + + +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)), + ) + monkeypatch.setattr(service_module.rules_repo, "current_version_no", AsyncMock(return_value=None)) + listed = AsyncMock() + 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.as_of is not None + assert result.rules == [] + listed.assert_not_awaited() diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 971a135..0f2dd8b 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -1,8 +1,10 @@ """Выбор счетов и расчёт шахматки без обращения к БД.""" +import datetime import uuid from decimal import Decimal from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -45,6 +47,11 @@ 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 ( + "/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv", + ("GET",), + ) in paths assert ("/consolidations/versions/{version_id}/intercompany-matrix/result-table", ("GET",)) in paths debit_parent = _account(AccountKind.A) @@ -75,6 +82,7 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: ] assert result.cells[0].amount_a == Decimal("100") assert result.cells[0].amount_b == Decimal("90") + assert result.cells[0].correction_ids_debit == result.cells[0].correction_ids_credit == [] assert result.cells[0].variance_percent == Decimal("11.11") assert isinstance(result, IntercompanyMatrixOut) response = result @@ -84,6 +92,150 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: ] +def test_unpaired_rows_uses_source_presence_and_preserves_osv_order() -> None: + company_a, company_b = uuid.uuid4(), uuid.uuid4() + debit_account, credit_account = uuid.uuid4(), uuid.uuid4() + paired_debit = _row(company_a, company_b, debit_account, debit="100") + paired_credit = _row(company_b, company_a, credit_account, credit="100") + unpaired_credit = _row(company_a, company_b, credit_account, credit="0") + for row in (paired_debit, paired_credit, unpaired_credit): + row.entry_ids = (uuid.uuid4(),) + rows = [unpaired_credit, paired_debit, paired_credit] + companies = [ + IntercompanyMatrixCompanyOut(id=company_a, name="А"), + IntercompanyMatrixCompanyOut(id=company_b, name="Б"), + ] + result = matrix.calculate_matrix( + rows, companies, matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing") + ) + + assert matrix.unpaired_rows(rows, result) == [unpaired_credit] + + +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(rows=[]), version, consolidation, {})) + ) + monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=[parent, child])) + rule = SimpleNamespace( + id=uuid.uuid4(), + 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)) + ) + monkeypatch.setattr(matrix.consolidation_repo, "correction_for_version_consolidation", AsyncMock(return_value=[])) + 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" + + current_version = AsyncMock(return_value=1) + monkeypatch.setattr(matrix.elimination_rules_repo, "current_version_no", current_version) + version.elimination_rules_version = None + _, 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" + current_version.assert_awaited_once() + + +async def test_preset_corrections_exclude_amounts_without_changing_osv(monkeypatch: pytest.MonkeyPatch) -> None: + company_a, company_b = uuid.uuid4(), uuid.uuid4() + debit_account, credit_account = uuid.uuid4(), uuid.uuid4() + debit_row = _row(company_a, company_b, debit_account, debit="1500") + credit_row = _row(company_b, company_a, credit_account, credit="1500") + debit_row.entry_ids = (uuid.uuid4(),) + credit_row.entry_ids = (uuid.uuid4(),) + companies = [ + IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"), + IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"), + ] + original = matrix.calculate_matrix( + [debit_row, credit_row], + companies, + matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing"), + ) + debit_correction_id, credit_correction_id, child_correction_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + corrections = [ + SimpleNamespace( + id=debit_correction_id, + legal_entity_id=company_a, + counterparty_id=company_b, + debit_adjustment=Decimal("1000"), + credit_adjustment=None, + ), + SimpleNamespace( + id=credit_correction_id, + legal_entity_id=company_b, + counterparty_id=company_a, + debit_adjustment=None, + credit_adjustment=Decimal("500"), + ), + SimpleNamespace( + id=child_correction_id, + legal_entity_id=company_b, + counterparty_id=company_a, + debit_adjustment=None, + credit_adjustment=Decimal("500"), + ), + ] + monkeypatch.setattr( + matrix.consolidation_repo, "correction_for_version_consolidation", AsyncMock(return_value=corrections) + ) + + adjusted = await matrix.IntercompanyMatrixService(object(), None, object())._apply_corrections( + original, uuid.uuid4(), uuid.uuid4() + ) + + assert adjusted.cells[0].amount_a == Decimal("500") + assert adjusted.cells[0].amount_b == Decimal("500") + assert adjusted.cells[0].variance_amount == Decimal("0") + assert adjusted.cells[0].osv_entry_ids_debit == [debit_row.entry_ids[0]] + assert adjusted.cells[0].osv_entry_ids_credit == [credit_row.entry_ids[0]] + assert adjusted.cells[0].correction_ids_debit == [debit_correction_id] + assert set(adjusted.cells[0].correction_ids_credit) == {credit_correction_id, child_correction_id} + assert adjusted.cells[0].model_dump(mode="json")["correction_ids_debit"] == [str(debit_correction_id)] + assert original.cells[0].correction_ids_debit == original.cells[0].correction_ids_credit == [] + assert original.cells[0].amount_a == Decimal("1500") + assert original.cells[0].amount_b == Decimal("1500") + assert credit_row.closing_credit == Decimal("1500") + + +async def test_preset_without_corrections_keeps_matrix(monkeypatch: pytest.MonkeyPatch) -> None: + original = matrix.IntercompanyMatrixOut(companies=[], cells=[]) + monkeypatch.setattr(matrix.consolidation_repo, "correction_for_version_consolidation", AsyncMock(return_value=[])) + + adjusted = await matrix.IntercompanyMatrixService(object(), None, object())._apply_corrections( + original, uuid.uuid4(), uuid.uuid4() + ) + + assert adjusted is original + + 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 20fcc97..e795213 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -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_matrix_preset, router, ) from src.models.enums import AuditAction, AuditSection, PeriodQuarter @@ -25,15 +26,17 @@ from src.services.intercompany_matrix import MatrixExportInfo from src.services.intercompany_matrix_export import DIAGONAL_FILL, METRICS, THRESHOLD_FILL, build_matrix_workbook -def _info(*, mode: str = "MANUAL") -> MatrixExportInfo: +def _info(*, mode: str = "MANUAL", 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, ) @@ -131,8 +134,17 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold for offset in range(4) ) + debit_correction_id = UUID("44444444-4444-4444-4444-444444444444") + credit_correction_id = UUID("55555555-5555-5555-5555-555555555555") + matrix.cells[0].correction_ids_debit = [debit_correction_id] + matrix.cells[0].correction_ids_credit = [credit_correction_id] + preset_sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, _info(mode="PRESET")))).active + assert preset_sheet is not None + assert str(debit_correction_id) in preset_sheet["D12"].comment.text + assert str(credit_correction_id) in preset_sheet["D13"].comment.text -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="Компания А") @@ -159,7 +171,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()) @@ -167,18 +179,28 @@ 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 [call.kwargs for call in audit.record.await_args_list] == [ { "action": AuditAction.EXPORT, @@ -208,11 +230,27 @@ 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), + "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 a5b1566..2e397df 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -79,6 +79,9 @@ 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}/unpaired-osv": {"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"}, } @@ -178,6 +181,11 @@ def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any summary = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv/summary"]["get"]["responses"] assert {"200", "404"} <= set(summary) assert summary["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/OsvSummaryOut") + unpaired = schema["paths"][ + "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv" + ]["get"] + assert {parameter["name"] for parameter in unpaired["parameters"]} == {"version_id", "rule_id"} + assert unpaired["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/UnpairedOsvOut") def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) -> None: -- 2.47.2 From c6a10e460492775706c7d42af0921073b4cc5f79 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Tue, 20 Oct 2026 15:35:46 +0300 Subject: [PATCH 02/20] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/services/intercompany_matrix_export.py | 5 ----- api/tests/test_intercompany_matrix_export.py | 6 +++--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index d26319a..a5c50bb 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -55,11 +55,6 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None: def _write_values(sheet: Worksheet, row: int, column: int, cell: IntercompanyMatrixCellOut) -> None: for offset, amount in enumerate((cell.variance_amount, cell.amount_a, cell.amount_b)): sheet.cell(row + offset, column, amount).number_format = MONEY_FORMAT - for offset, correction_ids in ((1, cell.correction_ids_debit), (2, cell.correction_ids_credit)): - if correction_ids: - sheet.cell(row + offset, column).comment = Comment( - "ID корректировок: " + ", ".join(map(str, correction_ids)), "АРМ КА" - ) if cell.variance_percent is not None: sheet.cell(row + 3, column, cell.variance_percent).number_format = PERCENT_FORMAT _skipped_comment(sheet, row, column, cell.skipped_osv_entry_count) diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index e795213..1f7761d 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -134,14 +134,14 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold for offset in range(4) ) - debit_correction_id = UUID("44444444-4444-4444-4444-444444444444") + debit_correction_id = UUID("796d1add-eaed-4802-8c79-8b3cdae2cca1") credit_correction_id = UUID("55555555-5555-5555-5555-555555555555") matrix.cells[0].correction_ids_debit = [debit_correction_id] matrix.cells[0].correction_ids_credit = [credit_correction_id] preset_sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, _info(mode="PRESET")))).active assert preset_sheet is not None - assert str(debit_correction_id) in preset_sheet["D12"].comment.text - assert str(credit_correction_id) in preset_sheet["D13"].comment.text + assert preset_sheet["D12"].comment is None + assert preset_sheet["D13"].comment is None async def test_export_routes_use_matrix_calculation() -> None: -- 2.47.2 From abe1d890d0c14eee74770d122493a218ec5daa96 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 21 Oct 2026 11:59:23 +0300 Subject: [PATCH 03/20] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consolidations/intercompany_matrix.py | 2 +- api/src/repositories/elimination_rules.py | 41 ++--- api/src/services/elimination_rules.py | 16 +- api/src/services/intercompany_matrix.py | 173 ++++++++++-------- api/tests/test_elimination_rules.py | 74 +++----- api/tests/test_intercompany_matrix.py | 11 +- 6 files changed, 147 insertions(+), 170 deletions(-) diff --git a/api/src/api/endpoints/consolidations/intercompany_matrix.py b/api/src/api/endpoints/consolidations/intercompany_matrix.py index eb10774..96f61e7 100644 --- a/api/src/api/endpoints/consolidations/intercompany_matrix.py +++ b/api/src/api/endpoints/consolidations/intercompany_matrix.py @@ -180,7 +180,7 @@ async def get_unpaired_osv( service: IntercompanyMatrixServiceDep, ) -> UnpairedOsvOut: """Все строки односторонних ячеек шахматки и их число.""" - return await service.unpaired_osv(version_id, rule_id) + return await service.get_unpaired_osv(version_id, rule_id) @router.get( diff --git a/api/src/repositories/elimination_rules.py b/api/src/repositories/elimination_rules.py index 1dc5dbc..476d132 100644 --- a/api/src/repositories/elimination_rules.py +++ b/api/src/repositories/elimination_rules.py @@ -1,56 +1,39 @@ """Чтение версионированных правил элиминации и счетов их сторон.""" -import datetime import uuid from collections.abc import Collection -from sqlalchemy import func, or_, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount async def list_rules_with_accounts( - session: AsyncSession, version_no: int, *, as_of: datetime.date | None = None + session: AsyncSession, version_no: int | None ) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]: - """Правила выбранной версии и счета сторон; для текущей версии — только действующие на дату.""" + """Правила заданной версии либо все правила без `valid_to`.""" + condition = ( + EliminationRule.version_no == version_no + if version_no is not None + else EliminationRule.valid_to.is_(None) + ) stmt = ( select(EliminationRule, EliminationRuleAccount) .outerjoin(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id) - .where(EliminationRule.version_no == version_no) + .where(condition) .order_by( EliminationRule.statement_type, EliminationRule.rule_no, EliminationRuleAccount.company_no, ) ) - if as_of is not None: - stmt = stmt.where( - EliminationRule.valid_from <= as_of, - or_(EliminationRule.valid_to.is_(None), EliminationRule.valid_to >= as_of), - ) return [(rule, account) for rule, account in (await session.execute(stmt)).all()] -async def current_version_no(session: AsyncSession, as_of: datetime.date) -> int | None: - """Старшая версия правил, у которой есть действующие на дату записи.""" - stmt = select(func.max(EliminationRule.version_no)).where( - EliminationRule.valid_from <= as_of, - or_(EliminationRule.valid_to.is_(None), EliminationRule.valid_to >= as_of), - ) - return await session.scalar(stmt) - - -def is_valid_for_version( - rule: EliminationRule | None, version_no: int | None, *, as_of: datetime.date | None = None -) -> bool: - """Проверить принадлежность правила версии и, если нужно, срок его действия.""" - return ( - rule is not None - and version_no is not None - and rule.version_no == version_no - and (as_of is None or (rule.valid_from <= as_of and (rule.valid_to is None or rule.valid_to >= as_of))) - ) +def is_valid_for_version(rule: EliminationRule | None, version_no: int | None) -> bool: + """Проверить номер версии либо отсутствие `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]: diff --git a/api/src/services/elimination_rules.py b/api/src/services/elimination_rules.py index 3ba8e45..be5608b 100644 --- a/api/src/services/elimination_rules.py +++ b/api/src/services/elimination_rules.py @@ -4,7 +4,6 @@ import uuid from sqlalchemy.ext.asyncio import AsyncSession -from src.common.clock import moscow_date, utc_now from src.common.exceptions import NotFoundError from src.domain.policy import VisibilityScope from src.repositories import consolidations as consolidations_repo @@ -27,17 +26,8 @@ class EliminationRuleService: raise NotFoundError(VERSION_NOT_FOUND) version, _ = found is_version_fixed = version.elimination_rules_version is not None - if is_version_fixed: - as_of = None - version_no = version.elimination_rules_version - else: - as_of = moscow_date(utc_now()) - version_no = await rules_repo.current_version_no(self._session, as_of) - rows = ( - await rules_repo.list_rules_with_accounts(self._session, version_no, as_of=as_of) - if version_no is not None - else [] - ) + version_no = version.elimination_rules_version + rows = await rules_repo.list_rules_with_accounts(self._session, version_no) by_id: dict[uuid.UUID, EliminationRuleOut] = {} for rule, account in rows: result = by_id.get(rule.id) @@ -64,6 +54,6 @@ class EliminationRuleService: return EliminationRulesOut( version_no=version_no, is_version_fixed=is_version_fixed, - as_of=as_of, + as_of=None, rules=list(by_id.values()), ) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 181b6c6..c28c3cc 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -10,7 +10,6 @@ from typing import Literal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.common.clock import moscow_date, utc_now from src.common.exceptions import DomainValidationError, NotFoundError from src.domain.consolidated_osv import ConsolidatedRow, Entity from src.domain.policy import VisibilityScope @@ -79,6 +78,18 @@ class MatrixSelection: measure: Measure +@dataclass(frozen=True) +class MatrixBuildResult: + """Матрица и данные свода, использованные при её построении.""" + + matrix: IntercompanyMatrixOut + version: ConsolidationVersion + consolidation: Consolidation + accounts: list[ChartOfAccount] + selection: MatrixSelection + rows: tuple[ConsolidatedRow, ...] + + @dataclass(frozen=True) class MatrixExportInfo: group_name: str @@ -97,7 +108,7 @@ class MatrixExportInfo: return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}{preset}.xlsx" -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: @@ -136,11 +147,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] = [] @@ -179,7 +190,7 @@ def calculate_matrix( return IntercompanyMatrixOut(companies=list(company_info), cells=cells) -def unpaired_rows(rows: Iterable[ConsolidatedRow], matrix: IntercompanyMatrixOut) -> list[ConsolidatedRow]: +def get_unpaired_rows(rows: Iterable[ConsolidatedRow], matrix: IntercompanyMatrixOut) -> list[ConsolidatedRow]: """Строки свода, попавшие только на одну сторону пары компаний в шахматке.""" entry_ids = { entry_id @@ -190,7 +201,7 @@ def unpaired_rows(rows: Iterable[ConsolidatedRow], matrix: IntercompanyMatrixOut return [row for row in rows if entry_ids.intersection(row.entry_ids)] -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} @@ -213,11 +224,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, @@ -229,10 +240,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( @@ -241,7 +252,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()) @@ -249,7 +260,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, @@ -273,7 +284,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: @@ -332,15 +343,19 @@ class IntercompanyMatrixService: credit_account_ids: Sequence[uuid.UUID] = (), rule_id: uuid.UUID | None = None, ) -> IntercompanyMatrixOut: - """Рассчитать матрицу по всем счетам, ручному выбору или правилу-пресету.""" - matrix, _, _, _, _, _ = await self._build_accounts( + """Построить шахматку по выбранному режиму. + + 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, @@ -352,7 +367,7 @@ class IntercompanyMatrixService: rule_id: uuid.UUID | None = None, ) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]: """Матрица и реквизиты книги из одного расчёта свода.""" - matrix, version, consolidation, accounts, selection, _ = await self._build_accounts( + result = await self._build_accounts( version_id, mode=mode, debit_account_ids=debit_account_ids, @@ -361,35 +376,35 @@ class IntercompanyMatrixService: ) 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() - by_id = {account.id: account.code for account in accounts} + 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 selection.company_1_account_ids)) - credit_codes = tuple(sorted(by_id[account_id] for account_id in selection.company_2_account_ids)) + 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=consolidation.year, - quarter=consolidation.quarter, - version_no=version.version_no, + 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=selection.measure, + measure=result.selection.measure, rule_id=rule_id, ) - return matrix, info + return result.matrix, info - async def unpaired_osv(self, version_id: uuid.UUID, rule_id: uuid.UUID) -> UnpairedOsvOut: + async def get_unpaired_osv(self, version_id: uuid.UUID, rule_id: uuid.UUID) -> UnpairedOsvOut: """Все строки свода без встречной стороны в шахматке заданного пресета.""" - matrix, _, _, _, _, rows = await self._build_accounts( + result = await self._build_accounts( version_id, mode="PRESET", debit_account_ids=(), credit_account_ids=(), rule_id=rule_id ) - unmatched = unpaired_rows(rows, matrix) + unmatched = get_unpaired_rows(result.rows, result.matrix) return UnpairedOsvOut(items=[row_out(row) for row in unmatched], total=len(unmatched)) async def _apply_corrections( @@ -449,7 +464,44 @@ class IntercompanyMatrixService: ) return IntercompanyMatrixOut(companies=matrix.companies, cells=cells) - async def _build_accounts( # noqa: PLR0912 - три режима выбора счетов и проверка пресета + 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]: + """Выбрать счета и показатель пресета для версии консолидации.""" + 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 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 = "closing" if rule.measure == "closing" else "turnover" + + return MatrixSelection(debit, credit, measure), rule + + async def _build_accounts( self, version_id: uuid.UUID, *, @@ -457,67 +509,36 @@ class IntercompanyMatrixService: debit_account_ids: Sequence[uuid.UUID], credit_account_ids: Sequence[uuid.UUID], rule_id: uuid.UUID | None = None, - ) -> tuple[ - IntercompanyMatrixOut, - ConsolidationVersion, - Consolidation, - list[ChartOfAccount], - MatrixSelection, - tuple[ConsolidatedRow, ...], - ]: + ) -> MatrixBuildResult: consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id) rows = consolidated.rows accounts = await directory_repo.list_accounts(self._session) - by_id, children = _account_tree(accounts) - measure: Measure = "closing" + 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": - if rule_id is None: - raise DomainValidationError("RULE_REQUIRED", "Выберите правило-пресет") - rule, rule_accounts = await elimination_rules_repo.rule_with_account(self._session, rule_id) - as_of = None if version.elimination_rules_version is not None else moscow_date(utc_now()) - version_no = ( - version.elimination_rules_version - if as_of is None - else await elimination_rules_repo.current_version_no(self._session, as_of) - ) - if rule is None or not elimination_rules_repo.is_valid_for_version(rule, version_no, as_of=as_of): - raise NotFoundError("правило-пресет не найдено для версии консолидации") - 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 = _selected_accounts(debit_ids, debit_excluded, "D", by_id, children) - credit = _selected_accounts(credit_ids, credit_excluded, "K", by_id, children) - measure = "closing" if rule.measure == "closing" else "turnover" + selection, rule = await self._resolve_preset_selection(rule_id, version, by_id, children) else: raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов") - selection = MatrixSelection(debit, credit, measure) matrix = self._calculate(rows, members, selection) if rule is not None: matrix = await self._apply_corrections(matrix, version_id, rule.id) - return matrix, version, consolidation, accounts, selection, rows + return MatrixBuildResult( + matrix=matrix, + version=version, + consolidation=consolidation, + accounts=accounts, + selection=selection, + rows=rows, + ) diff --git a/api/tests/test_elimination_rules.py b/api/tests/test_elimination_rules.py index 6ac4755..77504be 100644 --- a/api/tests/test_elimination_rules.py +++ b/api/tests/test_elimination_rules.py @@ -1,6 +1,5 @@ """Выбор версии правил для шахматки без обращения к БД.""" -import datetime import uuid from types import SimpleNamespace from unittest.mock import AsyncMock @@ -24,39 +23,35 @@ def _rule() -> SimpleNamespace: @pytest.mark.parametrize( - ("rule", "version_no", "as_of", "expected"), + ("rule", "version_no", "expected"), [ - (None, 3, None, False), - (SimpleNamespace(version_no=3), None, None, False), - (SimpleNamespace(version_no=3), 2, None, False), - (SimpleNamespace(version_no=3), 3, None, True), - ( - SimpleNamespace(version_no=3, valid_from=datetime.date(2024, 1, 1), valid_to=datetime.date(2024, 12, 31)), - 3, - datetime.date(2023, 12, 31), - False, - ), - ( - SimpleNamespace(version_no=3, valid_from=datetime.date(2024, 1, 1), valid_to=datetime.date(2024, 12, 31)), - 3, - datetime.date(2024, 12, 31), - True, - ), - ( - SimpleNamespace(version_no=3, valid_from=datetime.date(2024, 1, 1), valid_to=datetime.date(2024, 12, 31)), - 3, - datetime.date(2025, 1, 1), - False, - ), + (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, as_of: datetime.date | None, expected: bool + rule: SimpleNamespace | None, version_no: int | None, expected: bool ) -> None: - assert rules_repo.is_valid_for_version(rule, version_no, as_of=as_of) is expected + assert rules_repo.is_valid_for_version(rule, version_no) is expected -async def test_fixed_rules_version_ignores_current_date(monkeypatch: pytest.MonkeyPatch) -> None: +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) + assert "valid_to IS NULL" in str(session.execute.await_args.args[0]) + + 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() @@ -67,9 +62,7 @@ async def test_fixed_rules_version_ignores_current_date(monkeypatch: pytest.Monk "get_version", AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=rule.version_no), None)), ) - current = AsyncMock() listed = AsyncMock(return_value=[(rule, account)]) - monkeypatch.setattr(service_module.rules_repo, "current_version_no", current) monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed) result = await service_module.EliminationRuleService(session, scope).list_rules(version_id) @@ -79,34 +72,28 @@ async def test_fixed_rules_version_ignores_current_date(monkeypatch: pytest.Monk assert result.as_of is None assert [item.id for item in result.rules] == [rule.id] assert result.rules[0].company_1.accounts == [account_id] - current.assert_not_awaited() - listed.assert_awaited_once_with(session, rule.version_no, as_of=None) + listed.assert_awaited_once_with(session, rule.version_no) -async def test_unfixed_rules_use_latest_active_version_on_moscow_date(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_unfixed_rules_use_open_valid_to(monkeypatch: pytest.MonkeyPatch) -> None: session, scope = object(), object() version_id = uuid.uuid4() rule = _rule() - expected_date = datetime.date(2026, 10, 19) - monkeypatch.setattr(service_module, "utc_now", lambda: datetime.datetime(2026, 10, 18, 22, tzinfo=datetime.UTC)) monkeypatch.setattr( service_module.consolidations_repo, "get_version", AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)), ) - current = AsyncMock(return_value=rule.version_no) listed = AsyncMock(return_value=[(rule, None)]) - monkeypatch.setattr(service_module.rules_repo, "current_version_no", current) 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 result.as_of == expected_date + assert result.version_no is None + assert result.as_of is None assert [item.id for item in result.rules] == [rule.id] - current.assert_awaited_once_with(session, expected_date) - listed.assert_awaited_once_with(session, rule.version_no, as_of=expected_date) + listed.assert_awaited_once_with(session, None) async def test_no_current_rules_returns_empty_with_unfixed_flag(monkeypatch: pytest.MonkeyPatch) -> None: @@ -116,14 +103,13 @@ async def test_no_current_rules_returns_empty_with_unfixed_flag(monkeypatch: pyt "get_version", AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)), ) - monkeypatch.setattr(service_module.rules_repo, "current_version_no", AsyncMock(return_value=None)) - listed = AsyncMock() + 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.as_of is not None + assert result.as_of is None assert result.rules == [] - listed.assert_not_awaited() + listed.assert_awaited_once_with(session, None) diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 0f2dd8b..efdd2eb 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -58,9 +58,9 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: 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} @@ -109,7 +109,7 @@ def test_unpaired_rows_uses_source_presence_and_preserves_osv_order() -> None: rows, companies, matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing") ) - assert matrix.unpaired_rows(rows, result) == [unpaired_credit] + assert matrix.get_unpaired_rows(rows, result) == [unpaired_credit] async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkeypatch: pytest.MonkeyPatch) -> None: @@ -152,15 +152,12 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey assert captured[0].company_2_account_ids == {parent.id, child.id} assert captured[0].measure == "turnover" - current_version = AsyncMock(return_value=1) - monkeypatch.setattr(matrix.elimination_rules_repo, "current_version_no", current_version) version.elimination_rules_version = None _, 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" - current_version.assert_awaited_once() async def test_preset_corrections_exclude_amounts_without_changing_osv(monkeypatch: pytest.MonkeyPatch) -> None: -- 2.47.2 From 2c0a2ad93931b43668fda71bf7bf0373ef1ac56c Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 21 Oct 2026 12:15:20 +0300 Subject: [PATCH 04/20] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/services/consolidated_osv.py | 57 ++++++++++++++++--------- api/src/services/intercompany_matrix.py | 22 +++++----- api/tests/test_intercompany_matrix.py | 6 ++- 3 files changed, 52 insertions(+), 33 deletions(-) diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index 4e6542b..a9281ce 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 @@ -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,14 +147,13 @@ 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(self, version_id: uuid.UUID) -> ConsolidatedOsvBuildResult: + """Свод версии и данные его расчёта; версия вне видимости — 404.""" + return await self.build_with_members(version_id) async def build_with_members( self, version_id: uuid.UUID - ) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation, dict[uuid.UUID, domain.Entity]]: + ) -> ConsolidatedOsvBuildResult: """Свод и участники периметра, загруженные при его расчёте.""" found = await repo.get_version(self._session, self._scope, version_id) if found is None: @@ -174,12 +184,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(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,20 +204,20 @@ 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(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), @@ -210,27 +225,27 @@ class ConsolidatedOsvService: }, ) filename = ( - f"svod-osv_{group_name}_{consolidation.year}-{consolidation.quarter.value}_v{version.version_no}.xlsx" + f"svod-osv_{group_name}_{result.consolidation.year}-{result.consolidation.quarter.value}_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(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(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/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index c28c3cc..b21789d 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -317,8 +317,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 @@ -329,10 +329,10 @@ class IntercompanyMatrixService: async def build_result_table(self, version_id: uuid.UUID) -> IntercompanyResultTableOut: """Статичная таблица по пяти статьям""" - consolidated, _, _, 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()] - return calculate_result_table(consolidated.rows, companies, accounts) + companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in result.members.values()] + return calculate_result_table(result.consolidated.rows, companies, accounts) async def build_accounts( self, @@ -510,8 +510,8 @@ class IntercompanyMatrixService: credit_account_ids: Sequence[uuid.UUID], rule_id: uuid.UUID | None = None, ) -> MatrixBuildResult: - consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id) - rows = consolidated.rows + 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 = _make_account_tree(accounts) rule: EliminationRule | None = None @@ -528,16 +528,16 @@ class IntercompanyMatrixService: 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, version, by_id, children) + selection, rule = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children) else: raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов") - matrix = self._calculate(rows, members, selection) + matrix = self._calculate(rows, osv_result.members, selection) if rule is not None: matrix = await self._apply_corrections(matrix, version_id, rule.id) return MatrixBuildResult( matrix=matrix, - version=version, - consolidation=consolidation, + version=osv_result.version, + consolidation=osv_result.consolidation, accounts=accounts, selection=selection, rows=rows, diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index efdd2eb..236465f 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -120,7 +120,11 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey 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(rows=[]), version, consolidation, {})) + 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( -- 2.47.2 From 367a11ed38ba0886d5c9cc11441ea8be47e32147 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 21 Oct 2026 14:04:38 +0400 Subject: [PATCH 05/20] =?UTF-8?q?chore(api):=20format,=20mypy=20=D0=B8=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB;=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D0=BE=D0=B5?= =?UTF-8?q?=20as=5Fof=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/src/repositories/elimination_rules.py | 4 +--- api/src/schemas/consolidations.py | 1 - api/src/services/consolidated_osv.py | 9 +++------ api/src/services/elimination_rules.py | 1 - api/src/services/intercompany_matrix.py | 2 +- api/tests/test_api_intercompany_matrix.py | 5 ++--- api/tests/test_elimination_rules.py | 7 +------ 7 files changed, 8 insertions(+), 21 deletions(-) diff --git a/api/src/repositories/elimination_rules.py b/api/src/repositories/elimination_rules.py index 476d132..a7008e8 100644 --- a/api/src/repositories/elimination_rules.py +++ b/api/src/repositories/elimination_rules.py @@ -14,9 +14,7 @@ async def list_rules_with_accounts( ) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]: """Правила заданной версии либо все правила без `valid_to`.""" condition = ( - EliminationRule.version_no == version_no - if version_no is not None - else EliminationRule.valid_to.is_(None) + EliminationRule.version_no == version_no if version_no is not None else EliminationRule.valid_to.is_(None) ) stmt = ( select(EliminationRule, EliminationRuleAccount) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 8f79240..809ba09 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -340,7 +340,6 @@ class EliminationRulesOut(BaseModel): version_no: int | None is_version_fixed: bool - as_of: datetime.date | None rules: list[EliminationRuleOut] diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index a9281ce..d58860b 100644 --- a/api/src/services/consolidated_osv.py +++ b/api/src/services/consolidated_osv.py @@ -151,9 +151,7 @@ class ConsolidatedOsvService: """Свод версии и данные его расчёта; версия вне видимости — 404.""" return await self.build_with_members(version_id) - async def build_with_members( - self, version_id: uuid.UUID - ) -> ConsolidatedOsvBuildResult: + async def build_with_members(self, version_id: uuid.UUID) -> ConsolidatedOsvBuildResult: """Свод и участники периметра, загруженные при его расчёте.""" found = await repo.get_version(self._session, self._scope, version_id) if found is None: @@ -224,9 +222,8 @@ class ConsolidatedOsvService: "rows": len(rows), }, ) - filename = ( - f"svod-osv_{group_name}_{result.consolidation.year}-{result.consolidation.quarter.value}_v{result.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: diff --git a/api/src/services/elimination_rules.py b/api/src/services/elimination_rules.py index be5608b..d61b574 100644 --- a/api/src/services/elimination_rules.py +++ b/api/src/services/elimination_rules.py @@ -54,6 +54,5 @@ class EliminationRuleService: return EliminationRulesOut( version_no=version_no, is_version_fixed=is_version_fixed, - as_of=None, rules=list(by_id.values()), ) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index b21789d..9ea6d82 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -497,7 +497,7 @@ class IntercompanyMatrixService: 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 = "closing" if rule.measure == "closing" else "turnover" + measure: Measure = "closing" if rule.measure == "closing" else "turnover" return MatrixSelection(debit, credit, measure), rule diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 1c679d3..abecf82 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -221,8 +221,8 @@ async def test_preset_uses_current_rules_when_version_unfixed( 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 listed.json()["as_of"] is not None + assert listed.json()["version_no"] is None + 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 @@ -238,7 +238,6 @@ async def test_preset_uses_current_rules_when_version_unfixed( assert fixed.status_code == OK, fixed.text assert fixed.json()["is_version_fixed"] is True assert fixed.json()["version_no"] == rule.version_no - assert fixed.json()["as_of"] is None 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 diff --git a/api/tests/test_elimination_rules.py b/api/tests/test_elimination_rules.py index 77504be..87ab323 100644 --- a/api/tests/test_elimination_rules.py +++ b/api/tests/test_elimination_rules.py @@ -33,9 +33,7 @@ def _rule() -> SimpleNamespace: (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: +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 @@ -69,7 +67,6 @@ async def test_fixed_rules_use_version_number(monkeypatch: pytest.MonkeyPatch) - assert result.is_version_fixed is True assert result.version_no == rule.version_no - assert result.as_of is None 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) @@ -91,7 +88,6 @@ async def test_unfixed_rules_use_open_valid_to(monkeypatch: pytest.MonkeyPatch) assert result.is_version_fixed is False assert result.version_no is None - assert result.as_of is None assert [item.id for item in result.rules] == [rule.id] listed.assert_awaited_once_with(session, None) @@ -110,6 +106,5 @@ async def test_no_current_rules_returns_empty_with_unfixed_flag(monkeypatch: pyt assert result.is_version_fixed is False assert result.version_no is None - assert result.as_of is None assert result.rules == [] listed.assert_awaited_once_with(session, None) -- 2.47.2 From 0e95f95c712fbb9786a6bbc1051526359f8cd251 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 21 Oct 2026 14:08:08 +0400 Subject: [PATCH 06/20] =?UTF-8?q?docs(openspec):=20change=20intercompany-m?= =?UTF-8?q?atrix-presets=20+=20README=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20?= =?UTF-8?q?#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- README.md | 9 +- .../intercompany-matrix-presets/design.md | 59 +++++++++++++ .../intercompany-matrix-presets/proposal.md | 34 ++++++++ .../specs/audit-trail/spec.md | 19 +++++ .../specs/consolidated-osv/spec.md | 82 +++++++++++++++++++ .../specs/consolidations-api/spec.md | 23 ++++++ .../intercompany-matrix-presets/tasks.md | 4 + 7 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/intercompany-matrix-presets/design.md create mode 100644 openspec/changes/intercompany-matrix-presets/proposal.md create mode 100644 openspec/changes/intercompany-matrix-presets/specs/audit-trail/spec.md create mode 100644 openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md create mode 100644 openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md create mode 100644 openspec/changes/intercompany-matrix-presets/tasks.md diff --git a/README.md b/README.md index e4e9f66..a449cdd 100644 --- a/README.md +++ b/README.md @@ -870,7 +870,14 @@ 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`. Режим PRESET (счета и показатель из правила элиминации): +`GET …/intercompany-matrix/preset/{rule_id}`, его выгрузка `…/preset/{rule_id}/export` и строки сводной ОСВ без пары +`…/preset/{rule_id}/unpaired-osv`; в ячейке `correction_ids_debit/credit` (применённые корректировки правила). +`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной +версии правил — открытые правила (`valid_to IS NULL`). Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`, +`ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. В шапке xlsx — строка «Показатель», +audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`) и `rule_id`. Открытые вопросы по пресетам — +`openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта — `../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях `openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`. Расчёт читает сводную ОСВ версии, результат не хранится. diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md new file mode 100644 index 0000000..87e4f73 --- /dev/null +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -0,0 +1,59 @@ +## Context + +Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`). Шахматка — чистая функция от сводной ОСВ версии, +периметра и выбранных счетов; в БД не хранится (DECISION-5). Корректировки (`correction`) — хранимые ручные решения, +читаются, но в ядре пока никем не создаются. + +## Decisions + +1. Режим PRESET берёт правило по `rule_id`. Правило должно быть действительно для версии: при зафиксированном + `elimination_rules_version` — совпадать по `version_no`, иначе — иметь `valid_to IS NULL`; иначе 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. Применённые корректировки правила (`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` строится из `one_sided`-ячеек шахматки пресета (`osv_entry_ids_*`) и возвращает строки сводной ОСВ, + содержащие эти записи. +6. `GET …/elimination-rules` отдаёт объект с основанием выбора версии; `as_of` не вводится. +7. xlsx пресета: имя `intercompany-matrix_<ГК>_<год>-<квартал>_v_preset-.xlsx`, в шапке «Режим = Пресет», + коды счетов сторон (отсортированы) и строка «Показатель» (Сальдо/Оборот) для всех режимов. + +## Known issues / вопросы владельцу + +Спека описывает фактическое поведение PR; пункты ниже не закреплены как окончательные и ждут решения автора и владельца. + +1. При пустом `consolidation_version.elimination_rules_version` берутся текущие открытые правила. Это расходится с + DBML («фиксируется при первом расчёте, замораживается при VERIFIED») и критерием AURORA-1536; нужно решение + владельца, в том числе для версий в статусе `VERIFIED`. +2. «Актуальные» правила — `valid_to IS NULL` на сегодня, а не на отчётную дату консолидации. +3. Если открытыми остаются несколько `version_no`, список правил даёт дубли `rule_no`. +4. Семантика корректировки: `(rule, A, B)` дебетовой частью правит ячейку `(A, B)`, кредитовой — ячейку `(B, A)`; + `debit_account_id`, `credit_account_id` и `parent_correction_id` не учитываются; корректировки в ядре пока никто + не создаёт. +5. `unpaired-osv` построена на `one_sided` по наличию строк — это дефект AURORA-1651, и «без пары» здесь не равно + «без пары» по §5 и AURORA-1536. +6. Пресет здесь — правило Прил. 3, а §6 описывает пресеты как наборы счетов; PRESET по ОФР на оборотах + (`measure = turnover`) не подтверждён §5/§6. +7. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию; audit не пишет номер/версию правил + и ID применённых корректировок. + +## Decisions made without discussion + +- 2026-10-21. Change пишет ревьюер в ветке автора по решению владельца. Обоснование: автор не оформила OpenSpec. Цена + ошибки: автор переписывает change. +- 2026-10-21. Спека описывает фактическое поведение PR; спорное вынесено в Known issues. Обоснование: домен решает + автор и владелец, не ревьюер. Цена ошибки: после решения владельца спека меняется дельтой. +- 2026-10-21. Поле `as_of` удалено из `EliminationRulesOut` как мёртвое: после правок по ревью всегда `None`, в main + его не было, потребителей нет. Цена ошибки: если фронт ждал `as_of`, поле нужно вернуть. +- 2026-10-21. Интеграционный тест `test_preset_uses_current_rules_when_version_unfixed` приведён к фактическому + поведению (`version_no is None`, `is_version_fixed is False`), а не к старому ожиданию `version_no == rule.version_no`; + логика вычисления `version_no` не добавлялась. Цена ошибки: если автор решит возвращать номер версии, тест вернуть. +- 2026-10-21. mypy: `measure` аннотирован `Measure` без `cast`; слишком длинная f-строка имени файла свода ОСВ + (`consolidated_osv.py`) разбита на две без изменения результата (ruff E501). Цена ошибки: нулевая. +- 2026-10-21. В MODIFIED-блоке требования про правила элиминации и аудит экспорта приведён полный текст; требование + «XLSX шахматки» (ALL/MANUAL) не менялось, поведение PRESET описано отдельным ADDED-требованием. diff --git a/openspec/changes/intercompany-matrix-presets/proposal.md b/openspec/changes/intercompany-matrix-presets/proposal.md new file mode 100644 index 0000000..cb59d0e --- /dev/null +++ b/openspec/changes/intercompany-matrix-presets/proposal.md @@ -0,0 +1,34 @@ +## Why + +Шахматка ВГО (ТЗ §6) считалась только по всем счетам (ALL) или по счетам, выбранным вручную (MANUAL). Аналитику нужен +режим «пресет»: счета и показатель берутся из правила элиминации (Прил. 3), с учётом применённых корректировок и со +списком строк сводной ОСВ без пары. Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`, автор — Потапова). +Требования оформлены как дельты этого change'а при ревью (решение владельца 21.10.2026); спорное поведение вынесено в +`design.md`, `## Known issues`, и в спеке не закреплено как окончательное. + +## What Changes + +- Режим PRESET шахматки: `GET …/intercompany-matrix/preset/{rule_id}`, выгрузка `…/preset/{rule_id}/export`, список + строк без пары `…/preset/{rule_id}/unpaired-osv`. +- Показатель пресета (`closing` / `turnover`) берётся из правила; в ячейке появляются `correction_ids_debit/credit`, + суммы уменьшаются на применённые корректировки правила. +- `GET …/elimination-rules` возвращает объект `{version_no, is_version_fixed, rules}` вместо массива; у правила есть + `id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`). +- xlsx: для PRESET имя файла с `_preset-`, в шапке строка «Показатель»; audit `EXPORT` пишет `mode` + `ALL`/`MANUAL`/`PRESET` и `rule_id`. + +## 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..838a8d1 --- /dev/null +++ b/openspec/changes/intercompany-matrix-presets/specs/audit-trail/spec.md @@ -0,0 +1,19 @@ +## 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`. `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` правила, пустые массивы счетов и число ячеек 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..4e986b8 --- /dev/null +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md @@ -0,0 +1,82 @@ +## 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`; несуществующее или недействительное правило 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 + +### Requirement: Корректировки в шахматке пресета +В режиме PRESET расчёт SHALL учитывать корректировки `correction` со статусом `APPLIED`, у которых +`consolidation_version_id` равен версии, а `rule_id` — правилу пресета. Корректировка с `legal_entity_id = A` и +`counterparty_id = B` SHALL уменьшать `amount_a` ячейки `(A, B)` на `debit_adjustment` и `amount_b` ячейки `(B, A)` на +`credit_adjustment`. Затронутые ячейки SHALL получать пересчитанные `variance_amount`, `variance_percent`, +`exceeds_threshold` и ID корректировок в `correction_ids_debit`/`correction_ids_credit`; у остальных ячеек эти массивы +пусты (поля есть в ответе всех режимов). Корректировки без `legal_entity_id` или `counterparty_id` SHALL игнорироваться. + +#### Scenario: Корректировка по дебету +- **WHEN** есть применённая корректировка правила `(A, B)` с `debit_adjustment = 10` +- **THEN** у ячейки `(A, B)` `amount_a` меньше на 10, расхождение пересчитано, `correction_ids_debit` содержит её ID + +#### Scenario: Корректировка по кредиту +- **WHEN** есть применённая корректировка правила `(A, B)` с `credit_adjustment = 5` +- **THEN** у ячейки `(B, A)` `amount_b` меньше на 5, `correction_ids_credit` содержит её ID + +#### Scenario: Корректировка чужого правила или не применённая +- **WHEN** корректировка относится к другому правилу либо имеет статус не `APPLIED` +- **THEN** суммы ячеек не меняются, `correction_ids_*` пусты + +### Requirement: Строки сводной ОСВ без пары по пресету +`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv` +(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{items, total}`: все строки сводной ОСВ версии, чьи записи ОСВ +входят в `osv_entry_ids_debit/credit` ячеек шахматки пресета с `one_sided = true`. Формат строки — как на странице +сводной ОСВ, без пагинации; `total` — число элементов. Ошибки — как у расчёта пресета. + +#### Scenario: Есть односторонняя ячейка +- **WHEN** в шахматке пресета у ячейки `(A, B)` `one_sided = true` +- **THEN** `items` содержит строки сводной ОСВ с записями этой ячейки, `total` равен их числу + +#### Scenario: Все пары двусторонние +- **WHEN** у шахматки пресета нет односторонних ячеек +- **THEN** `items = []`, `total = 0` + +#### Scenario: Неизвестное правило +- **WHEN** `rule_id` не существует +- **THEN** 404 + +### Requirement: XLSX шахматки пресета +`GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт +пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_preset-.xlsx`. Шапка SHALL +содержать режим «Пресет», коды счетов дебета и кредита правила и строку «Показатель» со значением «Сальдо» для +`closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение +«Сальдо»). + +#### Scenario: Выгрузка по оборотам +- **WHEN** пользователь выгружает пресет с `measure = turnover` +- **THEN** шапка содержит «Режим — Пресет» и «Показатель — Оборот», имя файла заканчивается на `_preset-.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..1217e97 --- /dev/null +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md @@ -0,0 +1,23 @@ +## 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` — правилами этой версии. Если он не задан, `version_no = null`, `is_version_fixed = false`, а `rules` — +открытые правила (`valid_to IS 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 = null` и открытые правила с `valid_to IS NULL` diff --git a/openspec/changes/intercompany-matrix-presets/tasks.md b/openspec/changes/intercompany-matrix-presets/tasks.md new file mode 100644 index 0000000..7428843 --- /dev/null +++ b/openspec/changes/intercompany-matrix-presets/tasks.md @@ -0,0 +1,4 @@ +## 1. Реализация + +Реализация выполнена автором в PR RSHB/arm-core#141; задач плана нет. Доработки по замечаниям ревью — см. +`design.md`, `## Known issues`. -- 2.47.2 From 3adaf6f3c38a2a8ab4c673b3a973a7db99449112 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 21 Oct 2026 15:10:29 +0400 Subject: [PATCH 07/20] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D1=86=D0=B0=20=D0=BF=D0=BE=20=D1=88=D0=B0=D1=85=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=BA=D0=B5=20=D0=BF=D1=80=D0=B5=D1=81=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фиксация версии правил и выбор по дате составления консолидации — в AURORA-1536; корректировки и unpaired-osv выносятся из PR (код — ветка wip/shahmatka-corrections-unpaired); пресет = правило ББ Прил. 3 как дополнение к §6. Co-Authored-By: Claude Opus 5.5 --- .../intercompany-matrix-presets/design.md | 43 ++++++++++++------- .../intercompany-matrix-presets/tasks.md | 9 ++++ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md index 87e4f73..5b8a0f2 100644 --- a/openspec/changes/intercompany-matrix-presets/design.md +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -23,24 +23,35 @@ 7. xlsx пресета: имя `intercompany-matrix_<ГК>_<год>-<квартал>_v_preset-.xlsx`, в шапке «Режим = Пресет», коды счетов сторон (отсортированы) и строка «Показатель» (Сальдо/Оборот) для всех режимов. -## Known issues / вопросы владельцу +## Решения владельца (2026-10-21, ревью PR #141) -Спека описывает фактическое поведение PR; пункты ниже не закреплены как окончательные и ждут решения автора и владельца. +1. **Фиксация версии правил — в AURORA-1536.** Пока `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` (`0e95f95`) и возвращается с задачей корректировок. +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`. -1. При пустом `consolidation_version.elimination_rules_version` берутся текущие открытые правила. Это расходится с - DBML («фиксируется при первом расчёте, замораживается при VERIFIED») и критерием AURORA-1536; нужно решение - владельца, в том числе для версий в статусе `VERIFIED`. -2. «Актуальные» правила — `valid_to IS NULL` на сегодня, а не на отчётную дату консолидации. -3. Если открытыми остаются несколько `version_no`, список правил даёт дубли `rule_no`. -4. Семантика корректировки: `(rule, A, B)` дебетовой частью правит ячейку `(A, B)`, кредитовой — ячейку `(B, A)`; - `debit_account_id`, `credit_account_id` и `parent_correction_id` не учитываются; корректировки в ядре пока никто - не создаёт. -5. `unpaired-osv` построена на `one_sided` по наличию строк — это дефект AURORA-1651, и «без пары» здесь не равно - «без пары» по §5 и AURORA-1536. -6. Пресет здесь — правило Прил. 3, а §6 описывает пресеты как наборы счетов; PRESET по ОФР на оборотах - (`measure = turnover`) не подтверждён §5/§6. -7. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию; audit не пишет номер/версию правил - и ID применённых корректировок. +После выноса п. 3–4 автором соответствующие требования дельты `consolidated-osv` удаляются из change. + +## Known issues + +1. Если открытыми остаются несколько `version_no`, список правил даёт дубли `rule_no` (обязательно в PR: одна версия + или явная ошибка, тест). +2. При незафиксированной версии ответ `elimination-rules` отдаёт `version_no = null` — фронт не видит, по какой версии + посчитано (желательно отдавать фактический номер открытых правил). +3. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию; audit EXPORT не пишет номер и + версию правил. ## Decisions made without discussion diff --git a/openspec/changes/intercompany-matrix-presets/tasks.md b/openspec/changes/intercompany-matrix-presets/tasks.md index 7428843..53dca62 100644 --- a/openspec/changes/intercompany-matrix-presets/tasks.md +++ b/openspec/changes/intercompany-matrix-presets/tasks.md @@ -2,3 +2,12 @@ Реализация выполнена автором в PR RSHB/arm-core#141; задач плана нет. Доработки по замечаниям ревью — см. `design.md`, `## Known issues`. + +## 2. Доработки по решениям владельца 2026-10-21 (автор) + +- [ ] 2.1 Отдельным коммитом убрать учёт корректировок (`_apply_corrections`, `correction_for_version_consolidation`, + `correction_ids_*`) и ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами; + код сохранён в `wip/shahmatka-corrections-unpaired`. +- [ ] 2.2 PRESET — только правила `statement_type = balance`; правило ОФР — 422 `INVALID_RULE`, тест. +- [ ] 2.3 Несколько открытых `version_no` — одна версия (max) или явная ошибка, тест. +- [ ] 2.4 Ревьюер: убрать из дельты `consolidated-osv` требования про корректировки и `unpaired-osv`, обновить README. -- 2.47.2 From 6213de4498309181708523422da33ab33bca2a1e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 21 Oct 2026 15:51:06 +0400 Subject: [PATCH 08/20] =?UTF-8?q?docs(openspec):=20change=20=D1=88=D0=B0?= =?UTF-8?q?=D1=85=D0=BC=D0=B0=D1=82=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B2=20=E2=80=94=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=20AURORA-1747,=20=D0=BE=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=20=C2=A75=20=D0=B2=20AURORA-1536=20(=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/intercompany-matrix-presets/design.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md index 5b8a0f2..764a0e3 100644 --- a/openspec/changes/intercompany-matrix-presets/design.md +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -1,6 +1,9 @@ ## Context -Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`). Шахматка — чистая функция от сводной ОСВ версии, +Реализация — 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`) — хранимые ручные решения, читаются, но в ядре пока никем не создаются. @@ -25,7 +28,7 @@ ## Решения владельца (2026-10-21, ревью PR #141) -1. **Фиксация версии правил — в AURORA-1536.** Пока `consolidation_version.elimination_rules_version` пуст, шахматка +1. **Фиксация версии правил — в AURORA-1536 (следующий шаг, не этот change).** Пока `consolidation_version.elimination_rules_version` пуст, шахматка берёт открытые правила (`valid_to IS NULL`); сейчас версия правил одна (`seed/core/elimination_rules_v1.yaml`, `valid_to` не задаётся, API управления правилами нет), поэтому результат не плавает. Фиксировать версию — при создании версии консолидации или при первом расчёте, но не в GET-запросе. @@ -34,7 +37,8 @@ 3. **Корректировки из шахматки убираются** (решение 4 выше и `correction_ids_*` в ячейке). Причины: корректировки в ядре никто не создаёт (спринт корректировок), семантика ключа `(rule, A, B)` относительно ячейки (A, B) / (B, A) не утверждена, счета корректировки и `parent_correction_id` не учитывались; §6 описывает шахматку как суммы первички. - Код сохранён в ветке `wip/shahmatka-corrections-unpaired` (`0e95f95`) и возвращается с задачей корректировок. + Код сохранён в ветке `wip/shahmatka-corrections-unpaired` (`0e95f95`) и возвращается после решения по аналитике + AURORA-1643 «Учитывание корректировок в шахматке». 4. **Ручка `…/preset/{rule_id}/unpaired-osv` убирается** (решение 5 выше). Причины: «без пары» по наличию строк (дефект AURORA-1651), ложные срабатывания при пересечении счетов сторон (76 в правиле № 6), нет ячейки и стороны строки, расхождение с «без пары» §5. Сценарий закрывают AURORA-1653 (строки ОСВ ячейки) и AURORA-1536 (строки без -- 2.47.2 From e9dc67b2e951e406ee0b0524e4f187ec9132c6ca Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 21 Oct 2026 18:40:05 +0300 Subject: [PATCH 09/20] =?UTF-8?q?refactor(matrix):=20=D0=B2=D1=8B=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=82=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B5=D1=81=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- api/src/repositories/consolidations.py | 21 ------ api/src/schemas/consolidations.py | 2 - api/src/services/intercompany_matrix.py | 63 +--------------- api/tests/test_api_intercompany_matrix.py | 3 - api/tests/test_correction_repository.py | 34 --------- api/tests/test_intercompany_matrix.py | 75 ------------------- api/tests/test_intercompany_matrix_export.py | 8 -- .../intercompany-matrix-presets/design.md | 2 +- .../intercompany-matrix-presets/proposal.md | 9 +-- .../specs/consolidated-osv/spec.md | 20 ----- .../intercompany-matrix-presets/tasks.md | 5 +- 12 files changed, 10 insertions(+), 234 deletions(-) delete mode 100644 api/tests/test_correction_repository.py diff --git a/README.md b/README.md index a449cdd..4626427 100644 --- a/README.md +++ b/README.md @@ -872,7 +872,7 @@ API: список счетов `GET /directories/accounts`, правила ве `GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица `GET …/intercompany-matrix/result-table`. Режим PRESET (счета и показатель из правила элиминации): `GET …/intercompany-matrix/preset/{rule_id}`, его выгрузка `…/preset/{rule_id}/export` и строки сводной ОСВ без пары -`…/preset/{rule_id}/unpaired-osv`; в ячейке `correction_ids_debit/credit` (применённые корректировки правила). +`…/preset/{rule_id}/unpaired-osv`. `GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной версии правил — открытые правила (`valid_to IS NULL`). Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`, `ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. В шапке xlsx — строка «Показатель», diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 4afee3e..ac7cdd9 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -31,7 +31,6 @@ from src.models.directories import CompanyGroup, LegalEntity, LegalEntityAlias, from src.models.enums import ( ConsolidationApproach, ConsolidationStatus, - CorrectionStatus, DocumentStatus, DocumentType, FileStatus, @@ -41,7 +40,6 @@ from src.models.enums import ( RelatedGroupKind, ) from src.models.files import Document, StoredFile -from src.models.kfo import Correction from src.models.osv import OsvEntry from src.repositories.directories import LIKE_ESCAPE, escape_like @@ -565,22 +563,3 @@ async def current_related_groups( for entity_id, group in await session.execute(stmt): groups[entity_id][group.kind] = group return dict(groups) - - -async def correction_for_version_consolidation( - session: AsyncSession, version_id: uuid.UUID, rule_id: uuid.UUID -) -> list[Correction]: - """Применённые корректировки правила для версии консолидации.""" - if not version_id or not rule_id: - return [] - - stmt = ( - select(Correction) - .where( - Correction.consolidation_version_id == version_id, - Correction.rule_id == rule_id, - Correction.status == CorrectionStatus.APPLIED, - ) - .order_by(Correction.id) - ) - return list((await session.scalars(stmt)).all()) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 809ba09..00435a7 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -367,8 +367,6 @@ class IntercompanyMatrixCellOut(BaseModel): ) osv_entry_ids_debit: list[uuid.UUID] osv_entry_ids_credit: list[uuid.UUID] - correction_ids_debit: list[uuid.UUID] = Field(default_factory=list) - correction_ids_credit: list[uuid.UUID] = Field(default_factory=list) @field_validator("amount_a", "amount_b", "variance_amount", "variance_percent") @classmethod diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 9ea6d82..c77fe55 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -17,7 +17,6 @@ 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.repositories import consolidations as consolidation_repo from src.repositories import directories as directory_repo from src.repositories import elimination_rules as elimination_rules_repo from src.schemas.consolidations import ( @@ -407,63 +406,6 @@ class IntercompanyMatrixService: unmatched = get_unpaired_rows(result.rows, result.matrix) return UnpairedOsvOut(items=[row_out(row) for row in unmatched], total=len(unmatched)) - async def _apply_corrections( - self, matrix: IntercompanyMatrixOut, version_id: uuid.UUID, rule_id: uuid.UUID - ) -> IntercompanyMatrixOut: - """Исключение суммы применённых корректировок из сторон пары пресета.""" - corrections = await consolidation_repo.correction_for_version_consolidation( - self._session, version_id=version_id, rule_id=rule_id - ) - if not corrections: - return matrix - - debit_by_pair: dict[tuple[uuid.UUID, uuid.UUID], Decimal] = defaultdict(lambda: ZERO) - credit_by_pair: dict[tuple[uuid.UUID, uuid.UUID], Decimal] = defaultdict(lambda: ZERO) - debit_ids_by_pair: dict[tuple[uuid.UUID, uuid.UUID], set[uuid.UUID]] = defaultdict(set) - credit_ids_by_pair: dict[tuple[uuid.UUID, uuid.UUID], set[uuid.UUID]] = defaultdict(set) - for correction in corrections: - owner, counterparty = correction.legal_entity_id, correction.counterparty_id - if owner is None or counterparty is None: - continue - pair = (owner, counterparty) - if correction.debit_adjustment is not None: - debit_by_pair[pair] += correction.debit_adjustment - debit_ids_by_pair[pair].add(correction.id) - if correction.credit_adjustment is not None: - credit_by_pair[pair] += correction.credit_adjustment - credit_ids_by_pair[pair].add(correction.id) - - cells = [] - for cell in matrix.cells: - debit_pair = (cell.company_a_id, cell.company_b_id) - credit_pair = (cell.company_b_id, cell.company_a_id) - amount_a = cell.amount_a - debit_by_pair.get(debit_pair, ZERO) - amount_b = cell.amount_b - credit_by_pair.get(credit_pair, ZERO) - debit_ids = list(debit_ids_by_pair.get(debit_pair, ())) - credit_ids = list(credit_ids_by_pair.get(credit_pair, ())) - if not debit_ids and not credit_ids: - cells.append(cell) - continue - variance_amount = abs(amount_a - amount_b) - base = min(amount_a, amount_b) - variance_percent = None if base == ZERO else variance_amount / base * 100 - cells.append( - IntercompanyMatrixCellOut.model_validate( - { - **cell.model_dump(), - "amount_a": amount_a, - "amount_b": amount_b, - "correction_ids_debit": debit_ids, - "correction_ids_credit": credit_ids, - "variance_amount": variance_amount, - "variance_percent": variance_percent, - "exceeds_threshold": variance_percent is not None - and variance_percent > VARIANCE_THRESHOLD_PERCENT, - } - ) - ) - return IntercompanyMatrixOut(companies=matrix.companies, cells=cells) - async def _resolve_preset_selection( self, rule_id: uuid.UUID | None, @@ -514,7 +456,6 @@ class IntercompanyMatrixService: rows = osv_result.consolidated.rows accounts = await directory_repo.list_accounts(self._session) 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 не нужно указывать счета") @@ -528,12 +469,10 @@ class IntercompanyMatrixService: 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) + selection, _ = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children) else: raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов") matrix = self._calculate(rows, osv_result.members, selection) - if rule is not None: - matrix = await self._apply_corrections(matrix, version_id, rule.id) return MatrixBuildResult( matrix=matrix, version=osv_result.version, diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index abecf82..0f49a82 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -133,8 +133,6 @@ async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seed assert { "osv_entry_ids_debit", "osv_entry_ids_credit", - "correction_ids_debit", - "correction_ids_credit", "exceeds_threshold", } <= cell.keys() assert {"entry_osv_ids_debit", "entry_osv_ids_credit", "is_more_5_percent"}.isdisjoint(cell) @@ -142,7 +140,6 @@ async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seed assert cell["variance_percent"] is None assert cell["one_sided"] is False assert cell["osv_entry_ids_debit"] == cell["osv_entry_ids_credit"] == [] - assert cell["correction_ids_debit"] == cell["correction_ids_credit"] == [] assert cell["skipped_osv_entry_count"] == 0 diff --git a/api/tests/test_correction_repository.py b/api/tests/test_correction_repository.py deleted file mode 100644 index 916cf61..0000000 --- a/api/tests/test_correction_repository.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Запрос корректировок к строкам ОСВ версии консолидации.""" - -import uuid -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from sqlalchemy.dialects import postgresql - -from src.repositories.consolidations import correction_for_version_consolidation - - -async def test_correction_query_returns_all_applied_records_without_matching_osv() -> None: - session = AsyncMock() - corrections = [object(), object()] - session.scalars.return_value = SimpleNamespace(all=lambda: corrections) - version_id, rule_id = uuid.uuid4(), uuid.uuid4() - - assert await correction_for_version_consolidation(session, version_id, rule_id) == corrections - - stmt = session.scalars.await_args.args[0] - sql = str(stmt.compile(dialect=postgresql.dialect())) - assert "FROM dkb.correction" in sql - assert "osv_entry" not in sql - assert "correction.consolidation_version_id" in sql - assert "correction.rule_id" in sql - assert "correction.status" in sql - - -async def test_correction_query_requires_version_and_rule() -> None: - session = AsyncMock() - - assert await correction_for_version_consolidation(session, None, uuid.uuid4()) == [] - assert await correction_for_version_consolidation(session, uuid.uuid4(), None) == [] - session.scalars.assert_not_awaited() diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 236465f..b9e91b1 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -82,7 +82,6 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: ] assert result.cells[0].amount_a == Decimal("100") assert result.cells[0].amount_b == Decimal("90") - assert result.cells[0].correction_ids_debit == result.cells[0].correction_ids_credit == [] assert result.cells[0].variance_percent == Decimal("11.11") assert isinstance(result, IntercompanyMatrixOut) response = result @@ -143,7 +142,6 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey monkeypatch.setattr( matrix.elimination_rules_repo, "rule_with_account", AsyncMock(return_value=(rule, rule_accounts)) ) - monkeypatch.setattr(matrix.consolidation_repo, "correction_for_version_consolidation", AsyncMock(return_value=[])) session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(scalar_one=lambda: "ГК"))) service = matrix.IntercompanyMatrixService(session, None, osv_service) captured = [] @@ -164,79 +162,6 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey assert info.measure == "turnover" -async def test_preset_corrections_exclude_amounts_without_changing_osv(monkeypatch: pytest.MonkeyPatch) -> None: - company_a, company_b = uuid.uuid4(), uuid.uuid4() - debit_account, credit_account = uuid.uuid4(), uuid.uuid4() - debit_row = _row(company_a, company_b, debit_account, debit="1500") - credit_row = _row(company_b, company_a, credit_account, credit="1500") - debit_row.entry_ids = (uuid.uuid4(),) - credit_row.entry_ids = (uuid.uuid4(),) - companies = [ - IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"), - IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"), - ] - original = matrix.calculate_matrix( - [debit_row, credit_row], - companies, - matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing"), - ) - debit_correction_id, credit_correction_id, child_correction_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() - corrections = [ - SimpleNamespace( - id=debit_correction_id, - legal_entity_id=company_a, - counterparty_id=company_b, - debit_adjustment=Decimal("1000"), - credit_adjustment=None, - ), - SimpleNamespace( - id=credit_correction_id, - legal_entity_id=company_b, - counterparty_id=company_a, - debit_adjustment=None, - credit_adjustment=Decimal("500"), - ), - SimpleNamespace( - id=child_correction_id, - legal_entity_id=company_b, - counterparty_id=company_a, - debit_adjustment=None, - credit_adjustment=Decimal("500"), - ), - ] - monkeypatch.setattr( - matrix.consolidation_repo, "correction_for_version_consolidation", AsyncMock(return_value=corrections) - ) - - adjusted = await matrix.IntercompanyMatrixService(object(), None, object())._apply_corrections( - original, uuid.uuid4(), uuid.uuid4() - ) - - assert adjusted.cells[0].amount_a == Decimal("500") - assert adjusted.cells[0].amount_b == Decimal("500") - assert adjusted.cells[0].variance_amount == Decimal("0") - assert adjusted.cells[0].osv_entry_ids_debit == [debit_row.entry_ids[0]] - assert adjusted.cells[0].osv_entry_ids_credit == [credit_row.entry_ids[0]] - assert adjusted.cells[0].correction_ids_debit == [debit_correction_id] - assert set(adjusted.cells[0].correction_ids_credit) == {credit_correction_id, child_correction_id} - assert adjusted.cells[0].model_dump(mode="json")["correction_ids_debit"] == [str(debit_correction_id)] - assert original.cells[0].correction_ids_debit == original.cells[0].correction_ids_credit == [] - assert original.cells[0].amount_a == Decimal("1500") - assert original.cells[0].amount_b == Decimal("1500") - assert credit_row.closing_credit == Decimal("1500") - - -async def test_preset_without_corrections_keeps_matrix(monkeypatch: pytest.MonkeyPatch) -> None: - original = matrix.IntercompanyMatrixOut(companies=[], cells=[]) - monkeypatch.setattr(matrix.consolidation_repo, "correction_for_version_consolidation", AsyncMock(return_value=[])) - - adjusted = await matrix.IntercompanyMatrixService(object(), None, object())._apply_corrections( - original, uuid.uuid4(), uuid.uuid4() - ) - - assert adjusted is original - - 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 1f7761d..1b78373 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -134,14 +134,6 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold for offset in range(4) ) - debit_correction_id = UUID("796d1add-eaed-4802-8c79-8b3cdae2cca1") - credit_correction_id = UUID("55555555-5555-5555-5555-555555555555") - matrix.cells[0].correction_ids_debit = [debit_correction_id] - matrix.cells[0].correction_ids_credit = [credit_correction_id] - preset_sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, _info(mode="PRESET")))).active - assert preset_sheet is not None - assert preset_sheet["D12"].comment is None - assert preset_sheet["D13"].comment is None async def test_export_routes_use_matrix_calculation() -> None: diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md index 764a0e3..8858ffb 100644 --- a/openspec/changes/intercompany-matrix-presets/design.md +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -37,7 +37,7 @@ change не входит. Шахматка — чистая функция от 3. **Корректировки из шахматки убираются** (решение 4 выше и `correction_ids_*` в ячейке). Причины: корректировки в ядре никто не создаёт (спринт корректировок), семантика ключа `(rule, A, B)` относительно ячейки (A, B) / (B, A) не утверждена, счета корректировки и `parent_correction_id` не учитывались; §6 описывает шахматку как суммы первички. - Код сохранён в ветке `wip/shahmatka-corrections-unpaired` (`0e95f95`) и возвращается после решения по аналитике + Код корректировок выделен в ветку `AURORA-1747` и возвращается после решения по аналитике AURORA-1643 «Учитывание корректировок в шахматке». 4. **Ручка `…/preset/{rule_id}/unpaired-osv` убирается** (решение 5 выше). Причины: «без пары» по наличию строк (дефект AURORA-1651), ложные срабатывания при пересечении счетов сторон (76 в правиле № 6), нет ячейки и стороны diff --git a/openspec/changes/intercompany-matrix-presets/proposal.md b/openspec/changes/intercompany-matrix-presets/proposal.md index cb59d0e..1e58dee 100644 --- a/openspec/changes/intercompany-matrix-presets/proposal.md +++ b/openspec/changes/intercompany-matrix-presets/proposal.md @@ -1,7 +1,7 @@ ## Why Шахматка ВГО (ТЗ §6) считалась только по всем счетам (ALL) или по счетам, выбранным вручную (MANUAL). Аналитику нужен -режим «пресет»: счета и показатель берутся из правила элиминации (Прил. 3), с учётом применённых корректировок и со +режим «пресет»: счета и показатель берутся из правила элиминации (Прил. 3) и со списком строк сводной ОСВ без пары. Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`, автор — Потапова). Требования оформлены как дельты этого change'а при ревью (решение владельца 21.10.2026); спорное поведение вынесено в `design.md`, `## Known issues`, и в спеке не закреплено как окончательное. @@ -10,8 +10,7 @@ - Режим PRESET шахматки: `GET …/intercompany-matrix/preset/{rule_id}`, выгрузка `…/preset/{rule_id}/export`, список строк без пары `…/preset/{rule_id}/unpaired-osv`. -- Показатель пресета (`closing` / `turnover`) берётся из правила; в ячейке появляются `correction_ids_debit/credit`, - суммы уменьшаются на применённые корректировки правила. +- Показатель пресета (`closing` / `turnover`) берётся из правила. - `GET …/elimination-rules` возвращает объект `{version_no, is_version_fixed, rules}` вместо массива; у правила есть `id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`). - xlsx: для PRESET имя файла с `_preset-`, в шапке строка «Показатель»; audit `EXPORT` пишет `mode` @@ -24,11 +23,11 @@ ### Modified Capabilities - `consolidations-api`: ответ правил элиминации версии. -- `consolidated-osv`: режим PRESET шахматки, корректировки, строки без пары, xlsx пресета. +- `consolidated-osv`: режим PRESET шахматки, строки без пары, xlsx пресета. - `audit-trail`: аудит экспорта шахматки (режим PRESET). ## Impact Только `arm-core` (API, сервисы, схемы), таблицы и DBML не меняются. Ломающее изменение контракта -`GET …/elimination-rules` (массив → объект); поле `as_of` в ответ не входит. Потребителей старого формата в этом репозитории не проверялось — проверить при слиянии. Non-goals: фиксация версии правил, расчёт «актуальных» правил на отчётную дату, создание корректировок, +`GET …/elimination-rules` (массив → объект); поле `as_of` в ответ не входит. Потребителей старого формата в этом репозитории не проверялось — проверить при слиянии. Non-goals: фиксация версии правил, расчёт «актуальных» правил на отчётную дату, создание и применение корректировок, исправление `one_sided` (AURORA-1651). diff --git a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md index 4e986b8..e168e46 100644 --- a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md @@ -28,26 +28,6 @@ - **WHEN** `rule_id` не существует - **THEN** 404 -### Requirement: Корректировки в шахматке пресета -В режиме PRESET расчёт SHALL учитывать корректировки `correction` со статусом `APPLIED`, у которых -`consolidation_version_id` равен версии, а `rule_id` — правилу пресета. Корректировка с `legal_entity_id = A` и -`counterparty_id = B` SHALL уменьшать `amount_a` ячейки `(A, B)` на `debit_adjustment` и `amount_b` ячейки `(B, A)` на -`credit_adjustment`. Затронутые ячейки SHALL получать пересчитанные `variance_amount`, `variance_percent`, -`exceeds_threshold` и ID корректировок в `correction_ids_debit`/`correction_ids_credit`; у остальных ячеек эти массивы -пусты (поля есть в ответе всех режимов). Корректировки без `legal_entity_id` или `counterparty_id` SHALL игнорироваться. - -#### Scenario: Корректировка по дебету -- **WHEN** есть применённая корректировка правила `(A, B)` с `debit_adjustment = 10` -- **THEN** у ячейки `(A, B)` `amount_a` меньше на 10, расхождение пересчитано, `correction_ids_debit` содержит её ID - -#### Scenario: Корректировка по кредиту -- **WHEN** есть применённая корректировка правила `(A, B)` с `credit_adjustment = 5` -- **THEN** у ячейки `(B, A)` `amount_b` меньше на 5, `correction_ids_credit` содержит её ID - -#### Scenario: Корректировка чужого правила или не применённая -- **WHEN** корректировка относится к другому правилу либо имеет статус не `APPLIED` -- **THEN** суммы ячеек не меняются, `correction_ids_*` пусты - ### Requirement: Строки сводной ОСВ без пары по пресету `GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv` (`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{items, total}`: все строки сводной ОСВ версии, чьи записи ОСВ diff --git a/openspec/changes/intercompany-matrix-presets/tasks.md b/openspec/changes/intercompany-matrix-presets/tasks.md index 53dca62..e2bd75c 100644 --- a/openspec/changes/intercompany-matrix-presets/tasks.md +++ b/openspec/changes/intercompany-matrix-presets/tasks.md @@ -5,8 +5,9 @@ ## 2. Доработки по решениям владельца 2026-10-21 (автор) -- [ ] 2.1 Отдельным коммитом убрать учёт корректировок (`_apply_corrections`, `correction_for_version_consolidation`, - `correction_ids_*`) и ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами; +- [x] 2.1 Отдельным коммитом убрать учёт корректировок (`_apply_corrections`, `correction_for_version_consolidation`, + `correction_ids_*`) вместе с тестами; код выделен в `AURORA-1747`. +- [ ] 2.1a Убрать ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами; код сохранён в `wip/shahmatka-corrections-unpaired`. - [ ] 2.2 PRESET — только правила `statement_type = balance`; правило ОФР — 422 `INVALID_RULE`, тест. - [ ] 2.3 Несколько открытых `version_no` — одна версия (max) или явная ошибка, тест. -- 2.47.2 From 38c8eefa184c9e57f04d6a3e72af5c36cefc63d3 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 21 Oct 2026 18:43:19 +0300 Subject: [PATCH 10/20] =?UTF-8?q?fix(matrix):=20=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=87=D0=B8=D1=82=D1=8C=20PRESET=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=D0=BC=D0=B8=20=D0=91=D0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/services/intercompany_matrix.py | 4 +++- api/tests/test_api_intercompany_matrix.py | 19 +++++++++++++++++- api/tests/test_intercompany_matrix.py | 20 ++++++++++++++++++- .../specs/consolidated-osv/spec.md | 6 +++++- .../intercompany-matrix-presets/tasks.md | 2 +- 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index c77fe55..ae8c6bf 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -16,7 +16,7 @@ from src.domain.policy import VisibilityScope 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 ( @@ -419,6 +419,8 @@ class IntercompanyMatrixService: 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 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", "Неверное направление или показатель правила-пресета") diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 0f49a82..34607b3 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -17,7 +17,7 @@ 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 @@ -179,6 +179,23 @@ async def test_preset_uses_rule_sides_and_exclusions(auth_client: AuthClientFact assert audit.after_value["rule_id"] == str(rule.id) +@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_unpaired_osv_returns_rows_and_total(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: world = await _world(seeded) rule = ( diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index b9e91b1..7aa7b1d 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -7,9 +7,10 @@ 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, @@ -128,6 +129,7 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=[parent, child])) rule = SimpleNamespace( id=uuid.uuid4(), + statement_type=StatementType.balance, version_no=1, side_1="D", measure="turnover", @@ -162,6 +164,22 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey assert info.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/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md index e168e46..6c5d008 100644 --- a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md @@ -9,7 +9,7 @@ `closing` — конечное сальдо (`closing_debit`/`closing_credit`), `turnover` — обороты (`turnover_debit`/`turnover_credit`). Правило SHALL быть действительным для версии консолидации: при заданном `elimination_rules_version` — с тем же `version_no`, иначе — без `valid_to`; несуществующее или недействительное правило SHALL давать 404. Ошибки 422: -`RULE_REQUIRED` — правило не выбрано, `INVALID_RULE` — неверные `side_1`, `measure` или сторона счёта правила, +`RULE_REQUIRED` — правило не выбрано, `INVALID_RULE` — правило ОФР либо неверные `side_1`, `measure` или сторона счёта правила, `ACCOUNTS_REQUIRED` — у одной из сторон нет счетов. Невидимая и несуществующая версия SHALL отвечать 404 `NOT_FOUND`. #### Scenario: Пресет по сальдо @@ -28,6 +28,10 @@ - **WHEN** `rule_id` не существует - **THEN** 404 +#### Scenario: Правило ОФР +- **WHEN** у правила `statement_type = income` +- **THEN** расчёт и выгрузка возвращают 422 `INVALID_RULE` + ### Requirement: Строки сводной ОСВ без пары по пресету `GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv` (`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{items, total}`: все строки сводной ОСВ версии, чьи записи ОСВ diff --git a/openspec/changes/intercompany-matrix-presets/tasks.md b/openspec/changes/intercompany-matrix-presets/tasks.md index e2bd75c..5a60294 100644 --- a/openspec/changes/intercompany-matrix-presets/tasks.md +++ b/openspec/changes/intercompany-matrix-presets/tasks.md @@ -9,6 +9,6 @@ `correction_ids_*`) вместе с тестами; код выделен в `AURORA-1747`. - [ ] 2.1a Убрать ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами; код сохранён в `wip/shahmatka-corrections-unpaired`. -- [ ] 2.2 PRESET — только правила `statement_type = balance`; правило ОФР — 422 `INVALID_RULE`, тест. +- [x] 2.2 PRESET — только правила `statement_type = balance`; правило ОФР — 422 `INVALID_RULE`, тест. - [ ] 2.3 Несколько открытых `version_no` — одна версия (max) или явная ошибка, тест. - [ ] 2.4 Ревьюер: убрать из дельты `consolidated-osv` требования про корректировки и `unpaired-osv`, обновить README. -- 2.47.2 From 3eb5362e58f9758fcbc094ba6403d544df0c2d6c Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 21 Oct 2026 18:51:06 +0300 Subject: [PATCH 11/20] =?UTF-8?q?fix(matrix):=20=D0=B2=D1=8B=D0=B1=D0=B8?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4?= =?UTF-8?q?=D0=BD=D1=8E=D1=8E=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D1=83?= =?UTF-8?q?=D1=8E=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8E=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- api/src/repositories/elimination_rules.py | 20 +++++++++++--- api/src/services/elimination_rules.py | 2 ++ api/src/services/intercompany_matrix.py | 4 +++ api/tests/test_api_intercompany_matrix.py | 26 ++++++++++++++++++- api/tests/test_elimination_rules.py | 7 +++-- api/tests/test_intercompany_matrix.py | 3 +++ .../intercompany-matrix-presets/design.md | 14 ++++------ .../intercompany-matrix-presets/proposal.md | 3 ++- .../specs/consolidated-osv/spec.md | 2 +- .../specs/consolidations-api/spec.md | 11 +++++--- .../intercompany-matrix-presets/tasks.md | 2 +- 12 files changed, 74 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 4626427..b775d9f 100644 --- a/README.md +++ b/README.md @@ -874,7 +874,7 @@ API: список счетов `GET /directories/accounts`, правила ве `GET …/intercompany-matrix/preset/{rule_id}`, его выгрузка `…/preset/{rule_id}/export` и строки сводной ОСВ без пары `…/preset/{rule_id}/unpaired-osv`. `GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной -версии правил — открытые правила (`valid_to IS NULL`). Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`, +версии правил — открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ сообщает этот номер. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`, `ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. В шапке xlsx — строка «Показатель», audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`) и `rule_id`. Открытые вопросы по пресетам — `openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта — diff --git a/api/src/repositories/elimination_rules.py b/api/src/repositories/elimination_rules.py index a7008e8..bcd12ba 100644 --- a/api/src/repositories/elimination_rules.py +++ b/api/src/repositories/elimination_rules.py @@ -3,7 +3,7 @@ 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 @@ -12,9 +12,14 @@ from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRu async def list_rules_with_accounts( session: AsyncSession, version_no: int | None ) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]: - """Правила заданной версии либо все правила без `valid_to`.""" + """Правила заданной версии либо открытые правила последней версии.""" condition = ( - EliminationRule.version_no == version_no if version_no is not None else EliminationRule.valid_to.is_(None) + 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) @@ -29,6 +34,15 @@ async def list_rules_with_accounts( return [(rule, account) for rule, account in (await session.execute(stmt)).all()] +def _current_open_version_stmt(): + return select(func.max(EliminationRule.version_no)).where(EliminationRule.valid_to.is_(None)) + + +async def current_open_version_no(session: AsyncSession) -> int | None: + """Номер последней версии, в которой есть открытые правила.""" + return await session.scalar(_current_open_version_stmt()) + + def is_valid_for_version(rule: EliminationRule | None, version_no: int | None) -> bool: """Проверить номер версии либо отсутствие `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) diff --git a/api/src/services/elimination_rules.py b/api/src/services/elimination_rules.py index d61b574..bf15ca1 100644 --- a/api/src/services/elimination_rules.py +++ b/api/src/services/elimination_rules.py @@ -28,6 +28,8 @@ class EliminationRuleService: 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) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index ae8c6bf..5d6ba47 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -419,6 +419,10 @@ class IntercompanyMatrixService: 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"): diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 34607b3..77da4a4 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -235,7 +235,7 @@ async def test_preset_uses_current_rules_when_version_unfixed( 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"] is None + 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) @@ -258,6 +258,30 @@ async def test_preset_uses_current_rules_when_version_unfixed( 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 index 87ab323..fd5615e 100644 --- a/api/tests/test_elimination_rules.py +++ b/api/tests/test_elimination_rules.py @@ -42,7 +42,10 @@ async def test_rules_filter_by_version_or_open_valid_to() -> None: session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(all=lambda: []))) await rules_repo.list_rules_with_accounts(session, None) - assert "valid_to IS NULL" in str(session.execute.await_args.args[0]) + 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]) @@ -87,7 +90,7 @@ async def test_unfixed_rules_use_open_valid_to(monkeypatch: pytest.MonkeyPatch) result = await service_module.EliminationRuleService(session, scope).list_rules(version_id) assert result.is_version_fixed is False - assert result.version_no is None + assert result.version_no == rule.version_no assert [item.id for item in result.rules] == [rule.id] listed.assert_awaited_once_with(session, None) diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 7aa7b1d..2b2936d 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -157,6 +157,9 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey 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",) diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md index 8858ffb..d08da89 100644 --- a/openspec/changes/intercompany-matrix-presets/design.md +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -10,7 +10,8 @@ change не входит. Шахматка — чистая функция от ## Decisions 1. Режим PRESET берёт правило по `rule_id`. Правило должно быть действительно для версии: при зафиксированном - `elimination_rules_version` — совпадать по `version_no`, иначе — иметь `valid_to IS NULL`; иначе 404. + `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 @@ -50,11 +51,7 @@ change не входит. Шахматка — чистая функция от ## Known issues -1. Если открытыми остаются несколько `version_no`, список правил даёт дубли `rule_no` (обязательно в PR: одна версия - или явная ошибка, тест). -2. При незафиксированной версии ответ `elimination-rules` отдаёт `version_no = null` — фронт не видит, по какой версии - посчитано (желательно отдавать фактический номер открытых правил). -3. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию; audit EXPORT не пишет номер и +1. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию; audit EXPORT не пишет номер и версию правил. ## Decisions made without discussion @@ -65,9 +62,8 @@ change не входит. Шахматка — чистая функция от автор и владелец, не ревьюер. Цена ошибки: после решения владельца спека меняется дельтой. - 2026-10-21. Поле `as_of` удалено из `EliminationRulesOut` как мёртвое: после правок по ревью всегда `None`, в main его не было, потребителей нет. Цена ошибки: если фронт ждал `as_of`, поле нужно вернуть. -- 2026-10-21. Интеграционный тест `test_preset_uses_current_rules_when_version_unfixed` приведён к фактическому - поведению (`version_no is None`, `is_version_fixed is False`), а не к старому ожиданию `version_no == rule.version_no`; - логика вычисления `version_no` не добавлялась. Цена ошибки: если автор решит возвращать номер версии, тест вернуть. +- 2026-10-21. При незаданной версии правил выбирается максимальный открытый `version_no`; он возвращается в ответе + `elimination-rules`, чтобы фронт видел фактически применённую версию. - 2026-10-21. mypy: `measure` аннотирован `Measure` без `cast`; слишком длинная f-строка имени файла свода ОСВ (`consolidated_osv.py`) разбита на две без изменения результата (ruff E501). Цена ошибки: нулевая. - 2026-10-21. В MODIFIED-блоке требования про правила элиминации и аудит экспорта приведён полный текст; требование diff --git a/openspec/changes/intercompany-matrix-presets/proposal.md b/openspec/changes/intercompany-matrix-presets/proposal.md index 1e58dee..4c29410 100644 --- a/openspec/changes/intercompany-matrix-presets/proposal.md +++ b/openspec/changes/intercompany-matrix-presets/proposal.md @@ -12,7 +12,8 @@ строк без пары `…/preset/{rule_id}/unpaired-osv`. - Показатель пресета (`closing` / `turnover`) берётся из правила. - `GET …/elimination-rules` возвращает объект `{version_no, is_version_fixed, rules}` вместо массива; у правила есть - `id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`). + `id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`) + с максимальным `version_no`; ответ содержит этот номер. - xlsx: для PRESET имя файла с `_preset-`, в шапке строка «Показатель»; audit `EXPORT` пишет `mode` `ALL`/`MANUAL`/`PRESET` и `rule_id`. diff --git a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md index 6c5d008..334b71c 100644 --- a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md @@ -8,7 +8,7 @@ раскрываются по иерархии и фильтруются по виду, как в MANUAL. Показатель SHALL браться из `measure` правила: `closing` — конечное сальдо (`closing_debit`/`closing_credit`), `turnover` — обороты (`turnover_debit`/`turnover_credit`). Правило SHALL быть действительным для версии консолидации: при заданном `elimination_rules_version` — с тем же -`version_no`, иначе — без `valid_to`; несуществующее или недействительное правило SHALL давать 404. Ошибки 422: +`version_no`, иначе — без `valid_to` и с максимальным открытым `version_no`; несуществующее или недействительное правило SHALL давать 404. Ошибки 422: `RULE_REQUIRED` — правило не выбрано, `INVALID_RULE` — правило ОФР либо неверные `side_1`, `measure` или сторона счёта правила, `ACCOUNTS_REQUIRED` — у одной из сторон нет счетов. Невидимая и несуществующая версия SHALL отвечать 404 `NOT_FOUND`. diff --git a/openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md index 1217e97..fe54402 100644 --- a/openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md @@ -4,8 +4,9 @@ `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` — правилами этой версии. Если он не задан, `version_no = null`, `is_version_fixed = false`, а `rules` — -открытые правила (`valid_to IS NULL`); при их отсутствии `rules = []`. Каждое правило SHALL содержать `id`, `rule_no`, +`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`. @@ -20,4 +21,8 @@ SHALL возвращать объект `{version_no, is_version_fixed, rules}`. #### Scenario: Версия правил не зафиксирована - **WHEN** у версии консолидации `elimination_rules_version` пуст -- **THEN** ответ содержит `is_version_fixed = false`, `version_no = null` и открытые правила с `valid_to IS NULL` +- **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 index 5a60294..6db50ca 100644 --- a/openspec/changes/intercompany-matrix-presets/tasks.md +++ b/openspec/changes/intercompany-matrix-presets/tasks.md @@ -10,5 +10,5 @@ - [ ] 2.1a Убрать ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами; код сохранён в `wip/shahmatka-corrections-unpaired`. - [x] 2.2 PRESET — только правила `statement_type = balance`; правило ОФР — 422 `INVALID_RULE`, тест. -- [ ] 2.3 Несколько открытых `version_no` — одна версия (max) или явная ошибка, тест. +- [x] 2.3 Несколько открытых `version_no` — только максимальная версия; вернуть её номер, тест. - [ ] 2.4 Ревьюер: убрать из дельты `consolidated-osv` требования про корректировки и `unpaired-osv`, обновить README. -- 2.47.2 From b0789334286e1d4db683aeb865ec11d7bf3c14b0 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 21 Oct 2026 18:54:58 +0300 Subject: [PATCH 12/20] =?UTF-8?q?fix(audit):=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=8F=D1=82=D1=8C=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=20=D0=B8=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8E=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=20=D0=B2=20=D1=8D=D0=BA=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D0=B5=20=D0=BF=D1=80=D0=B5=D1=81=D0=B5?= =?UTF-8?q?=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 ++- .../endpoints/consolidations/intercompany_matrix.py | 12 +++++++++++- api/src/services/intercompany_matrix.py | 11 +++++++++-- api/tests/test_api_intercompany_matrix.py | 5 +++++ api/tests/test_intercompany_matrix.py | 3 +++ api/tests/test_intercompany_matrix_export.py | 5 ++++- .../changes/intercompany-matrix-presets/design.md | 3 +-- .../changes/intercompany-matrix-presets/proposal.md | 2 +- .../specs/audit-trail/spec.md | 5 +++-- 9 files changed, 39 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b775d9f..f40532f 100644 --- a/README.md +++ b/README.md @@ -876,7 +876,8 @@ API: список счетов `GET /directories/accounts`, правила ве `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 — строка «Показатель», -audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`) и `rule_id`. Открытые вопросы по пресетам — +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.8; требования — в существующих спецификациях `openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`. diff --git a/api/src/api/endpoints/consolidations/intercompany_matrix.py b/api/src/api/endpoints/consolidations/intercompany_matrix.py index 96f61e7..d4645ca 100644 --- a/api/src/api/endpoints/consolidations/intercompany_matrix.py +++ b/api/src/api/endpoints/consolidations/intercompany_matrix.py @@ -52,6 +52,8 @@ async def _export_response( *, selection: IntercompanyMatrixManualIn | None = None, ) -> Response: + 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 = await _xlsx_response(matrix, info) await audit.record( action=AuditAction.EXPORT, @@ -64,7 +66,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)} if info.rule_id is not None 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), }, ) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 5d6ba47..29b8cac 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -87,6 +87,7 @@ class MatrixBuildResult: accounts: list[ChartOfAccount] selection: MatrixSelection rows: tuple[ConsolidatedRow, ...] + rule: EliminationRule | None @dataclass(frozen=True) @@ -100,6 +101,8 @@ class MatrixExportInfo: 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 @property def filename(self) -> str: @@ -394,7 +397,9 @@ class IntercompanyMatrixService: debit_account_codes=debit_codes, credit_account_codes=credit_codes, measure=result.selection.measure, - rule_id=rule_id, + 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, ) return result.matrix, info @@ -462,6 +467,7 @@ class IntercompanyMatrixService: rows = osv_result.consolidated.rows accounts = await directory_repo.list_accounts(self._session) 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 не нужно указывать счета") @@ -475,7 +481,7 @@ class IntercompanyMatrixService: credit = _select_accounts(credit_account_ids, (), "K", by_id, children) selection = MatrixSelection(debit, credit, "closing") elif mode == "PRESET": - selection, _ = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children) + selection, rule = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children) else: raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов") matrix = self._calculate(rows, osv_result.members, selection) @@ -486,4 +492,5 @@ class IntercompanyMatrixService: accounts=accounts, selection=selection, rows=rows, + rule=rule, ) diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 77da4a4..c56f9cc 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -177,6 +177,8 @@ async def test_preset_uses_rule_sides_and_exclusions(auth_client: AuthClientFact 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"]) @@ -241,6 +243,9 @@ async def test_preset_uses_current_rules_when_version_unfixed( 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() diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 2b2936d..2132df7 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -129,6 +129,7 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=[parent, child])) rule = SimpleNamespace( id=uuid.uuid4(), + rule_no=6, statement_type=StatementType.balance, version_no=1, side_1="D", @@ -165,6 +166,8 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey 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 async def test_preset_rejects_income_rule_before_selecting_accounts(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index 1b78373..0a53491 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -37,6 +37,8 @@ def _info(*, mode: str = "MANUAL", rule_id: UUID | None = None) -> MatrixExportI 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, ) @@ -135,7 +137,6 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold ) - 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") @@ -234,6 +235,8 @@ async def test_export_routes_use_matrix_calculation() -> None: "debit_account_ids": [], "credit_account_ids": [], "rule_id": str(account_id), + "rule_no": 6, + "rule_version_no": 3, "cells": 1, }, }, diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md index d08da89..cd983a2 100644 --- a/openspec/changes/intercompany-matrix-presets/design.md +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -51,8 +51,7 @@ change не входит. Шахматка — чистая функция от ## Known issues -1. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию; audit EXPORT не пишет номер и - версию правил. +1. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию. ## Decisions made without discussion diff --git a/openspec/changes/intercompany-matrix-presets/proposal.md b/openspec/changes/intercompany-matrix-presets/proposal.md index 4c29410..25ab009 100644 --- a/openspec/changes/intercompany-matrix-presets/proposal.md +++ b/openspec/changes/intercompany-matrix-presets/proposal.md @@ -15,7 +15,7 @@ `id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ содержит этот номер. - xlsx: для PRESET имя файла с `_preset-`, в шапке строка «Показатель»; audit `EXPORT` пишет `mode` - `ALL`/`MANUAL`/`PRESET` и `rule_id`. + `ALL`/`MANUAL`/`PRESET`, а для PRESET также `rule_id`, `rule_no` и фактический `rule_version_no`. ## Capabilities diff --git a/openspec/changes/intercompany-matrix-presets/specs/audit-trail/spec.md b/openspec/changes/intercompany-matrix-presets/specs/audit-trail/spec.md index 838a8d1..a38ff01 100644 --- a/openspec/changes/intercompany-matrix-presets/specs/audit-trail/spec.md +++ b/openspec/changes/intercompany-matrix-presets/specs/audit-trail/spec.md @@ -6,7 +6,8 @@ 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`. `mode` — `ALL`, +credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и +`rule_version_no` (фактическая версия применённого правила, в том числе при незафиксированной версии правил). `mode` — `ALL`, `MANUAL` или `PRESET`, массивы счетов — ID из запроса (в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка сводной ОСВ продолжает писать собственное событие `EXPORT`. @@ -16,4 +17,4 @@ credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть д #### Scenario: Выгрузка пресета - **WHEN** пользователь выгружает `GET …/intercompany-matrix/preset/{rule_id}/export` -- **THEN** событие `EXPORT` содержит `mode = PRESET`, `rule_id` правила, пустые массивы счетов и число ячеек +- **THEN** событие `EXPORT` содержит `mode = PRESET`, `rule_id`, `rule_no`, `rule_version_no`, пустые массивы счетов и число ячеек -- 2.47.2 From 9e39c4d426534e114435c6c4e1f2af85dadbd7cd Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 21 Oct 2026 18:59:25 +0300 Subject: [PATCH 13/20] =?UTF-8?q?feat(matrix):=20=D0=BF=D0=BE=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=D0=B5=20=D0=B8=D0=BC=D1=8F=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D0=B2=D0=B8=D0=B7=D0=B8=D1=82=D1=8B=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=20=D0=B2=20XLSX=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- api/src/services/intercompany_matrix.py | 27 +++++++++++++- .../services/intercompany_matrix_export.py | 37 ++++++++++++------- api/tests/test_api_intercompany_matrix.py | 10 +++-- api/tests/test_intercompany_matrix.py | 4 ++ api/tests/test_intercompany_matrix_export.py | 14 ++++++- .../intercompany-matrix-presets/design.md | 9 ++--- .../intercompany-matrix-presets/proposal.md | 2 +- .../specs/consolidated-osv/spec.md | 8 ++-- 9 files changed, 84 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index f40532f..928bfcc 100644 --- a/README.md +++ b/README.md @@ -875,7 +875,8 @@ API: список счетов `GET /directories/accounts`, правила ве `…/preset/{rule_id}/unpaired-osv`. `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 — строка «Показатель», +`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`. Контракт для фронта — diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 29b8cac..52f2742 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -103,10 +103,32 @@ class MatrixExportInfo: 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]: + 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: - preset = f"_preset-{self.rule_id}" if self.rule_id is not None else "" + 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" @@ -400,6 +422,9 @@ class IntercompanyMatrixService: 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 diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index a5c50bb..15111b3 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 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) @@ -33,10 +31,10 @@ 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: +def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> int: _text(sheet, 1, 1, "Шахматка внутригрупповых операций") mode_name = {"ALL": "Все счета", "MANUAL": "Выбранные счета", "PRESET": "Пресет"}[info.mode] - details = ( + details = [ ("ГК", info.group_name), ("Период", f"{info.year}-{info.quarter.value}"), ("Версия", f"v{info.version_no}"), @@ -45,11 +43,23 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None: ("Счета дебета", ", ".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 "ОФР" + details.extend( + ( + ("Правило", f"{rule_type} № {rule_no}"), + ("Статья А", article_1_line), + ("Статья Б", article_2_line), + ("Версия правил", f"v{rule_version_no}"), + ) + ) for row, (label, value) in enumerate(details, start=2): _text(sheet, row, 1, label) _text(sheet, row, 2, value) sheet.cell(row, 1).font = Font(bold=True) + return len(details) + 2 def _write_values(sheet: Worksheet, row: int, column: int, cell: IntercompanyMatrixCellOut) -> None: @@ -65,24 +75,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) @@ -113,7 +124,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 c56f9cc..22e15b8 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -166,14 +166,18 @@ async def test_preset_uses_rule_sides_and_exclusions(auth_client: AuthClientFact f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}/export" ) assert exported.status_code == OK, exported.text - assert f"preset-{rule.id}" in unquote(exported.headers["content-disposition"]) + 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 Decimal(str(sheet["D12"].value)) == Decimal("100") - assert Decimal(str(sheet["D13"].value)) == Decimal("90") + 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) diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 2132df7..aaef320 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -131,6 +131,8 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey 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", @@ -168,6 +170,8 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey 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_rejects_income_rule_before_selecting_accounts(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index 0a53491..6716573 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -15,7 +15,7 @@ from src.api.endpoints.consolidations.intercompany_matrix import ( export_intercompany_matrix_preset, 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, @@ -39,6 +39,9 @@ def _info(*, mode: str = "MANUAL", rule_id: UUID | None = None) -> MatrixExportI 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, ) @@ -194,6 +197,15 @@ async def test_export_routes_use_matrix_calculation() -> None: 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, diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md index cd983a2..4ef47b2 100644 --- a/openspec/changes/intercompany-matrix-presets/design.md +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -24,8 +24,9 @@ change не входит. Шахматка — чистая функция от 5. `unpaired-osv` строится из `one_sided`-ячеек шахматки пресета (`osv_entry_ids_*`) и возвращает строки сводной ОСВ, содержащие эти записи. 6. `GET …/elimination-rules` отдаёт объект с основанием выбора версии; `as_of` не вводится. -7. xlsx пресета: имя `intercompany-matrix_<ГК>_<год>-<квартал>_v_preset-.xlsx`, в шапке «Режим = Пресет», - коды счетов сторон (отсортированы) и строка «Показатель» (Сальдо/Оборот) для всех режимов. +7. xlsx пресета: имя `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.xlsx`, в шапке «Режим = Пресет», + номер правила, статьи сторон, фактическая версия правил, коды счетов сторон (отсортированы) и строка «Показатель» + (Сальдо/Оборот) для всех режимов. ## Решения владельца (2026-10-21, ревью PR #141) @@ -49,10 +50,6 @@ change не входит. Шахматка — чистая функция от После выноса п. 3–4 автором соответствующие требования дельты `consolidated-osv` удаляются из change. -## Known issues - -1. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию. - ## Decisions made without discussion - 2026-10-21. Change пишет ревьюер в ветке автора по решению владельца. Обоснование: автор не оформила OpenSpec. Цена diff --git a/openspec/changes/intercompany-matrix-presets/proposal.md b/openspec/changes/intercompany-matrix-presets/proposal.md index 25ab009..6b4f00d 100644 --- a/openspec/changes/intercompany-matrix-presets/proposal.md +++ b/openspec/changes/intercompany-matrix-presets/proposal.md @@ -14,7 +14,7 @@ - `GET …/elimination-rules` возвращает объект `{version_no, is_version_fixed, rules}` вместо массива; у правила есть `id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ содержит этот номер. -- xlsx: для PRESET имя файла с `_preset-`, в шапке строка «Показатель»; audit `EXPORT` пишет `mode` +- xlsx: для PRESET имя файла с `_rule-balance-`, в шапке номер, статьи, версия правила и «Показатель»; audit `EXPORT` пишет `mode` `ALL`/`MANUAL`/`PRESET`, а для PRESET также `rule_id`, `rule_no` и фактический `rule_version_no`. ## Capabilities diff --git a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md index 334b71c..3adf433 100644 --- a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md @@ -52,14 +52,14 @@ ### Requirement: XLSX шахматки пресета `GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт -пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_preset-.xlsx`. Шапка SHALL -содержать режим «Пресет», коды счетов дебета и кредита правила и строку «Показатель» со значением «Сальдо» для -`closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение +пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.xlsx`. Шапка SHALL +содержать режим «Пресет», коды счетов дебета и кредита правила, номер правила, статьи А и Б, фактическую версию правил +и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение «Сальдо»). #### Scenario: Выгрузка по оборотам - **WHEN** пользователь выгружает пресет с `measure = turnover` -- **THEN** шапка содержит «Режим — Пресет» и «Показатель — Оборот», имя файла заканчивается на `_preset-.xlsx` +- **THEN** шапка содержит «Режим — Пресет», «Показатель — Оборот», номер, статьи и версию правила; имя файла заканчивается на `_rule-balance-.xlsx` #### Scenario: Правило не найдено - **WHEN** `rule_id` не существует -- 2.47.2 From 2f08ce4155a45306e09f3ef7ced409d657c9d5bc Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 21 Oct 2026 19:02:41 +0300 Subject: [PATCH 14/20] =?UTF-8?q?test(matrix):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D1=82=D1=8C=20=D0=BF=D1=80=D0=B5=D1=81=D0=B5?= =?UTF-8?q?=D1=82=20=D1=81=20=D0=BA=D1=80=D0=B5=D0=B4=D0=B8=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=BD=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/tests/test_intercompany_matrix.py | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index aaef320..d417d72 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -174,6 +174,40 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey 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, -- 2.47.2 From fabc9b48b2f85c271d9e290b24c20dba0f4d9bf0 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 21 Oct 2026 19:10:45 +0300 Subject: [PATCH 15/20] =?UTF-8?q?docs(matrix):=20=D1=83=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D1=8B?= =?UTF-8?q?=20=D0=A2=D0=97=20=D0=B2=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/api/endpoints/consolidations/elimination_rule.py | 2 +- .../api/endpoints/consolidations/intercompany_matrix.py | 5 +++-- api/src/repositories/elimination_rules.py | 9 +++++---- api/src/services/elimination_rules.py | 1 + api/src/services/intercompany_matrix.py | 7 ++++--- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/api/src/api/endpoints/consolidations/elimination_rule.py b/api/src/api/endpoints/consolidations/elimination_rule.py index 4ab60dd..60f93b1 100644 --- a/api/src/api/endpoints/consolidations/elimination_rule.py +++ b/api/src/api/endpoints/consolidations/elimination_rule.py @@ -29,5 +29,5 @@ async def list_rules( version_id: uuid.UUID, service: EliminationRuleServiceDep, ) -> 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 d4645ca..60dd321 100644 --- a/api/src/api/endpoints/consolidations/intercompany_matrix.py +++ b/api/src/api/endpoints/consolidations/intercompany_matrix.py @@ -173,7 +173,7 @@ async def build_intercompany_matrix_preset( rule_id: uuid.UUID, service: IntercompanyMatrixServiceDep, ) -> IntercompanyMatrixOut: - """Cчета пресета с подходящим видом дебета или кредита.""" + """Шахматка §6 по счетам сторон правила §4.""" return await service.build_accounts(version_id, mode="PRESET", rule_id=rule_id) @@ -189,7 +189,7 @@ async def get_unpaired_osv( rule_id: uuid.UUID, service: IntercompanyMatrixServiceDep, ) -> UnpairedOsvOut: - """Все строки односторонних ячеек шахматки и их число.""" + """Строки свода односторонних ячеек шахматки §6 и их число.""" return await service.get_unpaired_osv(version_id, rule_id) @@ -206,6 +206,7 @@ async def export_intercompany_matrix_preset( 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) diff --git a/api/src/repositories/elimination_rules.py b/api/src/repositories/elimination_rules.py index bcd12ba..d059395 100644 --- a/api/src/repositories/elimination_rules.py +++ b/api/src/repositories/elimination_rules.py @@ -12,7 +12,7 @@ from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRu async def list_rules_with_accounts( session: AsyncSession, version_no: int | None ) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]: - """Правила заданной версии либо открытые правила последней версии.""" + """Правила §4 заданной либо последней открытой версии.""" condition = ( EliminationRule.version_no == version_no if version_no is not None @@ -35,16 +35,17 @@ async def list_rules_with_accounts( 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: - """Проверить номер версии либо отсутствие `valid_to`.""" + """Проверить версию правила §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) @@ -76,7 +77,7 @@ async def list_rule_accounts( async def rule_with_account( session: AsyncSession, rule_id: uuid.UUID ) -> tuple[EliminationRule | None, list[EliminationRuleAccount]]: - """Правило и его счета; `company_no` задаёт сторону, `is_excluded` — исключение.""" + """Правило §4 и его счета; `company_no` задаёт сторону, `is_excluded` — исключение.""" rule = await session.get(EliminationRule, rule_id) if rule is None: return None, [] diff --git a/api/src/services/elimination_rules.py b/api/src/services/elimination_rules.py index bf15ca1..a67171e 100644 --- a/api/src/services/elimination_rules.py +++ b/api/src/services/elimination_rules.py @@ -21,6 +21,7 @@ class EliminationRuleService: self._scope = scope 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) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 52f2742..2e99a99 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -109,6 +109,7 @@ class MatrixExportInfo: @property def preset_details(self) -> tuple[StatementType, int, int, str, str]: + """Реквизиты правила §4 для имени и шапки XLSX шахматки §6.""" details = ( self.rule_statement_type, self.rule_no, @@ -215,7 +216,7 @@ def calculate_matrix( def get_unpaired_rows(rows: Iterable[ConsolidatedRow], matrix: IntercompanyMatrixOut) -> list[ConsolidatedRow]: - """Строки свода, попавшие только на одну сторону пары компаний в шахматке.""" + """Строки свода, попавшие только на одну сторону пары шахматки §6.""" entry_ids = { entry_id for cell in matrix.cells @@ -429,7 +430,7 @@ class IntercompanyMatrixService: return result.matrix, info async def get_unpaired_osv(self, version_id: uuid.UUID, rule_id: uuid.UUID) -> UnpairedOsvOut: - """Все строки свода без встречной стороны в шахматке заданного пресета.""" + """Строки свода без встречной стороны в шахматке §6 заданного пресета.""" result = await self._build_accounts( version_id, mode="PRESET", debit_account_ids=(), credit_account_ids=(), rule_id=rule_id ) @@ -443,7 +444,7 @@ class IntercompanyMatrixService: 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) -- 2.47.2 From 3de0c90198cca1b3eec3267c76a7749d1afc7019 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 21 Oct 2026 19:32:23 +0300 Subject: [PATCH 16/20] =?UTF-8?q?refactor(matrix):=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=8C=20unpaired-osv=20=D0=B8=D0=B7=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B5=D1=81=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +-- .../consolidations/intercompany_matrix.py | 17 ------------ api/src/schemas/consolidations.py | 7 ----- api/src/services/intercompany_matrix.py | 24 +---------------- api/tests/test_api_intercompany_matrix.py | 27 ------------------- api/tests/test_intercompany_matrix.py | 25 +---------------- api/tests/test_openapi_routes.py | 6 ----- .../intercompany-matrix-presets/design.md | 3 +-- .../intercompany-matrix-presets/proposal.md | 9 +++---- .../specs/consolidated-osv/spec.md | 18 ------------- .../intercompany-matrix-presets/tasks.md | 4 +-- 11 files changed, 10 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index 928bfcc..9b9eb3e 100644 --- a/README.md +++ b/README.md @@ -871,8 +871,7 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица `GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица `GET …/intercompany-matrix/result-table`. Режим PRESET (счета и показатель из правила элиминации): -`GET …/intercompany-matrix/preset/{rule_id}`, его выгрузка `…/preset/{rule_id}/export` и строки сводной ОСВ без пары -`…/preset/{rule_id}/unpaired-osv`. +`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 пресета оканчивается на diff --git a/api/src/api/endpoints/consolidations/intercompany_matrix.py b/api/src/api/endpoints/consolidations/intercompany_matrix.py index 60dd321..6e80018 100644 --- a/api/src/api/endpoints/consolidations/intercompany_matrix.py +++ b/api/src/api/endpoints/consolidations/intercompany_matrix.py @@ -16,7 +16,6 @@ from src.schemas.consolidations import ( IntercompanyMatrixManualIn, IntercompanyMatrixOut, IntercompanyResultTableOut, - UnpairedOsvOut, ) from src.services.intercompany_matrix import MatrixExportInfo from src.services.intercompany_matrix_export import build_matrix_workbook @@ -177,22 +176,6 @@ async def build_intercompany_matrix_preset( return await service.build_accounts(version_id, mode="PRESET", rule_id=rule_id) -@router.get( - "/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv", - response_model=UnpairedOsvOut, - dependencies=VIEW, - responses=ERRORS, - summary="Строки сводной ОСВ без пары по правилу-пресету", -) -async def get_unpaired_osv( - version_id: uuid.UUID, - rule_id: uuid.UUID, - service: IntercompanyMatrixServiceDep, -) -> UnpairedOsvOut: - """Строки свода односторонних ячеек шахматки §6 и их число.""" - return await service.get_unpaired_osv(version_id, rule_id) - - @router.get( "/{version_id}/intercompany-matrix/preset/{rule_id}/export", response_class=Response, diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 00435a7..d3863d7 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -232,13 +232,6 @@ class ConsolidatedRowOut(BaseModel): return None if value is None else value.quantize(CENT) -class UnpairedOsvOut(BaseModel): - """Все строки сводной ОСВ без встречной стороны по правилу и их количество.""" - - items: list[ConsolidatedRowOut] - total: int - - class GapOut(BaseModel): """Пробел свода: у ЮЛ по счёту нет квартальных ОСВ за перечисленные кварталы (строки по нему не строятся).""" diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 2e99a99..9becb76 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -26,9 +26,8 @@ from src.schemas.consolidations import ( IntercompanyResultAmountOut, IntercompanyResultArticleOut, IntercompanyResultTableOut, - UnpairedOsvOut, ) -from src.services.consolidated_osv import ConsolidatedOsvService, row_out +from src.services.consolidated_osv import ConsolidatedOsvService Measure = Literal["closing", "turnover"] Side = Literal["D", "K"] @@ -86,7 +85,6 @@ class MatrixBuildResult: consolidation: Consolidation accounts: list[ChartOfAccount] selection: MatrixSelection - rows: tuple[ConsolidatedRow, ...] rule: EliminationRule | None @@ -215,17 +213,6 @@ def calculate_matrix( return IntercompanyMatrixOut(companies=list(company_info), cells=cells) -def get_unpaired_rows(rows: Iterable[ConsolidatedRow], matrix: IntercompanyMatrixOut) -> list[ConsolidatedRow]: - """Строки свода, попавшие только на одну сторону пары шахматки §6.""" - entry_ids = { - entry_id - for cell in matrix.cells - if cell.one_sided - for entry_id in (*cell.osv_entry_ids_debit, *cell.osv_entry_ids_credit) - } - return [row for row in rows if entry_ids.intersection(row.entry_ids)] - - def _make_account_tree( accounts: Sequence[ChartOfAccount], ) -> tuple[dict[uuid.UUID, ChartOfAccount], dict[uuid.UUID, list[uuid.UUID]]]: @@ -429,14 +416,6 @@ class IntercompanyMatrixService: ) return result.matrix, info - async def get_unpaired_osv(self, version_id: uuid.UUID, rule_id: uuid.UUID) -> UnpairedOsvOut: - """Строки свода без встречной стороны в шахматке §6 заданного пресета.""" - result = await self._build_accounts( - version_id, mode="PRESET", debit_account_ids=(), credit_account_ids=(), rule_id=rule_id - ) - unmatched = get_unpaired_rows(result.rows, result.matrix) - return UnpairedOsvOut(items=[row_out(row) for row in unmatched], total=len(unmatched)) - async def _resolve_preset_selection( self, rule_id: uuid.UUID | None, @@ -517,6 +496,5 @@ class IntercompanyMatrixService: consolidation=osv_result.consolidation, accounts=accounts, selection=selection, - rows=rows, rule=rule, ) diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 22e15b8..35ced23 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -202,33 +202,6 @@ async def test_preset_rejects_income_rule(auth_client: AuthClientFactory, seeded assert response.json()["detail"]["code"] == "INVALID_RULE" -async def test_preset_unpaired_osv_returns_rows_and_total(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() - debit_document_id = await _source(seeded, world, credit=False, amount="0", account="62.01") - debit_entry = (await seeded.scalars(select(OsvEntry).where(OsvEntry.document_id == debit_document_id))).one() - - client = await auth_client(KA_RF) - url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}/unpaired-osv" - response = await client.get(url) - assert response.status_code == OK, response.text - assert response.json()["total"] == 1 - assert set(response.json()) == {"items", "total"} - assert response.json()["items"][0]["entry_ids"] == [str(debit_entry.id)] - assert response.json()["items"][0]["closing_debit"] == "0.00" - - await _source(seeded, world, credit=True, amount="100", account="60.01") - paired = await client.get(url) - assert paired.status_code == OK, paired.text - assert paired.json()["items"] == [] - assert paired.json()["total"] == 0 - assert (await client.get(url.replace(str(rule.id), str(uuid.uuid4())))).status_code == NOT_FOUND - - async def test_preset_uses_current_rules_when_version_unfixed( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index d417d72..7ffa900 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -49,10 +49,7 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: 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 ( - "/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv", - ("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) @@ -92,26 +89,6 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: ] -def test_unpaired_rows_uses_source_presence_and_preserves_osv_order() -> None: - company_a, company_b = uuid.uuid4(), uuid.uuid4() - debit_account, credit_account = uuid.uuid4(), uuid.uuid4() - paired_debit = _row(company_a, company_b, debit_account, debit="100") - paired_credit = _row(company_b, company_a, credit_account, credit="100") - unpaired_credit = _row(company_a, company_b, credit_account, credit="0") - for row in (paired_debit, paired_credit, unpaired_credit): - row.entry_ids = (uuid.uuid4(),) - rows = [unpaired_credit, paired_debit, paired_credit] - companies = [ - IntercompanyMatrixCompanyOut(id=company_a, name="А"), - IntercompanyMatrixCompanyOut(id=company_b, name="Б"), - ] - result = matrix.calculate_matrix( - rows, companies, matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing") - ) - - assert matrix.get_unpaired_rows(rows, result) == [unpaired_credit] - - 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) diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 2e397df..6a46dd8 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -80,7 +80,6 @@ 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/preset/{rule_id}": {"get"}, - "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv": {"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"}, } @@ -181,11 +180,6 @@ def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any summary = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv/summary"]["get"]["responses"] assert {"200", "404"} <= set(summary) assert summary["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/OsvSummaryOut") - unpaired = schema["paths"][ - "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv" - ]["get"] - assert {parameter["name"] for parameter in unpaired["parameters"]} == {"version_id", "rule_id"} - assert unpaired["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/UnpairedOsvOut") def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) -> None: diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md index 4ef47b2..9ae8db4 100644 --- a/openspec/changes/intercompany-matrix-presets/design.md +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -21,8 +21,7 @@ change не входит. Шахматка — чистая функция от `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` строится из `one_sided`-ячеек шахматки пресета (`osv_entry_ids_*`) и возвращает строки сводной ОСВ, - содержащие эти записи. +5. Первоначальная ручка `unpaired-osv` отменена решением владельца ниже и в текущую реализацию не входит. 6. `GET …/elimination-rules` отдаёт объект с основанием выбора версии; `as_of` не вводится. 7. xlsx пресета: имя `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.xlsx`, в шапке «Режим = Пресет», номер правила, статьи сторон, фактическая версия правил, коды счетов сторон (отсортированы) и строка «Показатель» diff --git a/openspec/changes/intercompany-matrix-presets/proposal.md b/openspec/changes/intercompany-matrix-presets/proposal.md index 6b4f00d..2a82c61 100644 --- a/openspec/changes/intercompany-matrix-presets/proposal.md +++ b/openspec/changes/intercompany-matrix-presets/proposal.md @@ -1,15 +1,14 @@ ## Why Шахматка ВГО (ТЗ §6) считалась только по всем счетам (ALL) или по счетам, выбранным вручную (MANUAL). Аналитику нужен -режим «пресет»: счета и показатель берутся из правила элиминации (Прил. 3) и со -списком строк сводной ОСВ без пары. Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`, автор — Потапова). +режим «пресет»: счета и показатель берутся из правила элиминации (Прил. 3). +Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`, автор — Потапова). Требования оформлены как дельты этого change'а при ревью (решение владельца 21.10.2026); спорное поведение вынесено в `design.md`, `## Known issues`, и в спеке не закреплено как окончательное. ## What Changes -- Режим PRESET шахматки: `GET …/intercompany-matrix/preset/{rule_id}`, выгрузка `…/preset/{rule_id}/export`, список - строк без пары `…/preset/{rule_id}/unpaired-osv`. +- Режим 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`) @@ -24,7 +23,7 @@ ### Modified Capabilities - `consolidations-api`: ответ правил элиминации версии. -- `consolidated-osv`: режим PRESET шахматки, строки без пары, xlsx пресета. +- `consolidated-osv`: режим PRESET шахматки и xlsx пресета. - `audit-trail`: аудит экспорта шахматки (режим PRESET). ## Impact diff --git a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md index 3adf433..c508637 100644 --- a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md @@ -32,24 +32,6 @@ - **WHEN** у правила `statement_type = income` - **THEN** расчёт и выгрузка возвращают 422 `INVALID_RULE` -### Requirement: Строки сводной ОСВ без пары по пресету -`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv` -(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{items, total}`: все строки сводной ОСВ версии, чьи записи ОСВ -входят в `osv_entry_ids_debit/credit` ячеек шахматки пресета с `one_sided = true`. Формат строки — как на странице -сводной ОСВ, без пагинации; `total` — число элементов. Ошибки — как у расчёта пресета. - -#### Scenario: Есть односторонняя ячейка -- **WHEN** в шахматке пресета у ячейки `(A, B)` `one_sided = true` -- **THEN** `items` содержит строки сводной ОСВ с записями этой ячейки, `total` равен их числу - -#### Scenario: Все пары двусторонние -- **WHEN** у шахматки пресета нет односторонних ячеек -- **THEN** `items = []`, `total = 0` - -#### Scenario: Неизвестное правило -- **WHEN** `rule_id` не существует -- **THEN** 404 - ### Requirement: XLSX шахматки пресета `GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.xlsx`. Шапка SHALL diff --git a/openspec/changes/intercompany-matrix-presets/tasks.md b/openspec/changes/intercompany-matrix-presets/tasks.md index 6db50ca..ea5bb8d 100644 --- a/openspec/changes/intercompany-matrix-presets/tasks.md +++ b/openspec/changes/intercompany-matrix-presets/tasks.md @@ -7,8 +7,8 @@ - [x] 2.1 Отдельным коммитом убрать учёт корректировок (`_apply_corrections`, `correction_for_version_consolidation`, `correction_ids_*`) вместе с тестами; код выделен в `AURORA-1747`. -- [ ] 2.1a Убрать ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами; +- [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` — только максимальная версия; вернуть её номер, тест. -- [ ] 2.4 Ревьюер: убрать из дельты `consolidated-osv` требования про корректировки и `unpaired-osv`, обновить README. +- [x] 2.4 Ревьюер: убрать из дельты `consolidated-osv` требования про корректировки и `unpaired-osv`, обновить README. -- 2.47.2 From 02d67ff733488e61311b7ff2ac523da556d2aabb Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Thu, 22 Oct 2026 15:35:56 +0300 Subject: [PATCH 17/20] =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BD=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=88=D0=BD=D0=B8=D0=B9=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4?= =?UTF-8?q?=20build=5Fwith=5Fmembers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/services/consolidated_osv.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index d58860b..3dd9166 100644 --- a/api/src/services/consolidated_osv.py +++ b/api/src/services/consolidated_osv.py @@ -110,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() @@ -147,12 +147,8 @@ class ConsolidatedOsvService: self._session = session self._scope = scope - async def build(self, version_id: uuid.UUID) -> ConsolidatedOsvBuildResult: - """Свод версии и данные его расчёта; версия вне видимости — 404.""" - return await self.build_with_members(version_id) - 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) @@ -191,7 +187,7 @@ class ConsolidatedOsvService: async def page(self, version_id: uuid.UUID, f: OsvFilters, page: OsvPageParams) -> Page[ConsolidatedRowOut]: """Страница свода после фильтров: `total` — число строк после фильтра, срез — в памяти (порядок D-16).""" - result = await self.build(version_id) + 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]( @@ -202,7 +198,7 @@ class ConsolidatedOsvService: """xlsx сводной ОСВ (spec «Экспорт xlsx», D-14): строки после тех же фильтров, что у `page`, без пагинации, в порядке D-16; лист пробелов — по своду целиком. Имя файла — `svod-osv_<ГК>_<год>-<квартал>_v.xlsx` (без percent-кодирования: оно в маршруте). `audit_event(EXPORT)` пишется после сборки книги, в транзакции запроса.""" - result = await self.build(version_id) + 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 = ( @@ -228,7 +224,7 @@ class ConsolidatedOsvService: async def summary(self, version_id: uuid.UUID) -> OsvSummaryOut: """Сводка: строки, несопоставленные, автосопоставленные по имени/алиасу, ОСВ-документы версии и пробелы.""" - result = await self.build(version_id) + 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, @@ -241,7 +237,7 @@ class ConsolidatedOsvService: async def row_for_entry(self, version_id: uuid.UUID, entry_id: uuid.UUID) -> ConsolidatedRowOut | None: """Строка свода, в `entry_ids` которой есть `entry_id`; `None` — строка в своде не участвует (пара с пробелом или документ не выбран источником при наличии нарастающего).""" - result = await self.build(version_id) + 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) -- 2.47.2 From d096459c757f493c7249e1c12af73bbe52f56e05 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 18:47:04 +0400 Subject: [PATCH 18/20] =?UTF-8?q?docs:=20change=20intercompany-matrix-pres?= =?UTF-8?q?ets=20=D0=B8=20README=20=E2=80=94=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E=20#141,=20=D1=80=D0=B0=D1=83=D0=BD=D0=B4=202=20(AURORA-1?= =?UTF-8?q?747)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: возвращена фраза про XLSX результирующей таблицы, потерянная при merge. Change: отменённое решение 4, ветка-закладка wip/shahmatka-corrections-unpaired, ссылки на снятый раздел Known issues, шапка xlsx пресета — счета правила и «кроме», статьи сторон при side_1 = K, tasks раунда 2. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- .../changes/intercompany-matrix-presets/design.md | 10 +++++++--- .../intercompany-matrix-presets/proposal.md | 4 ++-- .../specs/consolidated-osv/spec.md | 11 +++++++++-- .../changes/intercompany-matrix-presets/tasks.md | 15 ++++++++++++--- 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9b9eb3e..11fdaac 100644 --- a/README.md +++ b/README.md @@ -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`. Режим PRESET (счета и показатель из правила элиминации): +`GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export` +(аудит `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`, diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md index 9ae8db4..23679cf 100644 --- a/openspec/changes/intercompany-matrix-presets/design.md +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -17,7 +17,7 @@ change не входит. Шахматка — чистая функция от `ACCOUNTS_REQUIRED`; неверные `side_1`/`measure`/`company_no` — 422 `INVALID_RULE`; не передан `rule_id` — 422 `RULE_REQUIRED`. 3. Показатель (`closing`/`turnover`) берётся из правила; ALL и MANUAL остаются на `closing`. -4. Применённые корректировки правила (`status = APPLIED`, тот же `rule_id` и версия консолидации) уменьшают +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`. @@ -38,7 +38,7 @@ change не входит. Шахматка — чистая функция от 3. **Корректировки из шахматки убираются** (решение 4 выше и `correction_ids_*` в ячейке). Причины: корректировки в ядре никто не создаёт (спринт корректировок), семантика ключа `(rule, A, B)` относительно ячейки (A, B) / (B, A) не утверждена, счета корректировки и `parent_correction_id` не учитывались; §6 описывает шахматку как суммы первички. - Код корректировок выделен в ветку `AURORA-1747` и возвращается после решения по аналитике + Код корректировок сохранён в ветке `wip/shahmatka-corrections-unpaired` (PR #164 не сливается) и возвращается после решения по аналитике AURORA-1643 «Учитывание корректировок в шахматке». 4. **Ручка `…/preset/{rule_id}/unpaired-osv` убирается** (решение 5 выше). Причины: «без пары» по наличию строк (дефект AURORA-1651), ложные срабатывания при пересечении счетов сторон (76 в правиле № 6), нет ячейки и стороны @@ -53,7 +53,8 @@ change не входит. Шахматка — чистая функция от - 2026-10-21. Change пишет ревьюер в ветке автора по решению владельца. Обоснование: автор не оформила OpenSpec. Цена ошибки: автор переписывает change. -- 2026-10-21. Спека описывает фактическое поведение PR; спорное вынесено в Known issues. Обоснование: домен решает +- 2026-10-21. Спека описывает фактическое поведение PR; спорное вынесено в Known issues (раздел снят в раунде 2 — + все пункты закрыты автором). Обоснование: домен решает автор и владелец, не ревьюер. Цена ошибки: после решения владельца спека меняется дельтой. - 2026-10-21. Поле `as_of` удалено из `EliminationRulesOut` как мёртвое: после правок по ревью всегда `None`, в main его не было, потребителей нет. Цена ошибки: если фронт ждал `as_of`, поле нужно вернуть. @@ -63,3 +64,6 @@ change не входит. Шахматка — чистая функция от (`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`. Цена ошибки: нулевая. diff --git a/openspec/changes/intercompany-matrix-presets/proposal.md b/openspec/changes/intercompany-matrix-presets/proposal.md index 2a82c61..d697d73 100644 --- a/openspec/changes/intercompany-matrix-presets/proposal.md +++ b/openspec/changes/intercompany-matrix-presets/proposal.md @@ -3,8 +3,8 @@ Шахматка ВГО (ТЗ §6) считалась только по всем счетам (ALL) или по счетам, выбранным вручную (MANUAL). Аналитику нужен режим «пресет»: счета и показатель берутся из правила элиминации (Прил. 3). Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`, автор — Потапова). -Требования оформлены как дельты этого change'а при ревью (решение владельца 21.10.2026); спорное поведение вынесено в -`design.md`, `## Known issues`, и в спеке не закреплено как окончательное. +Требования оформлены как дельты этого change'а при ревью (решение владельца 21.10.2026); решения владельца и ревью — +`design.md`. ## What Changes diff --git a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md index c508637..5bf0fed 100644 --- a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md @@ -35,14 +35,21 @@ ### Requirement: XLSX шахматки пресета `GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.xlsx`. Шапка SHALL -содержать режим «Пресет», коды счетов дебета и кредита правила, номер правила, статьи А и Б, фактическую версию правил -и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение +содержать режим «Пресет», счета дебета и кредита так, как они заданы в правиле (без раскрытия по субсчетам), и отдельно +исключения правила («кроме …»), номер правила, статьи сторон дебета и кредита (при `side_1 = K` статьи меняются местами +вместе со сторонами), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение «Сальдо»). #### Scenario: Выгрузка по оборотам - **WHEN** пользователь выгружает пресет с `measure = turnover` - **THEN** шапка содержит «Режим — Пресет», «Показатель — Оборот», номер, статьи и версию правила; имя файла заканчивается на `_rule-balance-.xlsx` +#### Scenario: Шапка правила с исключениями +- **WHEN** выгружается пресет по правилу № 6 (seed v1: дебет `60.02, 60.22, 60.32, 62.01, 62.03, 62.21, 62.31, 76`, + исключения `76.07.5, 76.07.9`) +- **THEN** в шапке счета дебета — эти восемь кодов, отдельной строкой — «кроме 76.07.5, 76.07.9»; раскрытых субсчетов + (`76.01`, `76.02`, …) в шапке нет + #### Scenario: Правило не найдено - **WHEN** `rule_id` не существует - **THEN** 404, файл не отдаётся, событие аудита не пишется diff --git a/openspec/changes/intercompany-matrix-presets/tasks.md b/openspec/changes/intercompany-matrix-presets/tasks.md index ea5bb8d..6826ebe 100644 --- a/openspec/changes/intercompany-matrix-presets/tasks.md +++ b/openspec/changes/intercompany-matrix-presets/tasks.md @@ -1,14 +1,23 @@ ## 1. Реализация -Реализация выполнена автором в PR RSHB/arm-core#141; задач плана нет. Доработки по замечаниям ревью — см. -`design.md`, `## Known issues`. +Реализация выполнена автором в 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_*`) вместе с тестами; код выделен в `AURORA-1747`. + `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` правила. +- [ ] 3.3 Автор: название и описание PR, шапка xlsx пресета (счета правила и «кроме»), подписи статей при `side_1 = K`, + лишняя проверка в `_export_response`, реквизиты правила в `MatrixExportInfo` и `_filename`, описания ошибок preset в + OpenAPI. +- [ ] 3.4 После слияния: архив change. -- 2.47.2 From 5275d80fa17dcb578eaea4824f0587f2949835df Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 18:48:06 +0400 Subject: [PATCH 19/20] =?UTF-8?q?README:=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20?= =?UTF-8?q?=D1=88=D0=B0=D1=85=D0=BC=D0=B0=D1=82=D0=BA=D0=B8=20=D0=B3=D0=B0?= =?UTF-8?q?=D0=B9=D0=B4=D0=B0=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=C2=A75.7.1=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 11fdaac..a2a9eaa 100644 --- a/README.md +++ b/README.md @@ -880,7 +880,7 @@ API: список счетов `GET /directories/accounts`, правила ве 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.8; требования — в существующих спецификациях +`../docs/dev/frontend-api-guide.md` §5.7.1 (docs #146); требования — в существующих спецификациях `openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`. Расчёт читает сводную ОСВ версии, результат не хранится. -- 2.47.2 From 205e4dc602d978240a4a0ad897a899ba9e360d1f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 18:56:26 +0400 Subject: [PATCH 20/20] =?UTF-8?q?docs(openspec):=20intercompany-matrix-pre?= =?UTF-8?q?sets=20=E2=80=94=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0=20xlsx=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=84=D0=B0=D0=BA=D1=82=D1=83,=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D0=BE=D1=87=D0=B8=20=D1=80=D0=B0=D1=83=D0=BD=D0=B4=D0=B0?= =?UTF-8?q?=202=20=D0=B2=20=D1=82=D0=B5=D1=85=D0=B4=D0=BE=D0=BB=D0=B3=20AU?= =?UTF-8?q?RORA-1761=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/intercompany-matrix-presets/design.md | 5 +++++ .../specs/consolidated-osv/spec.md | 11 ++--------- openspec/changes/intercompany-matrix-presets/tasks.md | 4 ++-- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md index 23679cf..5895ea8 100644 --- a/openspec/changes/intercompany-matrix-presets/design.md +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -67,3 +67,8 @@ change не входит. Шахматка — чистая функция от - 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/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md index 5bf0fed..0327c62 100644 --- a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md @@ -35,21 +35,14 @@ ### Requirement: XLSX шахматки пресета `GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.xlsx`. Шапка SHALL -содержать режим «Пресет», счета дебета и кредита так, как они заданы в правиле (без раскрытия по субсчетам), и отдельно -исключения правила («кроме …»), номер правила, статьи сторон дебета и кредита (при `side_1 = K` статьи меняются местами -вместе со сторонами), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение +содержать режим «Пресет», коды счетов дебета и кредита (раскрытые по иерархии, с учётом исключений правила), номер +правила, статьи А и Б (`article_1_line` / `article_2_line`), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение «Сальдо»). #### Scenario: Выгрузка по оборотам - **WHEN** пользователь выгружает пресет с `measure = turnover` - **THEN** шапка содержит «Режим — Пресет», «Показатель — Оборот», номер, статьи и версию правила; имя файла заканчивается на `_rule-balance-.xlsx` -#### Scenario: Шапка правила с исключениями -- **WHEN** выгружается пресет по правилу № 6 (seed v1: дебет `60.02, 60.22, 60.32, 62.01, 62.03, 62.21, 62.31, 76`, - исключения `76.07.5, 76.07.9`) -- **THEN** в шапке счета дебета — эти восемь кодов, отдельной строкой — «кроме 76.07.5, 76.07.9»; раскрытых субсчетов - (`76.01`, `76.02`, …) в шапке нет - #### Scenario: Правило не найдено - **WHEN** `rule_id` не существует - **THEN** 404, файл не отдаётся, событие аудита не пишется diff --git a/openspec/changes/intercompany-matrix-presets/tasks.md b/openspec/changes/intercompany-matrix-presets/tasks.md index 6826ebe..2a4374d 100644 --- a/openspec/changes/intercompany-matrix-presets/tasks.md +++ b/openspec/changes/intercompany-matrix-presets/tasks.md @@ -17,7 +17,7 @@ - [x] 3.1 Ревьюер: влить `main`, вернуть фразу README про XLSX результирующей таблицы, поправить design/proposal/tasks. - [x] 3.2 Ревьюер: показатель пресета в docs #146 §6 — `measure` правила. -- [ ] 3.3 Автор: название и описание PR, шапка xlsx пресета (счета правила и «кроме»), подписи статей при `side_1 = K`, +- [x] 3.3 Мелкие замечания раунда 2 (шапка xlsx пресета — счета правила и «кроме», подписи статей при `side_1 = K`, лишняя проверка в `_export_response`, реквизиты правила в `MatrixExportInfo` и `_filename`, описания ошибок preset в - OpenAPI. + OpenAPI, проверка правила до расчёта свода) вынесены в технический долг AURORA-1761 (решение владельца 22.10.2026) — см. design.md. - [ ] 3.4 После слияния: архив change. -- 2.47.2