add-report-shahmatka #114

Merged
aep merged 9 commits from add-report-shahmatka into main 2026-10-16 14:58:13 +03:00
30 changed files with 2114 additions and 11 deletions

View File

@ -796,7 +796,8 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
### Ограничения Среза 4
- Маршрут согласования (`CONSOLIDATION_RF` и далее), «Добавить версию», `CONFIRM`, `EXTENDED`, гудвилл — Срезы 6–7;
шахматка и ВГО — Срез 5; ББ/ОФР в своде не участвуют; UI — отдельный исполнитель.
API шахматки, правил элиминации и результирующей таблицы ВГО реализован в Срезе 5; автоматическое сопоставление ВГО,
корректировки и UI — отдельная работа. ББ/ОФР в своде не участвуют.
- Открытые вопросы — `../docs/context/07-open-questions.md`: OQ-118 (запрет загрузки/подтверждения после `DATA_CORRECTION`),
OQ-119 (истечение блокировки), OQ-120 (`period_check`), OQ-121 (порог размера свода), OQ-122 (ячейка `PROCESSING`),
OQ-123 (общие строки у версий), OQ-124 (Q1-эквивалентность ОСВ), OQ-125 (склейка Q1 версии Q1), OQ-126 (область ручного
@ -805,6 +806,15 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
- Тест на реальных данных — `api/tests/test_consolidation_fixtures.py` (маркер `fixtures`): ОСВ РБ и Счастливочной за Q1–Q3 2024
→ версия Q3 → свод (1385 строк), 22 документа привязано, пробелы по остальным счетам.
## Шахматка и результирующая таблица ВГО (Срез 5)
API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица
`GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица
`GET …/intercompany-matrix/result-table`. Контракт для фронта —
`../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях
`openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`.
Расчёт читает сводную ОСВ версии, результат не хранится.
## Ошибки API
Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом:
@ -882,6 +892,11 @@ email без учёта регистра проверяет только сер
| `ENTITY_NOT_IN_PERIMETER` | 422 | загрузка в консолидацию: ЮЛ не входит в периметр ни одной версии «в работе» |
| `CONSOLIDATION_NOT_EDITABLE` | 409 | загрузка в консолидацию без версии «в работе» |
| `QUERY_TOO_SHORT` | 422 | кандидаты `scope=all`: `q` короче 3 символов после обрезки |
| `ACCOUNT_NOT_FOUND` | 422 | MANUAL шахматки или её экспорта: выбранного счёта нет в плане счетов |
| `ACCOUNT_KIND_MISMATCH` | 422 | MANUAL шахматки или её экспорта: вид счёта не подходит дебетовой/кредитовой стороне |
| `ACCOUNTS_REQUIRED` | 422 | сервис шахматки: не выбран дебетовый или кредитовый набор; через HTTP пустые массивы отклоняются раньше валидацией тела |
| `RESULT_ACCOUNTS_MISSING` | 422 | результирующая таблица ВГО: в плане счетов нет обязательных кодов статей |
| `ACCOUNTS_NOT_ALLOWED` / `INVALID_MODE` | 422 | защита внутреннего вызова сервиса шахматки; маршруты ALL/MANUAL не дают вызвать эти ветки |
| `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) |
| `VERSION_NOT_EDITABLE` | 409 | версия не «в работе» (правки) либо `CANCELLED` при «взять в работу» |
| `LOCK_REQUIRED` | 409 | правка версии без своей активной блокировки |

View File

@ -14,6 +14,8 @@ from src.services.consolidation_locks import ConsolidationLockService
from src.services.consolidation_status import ConsolidationStatusService
from src.services.consolidations import ConsolidationService
from src.services.counterparty_mapping import CounterpartyMappingService
from src.services.elimination_rules import EliminationRuleService
from src.services.intercompany_matrix import IntercompanyMatrixService
def get_consolidation_service(
@ -73,3 +75,25 @@ def get_counterparty_mapping_service(
CounterpartyMappingServiceDep = Annotated[CounterpartyMappingService, Depends(get_counterparty_mapping_service)]
def get_elimination_rule_service(
session: SessionDep, scope: Annotated[VisibilityScope, Depends(get_scope)]
) -> EliminationRuleService:
"""Сервис чтения правил элиминации из БД для версии консолидации."""
return EliminationRuleService(session, scope)
EliminationRuleServiceDep = Annotated[EliminationRuleService, Depends(get_elimination_rule_service)]
def get_intercompany_matrix_service(
session: SessionDep,
scope: Annotated[VisibilityScope, Depends(get_scope)],
osv_service: ConsolidatedOsvServiceDep,
) -> IntercompanyMatrixService:
"""Сервис отчёта intercompany-matrix (шахматки)."""
return IntercompanyMatrixService(session, scope, osv_service)
IntercompanyMatrixServiceDep = Annotated[IntercompanyMatrixService, Depends(get_intercompany_matrix_service)]

View File

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

View File

@ -0,0 +1,33 @@
"""Правила элиминации для версии консолидации."""
import uuid
from typing import Any
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
router = APIRouter(prefix="/consolidations/versions")
VIEW = [Depends(require("CONSOLIDATION_VIEW"))]
NOT_FOUND: dict[int | str, dict[str, Any]] = {
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}
}
@router.get(
"/{version_id}/elimination-rules",
response_model=list[EliminationRuleOut],
dependencies=VIEW,
responses=NOT_FOUND,
summary="Правила для шахматки",
)
async def list_rules(
version_id: uuid.UUID,
service: EliminationRuleServiceDep,
) -> list[EliminationRuleOut]:
"""Все правила выбранной версии со счетами и исключениями обеих сторон."""
return await service.list_rules(version_id)

View File

@ -0,0 +1,160 @@
"""Расчёт шахматки по всем или выбранным счетам."""
import uuid
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Response, status
from starlette.concurrency import run_in_threadpool
from src.api.dependencies.audit import RequestAudit, get_request_audit
from src.api.dependencies.auth import require
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.services.intercompany_matrix import MatrixExportInfo
from src.services.intercompany_matrix_export import build_matrix_workbook
router = APIRouter(prefix="/consolidations/versions")
VIEW = [Depends(require("CONSOLIDATION_VIEW"))]
ERRORS: dict[int | str, dict[str, Any]] = {
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "Версия не найдена либо недоступна"},
status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": ErrorDetail, "description": "Неверный выбор счетов"},
}
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
EXPORT_RESPONSES = {
**ERRORS,
status.HTTP_200_OK: {"content": {XLSX_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}},
}
async def _xlsx_response(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) -> Response:
content = await run_in_threadpool(build_matrix_workbook, matrix, info)
return Response(
content=content,
media_type=XLSX_MEDIA_TYPE,
headers={"Content-Disposition": content_disposition(info.filename, ascii_fallback="intercompany-matrix.xlsx")},
)
async def _export_response(
version_id: uuid.UUID,
matrix: IntercompanyMatrixOut,
info: MatrixExportInfo,
audit: RequestAudit,
*,
selection: IntercompanyMatrixManualIn | None = None,
) -> Response:
response = await _xlsx_response(matrix, info)
await audit.record(
action=AuditAction.EXPORT,
section=AuditSection.EXPORT,
object_type="consolidation_version",
object_id=version_id,
consolidation_version_id=version_id,
after_value={
"report": "intercompany_matrix",
"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 [],
"cells": len(matrix.cells),
},
)
return response
@router.get(
"/{version_id}/intercompany-matrix/all",
response_model=IntercompanyMatrixOut,
dependencies=VIEW,
responses=ERRORS,
summary="Рассчитать шахматку по всем счетам",
)
async def build_intercompany_matrix_all(
version_id: uuid.UUID,
service: IntercompanyMatrixServiceDep,
) -> IntercompanyMatrixOut:
"""Все счета с подходящим видом дебета или кредита."""
return await service.build_accounts(version_id, mode="ALL")
@router.get(
"/{version_id}/intercompany-matrix/all/export",
response_class=Response,
dependencies=VIEW,
responses=EXPORT_RESPONSES,
summary="Скачать шахматку по всем счетам в XLSX",
)
async def export_intercompany_matrix_all(
version_id: uuid.UUID,
service: IntercompanyMatrixServiceDep,
audit: Annotated[RequestAudit, Depends(get_request_audit)],
) -> Response:
matrix, info = await service.build_export(version_id, mode="ALL")
return await _export_response(version_id, matrix, info, audit)
@router.post(
"/{version_id}/intercompany-matrix/manual",
response_model=IntercompanyMatrixOut,
dependencies=VIEW,
responses=ERRORS,
summary="Рассчитать шахматку по выбранным счетам",
)
async def build_intercompany_matrix_manual(
version_id: uuid.UUID,
selection: IntercompanyMatrixManualIn,
service: IntercompanyMatrixServiceDep,
) -> IntercompanyMatrixOut:
"""Выбранные счета раскрываются по иерархии."""
return await service.build_accounts(
version_id,
mode="MANUAL",
debit_account_ids=selection.debit_account_ids,
credit_account_ids=selection.credit_account_ids,
)
@router.post(
"/{version_id}/intercompany-matrix/manual/export",
response_class=Response,
dependencies=VIEW,
responses=EXPORT_RESPONSES,
summary="Скачать шахматку по выбранным счетам в XLSX",
)
async def export_intercompany_matrix_manual(
version_id: uuid.UUID,
selection: IntercompanyMatrixManualIn,
service: IntercompanyMatrixServiceDep,
audit: Annotated[RequestAudit, Depends(get_request_audit)],
) -> Response:
matrix, info = await service.build_export(
version_id,
mode="MANUAL",
debit_account_ids=selection.debit_account_ids,
credit_account_ids=selection.credit_account_ids,
)
return await _export_response(
version_id,
matrix,
info,
audit,
selection=selection,
)
@router.get(
"/{version_id}/intercompany-matrix/result-table",
response_model=IntercompanyResultTableOut,
dependencies=VIEW,
responses=ERRORS,
summary="Результирующая таблица ВГО",
)
async def get_intercompany_result_table(
version_id: uuid.UUID,
service: IntercompanyMatrixServiceDep,
) -> IntercompanyResultTableOut:
"""Пять фиксированных статей: суммы по компаниям и итоги по группе."""
return await service.build_result_table(version_id)

View File

@ -2,10 +2,18 @@
from fastapi import APIRouter
from src.api.endpoints.directories import company_groups, import_, legal_entities, regional_offices, related_groups
from src.api.endpoints.directories import (
accounts,
company_groups,
import_,
legal_entities,
regional_offices,
related_groups,
)
router = APIRouter(prefix="/directories", tags=["directories"])
router.include_router(regional_offices.router)
router.include_router(accounts.router)
router.include_router(company_groups.router)
router.include_router(legal_entities.router)
router.include_router(related_groups.router)

View File

@ -0,0 +1,21 @@
"""Плоский список счетов плана счетов для выбора статьи расходов."""
from fastapi import APIRouter, Depends
from src.api.dependencies.auth import require
from src.api.dependencies.db import SessionDep
from src.repositories import directories as repo
from src.schemas.directories import AccountOut
router = APIRouter()
@router.get(
"/accounts",
response_model=list[AccountOut],
dependencies=[Depends(require("DIRECTORY_VIEW"))],
summary="Список счетов плана счетов",
)
async def list_accounts(session: SessionDep) -> list[AccountOut]:
"""Все счета одним списком"""
return [AccountOut.model_validate(account) for account in await repo.list_accounts(session)]

View File

@ -101,6 +101,7 @@ class SourceRow:
turnover_credit: Decimal | None
closing_debit: Decimal | None
closing_credit: Decimal | None
account_id: uuid.UUID | None = None
@dataclass(frozen=True)
@ -132,6 +133,7 @@ class ConsolidatedRow:
closing_debit: Decimal | None
closing_credit: Decimal | None
warnings: tuple[str, ...] = ()
account_id: uuid.UUID | None = None
@dataclass(frozen=True)
@ -342,6 +344,7 @@ def _compose( # noqa: PLR0913 - сборка строки свода из ча
legal_entity_inn=entity.inn,
account_code=representative.account_code,
account_root=representative.account_root,
account_id=representative.account_id,
period=context.period,
is_cumulative=is_cumulative,
row_number=representative.row_number,

View File

@ -14,7 +14,9 @@ from sqlalchemy import Select, Sequence, and_, delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.directory_import import DirectorySnapshot, EntitySnap, GroupSnap, OfficeSnap
from src.domain.osv_mapping import account_sort_key
from src.domain.policy import VisibilityScope, company_groups_visible, legal_entities_visible
from src.models.accounting import ChartOfAccount
from src.models.directories import (
CompanyGroup,
CompanyGroupHistory,
@ -32,6 +34,11 @@ LIKE_ESCAPE = "\\"
ALIAS_LOCK_PREFIX = "legal-entity-alias" # пространство ключей advisory-блокировки алиасов
async def list_accounts(session: AsyncSession) -> list[ChartOfAccount]:
accounts = (await session.scalars(select(ChartOfAccount))).all()
return sorted(accounts, key=lambda account: (account_sort_key(account.code), account.code))
def escape_like(text: str) -> str:
"""Экранирует `\\`, `%` и `_`, чтобы пользовательская строка искалась как подстрока, а не как шаблон LIKE."""
return text.replace(LIKE_ESCAPE, LIKE_ESCAPE * 2).replace("%", f"{LIKE_ESCAPE}%").replace("_", f"{LIKE_ESCAPE}_")

View File

@ -0,0 +1,53 @@
"""Чтение версионированных правил элиминации и счетов их сторон."""
import uuid
from collections.abc import Collection
from sqlalchemy import 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
) -> 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)
.order_by(
EliminationRule.statement_type,
EliminationRule.rule_no,
EliminationRuleAccount.company_no,
)
)
return [(rule, account) for rule, account in (await session.execute(stmt)).all()]
async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]:
"""Все правила заданной версии, включая ББ и ОФР, в порядке типа отчётности и номера."""
stmt = (
select(EliminationRule)
.where(EliminationRule.version_no == version_no)
.order_by(EliminationRule.statement_type, EliminationRule.rule_no)
)
return list((await session.scalars(stmt)).all())
async def list_rule_accounts(
session: AsyncSession, rule_ids: Collection[uuid.UUID]
) -> list[tuple[EliminationRuleAccount, str]]:
"""Счета правил с кодами из плана счетов; `company_no` задаёт сторону, `is_excluded` — исключение."""
if not rule_ids:
return []
stmt = (
select(EliminationRuleAccount, ChartOfAccount.code)
.join(ChartOfAccount, ChartOfAccount.id == EliminationRuleAccount.account_id)
.where(EliminationRuleAccount.rule_id.in_(rule_ids))
.order_by(EliminationRuleAccount.rule_id, EliminationRuleAccount.company_no, ChartOfAccount.code)
)
return [(account, code) for account, code in (await session.execute(stmt)).all()]

View File

@ -8,7 +8,8 @@
import datetime
import uuid
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal
from typing import Literal
from pydantic import BaseModel, Field, field_validator
@ -22,6 +23,7 @@ from src.models.enums import (
DocumentType,
EntityType,
PeriodQuarter,
StatementType,
)
YEAR_MIN = 2000
@ -304,3 +306,91 @@ class MapCounterpartyOut(BaseModel):
propagated: int
alias_created: bool
alias_moved_from: uuid.UUID | None
class IntercompanyMatrixRuleSideOut(BaseModel):
"""Счета одной стороны правила intercompany-matrix (шахматки) до раскрытия иерархии."""
accounts: list[uuid.UUID]
excluded: list[uuid.UUID]
class EliminationRuleOut(BaseModel):
"""Правило элиминации из БД; ключ — тип отчётности, версия и номер."""
rule_no: int
version_no: int
statement_type: StatementType
side_1: Literal["D", "K"]
measure: Literal["closing", "turnover"]
company_1: IntercompanyMatrixRuleSideOut
company_2: IntercompanyMatrixRuleSideOut
class IntercompanyMatrixManualIn(BaseModel):
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""
debit_account_ids: list[uuid.UUID] = Field(min_length=1)
credit_account_ids: list[uuid.UUID] = Field(min_length=1)
class IntercompanyMatrixCellOut(BaseModel):
"""Суммы и расхождение для пары компаний A и Б."""
company_a_id: uuid.UUID
company_b_id: uuid.UUID
amount_a: Decimal
amount_b: Decimal
variance_amount: Decimal
variance_percent: Decimal | None
exceeds_threshold: bool
one_sided: bool
skipped_osv_entry_count: int = Field(
ge=0,
description="Число исходных строк ОСВ с account_id=null у обеих компаний пары; в суммы они не вошли",
)
osv_entry_ids_debit: list[uuid.UUID]
osv_entry_ids_credit: list[uuid.UUID]
@field_validator("amount_a", "amount_b", "variance_amount", "variance_percent")
@classmethod
def _two_decimals(cls, value: Decimal | None) -> Decimal | None:
"""Суммы и процент в JSON всегда с двумя знаками; неопределённый процент — null."""
return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP)
class IntercompanyMatrixCompanyOut(BaseModel):
"""Компания на обеих осях шахматки."""
id: uuid.UUID
name: str
class IntercompanyMatrixOut(BaseModel):
"""Компании задают порядок обеих осей; диагональные ячейки отсутствуют."""
companies: list[IntercompanyMatrixCompanyOut]
cells: list[IntercompanyMatrixCellOut]
class IntercompanyResultAmountOut(BaseModel):
"""Сумма статьи у компании по всем остальным компаниям периметра."""
company_id: uuid.UUID
amount: Decimal
class IntercompanyResultArticleOut(BaseModel):
"""Статья ВГО и её суммы по компаниям и группе."""
code: str
name: str
amounts: list[IntercompanyResultAmountOut]
group_total: Decimal
class IntercompanyResultTableOut(BaseModel):
"""Фиксированная результирующая таблица ВГО выбранной версии."""
companies: list[IntercompanyMatrixCompanyOut]
articles: list[IntercompanyResultArticleOut]

View File

@ -6,7 +6,7 @@ from typing import Annotated, Literal, Self
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
from src.models.enums import EntityType, LeHistoryEvent, RelatedGroupKind
from src.models.enums import AccountKind, AccountRowType, EntityType, LeHistoryEvent, RelatedGroupKind
NAME_MAX_LENGTH = 500
MAX_ALIASES = 100 # алиасов в одном теле формы ЮЛ (#84): граница входа, 422 до обращения к БД
@ -38,6 +38,32 @@ class RegionalOfficeOut(RegionalOfficeBrief):
is_active: bool
class AccountOut(BaseModel):
"""Одна строка плана счетов; parent_id сохраняет иерархию в плоском списке."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
code: str
parent_id: uuid.UUID | None
name: str
kind: AccountKind | None
row_type: AccountRowType
is_off_balance: bool
@computed_field # type: ignore[prop-decorator]
@property
def is_debit(self) -> bool:
"""Счёт можно выбрать для дебетовой стороны: активный или активно-пассивный."""
return self.kind in (AccountKind.A, AccountKind.AP)
@computed_field # type: ignore[prop-decorator]
@property
def is_credit(self) -> bool:
"""Счёт можно выбрать для кредитовой стороны: пассивный или активно-пассивный."""
return self.kind in (AccountKind.P, AccountKind.AP)
class CompanyGroupCreate(BaseModel):
"""Создание ГК: `code` и `valid_from` назначает сервис."""

View File

@ -94,6 +94,7 @@ def _source_row(entry: OsvEntry) -> domain.SourceRow:
row_number=entry.row_number,
account_code=entry.account_code,
account_root=entry.account_root,
account_id=entry.account_id,
counterparty_name=entry.counterparty_name,
counterparty_inn=entry.counterparty_inn,
counterparty_id=entry.counterparty_id,
@ -135,8 +136,15 @@ class ConsolidatedOsvService:
self._session = session
self._scope = scope
async def _build(self, version_id: uuid.UUID) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation]:
async def build(self, version_id: uuid.UUID) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation]:
"""Свод версии (`domain.build`) вместе с версией и консолидацией; версия вне видимости — 404."""
consolidated, version, consolidation, _ = await self.build_with_members(version_id)
return consolidated, version, consolidation
async def build_with_members(
self, version_id: uuid.UUID
) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation, dict[uuid.UUID, domain.Entity]]:
"""Свод и участники периметра, загруженные при его расчёте."""
found = await repo.get_version(self._session, self._scope, version_id)
if found is None:
raise NotFoundError(VERSION_NOT_FOUND)
@ -166,11 +174,11 @@ class ConsolidatedOsvService:
rows=rows,
resolver=index,
)
return consolidated, version, consolidation
return consolidated, version, consolidation, members
async def page(self, version_id: uuid.UUID, f: OsvFilters, page: OsvPageParams) -> Page[ConsolidatedRowOut]:
"""Страница свода после фильтров: `total` — число строк после фильтра, срез — в памяти (порядок D-16)."""
consolidated, _, _ = await self._build(version_id)
consolidated, _, _ = await self.build(version_id)
rows = domain.apply_filter(consolidated.rows, domain_filter(f))
window = rows[page.offset : page.offset + page.limit]
return Page[ConsolidatedRowOut](
@ -181,7 +189,7 @@ class ConsolidatedOsvService:
"""xlsx сводной ОСВ (spec «Экспорт xlsx», D-14): строки после тех же фильтров, что у `page`, без пагинации, в
порядке D-16; лист пробелов — по своду целиком. Имя файла — `svod-osv_<ГК>_<год>-<квартал>_v<n>.xlsx` (без
percent-кодирования: оно в маршруте). `audit_event(EXPORT)` пишется после сборки книги, в транзакции запроса."""
consolidated, version, consolidation = await self._build(version_id)
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)
group_name = (
@ -208,7 +216,7 @@ class ConsolidatedOsvService:
async def summary(self, version_id: uuid.UUID) -> OsvSummaryOut:
"""Сводка: строки, несопоставленные, автосопоставленные по имени/алиасу, ОСВ-документы версии и пробелы."""
consolidated, _, _ = await self._build(version_id)
consolidated, _, _ = await self.build(version_id)
rows_total, unmapped_total, auto_matched_total = domain.summary(consolidated)
return OsvSummaryOut(
rows_total=rows_total,
@ -221,7 +229,7 @@ class ConsolidatedOsvService:
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)
consolidated, _, _ = await self.build(version_id)
row = next((row for row in consolidated.rows if entry_id in row.entry_ids), None)
return None if row is None else row_out(row)

View File

@ -0,0 +1,51 @@
"""Чтение правил элиминации для версии консолидации."""
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
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
VERSION_NOT_FOUND = "версия консолидации не найдена"
class EliminationRuleService:
"""Правила выбранной версии из БД с учетом видимости консолидации."""
def __init__(self, session: AsyncSession, scope: VisibilityScope) -> None:
self._session = session
self._scope = scope
async def list_rules(self, consolidation_version_id: uuid.UUID) -> list[EliminationRuleOut]:
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)
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(
{
"rule_no": rule.rule_no,
"version_no": rule.version_no,
"statement_type": rule.statement_type,
"side_1": rule.side_1,
"measure": rule.measure,
"company_1": IntercompanyMatrixRuleSideOut(accounts=[], excluded=[]),
"company_2": IntercompanyMatrixRuleSideOut(accounts=[], excluded=[]),
}
)
by_id[rule.id] = result
if account is None:
continue
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())

View File

@ -0,0 +1,376 @@
"""Расчёт шахматки по готовой сводной ОСВ и выбранным наборам счетов."""
import uuid
from collections import defaultdict
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from decimal import Decimal
from typing import Literal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.exceptions import DomainValidationError
from src.domain.consolidated_osv import ConsolidatedRow, Entity
from src.domain.policy import VisibilityScope
from src.models.accounting import ChartOfAccount
from src.models.consolidation import Consolidation, ConsolidationVersion
from src.models.directories import CompanyGroup
from src.models.enums import AccountKind, PeriodQuarter
from src.repositories import directories as directory_repo
from src.schemas.consolidations import (
IntercompanyMatrixCellOut,
IntercompanyMatrixCompanyOut,
IntercompanyMatrixOut,
IntercompanyResultAmountOut,
IntercompanyResultArticleOut,
IntercompanyResultTableOut,
)
from src.services.consolidated_osv import ConsolidatedOsvService
Measure = Literal["closing", "turnover"]
Side = Literal["D", "K"]
ZERO = Decimal("0")
VARIANCE_THRESHOLD_PERCENT = Decimal("5")
DEBIT_KINDS = frozenset({AccountKind.A, AccountKind.AP})
CREDIT_KINDS = frozenset({AccountKind.P, AccountKind.AP})
@dataclass(frozen=True)
class ResultArticleSpec:
code: str
name: str
side: Side
account_codes: tuple[str, ...]
excluded_codes: tuple[str, ...] = ()
RESULT_ARTICLES = (
ResultArticleSpec(
"receivables",
"Дебиторская задолженность",
"D",
("60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"),
("76.07.5", "76.07.9"),
),
ResultArticleSpec(
"payables",
"Кредиторская задолженность",
"K",
("60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"),
("76.07.1",),
),
ResultArticleSpec("financial_investments", "Финансовые вложения", "D", ("58.01", "58.03")),
ResultArticleSpec("long_term_loans", "Долгосрочные кредиты и займы", "K", ("67", "67.03")),
ResultArticleSpec("short_term_loans", "Краткосрочные кредиты и займы", "K", ("66", "66.03", "66.04")),
)
@dataclass(frozen=True)
class MatrixSelection:
"""Счета дебетовой и кредитовой сторон и показатель выбранного режима."""
company_1_account_ids: frozenset[uuid.UUID]
company_2_account_ids: frozenset[uuid.UUID]
measure: Measure
@dataclass(frozen=True)
class MatrixExportInfo:
group_name: str
year: int
quarter: PeriodQuarter
version_no: int
mode: Literal["ALL", "MANUAL"]
debit_account_codes: tuple[str, ...]
credit_account_codes: tuple[str, ...]
@property
def filename(self) -> str:
return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx"
def _amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal:
if measure == "closing":
value = row.closing_debit if side == "D" else row.closing_credit
else:
value = row.turnover_debit if side == "D" else row.turnover_credit
return value if value is not None else ZERO
def calculate_matrix(
rows: Iterable[ConsolidatedRow],
member_companies: Sequence[IntercompanyMatrixCompanyOut],
selection: MatrixSelection,
) -> IntercompanyMatrixOut:
"""Суммы по парам компаний;
Сторона 1: дебет ОСВ компании A по контрагенту Б.
Сторона 2: кредит ОСВ компании Б по контрагенту A.
"""
if selection.measure not in ("closing", "turnover"):
raise ValueError(f"неизвестный показатель правила: {selection.measure}")
company_info = tuple({company.id: company for company in member_companies}.values())
company_ids = tuple(company.id for company in company_info)
members = set(company_ids)
amounts_1: dict[tuple[uuid.UUID, uuid.UUID], dict] = defaultdict(lambda: {"amount": ZERO, "entry_osv_ids": set()})
amounts_2: dict[tuple[uuid.UUID, uuid.UUID], dict] = defaultdict(lambda: {"amount": ZERO, "entry_osv_ids": set()})
skipped_entries: dict[tuple[uuid.UUID, uuid.UUID], int] = defaultdict(int)
for row in rows:
owner = row.legal_entity_id
counterparty = row.counterparty_id
if owner not in members or counterparty not in members or owner == counterparty:
continue
pair = (owner, counterparty)
if row.account_id is None:
skipped_entries[pair] += len(row.entry_ids)
continue
if row.account_id in selection.company_1_account_ids:
item = amounts_1[pair]
item["amount"] += _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["entry_osv_ids"].update(row.entry_ids)
cells: list[IntercompanyMatrixCellOut] = []
for company_a in company_ids:
for company_b in company_ids:
if company_a == company_b:
continue
item_a = amounts_1.get((company_a, company_b))
item_b = amounts_2.get((company_b, company_a))
amount_a = item_a["amount"] if item_a is not None else ZERO
amount_b = item_b["amount"] if item_b is not None else ZERO
variance_amount = abs(amount_a - amount_b)
base = min(amount_a, amount_b)
variance_percent = None if base == ZERO else variance_amount / base * 100
exceeds_threshold = variance_percent is not None and variance_percent > VARIANCE_THRESHOLD_PERCENT
debit_ids = item_a["entry_osv_ids"] if item_a is not None else ()
credit_ids = item_b["entry_osv_ids"] if item_b is not None else ()
cells.append(
IntercompanyMatrixCellOut(
company_a_id=company_a,
company_b_id=company_b,
amount_a=amount_a,
amount_b=amount_b,
variance_amount=variance_amount,
variance_percent=variance_percent,
exceeds_threshold=exceeds_threshold,
one_sided=bool(debit_ids) != bool(credit_ids),
skipped_osv_entry_count=skipped_entries[(company_a, company_b)]
+ skipped_entries[(company_b, company_a)],
osv_entry_ids_debit=debit_ids,
osv_entry_ids_credit=credit_ids,
)
)
return IntercompanyMatrixOut(companies=list(company_info), cells=cells)
def _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}
children: dict[uuid.UUID, list[uuid.UUID]] = defaultdict(list)
for account in accounts:
if account.parent_id is not None:
children[account.parent_id].append(account.id)
return by_id, children
def _expand(roots: Iterable[uuid.UUID], children: dict[uuid.UUID, list[uuid.UUID]]) -> set[uuid.UUID]:
"""Выбранные счета и все их потомки."""
result: set[uuid.UUID] = set()
pending = list(roots)
while pending:
account_id = pending.pop()
if account_id not in result:
result.add(account_id)
pending.extend(children.get(account_id, ()))
return result
def _kinds(side: Side) -> frozenset[AccountKind]:
return DEBIT_KINDS if side == "D" else CREDIT_KINDS
def _selected_accounts(
included: Iterable[uuid.UUID],
excluded: Iterable[uuid.UUID],
side: Side,
by_id: dict[uuid.UUID, ChartOfAccount],
children: dict[uuid.UUID, list[uuid.UUID]],
) -> frozenset[uuid.UUID]:
roots = set(included)
missing = roots - by_id.keys()
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):
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))
def calculate_result_table(
rows: Iterable[ConsolidatedRow],
member_companies: Sequence[IntercompanyMatrixCompanyOut],
accounts: Sequence[ChartOfAccount],
) -> IntercompanyResultTableOut:
"""Фиксированные статьи ВГО: сальдо по всем контрагентам периметра для каждого ЮЛ."""
by_id, children = _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())
if missing:
raise DomainValidationError("RESULT_ACCOUNTS_MISSING", f"В плане счетов нет счетов: {', '.join(missing)}")
selected = {
article.code: _selected_accounts(
(by_code[code] for code in article.account_codes),
(by_code[code] for code in article.excluded_codes),
article.side,
by_id,
children,
)
for article in RESULT_ARTICLES
}
companies = tuple({company.id: company for company in member_companies}.values())
member_ids = {company.id for company in companies}
amounts: dict[str, dict[uuid.UUID, Decimal]] = {
article.code: defaultdict(lambda: ZERO) for article in RESULT_ARTICLES
}
for row in rows:
if (
row.legal_entity_id not in member_ids
or row.counterparty_id not in member_ids
or row.legal_entity_id == row.counterparty_id
or row.account_id is None
):
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)
articles: list[IntercompanyResultArticleOut] = []
for article in RESULT_ARTICLES:
company_amounts = [
IntercompanyResultAmountOut(company_id=company.id, amount=amounts[article.code].get(company.id, ZERO))
for company in companies
]
articles.append(
IntercompanyResultArticleOut(
code=article.code,
name=article.name,
amounts=company_amounts,
group_total=sum((item.amount for item in company_amounts), ZERO),
)
)
return IntercompanyResultTableOut(companies=list(companies), articles=articles)
class IntercompanyMatrixService:
"""Получает свод и периметр версии, затем вызывает чистый расчёт матрицы."""
def __init__(self, session: AsyncSession, scope: VisibilityScope, service_osv: ConsolidatedOsvService) -> None:
self._session = session
self._scope = scope
self._osv_service = service_osv
async def build(
self,
version_id: uuid.UUID,
selection: MatrixSelection,
) -> IntercompanyMatrixOut:
"""Счета принимает готовыми; выбор режима ALL/MANUAL выполняется вызывающим кодом."""
consolidated, _, _, members = await self._osv_service.build_with_members(version_id)
return self._calculate(consolidated.rows, members, selection)
def _calculate(
self, rows: Iterable[ConsolidatedRow], members: dict[uuid.UUID, Entity], selection: MatrixSelection
) -> IntercompanyMatrixOut:
"""Общий вызов расчёта с участниками из уже построенного свода."""
companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in members.values()]
return calculate_matrix(rows, companies, selection)
async def build_result_table(self, version_id: uuid.UUID) -> IntercompanyResultTableOut:
"""Статичная таблица по пяти статьям"""
consolidated, _, _, members = await self._osv_service.build_with_members(version_id)
accounts = await directory_repo.list_accounts(self._session)
companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in members.values()]
return calculate_result_table(consolidated.rows, companies, accounts)
async def build_accounts(
self,
version_id: uuid.UUID,
*,
mode: Literal["ALL", "MANUAL"],
debit_account_ids: Sequence[uuid.UUID] = (),
credit_account_ids: Sequence[uuid.UUID] = (),
) -> IntercompanyMatrixOut:
"""ALL использует все счета нужного вида; MANUAL раскрывает выбранные счета и потомков."""
matrix, _, _, _ = await self._build_accounts(
version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids
)
return matrix
async def build_export(
self,
version_id: uuid.UUID,
*,
mode: Literal["ALL", "MANUAL"],
debit_account_ids: Sequence[uuid.UUID] = (),
credit_account_ids: Sequence[uuid.UUID] = (),
) -> 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
)
group_name = (
await self._session.execute(
select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id)
)
).scalar_one()
by_id = {account.id: account.code for account in accounts}
info = MatrixExportInfo(
group_name=group_name,
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),
)
return matrix, info
async def _build_accounts(
self,
version_id: uuid.UUID,
*,
mode: Literal["ALL", "MANUAL"],
debit_account_ids: Sequence[uuid.UUID],
credit_account_ids: Sequence[uuid.UUID],
) -> tuple[IntercompanyMatrixOut, ConsolidationVersion, Consolidation, list[ChartOfAccount]]:
consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id)
accounts = await directory_repo.list_accounts(self._session)
by_id, children = _account_tree(accounts)
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)
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)
else:
raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов")
selection = MatrixSelection(debit, credit, "closing")
matrix = self._calculate(consolidated.rows, members, selection)
return matrix, version, consolidation, accounts

View File

@ -0,0 +1,117 @@
from io import BytesIO
from openpyxl import Workbook
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
from openpyxl.comments import Comment
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.worksheet import Worksheet
from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut
from src.services.intercompany_matrix import MatrixExportInfo
from src.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)
HEADER_FILL = PatternFill("solid", fgColor="E7EDF5")
DIAGONAL_FILL = PatternFill("solid", fgColor="F2F2F2")
OUTLINE_SIDE = Side(style="thin", color="A6A6A6")
def _text(sheet: Worksheet, row: int, column: int, value: str) -> None:
"""Записать текст без XML-символов и без превращения ведущего `=` в формулу."""
cell = sheet.cell(row, column, ILLEGAL_CHARACTERS_RE.sub("", value))
cell.data_type = "s"
def _skipped_comment(sheet: Worksheet, row: int, column: int, count: int) -> None:
if count:
sheet.cell(row, column).comment = Comment(f"Пропущено строк ОСВ без сопоставленного счёта: {count}", "АРМ КА")
def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None:
_text(sheet, 1, 1, "Шахматка внутригрупповых операций")
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 "Все счета"),
)
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)
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
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)
def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) -> bytes:
workbook = Workbook()
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, "Показатель")
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)
sheet.column_dimensions[get_column_letter(column)].width = 26
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)
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)
sheet.cell(start_row, 1).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
for offset, metric in enumerate(METRICS):
_text(sheet, start_row + offset, 2, metric)
sheet.cell(start_row + offset, 2).alignment = Alignment(horizontal="center", vertical="center")
for column, credit_company in enumerate(matrix.companies, start=3):
cell = cells.get((debit_company.id, credit_company.id))
for offset in range(len(METRICS)):
sheet.cell(start_row + offset, column).border = Border(
left=OUTLINE_SIDE,
right=OUTLINE_SIDE,
top=OUTLINE_SIDE if offset == 0 else Side(),
bottom=OUTLINE_SIDE if offset == len(METRICS) - 1 else Side(),
)
if debit_company.id == credit_company.id:
for offset in range(len(METRICS)):
sheet.cell(start_row + offset, column).fill = DIAGONAL_FILL
continue
if cell is None:
continue
_write_values(sheet, start_row, column, cell)
if cell.exceeds_threshold:
for offset in range(len(METRICS)):
marked = sheet.cell(start_row + offset, column)
marked.fill = THRESHOLD_FILL
marked.font = THRESHOLD_FONT
sheet.freeze_panes = f"C{DATA_START_ROW}"
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()

View File

@ -0,0 +1,52 @@
"""Плоский справочник счетов: все уровни и порядок кодов."""
import uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock
from src.api.endpoints.directories.accounts import list_accounts
from src.models.enums import AccountKind, AccountRowType
from src.repositories.directories import list_accounts as query_accounts
async def test_accounts_are_flat_and_naturally_sorted() -> None:
root_id, sub_id = uuid.uuid4(), uuid.uuid4()
rows = [
SimpleNamespace(
id=sub_id,
code="60.10",
parent_id=root_id,
name="Авансы",
kind=AccountKind.A,
row_type=AccountRowType.subaccount,
is_off_balance=False,
),
SimpleNamespace(
id=root_id,
code="60",
parent_id=None,
name="Расчёты",
kind=AccountKind.AP,
row_type=AccountRowType.account,
is_off_balance=False,
),
SimpleNamespace(
id=uuid.uuid4(),
code="60.2",
parent_id=root_id,
name="Поставщики",
kind=AccountKind.P,
row_type=AccountRowType.subaccount,
is_off_balance=False,
),
]
session = SimpleNamespace(scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: rows)))
assert [item.code for item in await query_accounts(session)] == ["60", "60.2", "60.10"]
response = await list_accounts(session)
assert [item.code for item in response] == ["60", "60.2", "60.10"]
assert response[0].parent_id is None
assert response[2].parent_id == root_id
assert response[2].id == sub_id
assert [item.model_dump(mode="json")["is_debit"] for item in response] == [True, False, True]
assert [item.model_dump(mode="json")["is_credit"] for item in response] == [True, True, False]

View File

@ -0,0 +1,288 @@
"""Критерии шахматки через HTTP, реальные сервисы и PostgreSQL."""
import uuid
from dataclasses import dataclass
from decimal import Decimal
from io import BytesIO
from urllib.parse import unquote
import pytest
from httpx import AsyncClient, Response
from openpyxl import load_workbook
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.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.osv import OsvEntry
from src.repositories import consolidations as consolidation_repo
from src.repositories import directories as directory_repo
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_confirmed_osv, make_consolidation, make_legal_entity
pytestmark = pytest.mark.integration
OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422
KA_RF = "ka_rf@armka.local"
ROUTES = ("all", "manual", "result-table", "all/export", "manual/export")
@dataclass
class World:
version: ConsolidationVersion
members: list[LegalEntity]
accounts: dict[str, ChartOfAccount]
@property
def selection(self) -> dict[str, list[str]]:
return {
"debit_account_ids": [str(self.accounts["62.01"].id)],
"credit_account_ids": [str(self.accounts["60.01"].id)],
}
async def _world(session: AsyncSession, *, members: int = 2) -> World:
office = (
await session.scalars(select(RegionalOffice).where(RegionalOffice.name == "Ростовский региональный филиал"))
).one()
user = (await session.scalars(select(AppUser).where(AppUser.email == KA_RF))).one()
group = await make_company_group(session, office=office)
entities = [await make_legal_entity(session, name=f"Компания {n}", group=group) for n in range(members)]
_, version = await make_consolidation(
session, group=group, year=2024, quarter=PeriodQuarter.Q1, members=entities, initiator=user
)
accounts = {account.code: account for account in (await session.scalars(select(ChartOfAccount))).all()}
return World(version, entities, accounts)
async def _request(client: AsyncClient, version_id: uuid.UUID, suffix: str, selection: dict) -> Response:
url = f"/consolidations/versions/{version_id}/intercompany-matrix/{suffix}"
return await client.post(url, json=selection) if suffix.startswith("manual") else await client.get(url)
async def _source(
session: AsyncSession, world: World, *, credit: bool, amount: str, account: str | None = None
) -> uuid.UUID:
owner, counterparty = reversed(world.members) if credit else world.members
code = account or ("60.01" if credit else "62.01")
document = await make_confirmed_osv(
session,
entity=owner,
account=code,
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=False,
rows=[
{
"name": counterparty.name,
"counterparty_id": counterparty.id,
"counterparty_match_kind": CounterpartyMatchKind.INN,
"account_id": world.accounts[code].id,
"closing_credit" if credit else "closing_debit": Decimal(amount),
}
],
)
session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id))
await session.flush()
return document.id
@pytest.mark.parametrize("suffix", ["all", "manual"])
async def test_empty_perimeter(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None:
world = await _world(seeded, members=0)
response = await _request(await auth_client(KA_RF), world.version.id, suffix, world.selection)
assert response.status_code == OK, response.text
assert response.json() == {"companies": [], "cells": []}
@pytest.mark.parametrize("suffix", ROUTES)
async def test_matrix_reuses_members_and_reads_accounts_through_repository(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch, suffix: str
) -> None:
world = await _world(seeded)
calls = {"members": 0, "accounts": 0}
original_members = consolidation_repo.members
original_accounts = directory_repo.list_accounts
async def counted_members(*args: object, **kwargs: object):
calls["members"] += 1
return await original_members(*args, **kwargs)
async def counted_accounts(*args: object, **kwargs: object):
calls["accounts"] += 1
return await original_accounts(*args, **kwargs)
monkeypatch.setattr(consolidation_repo, "members", counted_members)
monkeypatch.setattr(directory_repo, "list_accounts", counted_accounts)
response = await _request(await auth_client(KA_RF), world.version.id, suffix, world.selection)
assert response.status_code == OK, response.text
assert calls == {"members": 1, "accounts": 1}
async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)
await _source(seeded, world, credit=False, amount="999", account="58.03")
response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection)
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 {"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["skipped_osv_entry_count"] == 0
async def test_unmapped_account_source_is_visible_in_cell_count(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
world = await _world(seeded)
document_id = await _source(seeded, world, credit=False, amount="100")
entry = (await seeded.scalars(select(OsvEntry).where(OsvEntry.document_id == document_id))).one()
entry.account_id = None
await seeded.flush()
await _source(seeded, world, credit=True, amount="80")
response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection)
assert response.status_code == OK, response.text
cells = response.json()["cells"]
assert [cell["skipped_osv_entry_count"] for cell in cells] == [1, 1]
cell = next(c for c in cells if c["company_a_id"] == str(world.members[0].id))
assert cell["amount_a"] == "0.00"
assert cell["amount_b"] == "80.00"
assert cell["osv_entry_ids_debit"] == []
assert len(cell["osv_entry_ids_credit"]) == 1
@pytest.mark.parametrize("credit", [False, True])
@pytest.mark.parametrize("amount", ["0", "100.25"])
async def test_pair_without_counterpart(
auth_client: AuthClientFactory, seeded: AsyncSession, credit: bool, amount: str
) -> None:
world = await _world(seeded)
await _source(seeded, world, credit=credit, amount=amount)
response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection)
assert response.status_code == OK, response.text
cell = next(c for c in response.json()["cells"] if c["company_a_id"] == str(world.members[0].id))
assert Decimal(cell["amount_a"]) == (Decimal(0) if credit else Decimal(amount))
assert Decimal(cell["amount_b"]) == (Decimal(amount) if credit else Decimal(0))
assert cell["one_sided"] is True
assert cell["variance_percent"] is None
assert cell["exceeds_threshold"] is False
assert bool(cell["osv_entry_ids_credit"]) is credit
assert bool(cell["osv_entry_ids_debit"]) is not credit
@pytest.mark.parametrize("suffix", ROUTES)
async def test_visibility_and_permission(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None:
world = await _world(seeded)
foreign = await auth_client("client_manager@armka.local")
hidden = await _request(foreign, world.version.id, suffix, world.selection)
unknown = await _request(foreign, uuid.uuid4(), suffix, world.selection)
assert hidden.status_code == unknown.status_code == NOT_FOUND
assert hidden.json() == unknown.json()
forbidden = await _request(await auth_client("admin@armka.local"), world.version.id, suffix, world.selection)
assert forbidden.status_code == FORBIDDEN
assert "CONSOLIDATION_VIEW" in forbidden.json()["detail"]
@pytest.mark.parametrize(("debit", "credit", "percent"), [("8890", "8000", "11.13"), ("105001", "100000", "5.00")])
async def test_rounded_percent_in_api(
auth_client: AuthClientFactory, seeded: AsyncSession, debit: str, credit: str, percent: str
) -> None:
world = await _world(seeded)
await _source(seeded, world, credit=False, amount=debit)
await _source(seeded, world, credit=True, amount=credit)
response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection)
assert response.status_code == OK, response.text
cell = next(c for c in response.json()["cells"] if c["company_a_id"] == str(world.members[0].id))
assert cell["variance_percent"] == percent
assert cell["exceeds_threshold"] is True
assert cell["one_sided"] is False
@pytest.mark.parametrize("suffix", ["manual", "manual/export"])
@pytest.mark.parametrize("side", ["debit_account_ids", "credit_account_ids"])
@pytest.mark.parametrize("code", ["ACCOUNT_NOT_FOUND", "ACCOUNT_KIND_MISMATCH"])
async def test_invalid_account_selection(
auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str, side: str, code: str
) -> None:
world = await _world(seeded)
selection = world.selection
wrong_kind = world.accounts["80" if side == "debit_account_ids" else "01"]
selection[side] = [str(uuid.uuid4() if code == "ACCOUNT_NOT_FOUND" else wrong_kind.id)]
response = await _request(await auth_client(KA_RF), world.version.id, suffix, selection)
assert response.status_code == UNPROCESSABLE, response.text
assert response.json()["detail"]["code"] == code
async def test_result_accounts_missing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)
# Меняем код в тестовой транзакции, сохраняя FK существующих правил и потомков.
world.accounts["58.03"].code = "58.03-missing"
await seeded.flush()
response = await _request(await auth_client(KA_RF), world.version.id, "result-table", world.selection)
assert response.status_code == UNPROCESSABLE, response.text
assert response.json()["detail"]["code"] == "RESULT_ACCOUNTS_MISSING"
assert "58.03" in response.json()["detail"]["message"]
@pytest.mark.parametrize("mode", ["all", "manual"])
async def test_export_filename_header_and_percentage(
auth_client: AuthClientFactory, seeded: AsyncSession, mode: str
) -> None:
world = await _world(seeded)
await _source(seeded, world, credit=False, amount="100")
await _source(seeded, world, credit=True, amount="90")
group_name = (
await seeded.scalars(select(CompanyGroup.name).where(CompanyGroup.id == world.members[0].company_group_id))
).one()
response = await _request(await auth_client(KA_RF), world.version.id, f"{mode}/export", world.selection)
assert response.status_code == OK, response.text
disposition = response.headers["content-disposition"]
assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == (
f"intercompany-matrix_{group_name}_2024-Q1_v1.xlsx"
)
workbook = load_workbook(BytesIO(response.content))
sheet = workbook["Шахматка"]
assert [sheet[f"B{row}"].value for row in range(2, 9)] == [
group_name,
"2024-Q1",
"v1",
"руб.; расхождение — %",
"Выбранные счета" if mode == "manual" else "Все счета",
"62.01" if mode == "manual" else "Все счета",
"60.01" if mode == "manual" else "Все счета",
]
assert sheet["B14"].value == "Расхождение, %"
expected_percent = Decimal("11.11")
assert Decimal(str(sheet["D14"].value)) == expected_percent
assert sheet["D14"].number_format == '0.00"%"'
@pytest.mark.parametrize("mode", ["all", "manual"])
async def test_export_appends_audit(auth_client: AuthClientFactory, seeded: AsyncSession, mode: str) -> None:
world = await _world(seeded)
client = await auth_client(KA_RF)
query = select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT)
for _ in range(2):
response = await _request(client, world.version.id, f"{mode}/export", world.selection)
assert response.status_code == OK, response.text
events = (await seeded.scalars(query)).all()
assert len(events) == 2 # noqa: PLR2004 - отдельное событие на каждую выгрузку
assert events[0].id != events[1].id
for event in events:
assert event.section == AuditSection.EXPORT
assert event.object_type == "consolidation_version"
assert event.object_id == event.consolidation_version_id == world.version.id
assert event.user_id is not None and event.before_value is None
assert event.after_value == {
"report": "intercompany_matrix",
"mode": mode.upper(),
"cells": len(world.members),
**(world.selection if mode == "manual" else {"debit_account_ids": [], "credit_account_ids": []}),
}

View File

@ -0,0 +1,223 @@
"""Выбор счетов и расчёт шахматки без обращения к БД."""
import uuid
from decimal import Decimal
from types import SimpleNamespace
import pytest
from src.api.endpoints.consolidations.intercompany_matrix import router
from src.models.enums import AccountKind
from src.schemas.consolidations import (
IntercompanyMatrixCellOut,
IntercompanyMatrixCompanyOut,
IntercompanyMatrixOut,
IntercompanyResultTableOut,
)
from src.services import intercompany_matrix as matrix
def _account(kind: AccountKind, parent_id: uuid.UUID | None = None) -> SimpleNamespace:
return SimpleNamespace(id=uuid.uuid4(), kind=kind, parent_id=parent_id)
def _row(
owner: uuid.UUID,
counterparty: uuid.UUID,
account_id: uuid.UUID | None,
*,
debit: str = "0",
credit: str = "0",
) -> SimpleNamespace:
return SimpleNamespace(
legal_entity_id=owner,
counterparty_id=counterparty,
account_id=account_id,
closing_debit=Decimal(debit),
closing_credit=Decimal(credit),
turnover_debit=Decimal("0"),
turnover_credit=Decimal("0"),
entry_ids=(),
)
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/result-table", ("GET",)) in paths
debit_parent = _account(AccountKind.A)
debit_child = _account(AccountKind.A, debit_parent.id)
credit_parent = _account(AccountKind.P)
credit_child = _account(AccountKind.P, credit_parent.id)
by_id, children = matrix._account_tree([debit_parent, debit_child, credit_parent, credit_child])
debit = matrix._selected_accounts([debit_parent.id], [], "D", by_id, children)
credit = matrix._selected_accounts([credit_parent.id], [], "K", by_id, children)
assert debit == {debit_parent.id, debit_child.id}
assert credit == {credit_parent.id, credit_child.id}
company_a, company_b = uuid.uuid4(), uuid.uuid4()
result = matrix.calculate_matrix(
[
_row(company_a, company_b, debit_child.id, debit="100"),
_row(company_b, company_a, credit_child.id, credit="90"),
],
[
IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"),
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
],
matrix.MatrixSelection(debit, credit, "closing"),
)
assert [(company.id, company.name) for company in result.companies] == [
(company_a, "Компания А"),
(company_b, "Компания Б"),
]
assert result.cells[0].amount_a == Decimal("100")
assert result.cells[0].amount_b == Decimal("90")
assert result.cells[0].variance_percent == Decimal("11.11")
assert isinstance(result, IntercompanyMatrixOut)
response = result
assert response.model_dump(mode="json")["companies"] == [
{"id": str(company_a), "name": "Компания А"},
{"id": str(company_b), "name": "Компания Б"},
]
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()
companies = [
IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"),
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
]
selection = matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing")
debit = _row(company_a, company_b, debit_account)
credit = _row(company_b, company_a, credit_account)
debit.entry_ids = (uuid.uuid4(),)
credit.entry_ids = (uuid.uuid4(),)
for rows, expected in (([], False), ([debit], True), ([credit], True), ([debit, credit], False)):
cell = matrix.calculate_matrix(rows, companies, selection).cells[0]
assert cell.one_sided is expected
assert cell.model_dump(mode="json")["one_sided"] is expected
def test_unmapped_account_entries_are_counted_for_both_directions_of_pair() -> None:
company_a, company_b, outsider = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
account_id = uuid.uuid4()
missing_a = _row(company_a, company_b, None, debit="100")
missing_a.entry_ids = (uuid.uuid4(), uuid.uuid4())
missing_b = _row(company_b, company_a, None, credit="90")
missing_b.entry_ids = (uuid.uuid4(),)
outside = _row(company_a, outsider, None, debit="500")
outside.entry_ids = (uuid.uuid4(),)
matched = _row(company_a, company_b, account_id, debit="20")
matched.entry_ids = (uuid.uuid4(),)
companies = [
IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"),
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
]
result = matrix.calculate_matrix(
[missing_a, missing_b, outside, matched],
companies,
matrix.MatrixSelection(frozenset({account_id}), frozenset({account_id}), "closing"),
)
expected_skipped = len(missing_a.entry_ids) + len(missing_b.entry_ids)
assert [cell.skipped_osv_entry_count for cell in result.cells] == [expected_skipped, expected_skipped]
assert result.cells[0].model_dump(mode="json")["skipped_osv_entry_count"] == expected_skipped
assert result.cells[0].amount_a == Decimal("20")
assert result.cells[0].amount_b == Decimal("0")
@pytest.mark.parametrize(
("debit", "expected", "exceeds"),
[
("111.125", "11.13", True),
("111.124", "11.12", True),
("105", "5.00", False),
("105.004", "5.00", True),
("104.999", "5.00", False),
("100", "0.00", False),
],
)
def test_percent_rounding_preserves_threshold(debit: str, expected: str, exceeds: bool) -> None:
company_a, company_b, account = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
result = matrix.calculate_matrix(
[_row(company_a, company_b, account, debit=debit), _row(company_b, company_a, account, credit="100")],
[
IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"),
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
],
matrix.MatrixSelection(frozenset({account}), frozenset({account}), "closing"),
)
assert result.cells[0].variance_percent == Decimal(expected)
assert result.cells[0].model_dump(mode="json")["variance_percent"] == expected
assert result.cells[0].exceeds_threshold is exceeds
@pytest.mark.parametrize(
("value", "expected"),
[("0", "0.00"), ("1.005", "1.01"), ("-1.005", "-1.01"), ("3.873533480903112697872235289", "3.87")],
)
def test_cell_numbers_have_two_decimals(value: str, expected: str) -> None:
fields = ("amount_a", "amount_b", "variance_amount", "variance_percent")
cell = IntercompanyMatrixCellOut(
company_a_id=uuid.uuid4(),
company_b_id=uuid.uuid4(),
**dict.fromkeys(fields, value),
exceeds_threshold=False,
one_sided=False,
skipped_osv_entry_count=0,
osv_entry_ids_debit=[],
osv_entry_ids_credit=[],
)
assert {field: cell.model_dump(mode="json")[field] for field in fields} == dict.fromkeys(fields, expected)
def test_result_table_uses_fixed_articles_and_group_totals() -> None:
company_a, company_b, external = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
required_codes = {
code for article in matrix.RESULT_ARTICLES for code in (*article.account_codes, *article.excluded_codes)
}
kinds = {
code: AccountKind.A if article.side == "D" else AccountKind.P
for article in matrix.RESULT_ARTICLES
for code in article.account_codes
}
kinds.update({"76": AccountKind.AP, "76.07.1": AccountKind.P, "76.07.5": AccountKind.A, "76.07.9": AccountKind.A})
ids = {code: uuid.uuid4() for code in required_codes}
parents = {"67.03": "67", "66.03": "66", "66.04": "66", "76.07.1": "76", "76.07.5": "76", "76.07.9": "76"}
accounts = [
SimpleNamespace(id=ids[code], code=code, kind=kinds[code], parent_id=ids.get(parents.get(code)))
for code in required_codes
]
rows = [
_row(company_a, company_b, ids["60.02"], debit="100"),
_row(company_a, company_b, ids["76.07.5"], debit="50"),
_row(company_a, company_b, ids["76.07.1"], credit="20"),
_row(company_a, company_b, ids["58.01"], debit="30"),
_row(company_b, company_a, ids["60.01"], credit="70"),
_row(company_b, company_a, ids["67.03"], credit="40"),
_row(company_a, company_b, ids["66.04"], credit="10"),
_row(company_a, external, ids["60.02"], debit="999"),
]
result = matrix.calculate_result_table(
rows,
[
IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"),
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
],
accounts,
)
articles = {article.code: article for article in result.articles}
assert list(articles) == [article.code for article in matrix.RESULT_ARTICLES]
assert [item.amount for item in articles["receivables"].amounts] == [Decimal("100"), Decimal("0")]
assert articles["receivables"].group_total == Decimal("100")
assert articles["payables"].group_total == Decimal("70")
assert articles["financial_investments"].group_total == Decimal("30")
assert articles["long_term_loans"].group_total == Decimal("40")
assert articles["short_term_loans"].group_total == Decimal("10")
assert isinstance(result, IntercompanyResultTableOut)
response = result
assert response.articles[0].amounts[0].company_id == company_a

View File

@ -0,0 +1,231 @@
"""XLSX-выгрузка шахматки из готового результата расчёта."""
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import AsyncMock
from urllib.parse import unquote
from uuid import UUID
import pytest
from openpyxl import load_workbook
from src.api.endpoints.consolidations.intercompany_matrix import (
export_intercompany_matrix_all,
export_intercompany_matrix_manual,
router,
)
from src.models.enums import AuditAction, AuditSection, PeriodQuarter
from src.schemas.consolidations import (
IntercompanyMatrixCellOut,
IntercompanyMatrixCompanyOut,
IntercompanyMatrixManualIn,
IntercompanyMatrixOut,
)
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:
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 (),
)
def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold() -> None:
company_a = UUID("11111111-1111-1111-1111-111111111111")
company_b = UUID("22222222-2222-2222-2222-222222222222")
entry_id = UUID("33333333-3333-3333-3333-333333333333")
matrix = IntercompanyMatrixOut(
companies=[
IntercompanyMatrixCompanyOut(id=company_a, name="=Компания А"),
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
],
cells=[
IntercompanyMatrixCellOut(
company_a_id=company_a,
company_b_id=company_b,
amount_a="100.25",
amount_b="90.00",
variance_amount="10.25",
variance_percent="11.388888",
exceeds_threshold=True,
one_sided=True,
skipped_osv_entry_count=2,
osv_entry_ids_debit=[entry_id],
osv_entry_ids_credit=[],
),
IntercompanyMatrixCellOut(
company_a_id=company_b,
company_b_id=company_a,
amount_a="20",
amount_b="20",
variance_amount="0",
variance_percent="0",
exceeds_threshold=False,
one_sided=False,
skipped_osv_entry_count=0,
osv_entry_ids_debit=[],
osv_entry_ids_credit=[],
),
],
)
workbook = load_workbook(BytesIO(build_matrix_workbook(matrix, _info())))
sheet = workbook.active
assert sheet is not None
assert sheet.title == "Шахматка"
assert [cell.value for cell in sheet[10]] == ["Дебет ↓ / Кредит →", "Показатель", "=Компания А", "Компания Б"]
assert all(cell.alignment.horizontal == "center" and cell.alignment.vertical == "center" for cell in sheet[10])
assert sheet["C10"].data_type == "s"
assert sheet["A1"].value == "Шахматка внутригрупповых операций"
assert [(sheet.cell(row, 1).value, sheet.cell(row, 2).value) for row in range(2, 9)] == [
("ГК", 'ГК "Кавычки" / Бисквит'),
("Период", "2024-Q3"),
("Версия", "v2"),
("Единицы", "руб.; расхождение — %"),
("Режим", "Выбранные счета"),
("Счета дебета", "62.01"),
("Счета кредита", "60.01"),
]
assert sheet["A11"].value == "=Компания А"
assert sheet["A11"].data_type == "s"
assert sheet["A11"].alignment.horizontal == "center"
assert {str(merged) for merged in sheet.merged_cells.ranges} == {"A11:A14", "A15:A18"}
assert [sheet.cell(row, 2).value for row in range(11, 15)] == list(METRICS)
assert [sheet.cell(row, 2).value for row in range(15, 19)] == list(METRICS)
assert all(sheet.cell(row, 2).alignment.horizontal == "center" for row in range(11, 19))
assert sheet["A15"].value == "Компания Б"
assert [sheet.cell(row, 3).value for row in range(11, 15)] == [None] * 4
assert all(sheet.cell(row, 3).fill.fgColor.rgb == DIAGONAL_FILL.fgColor.rgb for row in range(11, 15))
assert all(sheet.cell(row, 4).fill.fgColor.rgb == DIAGONAL_FILL.fgColor.rgb for row in range(15, 19))
assert tuple(sheet.cell(row, 4).value for row in range(11, 15)) == (10.25, 100.25, 90, 11.39)
assert sheet["D14"].number_format == '0.00"%"'
assert all(sheet.cell(row, 4).fill.fgColor.rgb == THRESHOLD_FILL.fgColor.rgb for row in range(11, 15))
assert sheet["D11"].comment is not None
assert "2" in sheet["D11"].comment.text
assert all(
sheet.cell(row, column).comment is None
for row in range(11, 19)
for column in (3, 4)
if (row, column) != (11, 4)
)
assert [sheet.cell(row, 3).value for row in range(15, 19)] == [0, 20, 20, 0]
assert all(sheet.cell(row, 3).fill.fgColor.rgb != THRESHOLD_FILL.fgColor.rgb for row in range(15, 19))
assert sheet.freeze_panes == "C11"
for column, first_row in ((3, 11), (4, 11), (3, 15), (4, 15)):
top, middle, bottom = (sheet.cell(first_row + offset, column) for offset in (0, 1, 3))
assert top.border.top.style == "thin"
assert top.border.bottom.style is None
assert middle.border.top.style is None
assert middle.border.bottom.style is None
assert bottom.border.bottom.style == "thin"
assert all(
sheet.cell(first_row + offset, column).border.left.style == "thin"
and sheet.cell(first_row + offset, column).border.right.style == "thin"
for offset in range(4)
)
async def test_both_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="Компания А")
company_b = IntercompanyMatrixCompanyOut(id=account_id, name="Компания Б")
matrix = IntercompanyMatrixOut(
companies=[company_a, company_b],
cells=[
IntercompanyMatrixCellOut(
company_a_id=company_a.id,
company_b_id=company_b.id,
amount_a="0",
amount_b="0",
variance_amount="0",
variance_percent=None,
exceeds_threshold=False,
one_sided=False,
skipped_osv_entry_count=0,
osv_entry_ids_debit=[],
osv_entry_ids_credit=[],
)
],
)
calls = []
async def build_export(*args: object, **kwargs: object) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]:
calls.append((args, kwargs))
return matrix, _info(mode=kwargs["mode"])
service = SimpleNamespace(build_export=build_export)
audit = SimpleNamespace(record=AsyncMock())
selection = IntercompanyMatrixManualIn(debit_account_ids=[account_id], credit_account_ids=[account_id])
all_response = await export_intercompany_matrix_all(version_id, service, audit)
manual_response = await export_intercompany_matrix_manual(version_id, selection, service, audit)
assert calls == [
((version_id,), {"mode": "ALL"}),
((version_id,), {"mode": "MANUAL", "debit_account_ids": [account_id], "credit_account_ids": [account_id]}),
]
for response in (all_response, manual_response):
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
book = load_workbook(BytesIO(response.body))
assert book.active["B6"].value in {"Все счета", "Выбранные счета"}
assert [call.kwargs for call in audit.record.await_args_list] == [
{
"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": "ALL",
"debit_account_ids": [],
"credit_account_ids": [],
"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": "MANUAL",
"debit_account_ids": [str(account_id)],
"credit_account_ids": [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
async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.MonkeyPatch) -> None:
version_id = UUID("11111111-1111-1111-1111-111111111111")
service = SimpleNamespace(
build_export=AsyncMock(return_value=(IntercompanyMatrixOut(companies=[], cells=[]), _info()))
)
audit = SimpleNamespace(record=AsyncMock())
def fail_workbook(_matrix: IntercompanyMatrixOut, _info: MatrixExportInfo) -> bytes:
raise ValueError("xlsx failed")
monkeypatch.setattr("src.api.endpoints.consolidations.intercompany_matrix.build_matrix_workbook", fail_workbook)
with pytest.raises(ValueError, match="xlsx failed"):
await export_intercompany_matrix_all(version_id, service, audit)
audit.record.assert_not_awaited()

View File

@ -0,0 +1,100 @@
"""Точные пары «Русского бисквита» на 30.09.2024 из ОСВ и ручного эталона.
Решение владельца 08.10.2026: общий итог элиминации с книгой не сравниваем —
аналитик снимает полную сумму каждой стороны, система использует минимум пары.
Здесь явно выбраны только точные встречные строки. Для 62.01 это производство
молочной булочки (строка 21); аренда (строка 17) в эту пару не входит.
Тест проверяет расчёт выбранных пар, а не автоматический подбор строк из всей ОСВ.
"""
import json
import uuid
from decimal import ROUND_HALF_UP, Decimal
from pathlib import Path
from types import SimpleNamespace
import pytest
from openpyxl import load_workbook
from src.schemas.consolidations import IntercompanyMatrixCompanyOut
from src.services.intercompany_matrix import MatrixSelection, calculate_matrix
pytestmark = pytest.mark.fixtures
GOLDEN = Path("golden/expected_osv_parse")
RB = uuid.UUID("11111111-1111-1111-1111-111111111111")
BAKERY = uuid.UUID("22222222-2222-2222-2222-222222222222")
def _source(fixtures_dir: Path, folder: str, account: str, number: int) -> dict:
matches = [
row
for path in sorted((fixtures_dir / GOLDEN / folder / "2024-Q3").glob("*.json"))
for document in json.loads(path.read_text("utf-8"))["documents"]
if document["document_type"] == "OSV" and document["header"]["account_code_raw"] == account
for row in document["rows"]
if row["row_number"] == number and row["group_type"] == "COUNTERPARTY" and row["data_type"] == "BU"
]
assert len(matches) == 1, (folder, account, number)
return matches[0]
@pytest.mark.parametrize(
("debit_root", "debit_row", "credit_root", "credit_row", "amount", "golden_cells"),
[
("62", 21, "60", 409, "106311116.46", ("F51",)),
# F50 содержит 42 млн основного долга + 350 тыс. процентов; J29 — основной долг.
("76", 20, "66", 28, "350000.00", ("F50", "J29")),
],
)
def test_exact_pairs_against_russian_biscuit_golden( # noqa: PLR0913 - описание пары и ячеек эталона
fixtures_dir: Path,
*,
debit_root: str,
debit_row: int,
credit_root: str,
credit_row: int,
amount: str,
golden_cells: tuple[str, ...],
) -> None:
debit = _source(fixtures_dir, "schastl", debit_root, debit_row)
credit = _source(fixtures_dir, "rb", credit_root, credit_row)
assert debit["counterparty_inn"] == "3528052718"
assert "СЧАСТЛИВОЧНАЯ" in credit["counterparty_name"]
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
rows = [
SimpleNamespace(
legal_entity_id=owner,
counterparty_id=counterparty,
account_id=account_id,
closing_debit=Decimal(source["closing_debit"] or "0"),
closing_credit=Decimal(source["closing_credit"] or "0"),
entry_ids=(uuid.uuid4(),),
)
for owner, counterparty, account_id, source in (
(BAKERY, RB, debit_account, debit),
(RB, BAKERY, credit_account, credit),
)
]
result = calculate_matrix(
rows,
[
IntercompanyMatrixCompanyOut(id=BAKERY, name="Пекарня Счастливочная"),
IntercompanyMatrixCompanyOut(id=RB, name="Русский бисквит"),
],
MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing"),
)
cell = result.cells[0]
assert cell.amount_a == cell.amount_b == Decimal(amount)
assert cell.variance_amount == cell.variance_percent == Decimal(0)
assert cell.one_sided is False and cell.exceeds_threshold is False
with (fixtures_dir / "golden/Консолидация_Группа РБ.xlsx").open("rb") as source:
workbook = load_workbook(source, read_only=True, data_only=True)
try:
sheet = workbook["01.10.2024"]
expected = Decimal(str(sheet[golden_cells[0]].value))
for address in golden_cells[1:]:
expected -= Decimal(str(sheet[address].value))
finally:
workbook.close()
# В книге тысячи рублей с округлением до целого; входные ОСВ содержат рубли и копейки.
assert (cell.amount_a / 1000).quantize(Decimal("1"), rounding=ROUND_HALF_UP) == expected

View File

@ -20,6 +20,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
"/api/v1/me": {"get"},
# directories-api: филиалы, ГК, ГСК/ГСЗ, ЮЛ/ИП
f"{D}/regional-offices": {"get"},
f"{D}/accounts": {"get"},
f"{D}/company-groups": {"get", "post"},
f"{D}/company-groups/{{group_id}}": {"get", "patch"},
f"{D}/company-groups/{{group_id}}/archive": {"post"},
@ -72,6 +73,13 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
"/api/v1/consolidations/versions/{version_id}/osv/export": {"get"},
"/api/v1/consolidations/versions/{version_id}/counterparty-candidates": {"get"},
"/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty": {"post"},
# шахматка ВГО и правила элиминации
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"},
"/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/result-table": {"get"},
}
# Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-16

View File

@ -0,0 +1,50 @@
## Context
Реализация — PR RSHB/arm-core#114 (AURORA-1537). Шахматка — чистая функция от сводной ОСВ версии, периметра и выбранных
счетов; в БД не хранится (инвариант «производные консолидации не хранятся», DECISION-5).
## Decisions
1. Матрица считается поверх `ConsolidatedOsvService.build_with_members` (бывший приватный `_build`): строки свода и
участники периметра берутся из того же расчёта, что и сводная ОСВ; в `SourceRow`/`ConsolidatedRow` добавлено
`account_id`.
2. Пара `(A, B)`: сторона 1 — дебет ОСВ A по контрагенту B, сторона 2 — кредит ОСВ B по контрагенту A. Расчёт
поддерживает показатели `closing`/`turnover`, но в режимах ALL и MANUAL всегда используется конечное сальдо
(`closing`).
3. Режим ALL берёт все счета вида `A`/`AP` (дебет) и `P`/`AP` (кредит); MANUAL раскрывает выбранные счета по иерархии
`chart_of_account` и фильтрует по виду.
4. Порог расхождения 5 % проверяется по точному значению до округления; суммы в JSON — десятичные строки с двумя
знаками (`ROUND_HALF_UP`).
5. Строки пары без `account_id` в суммы не входят и считаются в `skipped_osv_entry_count`.
6. Результирующая таблица — пять фиксированных статей (`RESULT_ARTICLES`) со списками кодов счетов и исключений;
отсутствие любого кода в плане счетов — 422 `RESULT_ACCOUNTS_MISSING`.
7. xlsx: имя файла `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>.xlsx`; каждая выгрузка пишет
собственное событие `audit_event(EXPORT)`.
8. Сверка с эталоном «Русский бисквит» — только по точным парам счетов и компаний; общая сумма элиминации эталона не
приравнивается к минимуму по паре (решение владельца 08.10.2026).
## Known issues
Требования ниже описывают текущее поведение; дефекты вынесены в задачи Jira и в этом change не исправляются.
- AURORA-1651: `one_sided` считается по наличию исходных записей, а не по суммам и строкам-основаниям — пара «100 млн
против строки с нулевым сальдо» не подсвечивается, «0 против 0» подсвечивается ложно; подсветка таких пар в xlsx;
режим ALL захватывает забалансовые счета 001–012.
- AURORA-1652: `GET …/elimination-rules` пуст до задачи 1536, хотя требование описывает непустой ответ; формат сумм
результирующей таблицы `"0"` вместо `"0.00"` (расхождение с форматом матрицы); ссылки на §§ в docstring; ссылка README
на гайд фронта.
- AURORA-1653: переход «ячейка → сводная ОСВ» — фильтр по паре в `GET …/osv`; `osv_entry_ids_*` содержат ID нулевых
строк и не отсортированы.
## Decisions made without discussion
- 16.10.2026. Требования автора перенесены из `openspec/specs/*` в дельты `ADDED` дословно, без правки содержания.
Обоснование: владелец поручил оформить change процессно, а содержательные замечания ревью уже вынесены в задачи.
Цена ошибки: низкая — текст можно поправить в этом же change'е до архивации.
- 16.10.2026. Спека фиксирует текущее поведение, включая семантику `one_sided` и формат `"0"` результирующей таблицы;
исправление требований идёт вместе с кодом в AURORA-1651/1652/1653 (там же MODIFIED-дельты). Обоснование: иначе спека
разойдётся с кодом PR. Цена ошибки: до исправления спека описывает известные дефекты (отмечены в `## Known issues`).
- 16.10.2026. Все требования оформлены как `ADDED` в существующих capability, новая capability не заводится.
Обоснование: шахматка — отчёт по сводной ОСВ (consolidated-osv), API версий (consolidations-api), справочников и
аудита; отдельная capability дробила бы уже согласованную структуру. Цена ошибки: низкая, перенос между capability —
правка путей дельт.

View File

@ -0,0 +1,32 @@
## Why
Отчёт «Шахматка ВГО» (ТЗ §6): аналитик сверяет внутригрупповые обороты пар компаний периметра по строкам сводной ОСВ
версии, видит расхождения сверх порога, получает результирующую таблицу по пяти статьям и выгрузку в xlsx. Jira
AURORA-1537, реализация — PR RSHB/arm-core#114 (автор — Потапова). Требования автор дописала прямо в
`openspec/specs/`; при подготовке к слиянию они оформлены как дельты этого change'а (решение владельца 16.10.2026).
## What Changes
- Расчёт шахматки без хранения в БД (`GET …/intercompany-matrix/all`, `POST …/intercompany-matrix/manual`) по строкам
сводной ОСВ версии и её периметру; режимы ALL и MANUAL.
- Результирующая таблица ВГО из пяти фиксированных статей (`GET …/intercompany-matrix/result-table`).
- Выгрузка xlsx шахматки (`…/all/export`, `…/manual/export`) и аудит `EXPORT` каждой выгрузки.
- Правила элиминации версии (`GET …/elimination-rules`).
- Плоский список счетов для фильтров (`GET /api/v1/directories/accounts`).
- В сводную ОСВ добавлен `account_id` строки (внутреннее поле домена, в API свода не выходит).
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `consolidated-osv`: шахматка по сводной ОСВ и её xlsx.
- `consolidations-api`: правила элиминации версии, результирующая таблица ВГО.
- `directories-api`: плоский список счетов.
- `audit-trail`: аудит экспорта шахматки.
## Impact
Только `arm-core` (API, сервисы, схемы), таблицы и DBML не меняются. Код реализован и протестирован в PR #114; этот
change фиксирует требования к текущему поведению ветки, включая известные дефекты (см. `design.md`, `## Known issues`).

View File

@ -0,0 +1,13 @@
## ADDED Requirements
### Requirement: Аудит экспорта шахматки
Каждый успешный `GET …/intercompany-matrix/all/export` и `POST …/intercompany-matrix/manual/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}`. `mode` — `ALL` или `MANUAL`, массивы счетов — ID из запроса (в ALL пустые),
`cells` — число ячеек. Выгрузка сводной ОСВ продолжает писать собственное событие `EXPORT`.
#### Scenario: Две выгрузки
- **WHEN** пользователь дважды выгружает ту же шахматку
- **THEN** в `audit_event` две разные записи `EXPORT`, каждая со своим payload и числом ячеек

View File

@ -0,0 +1,49 @@
## ADDED Requirements
### Requirement: Шахматка по сводной ОСВ версии
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/all` и
`POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual`
(`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу без сохранения в БД, из строк сводной ОСВ версии
и её периметра. ALL включает все счета вида `A`/`AP` для дебета и `P`/`AP` для кредита. MANUAL принимает
обязательные непустые `debit_account_ids: UUID[]` и `credit_account_ids: UUID[]`, раскрывает выбранные счета
по иерархии и оставляет подходящий вид. Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный вид —
422 `ACCOUNT_KIND_MISMATCH`; пустой массив — 422 валидации тела. Невидимая и несуществующая версия SHALL
отвечать одинаковым 404 `NOT_FOUND`.
Ответ SHALL содержать `companies[] {id, name}` для всего периметра и `cells[]` для каждой упорядоченной пары
разных компаний: `company_a_id`, `company_b_id`, `amount_a`, `amount_b`, `variance_amount`, `variance_percent`,
`exceeds_threshold`, `one_sided`, `skipped_osv_entry_count`, `osv_entry_ids_debit`, `osv_entry_ids_credit`.
Для пары `(A, B)` `amount_a` — сумма `closing_debit` строк A с контрагентом B по дебетовым счетам,
`amount_b` — сумма `closing_credit` строк B с контрагентом A по кредитовым. Расхождение — модуль разности;
процент — расхождение / меньшую сумму × 100 или null при нуле. `exceeds_threshold` SHALL быть true при точном
проценте > 5, до округления; суммы и процент в JSON SHALL быть десятичными строками с двумя знаками после
запятой (`ROUND_HALF_UP`). `one_sided` SHALL быть true при наличии исходных записей ОСВ только с одной стороны,
в том числе при нулевой сумме. `skipped_osv_entry_count` SHALL считать исходные строки ОСВ пары с
`account_id = null` на обеих сторонах; они не входят в суммы. Массивы `osv_entry_ids_debit/credit` SHALL
содержать ID исходных записей соответствующей стороны.
#### Scenario: Пустой периметр и отсутствие ОСВ
- **WHEN** у версии нет членов периметра
- **THEN** `companies = []`, `cells = []`; если компании есть, но ОСВ по выбранным счетам нет, ячейки присутствуют с нулевыми суммами
#### Scenario: Только одна сторона пары
- **WHEN** у A есть строка ОСВ с контрагентом B, а у B встречной строки нет
- **THEN** для `(A, B)` `one_sided = true`, `variance_percent = null`, соответствующий массив `osv_entry_ids_*` пуст
#### Scenario: Счёт не сопоставлен
- **WHEN** у пары есть строка свода с `account_id = null` и двумя `entry_ids`
- **THEN** она не входит в суммы, `skipped_osv_entry_count` обеих направленных ячеек пары увеличивается на 2
#### Scenario: Эталон «Русский бисквит»
- **WHEN** матрица строится по ОСВ РБ и Пекарни Счастливочной из эталонных фикстур
- **THEN** суммы сравниваются с эталоном только для точных пар счетов и компаний; общая сумма элиминации эталона не приравнивается к минимуму по паре в API (решение владельца 08.10.2026)
### Requirement: XLSX шахматки
`GET …/intercompany-matrix/all/export` и `POST …/intercompany-matrix/manual/export` SHALL использовать
те же режимы, тело и права, что и соответствующие JSON-маршруты. Ответ — XLSX с именем
`intercompany-matrix_<ГК>_<год>-<квартал>_v<n>.xlsx`, шапкой (ГК, период, версия, единицы, режим,
выбранные счета) и строками разницы, дебета, кредита и процента расхождения для каждой пары.
#### Scenario: Экспорт выбранных счетов
- **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1
- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования

View File

@ -0,0 +1,29 @@
## ADDED Requirements
### Requirement: Правила элиминации версии
`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК)
SHALL возвращать все правила версии массивом. Каждое правило SHALL содержать `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`
### Requirement: Результирующая таблица ВГО
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table`
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра
по имени; статья содержит `code`, `name`, `amounts[] {company_id, amount}` и `group_total`.
Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения,
долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра
из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям.
Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
#### Scenario: Компания без ОСВ
- **WHEN** в периметре есть компания без подходящих строк ОСВ
- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0"`
#### Scenario: Нет обязательного счёта
- **WHEN** в плане счетов отсутствует счёт 58.03
- **THEN** ответ 422 `RESULT_ACCOUNTS_MISSING`, сообщение содержит `58.03`

View File

@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Плоский список счетов для фильтров шахматки
`GET /api/v1/directories/accounts` SHALL возвращать пользователю с `DIRECTORY_VIEW` все строки
`chart_of_account` одним массивом в естественном порядке кодов. Каждая строка SHALL содержать `id`, `code`,
`parent_id`, `name`, `kind` (`A`/`P`/`AP`/null), `row_type`, `is_off_balance`, `is_debit`, `is_credit`;
корневые счета имеют `parent_id = null`. `is_debit = true` для `A` и `AP`, `is_credit = true` для `P` и `AP`;
при `kind = null` оба признака ложны.
#### Scenario: Счета разных уровней
- **WHEN** в плане счетов есть счёт, субсчёт и аналитика
- **THEN** ответ содержит все три строки без вложенности, а `parent_id` указывает на родительскую строку
#### Scenario: Нет права на справочник
- **WHEN** пользователь без `DIRECTORY_VIEW` запрашивает список счетов
- **THEN** ответ 403

View File

@ -0,0 +1,4 @@
## 1. Реализация
Реализация уже выполнена автором в PR RSHB/arm-core#114 (AURORA-1537); задач плана нет. Доработки по замечаниям ревью —
AURORA-1651, AURORA-1652, AURORA-1653.