Гудвилл: заготовка вклада КФО вынесена в AURORA-1546

Удалены contributions/load_for_kfo и фильтры общего свода ОСВ (load_osv_rows,
build_with_members возвращены к main); OpenSpec change без требований вклада КФО.
План доводки по ревью — docs/superpowers/plans/2026-11-06-goodwill-review-back.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-06 18:20:04 +04:00
parent 94895b6de7
commit 3e07276a96
12 changed files with 199 additions and 407 deletions

View File

@ -91,42 +91,3 @@ def totals(rows: Sequence[Row], enabled: bool) -> dict[str, Decimal]:
"goodwill_amount": sum((r.goodwill_amount or ZERO for r in selected), ZERO), "goodwill_amount": sum((r.goodwill_amount or ZERO for r in selected), ZERO),
"goodwill_in_kfo_amount": sum((r.goodwill_amount or ZERO for r in active if enabled and r.applicable), ZERO), "goodwill_in_kfo_amount": sum((r.goodwill_amount or ZERO for r in active if enabled and r.applicable), ZERO),
} }
@dataclass(frozen=True)
class Contribution:
version_id: uuid.UUID
rule_id: uuid.UUID
head_id: uuid.UUID
subsidiary_id: uuid.UUID
financial_investment_amount: Decimal
participation_amount: Decimal
goodwill_amount: Decimal
# Ключ исключения — balance, rule_id, head_id, subsidiary_id.
deltas: tuple[tuple[uuid.UUID, str, Decimal], ...]
def contributions(version_id: uuid.UUID, rule_id: uuid.UUID, rows: Sequence[Row], enabled: bool) -> list[Contribution]:
result = []
for row in rows:
if not enabled or not row.applicable:
continue
assert row.financial_investment_amount is not None
assert row.participation_amount is not None and row.goodwill_amount is not None
result.append(
Contribution(
version_id,
rule_id,
row.head_id,
row.subsidiary_id,
row.financial_investment_amount,
row.participation_amount,
row.goodwill_amount,
(
(row.head_id, "1170", -row.financial_investment_amount),
(row.subsidiary_id, "1310", -row.participation_amount),
(row.head_id, "GOODWILL", row.goodwill_amount),
),
)
)
return result

View File

@ -306,13 +306,7 @@ async def document_versions(session: AsyncSession, document_id: uuid.UUID) -> li
return list((await session.execute(stmt)).scalars()) return list((await session.execute(stmt)).scalars())
async def load_osv_rows( async def load_osv_rows(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[OsvEntry, Document, LegalEntity]]:
session: AsyncSession,
version_id: uuid.UUID,
*,
company_pairs: Collection[tuple[uuid.UUID, uuid.UUID]] | None = None,
account_ids: Collection[uuid.UUID] | None = None,
) -> list[tuple[OsvEntry, Document, LegalEntity]]:
"""Исходные строки свода: контрагентские (`group_type = COUNTERPARTY`) строки BU/SUM привязанных документов. """Исходные строки свода: контрагентские (`group_type = COUNTERPARTY`) строки BU/SUM привязанных документов.
`TOTAL` не хранится, строки `ACCOUNT` и данные `NU` отбрасываются. Порядок: имя ЮЛ, код счёта документа, квартал, `TOTAL` не хранится, строки `ACCOUNT` и данные `NU` отбрасываются. Порядок: имя ЮЛ, код счёта документа, квартал,
@ -332,22 +326,6 @@ async def load_osv_rows(
LegalEntity.name, Document.osv_account_code, Document.period_quarter, OsvEntry.row_number, OsvEntry.id LegalEntity.name, Document.osv_account_code, Document.period_quarter, OsvEntry.row_number, OsvEntry.id
) )
) )
if company_pairs is not None:
# NULL-контрагент может быть сопоставлен общим дорезолвом; точная пара проверяется после него.
stmt = stmt.where(
or_(
false(),
*(
and_(
OsvEntry.owner_legal_entity_id == head,
or_(OsvEntry.counterparty_id == subsidiary, OsvEntry.counterparty_id.is_(None)),
)
for head, subsidiary in company_pairs
),
)
)
if account_ids is not None:
stmt = stmt.where(OsvEntry.account_id.in_(account_ids))
return [(row[0], row[1], row[2]) for row in await session.execute(stmt)] return [(row[0], row[1], row[2]) for row in await session.execute(stmt)]

View File

@ -1,6 +1,5 @@
"""Пакетное чтение goodwill и привязанных подтверждённых ББ.""" """Пакетное чтение goodwill и привязанных подтверждённых ББ."""
from collections.abc import Collection
from uuid import UUID from uuid import UUID
from sqlalchemy import select from sqlalchemy import select
@ -25,10 +24,8 @@ from src.models.statements import (
) )
async def saved(session: AsyncSession, version_id: UUID, *, included_only: bool = False) -> list[Goodwill]: async def saved(session: AsyncSession, version_id: UUID) -> list[Goodwill]:
stmt = select(Goodwill).where(Goodwill.consolidation_version_id == version_id) stmt = select(Goodwill).where(Goodwill.consolidation_version_id == version_id)
if included_only:
stmt = stmt.where(Goodwill.include_in_kfo.is_(True))
return list((await session.scalars(stmt)).all()) return list((await session.scalars(stmt)).all())
@ -39,7 +36,7 @@ async def accounts(session: AsyncSession) -> list[ChartOfAccount]:
return list((await session.scalars(select(ChartOfAccount).join(tree, ChartOfAccount.id == tree.c.id))).all()) return list((await session.scalars(select(ChartOfAccount).join(tree, ChartOfAccount.id == tree.c.id))).all())
async def forms(session: AsyncSession, version_id: UUID, company_ids: Collection[UUID] | None = None): async def forms(session: AsyncSession, version_id: UUID):
stmt = ( stmt = (
select(Document, Meta) select(Document, Meta)
.join(Meta, Meta.document_id == Document.id) .join(Meta, Meta.document_id == Document.id)
@ -50,8 +47,6 @@ async def forms(session: AsyncSession, version_id: UUID, company_ids: Collection
Meta.statement_type == StatementType.balance, Meta.statement_type == StatementType.balance,
) )
) )
if company_ids is not None:
stmt = stmt.where(Document.legal_entity_id.in_(company_ids))
return list((await session.execute(stmt)).all()) return list((await session.execute(stmt)).all())

View File

@ -13,7 +13,7 @@
import uuid import uuid
from collections.abc import Collection, Sequence from collections.abc import Collection, Sequence
from dataclasses import dataclass, replace from dataclasses import dataclass
from typing import Literal from typing import Literal
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
@ -149,13 +149,7 @@ class ConsolidatedOsvService:
self._session = session self._session = session
self._scope = scope self._scope = scope
async def build_with_members( async def build_with_members(self, version_id: uuid.UUID) -> ConsolidatedOsvBuildResult:
self,
version_id: uuid.UUID,
*,
company_pairs: Collection[tuple[uuid.UUID, uuid.UUID]] | None = None,
account_ids: Collection[uuid.UUID] | None = None,
) -> ConsolidatedOsvBuildResult:
"""Свод и участники периметра, загруженные при его расчёте; версия вне видимости — 404.""" """Свод и участники периметра, загруженные при его расчёте; версия вне видимости — 404."""
found = await repo.get_version(self._session, self._scope, version_id) found = await repo.get_version(self._session, self._scope, version_id)
if found is None: if found is None:
@ -173,12 +167,7 @@ class ConsolidatedOsvService:
for document in await consolidation_documents.for_consolidation(self._session, bound, consolidation) for document in await consolidation_documents.for_consolidation(self._session, bound, consolidation)
] ]
# ровно то, что отдал `load_osv_rows`: он оставляет `COUNTERPARTY`/`BU`+`SUM`, домен их не перефильтровывает # ровно то, что отдал `load_osv_rows`: он оставляет `COUNTERPARTY`/`BU`+`SUM`, домен их не перефильтровывает
rows = [ rows = [_source_row(entry) for entry, _, _ in await repo.load_osv_rows(self._session, version.id)]
_source_row(entry)
for entry, _, _ in await repo.load_osv_rows(
self._session, version.id, company_pairs=company_pairs, account_ids=account_ids
)
]
index = await CounterpartyIndex.load(self._session) index = await CounterpartyIndex.load(self._session)
entities = {**await _entities(self._session, _candidate_ids(rows, index) - members.keys()), **members} entities = {**await _entities(self._session, _candidate_ids(rows, index) - members.keys()), **members}
consolidated = domain.build( consolidated = domain.build(
@ -191,12 +180,6 @@ class ConsolidatedOsvService:
rows=rows, rows=rows,
resolver=index, resolver=index,
) )
if company_pairs is not None:
pairs = set(company_pairs)
consolidated = replace(
consolidated,
rows=tuple(row for row in consolidated.rows if (row.legal_entity_id, row.counterparty_id) in pairs),
)
return ConsolidatedOsvBuildResult( return ConsolidatedOsvBuildResult(
consolidated=consolidated, consolidated=consolidated,
version=version, version=version,

View File

@ -15,9 +15,8 @@ from src.common.exceptions import ConflictError, DomainValidationError, NotFound
from src.domain import goodwill as domain from src.domain import goodwill as domain
from src.domain.policy import VisibilityScope, ensure_version_editable from src.domain.policy import VisibilityScope, ensure_version_editable
from src.models.access import AppUser from src.models.access import AppUser
from src.models.accounting import EliminationRule
from src.models.directories import LegalEntity from src.models.directories import LegalEntity
from src.models.enums import AuditAction, AuditSection, StatementType from src.models.enums import AuditAction, AuditSection
from src.models.kfo import Goodwill from src.models.kfo import Goodwill
from src.repositories import consolidations as versions from src.repositories import consolidations as versions
from src.repositories import goodwill as repo from src.repositories import goodwill as repo
@ -30,12 +29,11 @@ from src.schemas.goodwill import (
GoodwillTotalsOut, GoodwillTotalsOut,
WarningOut, WarningOut,
) )
from src.services.consolidation_guard import VERSION_NOT_FOUND, lock_editable_version from src.services.consolidation_guard import lock_editable_version
from src.services.goodwill_export import build_workbook, matches_sign from src.services.goodwill_export import build_workbook, matches_sign
from src.services.goodwill_source import GoodwillSource from src.services.goodwill_source import GoodwillSource
MAX_SHARE = Decimal(100) MAX_SHARE = Decimal(100)
BB_RULE_NO = 2
MESSAGES = { MESSAGES = {
"CAPITAL_SOURCE_MISSING": "Нет однозначного значения 1310 на конец отчётного периода", "CAPITAL_SOURCE_MISSING": "Нет однозначного значения 1310 на конец отчётного периода",
@ -258,40 +256,3 @@ class GoodwillService:
after_value={"report": "goodwill", "rows": sum(matches_sign(row, sign) for row in table.rows)}, after_value={"report": "goodwill", "rows": sum(matches_sign(row, sign) for row in table.rows)},
) )
return content return content
async def contributions(self, version_id: UUID) -> list[domain.Contribution]:
found = await versions.get_version(self.session, self.scope, version_id)
if found is None:
raise NotFoundError(VERSION_NOT_FOUND)
version, _ = found
if not version.goodwill_enabled:
return []
saved = await repo.saved(self.session, version_id, included_only=True)
if not saved:
return []
pairs = {(row.head_legal_entity_id, row.subsidiary_legal_entity_id) for row in saved}
sources = await self.source.load_for_kfo(version_id, pairs)
decisions = {
(r.head_legal_entity_id, r.subsidiary_legal_entity_id): domain.Decision(
r.ownership_share, r.include_in_kfo, r.comment
)
for r in saved
}
rows = domain.build(sources.investments, decisions, sources.capital)
if not any(row.applicable for row in rows):
return []
rules = list(
(
await self.session.scalars(
select(EliminationRule).where(
EliminationRule.version_no == sources.context.version.elimination_rules_version,
EliminationRule.statement_type == StatementType.balance,
EliminationRule.rule_no == BB_RULE_NO,
)
)
).all()
)
if len(rules) != 1:
raise ConflictError("GOODWILL_RULE_MISSING", "Нет однозначного ББ-2 закреплённой версии правил")
return domain.contributions(version_id, rules[0].id, rows, sources.context.version.goodwill_enabled)

View File

@ -9,7 +9,7 @@ from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.goodwill import Investment, Pair from src.domain.goodwill import Investment
from src.domain.statement_aggregate import AggLine, aggregate_by_code from src.domain.statement_aggregate import AggLine, aggregate_by_code
from src.repositories import goodwill as repo from src.repositories import goodwill as repo
from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService
@ -71,23 +71,11 @@ class GoodwillSource:
self.session, self.osv = session, osv self.session, self.osv = session, osv
async def load(self, version_id: UUID) -> Sources: async def load(self, version_id: UUID) -> Sources:
return await self._load(version_id)
async def load_for_kfo(self, version_id: UUID, pairs: set[Pair]) -> Sources:
"""Источники только сохранённых выбранных пар; таблица использует обычный load."""
return await self._load(version_id, pairs)
async def _load(self, version_id: UUID, pairs: set[Pair] | None = None) -> Sources:
accounts = await repo.accounts(self.session) accounts = await repo.accounts(self.session)
allowed = {account.id for account in accounts} allowed = {account.id for account in accounts}
if pairs is None: ctx = await self.osv.build_with_members(version_id)
ctx = await self.osv.build_with_members(version_id)
else:
ctx = await self.osv.build_with_members(version_id, company_pairs=pairs, account_ids=allowed)
result = Sources(ctx, investments=_investments(ctx, allowed)) result = Sources(ctx, investments=_investments(ctx, allowed))
forms = await repo.forms( forms = await repo.forms(self.session, version_id)
self.session, version_id, {subsidiary for _, subsidiary in pairs} if pairs is not None else None
)
columns, values = await repo.form_data(self.session, [m.id for _, m in forms]) columns, values = await repo.form_data(self.session, [m.id for _, m in forms])
month = int(ctx.consolidation.quarter.value[1:]) * 3 month = int(ctx.consolidation.quarter.value[1:]) * 3
date = dt.date(ctx.consolidation.year, month, calendar.monthrange(ctx.consolidation.year, month)[1]) date = dt.date(ctx.consolidation.year, month, calendar.monthrange(ctx.consolidation.year, month)[1])

View File

@ -1,227 +0,0 @@
"""Гудвилл изменяет актив и пассив на одинаковую сумму, не смешивает пары."""
from decimal import Decimal
from http import HTTPStatus
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import UUID
import pytest
from sqlalchemy import func, select
from src.common.exceptions import ConflictError
from src.domain.goodwill import Decision, Investment, build, contributions
from src.domain.policy import VisibilityScope
from src.models.access import AppUser
from src.models.accounting import ChartOfAccount, EliminationRule
from src.models.consolidation import ConsolidationDocument
from src.models.enums import DocumentType, PeriodQuarter, StatementType
from src.models.files import StoredFile
from src.models.kfo import Correction, Goodwill, KfoResult
from src.models.osv import OsvEntry
from src.models.statements import FinancialStatementMeta
from src.repositories import consolidations as version_repo
from src.repositories import goodwill as goodwill_repo
from src.services.consolidated_osv import ConsolidatedOsvService
from src.services.goodwill import GoodwillService
from src.services.goodwill_source import GoodwillSource
from tests.factories import make_confirmed_osv
from tests.test_api_consolidated_osv import KA_RF
from tests.test_api_goodwill import goodwill_world
from tests.test_goodwill_golden import investment_rows
BB_RULE_NO = 2
pytestmark = pytest.mark.fixtures
def test_contribution_balance(fixtures_dir: Path) -> None:
a, b, version, rule = (UUID(int=i) for i in range(1, 5))
fv = Decimal(investment_rows(fixtures_dir)[0]["closing_debit"])
saved = {(a, b): Decision(Decimal("80"), True, "Подтверждено")}
rows = build([Investment(a, b, fv)], saved, {b: Decimal("100000000")})
item = contributions(version, rule, rows, True)[0]
assert item.rule_id == rule and item.head_id == a and item.subsidiary_id == b
assert sum(v for _, code, v in item.deltas if code in ("1170", "GOODWILL")) == -item.participation_amount
assert dict((code, value) for _, code, value in item.deltas)["1310"] == -item.participation_amount
assert contributions(version, rule, rows, False) == []
assert contributions(version, rule, build([Investment(a, b, fv)], saved, {}), True) == []
assert contributions(version, rule, build([Investment(a, b, fv)], saved, {b: fv * 2}), True) == []
@pytest.mark.integration
async def test_service_uses_pinned_rule_without_writing_kfo(db_session, auth_client, fixtures_dir):
world = await goodwill_world(db_session, fixtures_dir)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{world.version.id}"
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
assert (
await client.put(
base + f"/goodwill/{world.a.id}/{world.b.id}",
json=dict(ownership_share="80", include_in_kfo=True, comment="Да"),
)
).status_code == HTTPStatus.OK
assert (await client.put(base + "/goodwill-settings", json={"goodwill_enabled": True})).status_code == HTTPStatus.OK
pinned = (
await db_session.scalars(
select(EliminationRule).where(
EliminationRule.version_no == 1,
EliminationRule.rule_no == BB_RULE_NO,
EliminationRule.statement_type == StatementType.balance,
)
)
).one()
other = EliminationRule(
**{
col.name: getattr(pinned, col.name)
for col in EliminationRule.__table__.columns
if col.name not in ("id", "version_no")
},
version_no=2,
)
db_session.add(other)
await db_session.flush()
principal_user = (await db_session.scalars(select(AppUser).where(AppUser.email == KA_RF))).one()
scope = VisibilityScope(office_id=None)
service = GoodwillService(
db_session,
scope,
SimpleNamespace(user=principal_user, permissions={"CONSOLIDATION_EDIT"}),
GoodwillSource(db_session, ConsolidatedOsvService(db_session, scope)),
)
result = await service.contributions(world.version.id)
assert len(result) == 1 and result[0].rule_id == pinned.id
assert await db_session.scalar(select(func.count()).select_from(KfoResult)) == 0
assert await db_session.scalar(select(func.count()).select_from(Correction)) == 0
# Дорезолв на чтение сохраняет те же выбранные пары и суммы.
entry = (
await db_session.scalars(
select(OsvEntry).where(OsvEntry.owner_legal_entity_id == world.a.id, OsvEntry.account_code == "58.01")
)
).one()
entry.counterparty_id = None
entry.counterparty_name = world.b.name
await db_session.flush()
assert await service.contributions(world.version.id) == result
pinned.rule_no = 99
await db_session.flush()
with pytest.raises(ConflictError) as error:
await service.contributions(world.version.id)
assert error.value.code == "GOODWILL_RULE_MISSING"
world.version.goodwill_enabled = False
await db_session.flush()
assert await service.contributions(world.version.id) == []
@pytest.mark.integration
async def test_kfo_loads_only_saved_selected_pairs(db_session, auth_client, fixtures_dir, monkeypatch):
world = await goodwill_world(db_session, fixtures_dir)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{world.version.id}"
await client.post(base + "/lock")
await client.put(
base + f"/goodwill/{world.a.id}/{world.b.id}",
json=dict(ownership_share="80", include_in_kfo=True, comment="Да"),
)
await client.put(base + "/goodwill-settings", json={"goodwill_enabled": True})
db_session.add(
Goodwill(
consolidation_version_id=world.version.id,
head_legal_entity_id=world.a.id,
subsidiary_legal_entity_id=world.chkf.id,
ownership_share=Decimal("100"),
include_in_kfo=False,
)
)
account = (await db_session.scalars(select(ChartOfAccount).where(ChartOfAccount.code == "58.01"))).one()
for owner, other in ((world.a, world.chkf), (world.b, world.a)):
doc = await make_confirmed_osv(
db_session,
entity=owner,
account="58.01",
year=2024,
quarter=PeriodQuarter.Q3,
is_cumulative=True,
rows=[
dict(
name=other.name,
counterparty_id=other.id,
account_id=account.id,
closing_debit=Decimal("269997300.00"),
)
],
)
db_session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=doc.id))
extra = await make_confirmed_osv(
db_session, entity=world.a, account="80", year=2024, quarter=PeriodQuarter.Q3, is_cumulative=True, rows=[]
)
extra.document_type = DocumentType.BALANCE
extra.osv_account_code = None
file = await db_session.get(StoredFile, extra.stored_file_id)
meta = FinancialStatementMeta(
document_id=extra.id,
recognition_job_id=file.current_recognition_job_id,
statement_type=StatementType.balance,
units="тыс. руб.",
)
db_session.add(meta)
db_session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=extra.id))
await db_session.flush()
original_rows, original_forms = version_repo.load_osv_rows, goodwill_repo.forms
loaded_pairs, loaded_form_owners = [], []
async def capture_rows(*args, **kwargs):
assert kwargs["company_pairs"] == {(world.a.id, world.b.id)}
assert account.id in kwargs["account_ids"]
rows = await original_rows(*args, **kwargs)
loaded_pairs.extend((e.owner_legal_entity_id, e.counterparty_id) for e, _, _ in rows)
return rows
async def capture_forms(*args, **kwargs):
rows = await original_forms(*args, **kwargs)
loaded_form_owners.extend(doc.legal_entity_id for doc, _ in rows)
return rows
monkeypatch.setattr(version_repo, "load_osv_rows", capture_rows)
monkeypatch.setattr(goodwill_repo, "forms", capture_forms)
user = (await db_session.scalars(select(AppUser).where(AppUser.email == KA_RF))).one()
scope = VisibilityScope(office_id=None)
source = GoodwillSource(db_session, ConsolidatedOsvService(db_session, scope))
source.load = AsyncMock(side_effect=AssertionError("Полная таблица для КФО не нужна"))
service = GoodwillService(db_session, scope, SimpleNamespace(user=user), source)
result = await service.contributions(world.version.id)
assert len(result) == 1
assert {(r.head_id, r.subsidiary_id) for r in result} == {(world.a.id, world.b.id)}
assert loaded_pairs and set(loaded_pairs) == {(world.a.id, world.b.id)}
assert loaded_form_owners == [world.b.id]
source.load.assert_not_awaited()
@pytest.mark.integration
async def test_kfo_without_selected_rows_does_not_load_sources(db_session, auth_client, fixtures_dir):
world = await goodwill_world(db_session, fixtures_dir)
world.version.goodwill_enabled = True
await db_session.flush()
user = (await db_session.scalars(select(AppUser).where(AppUser.email == KA_RF))).one()
scope = VisibilityScope(office_id=None)
source = GoodwillSource(db_session, ConsolidatedOsvService(db_session, scope))
source.load_for_kfo = AsyncMock(side_effect=AssertionError("Нет выбранных пар — нет источников"))
service = GoodwillService(db_session, scope, SimpleNamespace(user=user), source)
assert await service.contributions(world.version.id) == []
source.load_for_kfo.assert_not_awaited()
db_session.add(
Goodwill(
consolidation_version_id=world.version.id,
head_legal_entity_id=world.a.id,
subsidiary_legal_entity_id=world.b.id,
ownership_share=Decimal("100"),
include_in_kfo=True,
comment="Да",
)
)
world.version.goodwill_enabled = False
await db_session.flush()
assert await service.contributions(world.version.id) == []
source.load_for_kfo.assert_not_awaited()

View File

@ -0,0 +1,181 @@
# Гудвилл: доводка бэка по ревью (arm-core #222) — план
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** довести PR arm-core #222 (ветка `feat/goodwill-back`, AURORA-1543) до мержа: убрать невызываемую заготовку КФО,
итоги по ГО и УК отдать с бэка, долю ограничить двумя знаками, сохранение сделать пакетным в одной транзакции, единицы ББ
читать через словарь `arm_statements`, привести контракт в docs (ветка `feat/204-goodwill-contract`, issue docs #204),
§8 правил и OpenSpec change к фактическому API.
**Architecture:** чистый расчёт — `api/src/domain/goodwill.py`; загрузка источников — `services/goodwill_source.py` +
`repositories/goodwill.py`; сервис/маршруты — `services/goodwill.py`, `api/endpoints/consolidations/goodwill.py`; экспорт —
`services/goodwill_export.py`. Общий свод ОСВ (`repositories/consolidations.load_osv_rows`,
`services/consolidated_osv.build_with_members`) возвращается к состоянию `main`.
**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, pydantic v2, openpyxl, pytest (маркеры `integration`, `fixtures`).
**Spec:** `openspec/changes/goodwill-table/` (этого репозитория) + страница аналитики DOC-259025 «Гудвилл» (версия 03.11.2026,
`mcp__sdlc-wiki__page code=DOC-259025`) + решения ревью ниже.
Рабочие каталоги: arm-core — `/Users/andrey.epifancev/projects/awp-ca/.wt/review-222/arm-core` (ветка `feat/goodwill-back`),
docs — `/Users/andrey.epifancev/projects/awp-ca/.wt/review-222/docs` (ветка `feat/204-goodwill-contract`). Git — только
`git -C <абсолютный путь worktree>`.
## Global Constraints
- Деньги — `Decimal`, `ROUND_HALF_UP` до 0.01, в API — строки с двумя знаками в рублях; `null` ≠ `0.00`.
- Доля владения: строка `0`…`100`, не больше **двух** знаков после запятой, запятая или точка; лишние знаки — 422
`INVALID_SHARE`, без округления и обрезки. В ответе — строка с двумя знаками (`"80.00"`).
- `is_head_entity` не используется; ГО — владелец строки 58.01 (и потомков) с контрагентом из периметра.
- Аудит — `GOODWILL_EDIT` (строки: `object_type=goodwill`; переключатель: `object_type=consolidation_version`,
`changed_field=goodwill_enabled`), выгрузка — `EXPORT`; новых enum и колонок нет, миграций нет.
- Коммиты — по-русски, в конце `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Решения ревью (дописать в `openspec/changes/goodwill-table/design.md`, раздел `## Decisions made without discussion`):
R-1 доля — 2 знака (DOC-259025 §4, §5.2, §9; DBML `numeric(7,4)` хранит без изменений); R-2 построчный `PUT …/goodwill/{head}/{sub}`
заменён пакетным `PUT …/goodwill` в одной транзакции (DOC-259025 §5.1 п. 4); R-3 заготовка вклада КФО (`contributions`,
`load_for_kfo`, фильтры общего свода) вынесена в AURORA-1546; R-4 итоги по ГО и итог УК считает бэк; R-5 единицы ББ —
через словарь единиц `arm_statements`; R-6 пометка результата КФО устаревшим — в AURORA-1546 (результата КФО ещё нет).
## Review Focus
- Пакет, где одна строка невалидна: ничего не сохраняется, ни одной записи `goodwill` и `audit_event`, в 422 есть ключ строки.
- Повторное сохранение без изменений: `updated_by/updated_at` не меняются, аудита нет (фронт шлёт весь набор отмеченных строк).
- Единицы ББ, исправленные аналитиком вручную («тыс.руб.», «в тысячах рублей»): УК всё равно переводится в рубли.
- Итог «Всего» блока: ФВ и УК — по всем действующим строкам блока, P и G — только отмеченным; «Нет вложений» не входит.
- Доля `"80,123"` — 422, а не молча `80.12`.
---
### Task 1: Убрать заготовку вклада КФО
**Files:**
- Modify: `api/src/domain/goodwill.py` (удалить `Contribution`, `contributions`)
- Modify: `api/src/services/goodwill.py` (удалить `contributions`, `BB_RULE_NO`, импорт `EliminationRule`, `StatementType`)
- Modify: `api/src/services/goodwill_source.py` (удалить `load_for_kfo`, параметр `pairs` у `_load`)
- Modify: `api/src/repositories/goodwill.py` (удалить `included_only` у `saved`, `company_ids` у `forms`)
- Restore from `origin/main`: `api/src/repositories/consolidations.py`, `api/src/services/consolidated_osv.py`
- Delete: `api/tests/test_goodwill_contribution.py`
- Modify: `openspec/changes/goodwill-table/{proposal,design,tasks}.md`, `specs/goodwill-table/spec.md`
**Interfaces:**
- Produces: `GoodwillSource.load(version_id: UUID) -> Sources` (единственный метод загрузки); `repo.saved(session, version_id) -> list[Goodwill]`;
`repo.forms(session, version_id)`.
- [ ] **Step 1:** `git -C <core> checkout origin/main -- api/src/repositories/consolidations.py api/src/services/consolidated_osv.py`;
убедиться `git diff origin/main -- <эти два файла>` пуст.
- [ ] **Step 2:** удалить перечисленный код и тест; `grep -rn "contributions\|load_for_kfo\|company_pairs\|included_only" api/src api/tests` — пусто.
- [ ] **Step 3:** в OpenSpec удалить требования «Подготовка вклада в КФО» и «Источники КФО только выбранных сохранённых пар»,
в design §6 и разделе «Отбор источников для КФО» оставить одну фразу «перенесено в AURORA-1546 (R-3)», в tasks.md пункты
4.2, 4.3, 7.x — пометить «перенесено в AURORA-1546».
- [ ] **Step 4:** `make lint` и `cd api && .venv/bin/pytest -m integration tests/test_api_goodwill.py tests/test_api_goodwill_mutations.py tests/test_goodwill_export.py tests/test_api_consolidated_osv.py -q` — зелёные.
- [ ] **Step 5:** коммит «Гудвилл: заготовка вклада КФО вынесена в AURORA-1546».
### Task 2: Доля — два знака, расчёт в домене, итоги по ГО и УК
**Files:**
- Modify: `api/src/domain/goodwill.py`, `api/src/schemas/goodwill.py`, `api/src/services/goodwill.py`, `api/src/services/goodwill_export.py`
- Test: `api/tests/test_goodwill_domain.py`, `api/tests/test_api_goodwill.py`, `api/tests/test_goodwill_export.py`, `api/tests/test_api_goodwill_mutations.py`
**Interfaces:**
- Produces (домен): `participation(capital: Decimal, share: Decimal) -> Decimal` (`capital*share/100`, HALF_UP до 0.01) — единственное
место формулы, `build` и сервис используют его; `totals(rows, enabled) -> dict[str, Decimal]` с ключами
`financial_investment_amount, charter_capital_amount, participation_amount, goodwill_amount, goodwill_in_kfo_amount`
(ФВ и УК — все действующие строки; P/G — действующие отмеченные; в КФО — `applicable` и `enabled`);
`group_totals(rows, enabled) -> dict[UUID, dict[str, Decimal]]` = `totals` по строкам каждой ГО (`head_id`).
- Produces (схемы): `GoodwillTotalsOut` + поле `charter_capital_amount: Money`; `GoodwillGroupOut {head_legal_entity: RefOut, totals: GoodwillTotalsOut}`;
`GoodwillTableOut.groups: list[GoodwillGroupOut]` — порядок как у строк (имя ГО, id); `Share` сериализуется `format(v, ".2f")`.
- Produces (сервис): `validate_decision(data) -> domain.Decision` принимает `^\d{1,3}(?:[.,]\d{1,2})?$` и `≤ 100`.
- [ ] **Step 1: тест домена на примере DOC-259025 §11.1** — `test_goodwill_domain.py::test_doc_example_totals`: ГО «Альфа»;
Бета ФВ 180000 УК 100000 доля 80 отмечена, комментарий → P 80000.00, G 100000.00; Гамма ФВ 50000 УК 100000 доля 80 отмечена → P 80000.00, G −30000.00;
Дельта ФВ 60000 УК 60000 без доли, не отмечена → P/G None. `totals(rows, True)` = ФВ 290000.00, УК 260000.00, P 160000.00,
G 70000.00, в КФО 100000.00; `totals(rows, False)["goodwill_in_kfo_amount"] == 0`; `group_totals` для «Альфа» совпадает с `totals`;
строка «Нет вложений» (сохранённое решение без ФВ) не меняет ни одной суммы.
- [ ] **Step 2: тест доли** — `test_api_goodwill_mutations.py`: параметры `test_invalid_share` дополнить `"80.123"`, `"80,123"`, `"100.01"`, `"-1"`, `"1e2"`;
`test_valid_share` — `"0"`, `"100"`, `"80,12"`, `"7.5"`; ответ строки отдаёт `"80.12"`.
- [ ] **Step 3:** прогнать — FAIL (нет `participation`, `group_totals`, `charter_capital_amount`, регулярка пропускает 4 знака).
- [ ] **Step 4:** реализовать по Interfaces; в `service.save` (до Task 3) и `build` убрать собственные формулы P в пользу `participation`;
`table()` заполняет `groups` из `group_totals`; сообщение `INVALID_SHARE` — «Доля — число от 0 до 100, не больше двух знаков после запятой».
- [ ] **Step 5: экспорт** — «Всего» блока брать из `table.groups` (удалить `_totals`), в «Итого по листу» добавить УК, формат доли `"0.00"`;
`test_goodwill_export.py`: значение УК в строке «Итого по листу» равно `totals.charter_capital_amount`.
- [ ] **Step 6:** `test_api_goodwill.py`: в ответе таблицы есть `groups[0].totals` и `totals.charter_capital_amount` с ожидаемыми строками.
- [ ] **Step 7:** `make lint`, интеграционные тесты гудвилла — PASS; коммит «Гудвилл: доля до двух знаков, итоги по ГО и УК с бэка».
### Task 3: Пакетное сохранение в одной транзакции
**Files:**
- Modify: `api/src/schemas/goodwill.py`, `api/src/services/goodwill.py`, `api/src/api/endpoints/consolidations/goodwill.py`
- Test: `api/tests/test_api_goodwill_mutations.py`, `api/tests/test_api_goodwill.py`, `api/tests/test_goodwill_export.py`, `api/tests/test_openapi_routes.py`
**Interfaces:**
- Consumes: `validate_decision`, `participation` (Task 2).
- Produces: маршрут `PUT /api/v1/consolidations/versions/{version_id}/goodwill`, право `CONSOLIDATION_EDIT`, тело
`GoodwillBatchIn {rows: list[GoodwillRowKeyIn]}` (`min_length=1`, `extra="forbid"`), где
`GoodwillRowKeyIn = {head_legal_entity_id: UUID, subsidiary_legal_entity_id: UUID, ownership_share: str | None, include_in_kfo: bool, comment: str | None (≤1000)}`;
ответ 200 `GoodwillTableOut`. Маршрут `PUT …/goodwill/{head}/{sub}` и `GoodwillService.save` удаляются; новый
`GoodwillService.save_rows(version_id: UUID, rows: list[GoodwillRowKeyIn], audit: RequestAudit) -> GoodwillTableOut`.
- Ошибки (все — откат всего пакета): 404 `NOT_FOUND` «Пара не найдена» и 422 `INVALID_SHARE` / `COMMENT_REQUIRED` /
`MONEY_OUT_OF_RANGE` с доп. полями `head_legal_entity_id`, `subsidiary_legal_entity_id` (для `NotFoundError` — создать
`DomainError(404, "NOT_FOUND", …, extra=…)`); пара дважды в пакете — 422 `DUPLICATE_ROW` с теми же полями; 409 правок версии — как раньше.
- [ ] **Step 1: тесты** (`test_api_goodwill_mutations.py`, переписать вызовы на пакет):
`test_batch_rolls_back_when_one_row_invalid` — две пары, вторая с `include_in_kfo=true` без комментария → 422 `COMMENT_REQUIRED`,
`detail.subsidiary_legal_entity_id` = id второй ДО, в БД 0 записей `goodwill` и 0 `audit_event(GOODWILL_EDIT)`;
`test_unchanged_rows_are_not_rewritten` — сохранить строку, повторить тот же пакет → `updated_at` прежний, событий `GOODWILL_EDIT` по-прежнему одно;
`test_new_row_with_defaults_is_not_created` — пакет `{share: null, include: false, comment: null}` по паре без записи → записей 0;
`test_duplicate_pair_in_batch` → 422 `DUPLICATE_ROW`; `test_unknown_pair` → 404 с ключом пары. Сценарии прежнего
`test_save_settings_and_disappearing_pair` и `test_money_overflow_keeps_database_unchanged` сохранить, переведя на пакет.
- [ ] **Step 2:** `test_openapi_routes.py` — путь `…/goodwill`: `{"get", "put"}`, путь `…/goodwill/{head_legal_entity_id}/{subsidiary_legal_entity_id}` удалён.
- [ ] **Step 3:** прогнать — FAIL.
- [ ] **Step 4:** реализовать `save_rows`: `lock_editable_version` → одна сборка таблицы → проверка дублей, пар и ввода всех
строк до любой записи → upsert только изменённых (сравнение `ownership_share`, `include_in_kfo`, `comment` с сохранённым;
для пары без записи «изменение» — отличие от `Decision()`) с пересчётом сумм как сейчас → `flush` → одна сборка таблицы →
`GOODWILL_EDIT` по каждой изменённой строке (`before` — строка ответа до, `null` для новой; `after` — строка ответа после) →
вернуть таблицу. Поведение «отсутствующий ввод не затирает сохранённое у неактивной пары» сохранить.
- [ ] **Step 5:** PASS; `make lint`; коммит «Гудвилл: пакетное сохранение таблицы одной транзакцией».
### Task 4: Единицы ББ через словарь arm_statements
**Files:**
- Modify: `api/src/services/statements_engine.py` (Protocol `StatementsEngine` и `ArmStatementsEngine`), `api/src/services/goodwill_source.py`
- Test: `api/tests/test_goodwill_source.py`
**Interfaces:**
- Produces: `StatementsEngine.canonical_units(text: str | None) -> str | None` — `normalize_caption(text)` (из `arm_statements.text`)
и первый совпавший шаблон `self._rules.units.spellings` → каноническая единица («руб.», «тыс. руб.», «млн руб.»), иначе `None`.
`capital_amounts` берёт множитель по `default_engine().canonical_units(units)`.
- [ ] **Step 1: тест** `test_capital_units_spellings` (параметры): `"тыс.руб."`, `"в тысячах рублей"`, `"тыс. руб."` → УК = значение × 1000;
`"руб"` → × 1; `"млн. руб."` → × 1000000; `"штук"` и `None` → `(None, None)`.
- [ ] **Step 2:** FAIL → реализовать → PASS; если пакетный словарь в ядре подменяется заглушкой в тестах (`StatementsEngine` фейки в `api/tests`),
добавить метод и туда (`grep -rn "class .*Engine" api/tests`).
- [ ] **Step 3:** `make lint`; коммит «Гудвилл: единицы ББ по словарю arm_statements».
### Task 5: Контракт, §8, OpenSpec, README
**Files:**
- docs: `dev/api-contract-goodwill.md`, `dev/api-contract-goodwill.openapi.yaml`, `context/05-domain-rules.md` (§8 «Гудвилл»)
- arm-core: `api/tests/test_goodwill_contract.py`, `README.md` (раздел «Таблица гудвилла», таблица «Ошибки API»),
`openspec/changes/goodwill-table/{proposal,design,tasks}.md`, `specs/goodwill-table/spec.md`
**Interfaces:**
- Consumes: фактический `app.openapi()` после Tasks 1–4.
- [ ] **Step 1: тест контракта** — читать `Path(__file__).resolve().parents[3] / "docs/dev/api-contract-goodwill.openapi.yaml"`,
при отсутствии файла — `pytest.skip("нет ../docs: контракт гудвилла не проверен")`; сравнение путей `/goodwill` и всех схем
контракта с `app.openapi()` — как сейчас; проверку `validate_decision` перевести на `"80,12"` → `Decimal("80.12")`.
- [ ] **Step 2: OpenAPI-копия** — выгрузить из `app.openapi()` пути с `/goodwill` и все схемы, на которые они ссылаются
(транзитивно, включая `RefOut`, `WarningOut`, `HTTPValidationError`), в `docs/dev/api-contract-goodwill.openapi.yaml` (`openapi`, `info`
с версией `1.0`, `paths`, `components.schemas`); тест Step 1 — PASS.
- [ ] **Step 3: MD-контракт** — переписать `docs/dev/api-contract-goodwill.md` под факт: источники (DOC-259025 03.11 первым), понятия
(ГО по вложениям, «Нет вложений», P/G только у отмеченных, предупреждения `CAPITAL_SOURCE_MISSING`/`CAPITAL_SOURCE_AMBIGUOUS`/
`OWNERSHIP_TOTAL_EXCEEDED`), эндпоинты (GET, PUT пакет, PUT settings, GET export с `sign=all|positive|negative|zero`), схемы, ошибки,
пример ответа с `groups`, открытые вопросы OQ-10/OQ-37; раздел «Что изменилось против черновика 30.10» — для фронта.
- [ ] **Step 4: §8** — заменить абзац «Гудвилл» в `05-domain-rules.md` на выжимку DOC-259025 §§1–8 с решениями R-1, R-5 и
пометкой «применение в КФО и исключение ББ-2 — AURORA-1546, не реализовано».
- [ ] **Step 5: arm-core** — README: ссылка на `../docs/dev/api-contract-goodwill.md`, пакетный PUT, коды `INVALID_SHARE`,
`COMMENT_REQUIRED`, `MONEY_OUT_OF_RANGE`, `DUPLICATE_ROW` в «Ошибки API»; OpenSpec — требования «Построчное сохранение» → «Пакетное сохранение»,
доля — два знака, итоги по ГО и УК, единицы; раздел `## Decisions made without discussion` с R-1…R-6 (дата 2026-11-06, решение,
обоснование, цена ошибки); `openspec validate goodwill-table --strict` — valid.
- [ ] **Step 6:** `make check` (весь unit + lint) и `make test-integration` в arm-core — зелёные; `make dbml-check` — без расхождений.
- [ ] **Step 7:** коммиты: docs — «Гудвилл: контракт API и §8 под DOC-259025 и ревью core #222 (#204)»; arm-core — «Гудвилл: контракт в docs, README, OpenSpec под ревью».

View File

@ -42,7 +42,7 @@ GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениям
### 6. Граница КФО ### 6. Граница КФО
Внутренний DTO/чистая функция возвращает применимые пары с version_id, head_id, subsidiary_id, FV/P/G, дельтами и ключом исключения (balance, rule_id ББ-2 закреплённой elimination_rules_version, head_id, subsidiary_id). Переключатель — дополнительное обязательное условие. При выключенном переключателе или отсутствии применимых пар вклад пуст и правило не запрашивается. Для непустого вклада нет правила ББ-2 в закреплённом наборе — подготовка вклада даёт явный конфликт GOODWILL_RULE_MISSING, не подставляет старое правило №7. GET таблицы может работать без подготовки вклада. Не записывать KfoResult и не менять статусы/суммы Correction. Не создавать фиктивный механизм инвалидирования: требование AURORA-1543 о stale и фиксация VERIFIED перенесены в будущий change КФО с явной зависимостью на этот контракт. Подготовка вклада в КФО (применимые пары, изменения 1170/1310/GOODWILL, исключение ББ-2), выбор источников только сохранённых выбранных пар и пометка результата КФО устаревшим в этот change не входят — перенесено в AURORA-1546 (R-3, R-6). Этот change не пишет KfoResult и не трогает Correction.
### 7. XLSX ### 7. XLSX
@ -61,12 +61,3 @@ GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениям
Миграция БД не требуется: существующие поля и enum достаточны. Выкладка добавляет маршруты и обновлённый контракт; перед подключением фронта проверить nullable поля. Откат кода оставляет goodwill и аудит в совместимой схеме. Пока фронт отдельно, существующие страницы не изменяются. Миграция БД не требуется: существующие поля и enum достаточны. Выкладка добавляет маршруты и обновлённый контракт; перед подключением фронта проверить nullable поля. Откат кода оставляет goodwill и аудит в совместимой схеме. Пока фронт отдельно, существующие страницы не изменяются.
По решению пользователя ФВ пары — сумма только заполненных closing_debit; пустые значения остальных строк не блокируют расчёт P/G и сохранение ручного ввода. По решению пользователя ФВ пары — сумма только заполненных closing_debit; пустые значения остальных строк не блокируют расчёт P/G и сохранение ручного ввода.
## Отбор источников для КФО по запросу пользователя
У таблицы остаётся полный load. contributions проверяет видимость/переключатель, выбирает только сохранённые
include_in_kfo=true и вызывает load_for_kfo с направленными парами. Репозиторий ОСВ получает company_pairs и
account_ids ветки 58.01, репозиторий ББ — идентификаторы выбранных ДО. Общая нормализация периодов и дорезолв
сохраняются: NULL-контрагенты выбранных владельцев/счетов рассматриваются как кандидаты, итог свода после
дорезолва фильтруется строго по выбранным парам. Невыбранные сопоставленные пары не загружаются из osv_entry.
Без выбранных записей и при выключении нет загрузки ОСВ/ББ.

View File

@ -11,7 +11,7 @@
- Найденные пары объединяются с сохранёнными `goodwill`; исчезнувшие остаются с вычисляемым `has_investments=false`, без участия в итогах. - Найденные пары объединяются с сохранёнными `goodwill`; исчезнувшие остаются с вычисляемым `has_investments=false`, без участия в итогах.
- Доля 0–100%, до четырёх знаков; нерассчитанные P/G допускают null. Сохранение одной строки атомарно. - Доля 0–100%, до четырёх знаков; нерассчитанные P/G допускают null. Сохранение одной строки атомарно.
- Расчёт по §8 domain-rules с уточнениями PDF §§3–10 и решениями пользователя: ASSUMPTION(OQ-10) — УК только из 1310 ББ, 1350 не входит; ASSUMPTION(OQ-37) — подготовленный вклад имеет технический код GOODWILL. - Расчёт по §8 domain-rules с уточнениями PDF §§3–10 и решениями пользователя: ASSUMPTION(OQ-10) — УК только из 1310 ББ, 1350 не входит; ASSUMPTION(OQ-37) — подготовленный вклад имеет технический код GOODWILL.
- Подготовка чистого результата для будущего КФО: применимые пары, изменения 1170/1310/GOODWILL и исключаемое направление ББ-2. Запись КФО не выполняется. - Подготовка вклада в КФО и исключение ББ-2 — не в этом change, перенесено в AURORA-1546 (R-3).
- Уточнения PDF, отсутствующие в §8 (неактивные пары, выборка по дереву, итоги, исключение ББ-2), требуют отдельной синхронизации в docs; при реализации не подменять ими молча старую редакцию правил. - Уточнения PDF, отсутствующие в §8 (неактивные пары, выборка по дереву, итоги, исключение ББ-2), требуют отдельной синхронизации в docs; при реализации не подменять ими молча старую редакцию правил.
## Capabilities ## Capabilities

View File

@ -81,27 +81,9 @@ GET …/goodwill/export SHALL выгружать XLSX с составом и п
- **WHEN** выгружается таблица реальной версии с сохранёнными решениями и неактивной парой - **WHEN** выгружается таблица реальной версии с сохранёнными решениями и неактивной парой
- **THEN** XLSX соответствует серверной таблице, содержит «Нет вложений» и не учитывает эту строку в итогах; отдельное событие EXPORT содержит report=goodwill и число строк - **THEN** XLSX соответствует серверной таблице, содержит «Нет вложений» и не учитывает эту строку в итогах; отдельное событие EXPORT содержит report=goodwill и число строк
### Requirement: Подготовка вклада в КФО
Система SHALL готовить данные только из сохранённого ввода и актуальных источников: при включённом переключателе, действующей выбранной паре, заполненных доле/комментарии и G>0 — 1170 ГО −ФВ, 1310 ДО −P, GOODWILL ГО +G и направление исключения ББ-2 закреплённой версии правил. Запись КФО и изменение корректировок MUST NOT выполняться.
#### Scenario: Неположительный гудвилл
- **WHEN** пересчитанная сохранённая пара имеет G≤0, нет УК либо выключен переключатель
- **THEN** её специальный вклад пуст, ББ-2 не исключается; отрицательное G не переносится в 1370 и не уменьшает положительный вклад других пар
### Requirement: Пустые значения конечного дебета ### Requirement: Пустые значения конечного дебета
По решению пользователя система SHALL суммировать только заполненные closing_debit пары; пустые значения остальных строк MUST NOT блокировать расчёт P/G и сохранение ручного ввода. По решению пользователя система SHALL суммировать только заполненные closing_debit пары; пустые значения остальных строк MUST NOT блокировать расчёт P/G и сохранение ручного ввода.
#### Scenario: Заполненная и пустая строки одной пары #### Scenario: Заполненная и пустая строки одной пары
- **WHEN** у пары есть вложения 269997300.00 и строка с closing_debit=null, доля 100%, УК 100000000.00 и комментарий - **WHEN** у пары есть вложения 269997300.00 и строка с closing_debit=null, доля 100%, УК 100000000.00 и комментарий
- **THEN** сохранение разрешено; ФВ=269997300.00, P=100000000.00, G=169997300.00 - **THEN** сохранение разрешено; ФВ=269997300.00, P=100000000.00, G=169997300.00
### Requirement: Источники КФО только выбранных сохранённых пар
Подготовка КФО SHALL брать только сохранённые goodwill выбранной версии с include_in_kfo=true, ОСВ этих направленных пар и ББ выбранных ДО. При выключенном переключателе или пустом выборе источники ОСВ/ББ MUST NOT загружаться. Дорезолв SHALL сохранять соответствие расчёту таблицы.
#### Scenario: Выбранная и невыбранная пары
- **WHEN** сохранены А→Б с include_in_kfo=true и А→В с false, а для Б→А goodwill отсутствует
- **THEN** загружаются ОСВ А→Б и ББ Б; А→В и Б→А не дают источников и вклада, полный load таблицы не вызывается
#### Scenario: Пустой выбор
- **WHEN** выбранных сохранённых строк нет либо goodwill_enabled=false
- **THEN** вклад пуст, загрузка ОСВ и ББ не выполняется

View File

@ -25,8 +25,8 @@
## 4. Экспорт и граница КФО ## 4. Экспорт и граница КФО
- [x] 4.1 arm-core: добавить тесты экспорта гудвилла и сервис экспорта гудвилла, маршрут экспорта; проверить содержимое XLSX, групповые/общие итоги, пустые ячейки null, текстовую безопасность, имя кириллицей и EXPORT с report=goodwill. Документировать экспорт в локальном MD/OpenAPI. - [x] 4.1 arm-core: добавить тесты экспорта гудвилла и сервис экспорта гудвилла, маршрут экспорта; проверить содержимое XLSX, групповые/общие итоги, пустые ячейки null, текстовую безопасность, имя кириллицей и EXPORT с report=goodwill. Документировать экспорт в локальном MD/OpenAPI.
- [x] 4.2 arm-core: добавить чистую подготовку вклада в доменном модуле расчёта гудвилла и адаптер сохранённого ввода в сервисе гудвилла; тестами подготовки вклада гудвилла проверить баланс −ФВ+G=−P, только положительные G, выключенный режим, отсутствие капитала, закреплённое ББ-2 и направление исключения. Проверить, что KfoResult и Correction не записываются. - [x] 4.2 перенесено в AURORA-1546 (R-3): подготовка вклада в КФО удалена из этого change.
- [x] 4.3 arm-core: описать внутренний контракт вклада и ограничения этапа в описательном контракте API; проверка — документ явно отделяет подготовку от применения, устаревания и снимка КФО, не обещает завершение полного AURORA-1543. - [x] 4.3 перенесено в AURORA-1546 (R-3).
## 5. Сквозная проверка и передача ## 5. Сквозная проверка и передача
@ -36,7 +36,7 @@
## Workflow follow-up ## Workflow follow-up
- Отдельный change ../docs синхронизирует архитектуру, доменные правила, открытые вопросы и общий API-контракт; не назначать новые OQ-номера без проверки реестра. - Отдельный change ../docs синхронизирует архитектуру, доменные правила, открытые вопросы и общий API-контракт; не назначать новые OQ-номера без проверки реестра.
- Следующий change КФО реализует запись результата, устаревание, применение/исключение ББ-2 и фиксацию VERIFIED; AURORA-1543 полностью закрывается после его приёмки. - Следующий change КФО (AURORA-1546) реализует подготовку вклада, запись результата, устаревание, применение/исключение ББ-2 и фиксацию VERIFIED; AURORA-1543 полностью закрывается после его приёмки.
- Фронт AURORA-1544 подключается по согласованному обновлённому контракту, отдельной задачей. - Фронт AURORA-1544 подключается по согласованному обновлённому контракту, отдельной задачей.
## 6. Исправления по ревью пользователя ## 6. Исправления по ревью пользователя
@ -47,9 +47,8 @@
## 7. Выбор источников для КФО ## 7. Выбор источников для КФО
- [x] 7.1 arm-core: отдельный GoodwillSource.load_for_kfo; contributions загружает только goodwill include_in_kfo=true, ОСВ выбранных направленных пар и ББ выбранных ДО. Проверить SQL-отбор и отсутствие полной загрузки при пустом выборе/выключении. - [x] 7.1 перенесено в AURORA-1546 (R-3): `load_for_kfo` и фильтры общего свода удалены.
- [x] 7.2 arm-core: обновить описание внутреннего контракта и спецификацию; проверить регрессию таблицы/ОСВ, линтеры и типы. - [x] 7.2 перенесено в AURORA-1546 (R-3).
## 8. Экспорт по экранной таблице ## 8. Экспорт по экранной таблице