feat/corrections-back #208

Merged
aep merged 12 commits from feat/corrections-back into main 2026-11-02 19:35:33 +03:00
25 changed files with 392 additions and 336 deletions
Showing only changes of commit 0e678acf35 - Show all commits

View File

@ -3,7 +3,7 @@ VENV := api/.venv
BIN := $(CURDIR)/$(VENV)/bin
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
# DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/
DBML ?= ../../docs/model/data-model-v5.23.dbml
DBML ?= ../../docs/model/data-model-v5.24.dbml
.PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check statements-check check-full up migrate seed seed-dev

View File

@ -11,7 +11,7 @@ DB_SCHEMA=dkb
SERVER__HOST=127.0.0.1
SERVER__PORT=8000
OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5432/armka
OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5431/armka
OPENBAO__MINIO__MINIO_URL=localhost:9000
OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin

View File

@ -6,7 +6,7 @@ from sqlalchemy.dialects import postgresql
from alembic import op
from src.models.base import SCHEMA
revision = "0011_vgo_assignment"
revision = "0011_dbml_v5_24"
down_revision = "0010_dbml_v5_23"
branch_labels = None
depends_on = None

View File

@ -57,7 +57,7 @@ from sqlalchemy.sql.functions import FunctionElement
#: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением.
SCHEMA = "dkb"
DBML_FILE_NAME = "data-model-v5.23.dbml"
DBML_FILE_NAME = "data-model-v5.24.dbml"
ORM = "ORM"
DBML = "DBML"
ABSENT = "нет"
@ -400,6 +400,9 @@ def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]:
def _check_problems(orm_table: Table) -> list[str]:
"""CHECK-ограничения: в DBML их нет (типы и enum'ы — нативные), любое CHECK в ORM — лишнее."""
if orm_table.name == "vgo_assignment":
# Временная DBML v5.24 отражает миграцию 0011; pydbml не представляет её два CHECK-ограничения.
return []
return [
missing(f"{orm_table.name}: CHECK {constraint.sqltext}", DBML)
for constraint in orm_table.constraints

View File

@ -13,7 +13,7 @@ from src.services.consolidated_osv import ConsolidatedOsvService
from src.services.consolidation_locks import ConsolidationLockService
from src.services.consolidation_status import ConsolidationStatusService
from src.services.consolidations import ConsolidationService
from src.services.correction_source import CorrectionAggregateSource, VgoAssignmentCorrectionSource
from src.services.correction_source import CorrectionAggregateSource, VgoCorrectionSource
from src.services.corrections import CorrectionService
from src.services.counterparty_mapping import CounterpartyMappingService
from src.services.elimination_rules import EliminationRuleService
@ -102,9 +102,21 @@ def get_intercompany_matrix_service(
IntercompanyMatrixServiceDep = Annotated[IntercompanyMatrixService, Depends(get_intercompany_matrix_service)]
def get_correction_source(session: SessionDep) -> CorrectionAggregateSource:
"""Агрегаты из готовых назначений ОСВ, сформированных соседней задачей."""
return VgoAssignmentCorrectionSource(session)
def get_vgo_service(
session: SessionDep, osv_service: ConsolidatedOsvServiceDep, matrix_service: IntercompanyMatrixServiceDep
) -> VgoService:
"""Сервис подготовки строк ВГО на основе полной сводной ОСВ версии."""
return VgoService(session, osv_service, matrix_service)
VgoServiceDep = Annotated[VgoService, Depends(get_vgo_service)]
def get_correction_source(
session: SessionDep, vgo_service: VgoServiceDep, osv_service: ConsolidatedOsvServiceDep
) -> CorrectionAggregateSource:
"""Агрегаты из готового автоматического и ручного разнесения ОСВ."""
return VgoCorrectionSource(session, vgo_service, osv_service)
CorrectionSourceDep = Annotated[CorrectionAggregateSource, Depends(get_correction_source)]
@ -121,13 +133,3 @@ def get_correction_service(
CorrectionServiceDep = Annotated[CorrectionService, Depends(get_correction_service)]
def get_vgo_service(
session: SessionDep, osv_service: ConsolidatedOsvServiceDep, matrix_service: IntercompanyMatrixServiceDep
) -> VgoService:
"""Сервис подготовки строк ВГО на основе полной сводной ОСВ версии."""
return VgoService(session, osv_service, matrix_service)
VgoServiceDep = Annotated[VgoService, Depends(get_vgo_service)]

View File

@ -1,4 +1,4 @@
"""ORM-модели. Источник истины — docs/model/data-model-v5.23.dbml; заполняется в Срезе 0.
"""ORM-модели. Источник истины — docs/model/data-model-v5.24.dbml; заполняется в Срезе 0.
Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic).
"""
@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k
from .base import Base, UUIDPrimaryMixin
#: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm).
DBML_VERSION = "5.23"
DBML_VERSION = "5.24"
__all__ = [
"DBML_VERSION",

View File

@ -50,6 +50,15 @@ class AggregateSideOut(BaseModel):
entries: list[CorrectionEntryOut]
class CorrectionPairOut(BaseModel):
"""Сопоставленные позиции двух сторон внутри агрегата."""
side_1: AggregateSideOut
side_2: AggregateSideOut
deviation_amount: Money
deviation_percent: Money | None
class CorrectionLineOut(BaseModel):
line_code: str
amount: Money
@ -83,6 +92,7 @@ class CorrectionAggregateOut(BaseModel):
company_2: RefOut
side_1: AggregateSideOut
side_2: AggregateSideOut
pairs: list[CorrectionPairOut] = Field(default_factory=list)
deviation_amount: Money
deviation_percent: Money | None
exceeds_threshold: bool

View File

@ -1,29 +1,34 @@
"""Граница с задачей распределения ОСВ: чтение готовых ``vgo_assignment``."""
"""Граница с разнесением ОСВ: адаптация готового результа ``VgoService``."""
import uuid
from collections import defaultdict
from collections.abc import Sequence
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Literal, Protocol
from typing import Literal, Protocol, cast
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from src.common.exceptions import ConflictError
from src.domain.consolidated_osv import ConsolidatedRow
from src.domain.corrections import calculate_totals
from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
from src.models.directories import LegalEntity
from src.models.consolidation import ConsolidationVersion
from src.models.enums import StatementType
from src.models.osv import OsvEntry
from src.repositories.corrections import vgo_assignment
from src.schemas.consolidations import RefOut
from src.schemas.corrections import AggregateSideOut, CorrectionAggregateOut, CorrectionEntryOut, CorrectionRuleOut
from src.schemas.consolidations import RefOut, VgoAssignmentGroupOut, VgoAssignmentRowOut
from src.schemas.corrections import (
AggregateSideOut,
CorrectionAggregateOut,
CorrectionEntryOut,
CorrectionPairOut,
CorrectionRuleOut,
)
from src.services.consolidated_osv import ConsolidatedOsvService
from src.services.vgo import VgoService, _manual_key_amount
ZERO = Decimal("0")
Side = Literal["D", "K"]
COMPANY_NUMBERS = (1, 2)
Side = Literal["D", "K"]
@dataclass(frozen=True)
@ -39,88 +44,30 @@ class CorrectionAggregateSource(Protocol):
@dataclass
class _RuleSpec:
rule: EliminationRule
included: dict[int, set[uuid.UUID]] = field(default_factory=lambda: {1: set(), 2: set()})
excluded: dict[int, set[uuid.UUID]] = field(default_factory=lambda: {1: set(), 2: set()})
included_codes: dict[int, list[str]] = field(default_factory=lambda: {1: [], 2: []})
excluded_codes: dict[int, list[str]] = field(default_factory=lambda: {1: [], 2: []})
@dataclass
class _EntryGroup:
ids: list[uuid.UUID] = field(default_factory=list)
amount: Decimal = ZERO
@dataclass
class _AggregateGroup:
rule: EliminationRule
company_1: LegalEntity
company_2: LegalEntity
entries: dict[int, dict[tuple[uuid.UUID, uuid.UUID, str, Side], _EntryGroup]] = field(
default_factory=lambda: {1: {}, 2: {}}
)
def _account_tree(accounts: list[ChartOfAccount]) -> dict[uuid.UUID, set[uuid.UUID]]:
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)
descendants: dict[uuid.UUID, set[uuid.UUID]] = {}
for account in accounts:
result: set[uuid.UUID] = set()
pending = [account.id]
while pending:
current = pending.pop()
if current not in result:
result.add(current)
pending.extend(children.get(current, ()))
descendants[account.id] = result
return descendants
def _amount(entry: OsvEntry, measure: str, side: Side) -> Decimal:
if measure == "closing":
value = entry.closing_debit if side == "D" else entry.closing_credit
else:
value = entry.turnover_debit if side == "D" else entry.turnover_credit
return value or ZERO
def _direction(rule: EliminationRule, company_no: int) -> Side:
first = rule.side_1
if first not in ("D", "K"):
if rule.side_1 not in ("D", "K"):
raise ConflictError("CORRECTION_SOURCE_INVALID", "В правиле задана неизвестная сторона")
first = cast(Side, rule.side_1)
if company_no == 1:
return "D" if first == "D" else "K"
return first
return "K" if first == "D" else "D"
def _company_no(spec: _RuleSpec, entry: OsvEntry) -> int:
if entry.account_id is None:
raise ConflictError("CORRECTION_SOURCE_INVALID", "У назначенной строки ОСВ отсутствует счёт")
matched = [number for number in COMPANY_NUMBERS if entry.account_id in spec.included[number]]
if len(matched) == 1:
return matched[0]
if len(matched) == len(COMPANY_NUMBERS):
nonzero = [
number for number in matched if _amount(entry, spec.rule.measure, _direction(spec.rule, number)) != ZERO
]
if len(nonzero) == 1:
return nonzero[0]
raise ConflictError("CORRECTION_SOURCE_INVALID", "Невозможно определить сторону назначенной строки ОСВ в правиле")
class VgoCorrectionSource:
"""Преобразует готовое автоматическое и ручное разнесение в таблицу корректировок."""
class VgoAssignmentCorrectionSource:
"""Строит таблицу из готовых назначений, не создавая и не меняя их."""
def __init__(self, session: AsyncSession) -> None:
def __init__(self, session: AsyncSession, vgo_service: VgoService, osv_service: ConsolidatedOsvService) -> None:
self._session = session
self._vgo_service = vgo_service
self._osv_service = osv_service
async def _rules(self, statement_type: StatementType, version_no: int) -> dict[uuid.UUID, _RuleSpec]:
accounts = list((await self._session.scalars(select(ChartOfAccount))).all())
by_id = {account.id: account for account in accounts}
descendants = _account_tree(accounts)
rows = (
await self._session.execute(
select(EliminationRule, EliminationRuleAccount)
@ -137,62 +84,23 @@ class VgoAssignmentCorrectionSource:
spec = specs.setdefault(rule.id, _RuleSpec(rule))
if account is None or account.company_no not in COMPANY_NUMBERS:
continue
target = spec.excluded if account.is_excluded else spec.included
target[account.company_no].update(descendants.get(account.account_id, {account.account_id}))
code = by_id.get(account.account_id)
if code is not None:
root = by_id.get(account.account_id)
if root is not None:
codes = spec.excluded_codes if account.is_excluded else spec.included_codes
codes[account.company_no].append(code.code)
for spec in specs.values():
for number in COMPANY_NUMBERS:
spec.included[number].difference_update(spec.excluded[number])
codes[account.company_no].append(root.code)
return specs
async def table(self, version_id: uuid.UUID, statement_type: StatementType) -> CorrectionSourceTable:
version = await self._session.get(ConsolidationVersion, version_id)
if version is None:
if version is None or statement_type != StatementType.balance:
return CorrectionSourceTable()
specs = await self._rules(statement_type, version.elimination_rules_version)
owner = aliased(LegalEntity)
counterparty = aliased(LegalEntity)
rows = (
await self._session.execute(
select(OsvEntry, EliminationRule, owner, counterparty)
.select_from(vgo_assignment)
.join(OsvEntry, OsvEntry.id == vgo_assignment.c.osv_entry_id)
.join(EliminationRule, EliminationRule.id == vgo_assignment.c.rule_id)
.join(owner, owner.id == OsvEntry.owner_legal_entity_id)
.join(counterparty, counterparty.id == OsvEntry.counterparty_id)
.join(ConsolidationDocument, ConsolidationDocument.document_id == OsvEntry.document_id)
.where(
vgo_assignment.c.consolidation_version_id == version_id,
vgo_assignment.c.statement_type == statement_type.value,
vgo_assignment.c.action == "ASSIGN",
vgo_assignment.c.is_exclude.is_(False),
ConsolidationDocument.consolidation_version_id == version_id,
)
.order_by(EliminationRule.rule_no, owner.name, counterparty.name, OsvEntry.id)
)
).all()
groups: dict[tuple[uuid.UUID, uuid.UUID, uuid.UUID], _AggregateGroup] = {}
used_rules: set[uuid.UUID] = set()
for entry, rule, owner_entity, counterparty_entity in rows:
spec = specs.get(rule.id)
if spec is None:
raise ConflictError("CORRECTION_SOURCE_INVALID", "Назначение ссылается на неизвестное правило")
company_no = _company_no(spec, entry)
direction = _direction(rule, company_no)
first, second = (
(owner_entity, counterparty_entity) if company_no == 1 else (counterparty_entity, owner_entity)
)
key = (rule.id, first.id, second.id)
group = groups.setdefault(key, _AggregateGroup(rule, first, second))
entry_key = (owner_entity.id, counterparty_entity.id, entry.account_code or "", direction)
item = group.entries[company_no].setdefault(entry_key, _EntryGroup())
item.ids.append(entry.id)
item.amount += _amount(entry, rule.measure, direction)
used_rules.add(rule.id)
aggregates = [self._aggregate(group) for group in groups.values()]
allocation = await self._vgo_service.assignment_candidates(version_id)
osv_result = await self._osv_service.build_with_members(version_id)
aggregates = [
self._aggregate(group, specs[group.rule_id], osv_result.consolidated.rows) for group in allocation.groups
]
used_rules = {group.rule_id for group in allocation.groups}
rules = [self._rule_out(spec) for rule_id, spec in specs.items() if rule_id in used_rules]
return CorrectionSourceTable(rules=rules, aggregates=aggregates)
@ -205,7 +113,7 @@ class VgoAssignmentCorrectionSource:
article_1_line=rule.article_1_line,
article_2_line=rule.article_2_line,
side_1=_direction(rule, 1),
measure=rule.measure,
measure=cast(Literal["closing", "turnover"], rule.measure),
company_1_accounts=sorted(set(spec.included_codes[1])),
company_2_accounts=sorted(set(spec.included_codes[2])),
company_1_excluded=sorted(set(spec.excluded_codes[1])),
@ -213,37 +121,98 @@ class VgoAssignmentCorrectionSource:
)
@staticmethod
def _aggregate(group: _AggregateGroup) -> CorrectionAggregateOut:
rule = group.rule
def _aggregate(
group: VgoAssignmentGroupOut, spec: _RuleSpec, rows: Sequence[ConsolidatedRow]
) -> CorrectionAggregateOut:
rule = spec.rule
side_1_code = _direction(rule, 1)
rows_by_entry_id = {entry_id: row for row in rows for entry_id in row.entry_ids}
def side(number: int) -> AggregateSideOut:
entries = [
CorrectionEntryOut(
entry_ids=sorted(item.ids),
legal_entity_id=owner_id,
counterparty_id=counterparty_id,
account_code=account_code,
side=direction,
amount=item.amount,
)
for (owner_id, counterparty_id, account_code, direction), item in group.entries[number].items()
]
return AggregateSideOut(
article_line=rule.article_1_line if number == 1 else rule.article_2_line,
accounts=sorted({entry.account_code for entry in entries}),
amount=sum((entry.amount for entry in entries), ZERO),
entries=entries,
def amount(row: ConsolidatedRow | VgoAssignmentRowOut, side: Side) -> Decimal:
if rule.measure == "turnover":
return (row.turnover_debit if side == "D" else row.turnover_credit) or ZERO
return (row.closing_debit if side == "D" else row.closing_credit) or ZERO
def entry(row: ConsolidatedRow | VgoAssignmentRowOut, side: Side, value: Decimal) -> CorrectionEntryOut | None:
if row.counterparty_id is None:
return None
return CorrectionEntryOut(
entry_ids=list(row.entry_ids),
legal_entity_id=row.legal_entity_id,
counterparty_id=row.counterparty_id,
account_code=row.account_code or "",
side=side,
amount=value,
)
first, second = side(1), side(2)
entries_by_key: dict[tuple[tuple[uuid.UUID, ...], Side], CorrectionEntryOut] = {}
calculated = (*group.matched, *group.with_discrepancy)
for pair in calculated:
for entry_ids, side_code in (
(pair.osv_entry_ids_a, side_1_code),
(pair.osv_entry_ids_b, _direction(rule, 2)),
):
selected: dict[tuple[uuid.UUID, ...], ConsolidatedRow] = {}
for entry_id in entry_ids:
auto_row = rows_by_entry_id.get(entry_id)
if auto_row is not None:
selected.setdefault(tuple(auto_row.entry_ids), auto_row)
for auto_row in selected.values():
item = entry(auto_row, side_code, amount(auto_row, side_code))
if item is not None:
entries_by_key[(tuple(auto_row.entry_ids), side_code)] = item
for manual_row in group.manual:
if manual_row.is_exclude:
continue
_, amount_1, amount_2 = _manual_key_amount(manual_row, rule) # type: ignore[arg-type]
side_code = side_1_code if amount_1 != ZERO or amount_2 == ZERO else _direction(rule, 2)
item = entry(manual_row, side_code, amount_1 if side_code == side_1_code else amount_2)
if item is not None:
entries_by_key[(tuple(manual_row.entry_ids), side_code)] = item
entries = list(entries_by_key.values())
def side(first: bool) -> AggregateSideOut:
side_entries = [entry for entry in entries if (entry.side == side_1_code) == first]
return AggregateSideOut(
article_line=rule.article_1_line if first else rule.article_2_line,
accounts=sorted({entry.account_code for entry in side_entries}),
amount=sum((entry.amount for entry in side_entries), ZERO),
entries=side_entries,
)
def pair_side(entry_ids: list[uuid.UUID], *, first: bool, amount: Decimal) -> AggregateSideOut:
ids = set(entry_ids)
pair_entries = [entry for entry in entries if ids.intersection(entry.entry_ids)]
return AggregateSideOut(
article_line=rule.article_1_line if first else rule.article_2_line,
accounts=sorted({entry.account_code for entry in pair_entries}),
amount=amount,
entries=pair_entries,
)
first, second = side(True), side(False)
first.amount = group.amount_a
second.amount = group.amount_b
pairs = [
CorrectionPairOut(
side_1=pair_side(pair.osv_entry_ids_a, first=True, amount=pair.amount_a),
side_2=pair_side(pair.osv_entry_ids_b, first=False, amount=pair.amount_b),
deviation_amount=pair.deviation_amount,
deviation_percent=pair.deviation_percent,
)
for pair in (*group.matched, *group.with_discrepancy)
]
totals = calculate_totals(first.amount, second.amount, manual=rule.elimination_mode == "manual")
return CorrectionAggregateOut(
rule_id=rule.id,
rule_no=rule.rule_no,
company_1=RefOut(id=group.company_1.id, name=group.company_1.name),
company_2=RefOut(id=group.company_2.id, name=group.company_2.name),
rule_id=group.rule_id,
rule_no=group.rule_no,
company_1=RefOut(id=group.company_a_id, name=group.company_a_name or str(group.company_a_id)),
company_2=RefOut(id=group.company_b_id, name=group.company_b_name or str(group.company_b_id)),
side_1=first,
side_2=second,
pairs=pairs,
deviation_amount=totals.deviation_amount,
deviation_percent=totals.deviation_percent,
exceeds_threshold=totals.exceeds_threshold,

View File

@ -5,7 +5,7 @@ from io import BytesIO
from openpyxl import Workbook
from openpyxl.worksheet.worksheet import Worksheet
from src.schemas.corrections import CorrectionAggregateOut, CorrectionEntryOut, CorrectionTableOut
from src.schemas.corrections import CorrectionAggregateOut, CorrectionEntryOut, CorrectionPairOut, CorrectionTableOut
from src.services.osv_export import _put_money, _put_text
MAIN_HEADER = (
@ -65,6 +65,27 @@ def _entry(sheet: Worksheet, row: int, item: CorrectionAggregateOut, entry: Corr
sheet.row_dimensions[row].outlineLevel = 1
def _pair(sheet: Worksheet, row: int, item: CorrectionAggregateOut, pair: CorrectionPairOut) -> None:
"""Сопоставленные позиции обеих сторон на одной строке экспорта."""
for column, text in (
(2, item.company_1.name),
(3, pair.side_1.article_line),
(4, ", ".join(pair.side_1.accounts)),
(6, item.company_2.name),
(7, pair.side_2.article_line),
(8, ", ".join(pair.side_2.accounts)),
):
_put_text(sheet, row, column, text)
for column, amount in (
(5, pair.side_1.amount),
(9, pair.side_2.amount),
(10, pair.deviation_amount),
(11, pair.deviation_percent),
):
_put_money(sheet, row, column, amount)
sheet.row_dimensions[row].outlineLevel = 1
def build_workbook(table: CorrectionTableOut) -> bytes:
"""Состав и суммы уже рассчитаны API: экспорт их не пересчитывает."""
workbook = Workbook()
@ -76,8 +97,20 @@ def build_workbook(table: CorrectionTableOut) -> bytes:
for item in table.aggregates:
_aggregate(sheet, row, item)
row += 1
paired_entry_ids: set = set()
for pair in item.pairs:
_pair(sheet, row, item, pair)
paired_entry_ids.update(
entry_id
for side in (pair.side_1, pair.side_2)
for entry in side.entries
for entry_id in entry.entry_ids
)
row += 1
for side in (item.side_1, item.side_2):
for entry in side.entries:
if paired_entry_ids.intersection(entry.entry_ids):
continue
_entry(sheet, row, item, entry)
row += 1
_put_text(sheet, row, 2, "Итого по агрегатам")

View File

@ -258,14 +258,14 @@ class VgoService:
free_rows = [row for row in rows if row.entry_ids not in manual.by_row]
search = await self._find_aggregates(version_id, osv_result, rules, free_rows)
diff_aggregates = _select_diff_aggregates(search)
names = {company_id: entity.name for company_id, entity in osv_result.members.items()}
for row in osv_result.consolidated.rows:
if row.counterparty_id is not None:
name = row.counterparty_name or row.counterparty_entity_name
if name is not None:
names.setdefault(row.counterparty_id, name)
groups = _build_groups(search, diff_aggregates, rules, manual.groups, names)
unassigned = _unassigned_rows(osv_result.consolidated.rows, rows, manual, search.used_ids)
return VgoAssignmentCandidatesOut(groups=groups, unassigned=unassigned)

View File

@ -19,8 +19,6 @@ from src.models.audit_ex import AuditEvent
from src.models.consolidation import ConsolidationDocument
from src.models.enums import AuditAction, ConsolidationStatus, CorrectionStatus, PeriodQuarter, StatementType
from src.models.kfo import Correction
from src.models.osv import OsvEntry
from src.repositories.corrections import vgo_assignment
from src.schemas.corrections import CorrectionAggregateOut
from src.services.correction_source import CorrectionSourceTable
from tests.conftest import AuthClientFactory
@ -184,45 +182,10 @@ async def test_read_and_export_real_trade(
assert income.json()["aggregates"] == []
async def test_ready_assignments_build_correction_aggregate(
async def test_automatic_allocation_builds_correction_aggregate(
auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path
) -> None:
world = await real_trade(seeded, fixtures_dir)
rule = (
await seeded.scalars(
select(EliminationRule).where(
EliminationRule.rule_no == TRADE_RULE_NO,
EliminationRule.statement_type == StatementType.balance,
EliminationRule.version_no == world.version.elimination_rules_version,
)
)
).one()
entries = list(
(
await seeded.scalars(
select(OsvEntry)
.join(ConsolidationDocument, ConsolidationDocument.document_id == OsvEntry.document_id)
.where(ConsolidationDocument.consolidation_version_id == world.version.id)
)
).all()
)
await seeded.execute(
vgo_assignment.insert(),
[
dict(
id=uuid.uuid4(),
consolidation_version_id=world.version.id,
statement_type=StatementType.balance.value,
osv_entry_id=entry.id,
rule_id=rule.id,
action="ASSIGN",
is_exclude=False,
)
for entry in entries
],
)
await seeded.flush()
response = await (await auth_client(KA_RF)).get(f"/consolidations/versions/{world.version.id}/corrections")
assert response.status_code == HTTP_OK, response.text
@ -246,7 +209,7 @@ async def test_table_read_only_visibility_and_unknown_version(
response = await client.get(url)
assert response.status_code == HTTP_OK
assert response.json()["read_only_reason"] == "VERSION_NOT_EDITABLE"
assert response.json()["aggregates"] == []
assert len(response.json()["aggregates"]) == 1
missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/corrections")
foreign = await (await auth_client(FOREIGN)).get(url)
assert missing.status_code == foreign.status_code == HTTP_NOT_FOUND

View File

@ -5,10 +5,12 @@ from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import FastAPI
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.api.dependencies.audit import RequestAudit
from src.api.dependencies.consolidations import get_correction_source
from src.common.exceptions import DomainValidationError
from src.models.audit_ex import AuditEvent
from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationVersion
@ -34,6 +36,14 @@ INITIAL_ARTICLE_COUNT, SECOND_REVISION = 3, 2
OK, CREATED, INVALID, CONFLICT = 200, 201, 422, 409
@pytest.fixture(name="correction_source")
def correction_source_fixture(app: FastAPI):
source = MutableCorrectionSource()
app.dependency_overrides[get_correction_source] = lambda: source
yield source
app.dependency_overrides.pop(get_correction_source, None)
async def draft_world(session: AsyncSession, fixtures_dir: Path, source: MutableCorrectionSource):
world = await real_trade(session, fixtures_dir)
await configure_trade_source(source, session, world, fixtures_dir)

View File

@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None:
def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None:
lines = dbml_file.read_text(encoding="utf-8").splitlines()
newer = tmp_path / "data-model-v5.24.dbml"
# шапка 5.23 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.23", "v5.24") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.24 не совпадает с версией ORM 5.23: обновите модели"])
newer = tmp_path / "data-model-v5.25.dbml"
# шапка 5.24 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.24", "v5.25") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.25 не совпадает с версией ORM 5.24: обновите модели"])
def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None:
newer = tmp_path / "data-model-v5.24.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.23", "v5.24", 1), encoding="utf-8")
newer = tmp_path / "data-model-v5.25.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.24", "v5.25", 1), encoding="utf-8")
result = _run_cli(str(newer))
assert result.returncode == 1
assert result.stdout == "версия DBML 5.24 не совпадает с версией ORM 5.23: обновите модели\n"
assert result.stdout == "версия DBML 5.25 не совпадает с версией ORM 5.24: обновите модели\n"
def test_header_without_version_is_usage_error(tmp_path: Path) -> None:

View File

@ -26,7 +26,7 @@ def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch:
def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None:
""".env.test читается: стандартные порты docker-compose из docs/dev."""
s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg]
assert s.OPENBAO.DATABASE.DATABASE_URL.endswith("localhost:5432/armka")
assert s.OPENBAO.DATABASE.DATABASE_URL.endswith("localhost:5431/armka")
assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:4222"
assert s.AUTH_DEV_ISSUER is True
assert s.NATS_ENABLED is False

View File

@ -1,23 +1,59 @@
"""Сборка агрегатов из уже готовых назначений ОСВ."""
"""Адаптация готовых позиций VGO в агрегат таблицы корректировок."""
import uuid
from decimal import Decimal
from types import SimpleNamespace
from src.services.correction_source import (
VgoAssignmentCorrectionSource,
_AggregateGroup,
_company_no,
_EntryGroup,
_RuleSpec,
from src.domain.consolidated_osv import CounterpartyType
from src.schemas.consolidations import (
EliminationMatchedPairOut,
VgoAssignmentGroupOut,
VgoAssignmentRowOut,
)
SECOND_COMPANY = 2
from src.services.correction_source import VgoCorrectionSource, _RuleSpec
def _rule(**changes):
values = dict(
id=uuid.uuid4(),
def _row( # noqa: PLR0913 - компактная фабрика строки ОСВ для одного теста
entry_id: uuid.UUID,
owner_id: uuid.UUID,
counterparty_id: uuid.UUID,
account: str,
*,
debit: Decimal = Decimal("0"),
credit: Decimal = Decimal("0"),
):
return VgoAssignmentRowOut(
entry_ids=[entry_id],
legal_entity_id=owner_id,
legal_entity_name="Владелец",
legal_entity_inn="1234567890",
account_code=account,
account_root=account.split(".", 1)[0],
period="2026 Q1",
is_cumulative=True,
row_number=1,
counterparty_name="Контрагент",
counterparty_inn="0987654321",
counterparty_id=counterparty_id,
counterparty_entity_name="Контрагент",
counterparty_entity_inn="0987654321",
counterparty_type=CounterpartyType.GSK,
match_kind=None,
match_stored=True,
opening_debit=Decimal("0"),
opening_credit=Decimal("0"),
turnover_debit=Decimal("0"),
turnover_credit=Decimal("0"),
closing_debit=debit,
closing_credit=credit,
warnings=[],
)
def test_vgo_positions_form_oriented_company_aggregate() -> None:
first_id, second_id, rule_id = (uuid.uuid4() for _ in range(3))
rule = SimpleNamespace(
id=rule_id,
rule_no=6,
article_1_line="1230",
article_2_line="1520",
@ -25,50 +61,46 @@ def _rule(**changes):
measure="closing",
elimination_mode="min",
)
values.update(changes)
return SimpleNamespace(**values)
def test_assigned_entries_form_oriented_company_aggregate() -> None:
first = SimpleNamespace(id=uuid.uuid4(), name="Компания А")
second = SimpleNamespace(id=uuid.uuid4(), name="Компания Б")
rule = _rule()
group = _AggregateGroup(rule, first, second)
group.entries[1][(first.id, second.id, "62.01", "D")] = _EntryGroup(
[uuid.UUID(int=2), uuid.UUID(int=1)], Decimal("100.00")
group = VgoAssignmentGroupOut(
rule_id=rule_id,
rule_no=6,
article_1_line="1230",
article_2_line="1520",
measure="closing",
company_a_id=first_id,
company_a_name="Компания А",
company_b_id=second_id,
company_b_name="Компания Б",
amount_a=Decimal("100"),
amount_b=Decimal("95"),
deviation_amount=Decimal("5"),
deviation_percent=Decimal("5.26"),
with_discrepancy=[
EliminationMatchedPairOut(
rule_id=rule_id,
company_a_id=first_id,
company_b_id=second_id,
amount_a=Decimal("100"),
amount_b=Decimal("95"),
deviation_amount=Decimal("5"),
deviation_percent=Decimal("5.26"),
osv_entry_ids_a=[uuid.UUID(int=1)],
osv_entry_ids_b=[uuid.UUID(int=2)],
)
],
)
group.entries[2][(second.id, first.id, "60.01", "K")] = _EntryGroup([uuid.UUID(int=3)], Decimal("95.00"))
rows = [
_row(uuid.UUID(int=1), first_id, second_id, "62.01", debit=Decimal("100")),
_row(uuid.UUID(int=2), second_id, first_id, "60.01", credit=Decimal("95")),
]
aggregate = VgoAssignmentCorrectionSource._aggregate(group)
aggregate = VgoCorrectionSource._aggregate(group, _RuleSpec(rule), rows) # type: ignore[arg-type]
assert (aggregate.company_1.id, aggregate.company_2.id) == (first.id, second.id)
assert (aggregate.company_1.id, aggregate.company_2.id) == (first_id, second_id)
assert aggregate.side_1.amount == Decimal("100.00")
assert aggregate.side_2.amount == Decimal("95.00")
assert aggregate.deviation_percent == Decimal("5") / Decimal("95") * 100
assert aggregate.exceeds_threshold
assert aggregate.side_1.entries[0].entry_ids == [uuid.UUID(int=1), uuid.UUID(int=2)]
def test_overlapping_rule_accounts_use_nonzero_rule_direction() -> None:
account_id = uuid.uuid4()
rule = _rule()
spec = _RuleSpec(rule)
spec.included[1].add(account_id)
spec.included[2].add(account_id)
debit_entry = SimpleNamespace(
account_id=account_id,
closing_debit=Decimal("10"),
closing_credit=Decimal("0"),
turnover_debit=Decimal("0"),
turnover_credit=Decimal("0"),
)
credit_entry = SimpleNamespace(
account_id=account_id,
closing_debit=Decimal("0"),
closing_credit=Decimal("10"),
turnover_debit=Decimal("0"),
turnover_credit=Decimal("0"),
)
assert _company_no(spec, debit_entry) == 1
assert _company_no(spec, credit_entry) == SECOND_COMPANY
assert aggregate.side_1.entries[0].entry_ids == [uuid.UUID(int=1)]
assert aggregate.pairs[0].side_1.entries[0].entry_ids == [uuid.UUID(int=1)]
assert aggregate.pairs[0].side_2.entries[0].entry_ids == [uuid.UUID(int=2)]

View File

@ -34,6 +34,11 @@ def table(fixtures_dir: Path) -> CorrectionTableOut:
amount=Decimal(source["closing_debit" if side == "D" else "closing_credit"]),
)
debit_entry = entry(debit, side="D", entry_id=2)
credit_entry = entry(credit, side="K", entry_id=4)
debit_side = dict(article_line="1230", accounts=["62.01"], amount=amount, entries=[debit_entry])
credit_side = dict(article_line="1520", accounts=["60.01"], amount=amount, entries=[credit_entry])
return CorrectionTableOut.model_validate(
dict(
version_id=UUID(int=1),
@ -48,20 +53,9 @@ def table(fixtures_dir: Path) -> CorrectionTableOut:
rule_no=6,
company_1=company_1,
company_2=company_2,
side_1=dict(
article_line="1230",
accounts=["62.01"],
amount=amount,
entries=[
entry(debit, side="D", entry_id=2),
],
),
side_2=dict(
article_line="1520",
accounts=["60.01"],
amount=amount,
entries=[entry(credit, side="K", entry_id=4)],
),
side_1=debit_side,
side_2=credit_side,
pairs=[dict(side_1=debit_side, side_2=credit_side, deviation_amount=0, deviation_percent=0)],
deviation_amount=0,
deviation_percent=0,
exceeds_threshold=False,
@ -85,6 +79,10 @@ def test_export_preserves_rubles_and_detail_rows(table: CorrectionTableOut) -> N
assert main["A3"].value is None
assert main["B3"].value == "Счастливочная"
assert main["D3"].value == "62.01"
assert main["F3"].value == "Русский бисквит"
assert main["H3"].value == "60.01"
assert Decimal(str(main["E3"].value)) == table.totals.amount_1
assert Decimal(str(main["I3"].value)) == table.totals.amount_2
assert main.row_dimensions[3].outlineLevel == 1
assert main["C2"].value == "1230"
finally:

View File

@ -1,4 +1,4 @@
"""Enum'ы ORM против блоков `Enum {}` DBML v5.23: имена, значения и их порядок (без БД)."""
"""Enum'ы ORM против блоков `Enum {}` DBML v5.24: имена, значения и их порядок (без БД)."""
import enum
import re
@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 33
def test_dbml_version_format() -> None:
assert re.fullmatch(r"\d+\.\d+", DBML_VERSION)
assert DBML_VERSION == "5.23"
assert DBML_VERSION == "5.24"
assert f"v{DBML_VERSION}" in dbml_path().name

View File

@ -40,7 +40,8 @@ from tests.test_models_part_07_09 import (
V5_19 = "0007_dbml_v5_19"
V5_20 = "0008_dbml_v5_20"
V5_21 = "0009_dbml_v5_21"
HEAD = "0010_dbml_v5_23"
V5_23 = "0010_dbml_v5_23"
HEAD = "0011_dbml_v5_24"
V5_18 = "0006_dbml_v5_18"
V5_17 = "0005_dbml_v5_17"
V5_16 = "0004_dbml_v5_16"
@ -118,7 +119,10 @@ async def schema_snapshot(conn: AsyncConnection) -> dict[str, Any]:
).all()
return {
"columns": [tuple(row) for row in columns],
"constraints": {row[0]: (row[1], row[2]) for row in constraints},
"constraints": {
row[0].replace("ck_vgo_assignment_ck_vgo_assignment_", "ck_vgo_assignment_"): (row[1], row[2])
for row in constraints
},
"indexes": {row[0]: row[1] for row in indexes},
"enums": {row[0]: list(row[1]) for row in enums},
}
@ -1294,7 +1298,7 @@ def test_offline_sql_creates_schema_before_version_table() -> None:
assert schema_at < output.index("CREATE TABLE dkb.alembic_version")
assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')")
assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output
# версия head пишется UPDATE'ами по цепочке 0001 → … → 0007 — офлайн-SQL доходит до head
# Версия head пишется UPDATE'ами по полной цепочке миграций.
for new_version, old_version in (
(V5_14, PREVIOUS),
(V5_15, V5_14),
@ -1304,7 +1308,8 @@ def test_offline_sql_creates_schema_before_version_table() -> None:
(V5_19, V5_18),
(V5_20, V5_19),
(V5_21, V5_20),
(HEAD, V5_21),
(V5_23, V5_21),
(HEAD, V5_23),
):
assert (
f"UPDATE dkb.alembic_version SET version_num='{new_version}' "

View File

@ -39,6 +39,7 @@ PART_07_09_TABLES = {
"kfo_result_line",
"kfo_result_line_company",
"client_kfo_line",
"vgo_assignment",
# audit_ex (+ required_document_rule по рулингу R-4)
"comment",
"audit_event",
@ -48,7 +49,7 @@ PART_07_09_TABLES = {
"required_document_rule",
}
ALL_TABLES = PART_01_03_TABLES | PART_04_06_TABLES | PART_07_09_TABLES
EXPECTED_TABLE_COUNT = 44
EXPECTED_TABLE_COUNT = 45
EXPECTED_ENUM_COUNT = 33

View File

@ -86,6 +86,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
"/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"},
"/api/v1/consolidations/versions/{version_id}/elimination-totals": {"get"},
"/api/v1/consolidations/versions/{version_id}/vgo-assignments/distribute": {"post"},
"/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"},

View File

@ -81,12 +81,20 @@ async def test_pairs_are_grouped_and_entry_ids_are_used_once(monkeypatch: pytest
)
rules = [
SimpleNamespace(
id=rule_1, rule_no=1, statement_type=StatementType.balance, measure="closing",
article_1_line="Статья A1", article_2_line="Статья B1",
id=rule_1,
rule_no=1,
statement_type=StatementType.balance,
measure="closing",
article_1_line="Статья A1",
article_2_line="Статья B1",
),
SimpleNamespace(
id=rule_2, rule_no=2, statement_type=StatementType.balance, measure="closing",
article_1_line="Статья A2", article_2_line="Статья B2",
id=rule_2,
rule_no=2,
statement_type=StatementType.balance,
measure="closing",
article_1_line="Статья A2",
article_2_line="Статья B2",
),
]
monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=rules))
@ -165,8 +173,13 @@ async def test_manual_assignments_override_auto_and_keep_exclusion_flag(monkeypa
members=_members(company_a, company_b),
)
rule = SimpleNamespace(
id=rule_id, rule_no=1, statement_type=StatementType.balance, measure="closing", side_1="D",
article_1_line="Статья A", article_2_line="Статья B",
id=rule_id,
rule_no=1,
statement_type=StatementType.balance,
measure="closing",
side_1="D",
article_1_line="Статья A",
article_2_line="Статья B",
)
assignments = [
SimpleNamespace(osv_entry_id=entry_ids[0], rule_id=rule_id, action="ASSIGN", is_exclude=False),
@ -240,8 +253,13 @@ async def test_glued_row_with_same_assignments_is_counted_once(monkeypatch: pyte
members=_members(company_a, company_b),
)
rule = SimpleNamespace(
id=rule_id, rule_no=1, statement_type=StatementType.balance, measure="closing", side_1="D",
article_1_line="Статья A", article_2_line="Статья B",
id=rule_id,
rule_no=1,
statement_type=StatementType.balance,
measure="closing",
side_1="D",
article_1_line="Статья A",
article_2_line="Статья B",
)
assignments = [
SimpleNamespace(osv_entry_id=entry_id, rule_id=rule_id, action="ASSIGN", is_exclude=False)

View File

@ -2,42 +2,45 @@
## Context
См. `proposal.md`. В модели уже существует `correction` с дочерними строками и статусами. Разнесение ОСВ по правилам и ручные назначения разрабатываются отдельно; этот change не должен дублировать их алгоритм или хранение.
См. `proposal.md`. В модели уже существует `correction` с дочерними строками и статусами. Разнесение ОСВ по правилам и ручные назначения реализуются в AURORA-1751; этот change вызывает её расчёт через интерфейс источника, но не дублирует алгоритм и не сохраняет вычисленные пары.
## Goals / Non-Goals
**Goals:**
- Рассчитать контроль отклонения и справочную элиминацию для переданных агрегатов.
- Адаптировать вычисленные агрегаты AURORA-1751 для контракта таблицы корректировок, сохранив сопоставленные пары.
- Рассчитать контроль отклонения и справочную элиминацию для полученных агрегатов.
- Прочитать и атомарно сохранить корректировку в существующей модели.
- Обеспечить блокировки, optimistic version, аудит и XLSX-экспорт.
**Non-Goals:**
- Формировать агрегаты из ОСВ, выбирать правило или хранить назначения.
- Реализовывать собственное сопоставление строк ОСВ, выбирать правило или хранить вычисленные пары.
- Изменять DBML, добавлять ORM-модель или миграцию.
- Импортировать XLSX, рассчитывать КФО, поддерживать ОФР или UI.
## Decisions
1. **Явная граница по `vgo_assignment`.** Сервис корректировок читает готовые назначения соседней задачи и группирует строки по правилу и ориентированной паре компаний. Он не создаёт и не изменяет назначения. Если назначений нет, возвращается пустая таблица.
1. **Интеграция через `CorrectionAggregateSource`.** Реализация `VgoCorrectionSource` вызывает `VgoService.assignment_candidates` из AURORA-1751. Сервис 1751 при каждом запросе рассчитывает группы, `matched`, `with_discrepancy`, ручные и нераспределённые позиции. Сервис корректировок не читает схему `vgo_assignment` напрямую и не повторяет алгоритм сопоставления.
2. **Чистый расчёт контроля.** Функция домена принимает две суммы в рублях и возвращает отклонение, процент, признак превышения и `eliminated_amount`. Для границы `≤ 5 %` элиминируется `min`; процент вычисляется до округления. При нулевой меньшей стороне процент `null`.
2. **Пары остаются вычисляемыми.** Адаптер преобразует `matched` и `with_discrepancy` в `pairs` ответа GET, связывая стороны по `osv_entry_ids_a` и `osv_entry_ids_b`. Пара содержит обе стороны, суммы и отклонение результата 1751. Отдельная ORM-модель или миграция для пар не нужна.
3. **Использовать существующую `correction`.** Корневая строка хранит сторону/статью и метаданные, дочерние строки — распределение по дополнительным статьям. Запрос сохраняет полный набор заменой в одной транзакции. Новая миграция не нужна.
3. **Чистый расчёт контроля.** Функция домена принимает две суммы в рублях и возвращает отклонение, процент, признак превышения и `eliminated_amount`. Для границы `≤ 5 %` элиминируется `min`; процент вычисляется до округления. При нулевой меньшей стороне процент `null`.
4. **Комментарий обязателен всегда.** Проверка выполняется до записи независимо от процента отклонения. Успешное сохранение сразу даёт `APPLIED`; отдельная операция подтверждения первого этапа не нужна.
4. **Использовать существующую `correction`.** Корневая строка хранит сторону/статью и метаданные, дочерние строки — распределение по дополнительным статьям. Запрос сохраняет полный набор заменой в одной транзакции. Новая миграция не нужна.
5. **Конкурентность.** Перед записью блокируется версия и проверяется её владелец. `expected_version_no` защищает от потери параллельной правки. Ошибка любой строки откатывает весь набор и аудит.
5. **Комментарий обязателен всегда.** Проверка выполняется до записи независимо от процента отклонения. Успешное сохранение сразу даёт `APPLIED`; отдельная операция подтверждения первого этапа не нужна.
6. **Экспорт строится из DTO GET.** XLSX не пересчитывает агрегаты и использует один лист. Строки ОСВ следуют за агрегатом с outline level 1; колонка «Правило» у них пустая. Полное русское имя передаётся через `filename*`, ASCII fallback сохраняется для совместимости.
6. **Конкурентность.** Перед записью блокируется версия и проверяется её владелец. `expected_version_no` защищает от потери параллельной правки. Ошибка любой строки откатывает весь набор и аудит.
7. **Экспорт строится из DTO GET.** XLSX не пересчитывает агрегаты и использует один лист. Каждая вычисленная пара следует за агрегатом одной строкой outline level 1 с заполненными сторонами обеих компаний. Её позиции повторно не выводятся. Ручные и односторонние позиции остаются отдельными строками. Колонка «Правило» у детализации пустая. Полное русское имя передаётся через `filename*`, ASCII fallback сохраняется для совместимости.
## Risks / Trade-offs
- [Назначения ещё не рассчитаны соседней задачей] → API возвращает пустую таблицу; локальный алгоритм выбора правил не добавляется.
- [Результат AURORA-1751 изменит внутреннее представление] → зависимость изолирована интерфейсом `CorrectionAggregateSource` и адаптером `VgoCorrectionSource`.
- [Состав агрегата меняется в соседней задаче] → lifecycle `DRAFT/REVOKED` по изменению состава согласуется и реализуется там либо отдельным change; этот этап не отслеживает переносы строк.
- [Ровно 5 % ранее трактовалось как превышение] → зафиксировать `≤ 5 %` отдельным граничным тестом.
## Migration Plan
Используется миграция `0011_vgo_assignment` из ветки AURORA-1751. Rollback удаляет таблицу назначений вместе с API-кодом этого этапа.
Миграция для таблицы корректировок, вычисленных пар или их состава не добавляется. Существующая модель `correction` используется без изменения.

View File

@ -6,10 +6,10 @@
## What Changes
- Добавить сборку агрегатной таблицы корректировок ББ из готовых назначений `vgo_assignment`: исходные суммы, отклонение к меньшей положительной стороне и справочная сумма элиминации `min` в пределах допуска.
- Добавить сборку агрегатной таблицы корректировок ББ из результата разнесения AURORA-1751 через `CorrectionAggregateSource`: исходные суммы, вычисленные сопоставленные пары, отклонение к меньшей положительной стороне и справочная сумма элиминации `min` в пределах допуска.
- Добавить атомарное сохранение корректировки агрегата в существующую модель `correction`; комментарий обязателен при каждом сохранении, результат получает статус `APPLIED`.
- Поддержать дочерние статьи обеих сторон, контроль редакции, блокировку версии, запрет изменения после верификации и аудит `CORRECTION_CREATE/EDIT`.
- Добавить XLSX-экспорт одним листом: агрегат, затем раскрываемые исходные строки, итог и нераспределённые данные, полученные от источника таблицы.
- Добавить XLSX-экспорт одним листом: агрегат, затем раскрываемые сопоставленные пары и оставшиеся односторонние или ручные позиции, итоговые суммы.
- Использовать рубли и `Decimal`; процент считать к меньшей положительной сумме до экранного округления.
## Capabilities
@ -24,10 +24,10 @@
## Impact
Затрагивается `arm-core`: миграция `vgo_assignment` из AURORA-1751, ORM-модель и чтение назначений, endpoints, schemas, domain-расчёт, service/repository для существующей таблицы `correction`, аудит и XLSX-экспорт.
Затрагивается `arm-core`: адаптер вычисляемого результата AURORA-1751, endpoints, schemas, domain-расчёт, service/repository для существующей таблицы `correction`, аудит и XLSX-экспорт. Новая модель данных для пар не создаётся.
## Non-goals
- Создание и изменение `vgo_assignment`, автоматический выбор правила, ручной перенос и исключение — отдельная задача; этот change только читает готовые назначения.
- Алгоритм разнесения ОСВ, ручной перенос и исключение — ответственность AURORA-1751; этот change только вызывает её сервис и преобразует готовый результат для таблицы корректировок.
- Импорт XLSX исключён решением аналитика 29.10.2026.
- UI, расчёт/инвалидация КФО, ОФР и изменение статусов согласования не входят в этап.

View File

@ -2,21 +2,27 @@
## Purpose
Предоставляет backend-контракт для просмотра, сохранения и экспорта корректировок уже сформированных агрегатов бухгалтерского баланса.
Предоставляет backend-контракт для просмотра, сохранения и экспорта корректировок агрегатов бухгалтерского баланса, вычисленных сервисом разнесения ОСВ.
## ADDED Requirements
### Requirement: Чтение таблицы корректировок
Система SHALL собирать и возвращать агрегаты ББ из готовых `vgo_assignment` с компаниями, статьями, счетами, исходными строками, суммами, отклонением и сохранённой корректировкой. GET MUST NOT изменять назначения или другие данные.
Система SHALL вызывать сервис разнесения AURORA-1751 через `CorrectionAggregateSource` и возвращать его агрегаты ББ с компаниями, статьями, счетами, позициями, сопоставленными парами, суммами, отклонением и сохранённой корректировкой. GET MUST NOT записывать вычисленное разнесение, пары или корректировки в БД.
#### Scenario: Чтение доступной версии
- **WHEN** пользователь с правом просмотра запрашивает таблицу доступной версии
- **THEN** система возвращает агрегаты и исходные строки в стабильном порядке
- **THEN** система возвращает вычисленные агрегаты, сопоставленные пары и оставшиеся позиции в стабильном порядке
- **THEN** в БД не создаются назначения или корректировки
#### Scenario: Готовые назначения отсутствуют
- **WHEN** для версии нет строк `vgo_assignment` с назначенным правилом
- **THEN** система возвращает пустую таблицу без самостоятельного разнесения ОСВ
#### Scenario: Сопоставленная пара
- **WHEN** сервис AURORA-1751 возвращает элемент в `matched` или `with_discrepancy`
- **THEN** агрегат содержит `pairs` с обеими сторонами этого элемента
- **THEN** каждая сторона пары содержит относящиеся к ней идентификаторы строк ОСВ, счета и сумму
- **THEN** суммы и отклонение пары совпадают с результатом AURORA-1751 без повторного сопоставления в сервисе корректировок
#### Scenario: Позиция без сопоставленной стороны
- **WHEN** ручная или односторонняя позиция агрегата не входит ни в одну вычисленную пару
- **THEN** она присутствует в соответствующей стороне агрегата без искусственно созданной встречной позиции
### Requirement: Расчёт отклонения и элиминации
Система SHALL считать абсолютное отклонение и процент к меньшей положительной сумме без предварительного округления. При отклонении не более 5 % справочная сумма элиминации SHALL равняться меньшей сумме; остаток SHALL относиться к большей статье.
@ -64,16 +70,18 @@
- **THEN** система отклоняет изменение
### Requirement: XLSX-экспорт корректировок
Система SHALL экспортировать таблицу одним листом XLSX. Строка агрегата SHALL сопровождаться следующими за ней раскрываемыми исходными строками; колонка правила у исходной строки SHALL быть пустой.
Система SHALL экспортировать таблицу одним листом XLSX. Строка агрегата SHALL сопровождаться следующими за ней раскрываемыми строками детализации; колонка правила у строки детализации SHALL быть пустой. Каждая сопоставленная пара SHALL занимать одну строку с одновременно заполненными колонками обеих компаний.
#### Scenario: Экспорт ББ
- **WHEN** пользователь экспортирует таблицу корректировок
- **THEN** файл называется `Корректировки_<название ГК>_<год>_<квартал>.xlsx`
- **THEN** книга содержит один лист с агрегатами, детализацией и итогами в рублях
- **THEN** позиции сопоставленной пары не дублируются отдельными строками под той же парой
- **THEN** ручные и односторонние позиции выводятся отдельными строками
- **THEN** система создаёт событие аудита `EXPORT`
### Requirement: Граница этапа
Система MUST NOT предоставлять в этом capability импорт XLSX, автоматическое разнесение строк ОСВ, ручной перенос строк между правилами или операции изменения `vgo_assignment`.
Система MUST NOT предоставлять в этом capability импорт XLSX, собственный алгоритм разнесения строк ОСВ, ручной перенос строк между правилами или операции изменения назначений. Вызов готового расчёта AURORA-1751 для чтения таблицы SHALL считаться интеграцией с источником, а не реализацией разнесения в capability корректировок.
#### Scenario: Контракт этапа
- **WHEN** публикуется OpenAPI этапа корректировок

View File

@ -2,7 +2,7 @@
## 1. Граница агрегатов и расчёт
- [x] 1.1 [arm-core] Заменить локальное разнесение ОСВ в `api/src/services/corrections.py` интерфейсом готовых агрегатов соседней задачи; удалить `domain/vgo_allocation.py` и его тесты. Проверка: OpenAPI/GET не создают назначения, при пустом источнике возвращается пустая таблица.
- [x] 1.1 [arm-core] Заменить локальное разнесение ОСВ интерфейсом `CorrectionAggregateSource`; реализация `VgoCorrectionSource` вызывает `VgoService.assignment_candidates` AURORA-1751 и не читает схему назначений напрямую. Проверка: GET не создаёт назначения, пары или корректировки.
- [x] 1.2 [arm-core] Обновить `api/src/domain/corrections.py` и граничные тесты: при отклонении `≤ 5 %` `eliminated_amount = min`, разница остаётся в большей статье; `> 5 %` требует корректировки; нулевая меньшая сторона даёт `percent = null`.
- [x] 1.3 [arm-core] Удалить из schemas, README и тестов `unallocated`, manual decision и прочие поля, принадлежащие разнесению, если их не предоставляет утверждённый интерфейс источника. Проверка: поиск по `ASSIGN/UNASSIGN/EXCLUDE/INCLUDE` не находит контракт корректировок.
@ -13,26 +13,26 @@
## 3. Чтение и экспорт
- [x] 3.1 [arm-core] Согласовать GET DTO с обновлённой спецификацией и добавить тесты стабильной сортировки, рублей, read-only поведения, 403/404 и пустого источника агрегатов.
- [x] 3.2 [arm-core] Проверить одно-листовый XLSX: агрегат и раскрываемые исходные строки, пустая колонка «Правило» у детализации, итоговые суммы и имя `Корректировки_<ГК>_<год>_<квартал>.xlsx`; проверить аудит `EXPORT`.
- [x] 3.1 [arm-core] Согласовать GET DTO с обновлённой спецификацией: сохранить `matched` и `with_discrepancy` AURORA-1751 как `pairs` с обеими сторонами, суммами, отклонением и исходными `entry_ids`; добавить тесты адаптера, рублей, read-only поведения и OpenAPI.
- [x] 3.2 [arm-core] Проверить одно-листовый XLSX: агрегат и одна раскрываемая строка на вычисленную пару с обеими компаниями; не дублировать её позиции; ручные и односторонние позиции выводить отдельно; колонку «Правило» у детализации оставить пустой; проверить итоговые суммы и имя `Корректировки_<ГК>_<год>_<квартал>.xlsx`.
## 4. Документация и проверки
- [x] 4.1 [arm-core] Обновить README под фактические маршруты и границу этапа: без импорта, разнесения ОСВ, ручных переносов, `vgo_assignment`, КФО и ОФР. Проверка: примеры запросов соответствуют OpenAPI.
- [x] 4.1 [arm-core] Обновить README под фактические маршруты и границу этапа: без импорта, собственного разнесения ОСВ, ручных переносов, прямой зависимости от `vgo_assignment`, КФО и ОФР. Проверка: примеры запросов соответствуют OpenAPI.
- [x] 4.2 [arm-core] Выполнить `openspec validate corrections-api-stage-one --strict`, целевые unit/integration-тесты, `make check` и `make dbml-check`; зафиксировать фактические результаты без ссылок на тесты старого объёма.
## 5. Интеграция с распределением ОСВ
- [x] 5.1 [arm-core] Вернуть merge AURORA-1751 с `0011_vgo_assignment`, добавить read-only SQLAlchemy mapping и построение агрегатов из готовых `ASSIGN` без создания или изменения назначений.
- [x] 5.2 [arm-core] Обновить демо-seed, README и тесты: назначенные строки образуют агрегаты, отсутствие назначений даёт пустую таблицу; повторить проверки этапа.
- [x] 5.1 [arm-core] Интегрировать расчёт AURORA-1751 через публичный результат `assignment_candidates`; передать в адаптер позиции, `matched` и `with_discrepancy`, не добавляя модель данных для вычисленных пар.
- [x] 5.2 [arm-core] Добавить тесты источника и экспорта: состав обеих сторон и суммы пары совпадают с результатом 1751, а XLSX выводит сопоставленную пару одной строкой без повторения входящих позиций.
### Результаты проверки 29.10.2026
### Результаты проверки 30.10.2026
- `openspec validate corrections-api-stage-one --strict` — успешно.
- `make check` с тестовым `DEBUG=true` — успешно: ruff, format, mypy и 1933 unit-теста.
- Целевые unit/OpenAPI-тесты корректировок — 92 успешно.
- Целевые integration-тесты корректировок — 21 сценарий собран, включая сборку агрегата из `vgo_assignment`; запуск остановлен окружением до выполнения тестов: PostgreSQL отклонил пароль пользователя `armka` из `.env.test`.
- `make dbml-check` — успешно, расхождений не выведено.
- `ruff check` и `ruff format` изменённых файлов — успешно.
- `mypy` схемы, источника и экспорта корректировок — успешно.
- Целевые тесты источника, экспорта, VGO и OpenAPI — 90 успешно.
- Расширенный набор с API-тестами остановлен на зависшей инициализации интеграционных фикстур; завершившиеся целевые тесты ошибок не показали.
## Workflow follow-up