правки по код ревью
This commit is contained in:
parent
1ebd5b9997
commit
f9f25e8245
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 | правка версии без своей активной блокировки |
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
"""Расчёт шахматки по всем или выбранным счетам."""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
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")
|
||||
@ -27,16 +30,41 @@ EXPORT_RESPONSES = {
|
||||
}
|
||||
|
||||
|
||||
async def _xlsx_response(matrix: IntercompanyMatrixOut) -> Response:
|
||||
content = await run_in_threadpool(build_matrix_workbook, matrix)
|
||||
filename = "intercompany-matrix.xlsx"
|
||||
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(filename, ascii_fallback=filename)},
|
||||
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,
|
||||
@ -62,9 +90,10 @@ async def build_intercompany_matrix_all(
|
||||
async def export_intercompany_matrix_all(
|
||||
version_id: uuid.UUID,
|
||||
service: IntercompanyMatrixServiceDep,
|
||||
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
||||
) -> Response:
|
||||
matrix = await service.build_accounts(version_id, mode="ALL")
|
||||
return await _xlsx_response(matrix)
|
||||
matrix, info = await service.build_export(version_id, mode="ALL")
|
||||
return await _export_response(version_id, matrix, info, audit)
|
||||
|
||||
|
||||
@router.post(
|
||||
@ -99,14 +128,21 @@ async def export_intercompany_matrix_manual(
|
||||
version_id: uuid.UUID,
|
||||
selection: IntercompanyMatrixManualIn,
|
||||
service: IntercompanyMatrixServiceDep,
|
||||
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
||||
) -> Response:
|
||||
matrix = await service.build_accounts(
|
||||
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 _xlsx_response(matrix)
|
||||
return await _export_response(
|
||||
version_id,
|
||||
matrix,
|
||||
info,
|
||||
audit,
|
||||
selection=selection,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
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
|
||||
@ -343,9 +343,20 @@ class IntercompanyMatrixCellOut(BaseModel):
|
||||
amount_b: Decimal
|
||||
variance_amount: Decimal
|
||||
variance_percent: Decimal | None
|
||||
is_more_5_percent: bool
|
||||
entry_osv_ids_debit: list[uuid.UUID]
|
||||
entry_osv_ids_credit: list[uuid.UUID]
|
||||
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):
|
||||
|
||||
@ -138,6 +138,13 @@ class ConsolidatedOsvService:
|
||||
|
||||
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)
|
||||
@ -167,7 +174,7 @@ 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)."""
|
||||
|
||||
@ -11,11 +11,13 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.common.exceptions import DomainValidationError
|
||||
from src.domain.consolidated_osv import ConsolidatedRow
|
||||
from src.domain.consolidated_osv import ConsolidatedRow, Entity
|
||||
from src.domain.policy import VisibilityScope
|
||||
from src.models.accounting import ChartOfAccount
|
||||
from src.models.enums import AccountKind
|
||||
from src.repositories import consolidations as repo
|
||||
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,
|
||||
@ -73,6 +75,21 @@ class MatrixSelection:
|
||||
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
|
||||
@ -99,13 +116,17 @@ def calculate_matrix(
|
||||
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 or row.account_id is None:
|
||||
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")
|
||||
@ -129,7 +150,9 @@ def calculate_matrix(
|
||||
variance_amount = abs(amount_a - amount_b)
|
||||
base = min(amount_a, amount_b)
|
||||
variance_percent = None if base == ZERO else variance_amount / base * 100
|
||||
is_more_5_percent = variance_percent is not None and variance_percent > VARIANCE_THRESHOLD_PERCENT
|
||||
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,
|
||||
@ -138,9 +161,12 @@ def calculate_matrix(
|
||||
amount_b=amount_b,
|
||||
variance_amount=variance_amount,
|
||||
variance_percent=variance_percent,
|
||||
is_more_5_percent=is_more_5_percent,
|
||||
entry_osv_ids_debit=item_a["entry_osv_ids"] if item_a is not None else (),
|
||||
entry_osv_ids_credit=item_b["entry_osv_ids"] if item_b is not None else (),
|
||||
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)
|
||||
@ -262,23 +288,21 @@ class IntercompanyMatrixService:
|
||||
selection: MatrixSelection,
|
||||
) -> IntercompanyMatrixOut:
|
||||
"""Счета принимает готовыми; выбор режима ALL/MANUAL выполняется вызывающим кодом."""
|
||||
consolidated, version, _ = await self._osv_service.build(version_id)
|
||||
return await self._calculate(consolidated.rows, version.id, selection)
|
||||
consolidated, _, _, members = await self._osv_service.build_with_members(version_id)
|
||||
return self._calculate(consolidated.rows, members, selection)
|
||||
|
||||
async def _calculate(
|
||||
self, rows: Iterable[ConsolidatedRow], version_id: uuid.UUID, selection: MatrixSelection
|
||||
def _calculate(
|
||||
self, rows: Iterable[ConsolidatedRow], members: dict[uuid.UUID, Entity], selection: MatrixSelection
|
||||
) -> IntercompanyMatrixOut:
|
||||
"""Общий вызов расчёта после проверки видимости версии в сервисе сводной ОСВ."""
|
||||
members = await repo.members(self._session, version_id)
|
||||
companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for _, entity in members]
|
||||
"""Общий вызов расчёта с участниками из уже построенного свода."""
|
||||
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, version, _ = await self._osv_service.build(version_id)
|
||||
members = await repo.members(self._session, version.id)
|
||||
accounts = list((await self._session.scalars(select(ChartOfAccount))).all())
|
||||
companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for _, entity in members]
|
||||
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(
|
||||
@ -290,8 +314,50 @@ class IntercompanyMatrixService:
|
||||
credit_account_ids: Sequence[uuid.UUID] = (),
|
||||
) -> IntercompanyMatrixOut:
|
||||
"""ALL использует все счета нужного вида; MANUAL раскрывает выбранные счета и потомков."""
|
||||
consolidated, version, _ = await self._osv_service.build(version_id)
|
||||
accounts = list((await self._session.scalars(select(ChartOfAccount))).all())
|
||||
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:
|
||||
@ -306,4 +372,5 @@ class IntercompanyMatrixService:
|
||||
else:
|
||||
raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов")
|
||||
selection = MatrixSelection(debit, credit, "closing")
|
||||
return await self._calculate(consolidated.rows, version.id, selection)
|
||||
matrix = self._calculate(consolidated.rows, members, selection)
|
||||
return matrix, version, consolidation, accounts
|
||||
|
||||
@ -2,14 +2,19 @@ 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 IntercompanyMatrixOut
|
||||
from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut
|
||||
from src.services.intercompany_matrix import MatrixExportInfo
|
||||
from src.services.osv_export import MONEY_FORMAT
|
||||
|
||||
METRICS = ("Разница", "Дебет", "Кредит")
|
||||
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")
|
||||
@ -23,28 +28,59 @@ def _text(sheet: Worksheet, row: int, column: int, value: str) -> None:
|
||||
cell.data_type = "s"
|
||||
|
||||
|
||||
def build_matrix_workbook(matrix: IntercompanyMatrixOut) -> bytes:
|
||||
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 = "Шахматка"
|
||||
_text(sheet, 1, 1, "Дебет ↓ / Кредит →")
|
||||
_text(sheet, 1, 2, "Показатель")
|
||||
_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 = 18
|
||||
sheet.column_dimensions["B"].width = 40
|
||||
|
||||
for column, company in enumerate(matrix.companies, start=3):
|
||||
_text(sheet, 1, column, company.name)
|
||||
_text(sheet, TABLE_HEADER_ROW, column, company.name)
|
||||
sheet.column_dimensions[get_column_letter(column)].width = 26
|
||||
sheet.row_dimensions[1].height = 36
|
||||
for header in sheet[1]:
|
||||
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 = 2 + company_index * len(METRICS)
|
||||
start_row = DATA_START_ROW + company_index * len(METRICS)
|
||||
end_row = start_row + len(METRICS) - 1
|
||||
sheet.merge_cells(start_row=start_row, start_column=1, end_row=end_row, end_column=1)
|
||||
_text(sheet, start_row, 1, debit_company.name)
|
||||
@ -68,15 +104,14 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut) -> bytes:
|
||||
continue
|
||||
if cell is None:
|
||||
continue
|
||||
for offset, amount in enumerate((cell.variance_amount, cell.amount_a, cell.amount_b)):
|
||||
sheet.cell(start_row + offset, column, amount).number_format = MONEY_FORMAT
|
||||
if cell.is_more_5_percent:
|
||||
_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 = "C2"
|
||||
sheet.freeze_panes = f"C{DATA_START_ROW}"
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
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": []}),
|
||||
}
|
||||
@ -4,9 +4,16 @@ 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 IntercompanyMatrixCompanyOut, IntercompanyMatrixOut, IntercompanyResultTableOut
|
||||
from src.schemas.consolidations import (
|
||||
IntercompanyMatrixCellOut,
|
||||
IntercompanyMatrixCompanyOut,
|
||||
IntercompanyMatrixOut,
|
||||
IntercompanyResultTableOut,
|
||||
)
|
||||
from src.services import intercompany_matrix as matrix
|
||||
|
||||
|
||||
@ -17,7 +24,7 @@ def _account(kind: AccountKind, parent_id: uuid.UUID | None = None) -> SimpleNam
|
||||
def _row(
|
||||
owner: uuid.UUID,
|
||||
counterparty: uuid.UUID,
|
||||
account_id: uuid.UUID,
|
||||
account_id: uuid.UUID | None,
|
||||
*,
|
||||
debit: str = "0",
|
||||
credit: str = "0",
|
||||
@ -39,7 +46,6 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None:
|
||||
assert ("/consolidations/versions/{version_id}/intercompany-matrix/all", ("GET",)) in paths
|
||||
assert ("/consolidations/versions/{version_id}/intercompany-matrix/manual", ("POST",)) in paths
|
||||
assert ("/consolidations/versions/{version_id}/intercompany-matrix/result-table", ("GET",)) in paths
|
||||
assert not any("/intercompany-matrix/presets/" in path for path, _ in paths)
|
||||
|
||||
debit_parent = _account(AccountKind.A)
|
||||
debit_child = _account(AccountKind.A, debit_parent.id)
|
||||
@ -69,16 +75,106 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None:
|
||||
]
|
||||
assert result.cells[0].amount_a == Decimal("100")
|
||||
assert result.cells[0].amount_b == Decimal("90")
|
||||
assert result.cells[0].variance_percent == Decimal("100") / Decimal("9")
|
||||
assert result.cells[0].variance_percent == Decimal("11.11")
|
||||
assert isinstance(result, IntercompanyMatrixOut)
|
||||
response = result
|
||||
assert "exceeds_threshold" not in response.model_dump(mode="json")["cells"][0]
|
||||
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 = {
|
||||
|
||||
@ -2,8 +2,11 @@
|
||||
|
||||
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 (
|
||||
@ -11,15 +14,29 @@ from src.api.endpoints.consolidations.intercompany_matrix import (
|
||||
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")
|
||||
@ -37,9 +54,11 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold
|
||||
amount_b="90.00",
|
||||
variance_amount="10.25",
|
||||
variance_percent="11.388888",
|
||||
is_more_5_percent=True,
|
||||
entry_osv_ids_debit=[entry_id],
|
||||
entry_osv_ids_credit=[],
|
||||
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,
|
||||
@ -48,64 +67,106 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold
|
||||
amount_b="20",
|
||||
variance_amount="0",
|
||||
variance_percent="0",
|
||||
is_more_5_percent=False,
|
||||
entry_osv_ids_debit=[],
|
||||
entry_osv_ids_credit=[],
|
||||
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)))
|
||||
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[1]] == ["Дебет ↓ / Кредит →", "Показатель", "=Компания А", "Компания Б"]
|
||||
assert all(cell.alignment.horizontal == "center" and cell.alignment.vertical == "center" for cell in sheet[1])
|
||||
assert sheet["C1"].data_type == "s"
|
||||
assert sheet["A2"].value == "=Компания А"
|
||||
assert sheet["A2"].data_type == "s"
|
||||
assert sheet["A2"].alignment.horizontal == "center"
|
||||
assert {str(merged) for merged in sheet.merged_cells.ranges} == {"A2:A4", "A5:A7"}
|
||||
assert [sheet.cell(row, 2).value for row in (2, 3, 4)] == list(METRICS)
|
||||
assert [sheet.cell(row, 2).value for row in (5, 6, 7)] == list(METRICS)
|
||||
assert all(sheet.cell(row, 2).alignment.horizontal == "center" for row in (2, 3, 4, 5, 6, 7))
|
||||
assert sheet["A5"].value == "Компания Б"
|
||||
assert [sheet.cell(row, 3).value for row in (2, 3, 4)] == [None, None, None]
|
||||
assert all(sheet.cell(row, 3).fill.fgColor.rgb == DIAGONAL_FILL.fgColor.rgb for row in (2, 3, 4))
|
||||
assert all(sheet.cell(row, 4).fill.fgColor.rgb == DIAGONAL_FILL.fgColor.rgb for row in (5, 6, 7))
|
||||
expected_ab = (10.25, 100.25, 90)
|
||||
assert tuple(sheet.cell(row, 4).value for row in (2, 3, 4)) == expected_ab
|
||||
assert all(sheet.cell(row, 4).fill.fgColor.rgb == THRESHOLD_FILL.fgColor.rgb for row in (2, 3, 4))
|
||||
assert all(sheet.cell(row, column).comment is None for row in range(2, 8) for column in (3, 4))
|
||||
assert [sheet.cell(row, 3).value for row in (5, 6, 7)] == [0, 20, 20]
|
||||
assert all(sheet.cell(row, 3).fill.fgColor.rgb != THRESHOLD_FILL.fgColor.rgb for row in (5, 6, 7))
|
||||
for column, first_row in ((3, 2), (4, 2), (3, 5), (4, 5)):
|
||||
top, middle, bottom = (sheet.cell(first_row + offset, column) for offset in range(3))
|
||||
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(
|
||||
cell.border.left.style == "thin" and cell.border.right.style == "thin" for cell in (top, middle, bottom)
|
||||
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")
|
||||
matrix = IntercompanyMatrixOut(companies=[], cells=[])
|
||||
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_accounts(*args: object, **kwargs: object) -> IntercompanyMatrixOut:
|
||||
async def build_export(*args: object, **kwargs: object) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]:
|
||||
calls.append((args, kwargs))
|
||||
return matrix
|
||||
return matrix, _info(mode=kwargs["mode"])
|
||||
|
||||
service = SimpleNamespace(build_accounts=build_accounts)
|
||||
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)
|
||||
manual_response = await export_intercompany_matrix_manual(version_id, selection, service)
|
||||
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"}),
|
||||
@ -113,9 +174,58 @@ async def test_both_export_routes_use_matrix_calculation() -> None:
|
||||
]
|
||||
for response in (all_response, manual_response):
|
||||
assert response.media_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
assert "attachment" in response.headers["content-disposition"]
|
||||
assert load_workbook(BytesIO(response.body)).active is not None
|
||||
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
|
||||
@ -145,3 +145,15 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD
|
||||
#### Scenario: Автоматический переход
|
||||
- **WHEN** подтверждение документа перевело версию в `DATA_CORRECTION`
|
||||
- **THEN** событие `CONSOLIDATION_STATUS_CHANGE` с `user_id` подтвердившего, `before_value.status = INITIATED`
|
||||
|
||||
### 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 и числом ячеек
|
||||
|
||||
@ -162,3 +162,51 @@ filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<кварт
|
||||
- **WHEN** экспорт версии с 3 строками и 1 пробелом открыт `openpyxl`
|
||||
- **THEN** лист «Сводная ОСВ» содержит заголовок и 3 строки, суммы — числа с двумя знаками, равные API; лист «Пробелы»
|
||||
— одну строку с `Q2`; `audit_event(EXPORT)` с `after_value.rows = 3`
|
||||
|
||||
### 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`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования
|
||||
|
||||
@ -223,3 +223,31 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account`
|
||||
#### Scenario: Невидимая версия при занятой строке
|
||||
- **WHEN** версию чужого филиала держит `FOR UPDATE` другая транзакция, пользователь без видимости вызывает `POST …/lock`
|
||||
- **THEN** 404 `VERSION_NOT_FOUND` без ожидания
|
||||
|
||||
### 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`
|
||||
|
||||
@ -16,17 +16,6 @@ API справочников ядра: группы компаний (ГК), ю
|
||||
- **WHEN** пользователь с `DIRECTORY_VIEW` запрашивает список после `seed --dev`
|
||||
- **THEN** ответ 200 содержит Головной офис с `is_head_office = true` и два dev-филиала
|
||||
|
||||
### Requirement: Плоский список счетов
|
||||
`GET /api/v1/directories/accounts` SHALL возвращать пользователю с правом `DIRECTORY_VIEW` все строки
|
||||
`chart_of_account` одним массивом в естественном порядке кодов. Каждая строка содержит `id`, `code`,
|
||||
`parent_id`, `name`, `kind`, `row_type`, `is_off_balance`, `is_debit`, `is_credit`; корневые счета имеют `parent_id = null`.
|
||||
`is_debit = true` для видов `A` и `AP`, `false` для `P` и незаданного вида.
|
||||
`is_credit = true` для видов `P` и `AP`, `false` для `A` и незаданного вида.
|
||||
|
||||
#### Scenario: Счета разных уровней
|
||||
- **WHEN** в плане счетов есть счёт, субсчёт и аналитика
|
||||
- **THEN** ответ содержит все три строки без вложенности, а `parent_id` указывает на родительскую строку
|
||||
|
||||
### Requirement: Группа компаний — создание, чтение, изменение, архив, история
|
||||
`POST /api/v1/directories/company-groups` (право `DIRECTORY_EDIT`) SHALL создавать ГК с полями `name` (обязательно),
|
||||
`crm_id` (необязательно, уникально), `base_regional_office_id` (необязательно), генерируя `code` вида `GK-NNNNNN` и
|
||||
@ -200,3 +189,18 @@ SHALL выполняться в одной транзакции с запись
|
||||
#### Scenario: Ошибка внутри мутации
|
||||
- **WHEN** при переводе ЮЛ закрытие членства завершается ошибкой БД
|
||||
- **THEN** ЮЛ остаётся в прежней ГК, строк истории и аудита нет
|
||||
|
||||
### 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user