feat/corrections-back #208
2
Makefile
2
Makefile
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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, "Итого по агрегатам")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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}' "
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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` используется без изменения.
|
||||
|
||||
@ -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, расчёт/инвалидация КФО, ОФР и изменение статусов согласования не входят в этап.
|
||||
|
||||
@ -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 этапа корректировок
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user