add-report-shahmatka #114
17
README.md
17
README.md
@ -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 | правка версии без своей активной блокировки |
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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)
|
||||
|
||||
33
api/src/api/endpoints/consolidations/elimination_rule.py
Normal file
33
api/src/api/endpoints/consolidations/elimination_rule.py
Normal 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)
|
||||
160
api/src/api/endpoints/consolidations/intercompany_matrix.py
Normal file
160
api/src/api/endpoints/consolidations/intercompany_matrix.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
21
api/src/api/endpoints/directories/accounts.py
Normal file
21
api/src/api/endpoints/directories/accounts.py
Normal 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)]
|
||||
@ -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,
|
||||
|
||||
@ -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}_")
|
||||
|
||||
53
api/src/repositories/elimination_rules.py
Normal file
53
api/src/repositories/elimination_rules.py
Normal 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()]
|
||||
@ -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]
|
||||
|
||||
@ -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` назначает сервис."""
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
51
api/src/services/elimination_rules.py
Normal file
51
api/src/services/elimination_rules.py
Normal 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())
|
||||
376
api/src/services/intercompany_matrix.py
Normal file
376
api/src/services/intercompany_matrix.py
Normal 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
|
||||
117
api/src/services/intercompany_matrix_export.py
Normal file
117
api/src/services/intercompany_matrix_export.py
Normal 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()
|
||||
52
api/tests/test_api_accounts.py
Normal file
52
api/tests/test_api_accounts.py
Normal 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]
|
||||
288
api/tests/test_api_intercompany_matrix.py
Normal file
288
api/tests/test_api_intercompany_matrix.py
Normal 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": []}),
|
||||
}
|
||||
223
api/tests/test_intercompany_matrix.py
Normal file
223
api/tests/test_intercompany_matrix.py
Normal 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
|
||||
231
api/tests/test_intercompany_matrix_export.py
Normal file
231
api/tests/test_intercompany_matrix_export.py
Normal 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()
|
||||
100
api/tests/test_intercompany_matrix_golden.py
Normal file
100
api/tests/test_intercompany_matrix_golden.py
Normal 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
|
||||
@ -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 быть не должно
|
||||
|
||||
2
openspec/changes/intercompany-matrix/.openspec.yaml
Normal file
2
openspec/changes/intercompany-matrix/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-16
|
||||
50
openspec/changes/intercompany-matrix/design.md
Normal file
50
openspec/changes/intercompany-matrix/design.md
Normal 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 —
|
||||
правка путей дельт.
|
||||
32
openspec/changes/intercompany-matrix/proposal.md
Normal file
32
openspec/changes/intercompany-matrix/proposal.md
Normal 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`).
|
||||
@ -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 и числом ячеек
|
||||
@ -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`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования
|
||||
@ -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`
|
||||
@ -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
|
||||
4
openspec/changes/intercompany-matrix/tasks.md
Normal file
4
openspec/changes/intercompany-matrix/tasks.md
Normal file
@ -0,0 +1,4 @@
|
||||
## 1. Реализация
|
||||
|
||||
Реализация уже выполнена автором в PR RSHB/arm-core#114 (AURORA-1537); задач плана нет. Доработки по замечаниям ревью —
|
||||
AURORA-1651, AURORA-1652, AURORA-1653.
|
||||
Loading…
x
Reference in New Issue
Block a user