feat/goodwillback: бэкенд гудвилл #222
@ -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_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
|
||||
|
||||
@ -306,13 +306,7 @@ async def document_versions(session: AsyncSession, document_id: uuid.UUID) -> li
|
||||
return list((await session.execute(stmt)).scalars())
|
||||
|
||||
|
||||
async def load_osv_rows(
|
||||
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]]:
|
||||
async def load_osv_rows(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[OsvEntry, Document, LegalEntity]]:
|
||||
"""Исходные строки свода: контрагентские (`group_type = COUNTERPARTY`) строки BU/SUM привязанных документов.
|
||||
|
||||
`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
|
||||
)
|
||||
)
|
||||
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)]
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"""Пакетное чтение goodwill и привязанных подтверждённых ББ."""
|
||||
|
||||
from collections.abc import Collection
|
||||
from uuid import UUID
|
||||
|
||||
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)
|
||||
if included_only:
|
||||
stmt = stmt.where(Goodwill.include_in_kfo.is_(True))
|
||||
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())
|
||||
|
||||
|
||||
async def forms(session: AsyncSession, version_id: UUID, company_ids: Collection[UUID] | None = None):
|
||||
async def forms(session: AsyncSession, version_id: UUID):
|
||||
stmt = (
|
||||
select(Document, Meta)
|
||||
.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,
|
||||
)
|
||||
)
|
||||
if company_ids is not None:
|
||||
stmt = stmt.where(Document.legal_entity_id.in_(company_ids))
|
||||
return list((await session.execute(stmt)).all())
|
||||
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
import uuid
|
||||
from collections.abc import Collection, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
@ -149,13 +149,7 @@ class ConsolidatedOsvService:
|
||||
self._session = session
|
||||
self._scope = scope
|
||||
|
||||
async def build_with_members(
|
||||
self,
|
||||
version_id: uuid.UUID,
|
||||
*,
|
||||
company_pairs: Collection[tuple[uuid.UUID, uuid.UUID]] | None = None,
|
||||
account_ids: Collection[uuid.UUID] | None = None,
|
||||
) -> ConsolidatedOsvBuildResult:
|
||||
async def build_with_members(self, version_id: uuid.UUID) -> ConsolidatedOsvBuildResult:
|
||||
"""Свод и участники периметра, загруженные при его расчёте; версия вне видимости — 404."""
|
||||
found = await repo.get_version(self._session, self._scope, version_id)
|
||||
if found is None:
|
||||
@ -173,12 +167,7 @@ class ConsolidatedOsvService:
|
||||
for document in await consolidation_documents.for_consolidation(self._session, bound, consolidation)
|
||||
]
|
||||
# ровно то, что отдал `load_osv_rows`: он оставляет `COUNTERPARTY`/`BU`+`SUM`, домен их не перефильтровывает
|
||||
rows = [
|
||||
_source_row(entry)
|
||||
for entry, _, _ in await repo.load_osv_rows(
|
||||
self._session, version.id, company_pairs=company_pairs, account_ids=account_ids
|
||||
)
|
||||
]
|
||||
rows = [_source_row(entry) for entry, _, _ in await repo.load_osv_rows(self._session, version.id)]
|
||||
index = await CounterpartyIndex.load(self._session)
|
||||
entities = {**await _entities(self._session, _candidate_ids(rows, index) - members.keys()), **members}
|
||||
consolidated = domain.build(
|
||||
@ -191,12 +180,6 @@ class ConsolidatedOsvService:
|
||||
rows=rows,
|
||||
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(
|
||||
consolidated=consolidated,
|
||||
version=version,
|
||||
|
||||
@ -15,9 +15,8 @@ from src.common.exceptions import ConflictError, DomainValidationError, NotFound
|
||||
from src.domain import goodwill as domain
|
||||
from src.domain.policy import VisibilityScope, ensure_version_editable
|
||||
from src.models.access import AppUser
|
||||
from src.models.accounting import EliminationRule
|
||||
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.repositories import consolidations as versions
|
||||
from src.repositories import goodwill as repo
|
||||
@ -30,12 +29,11 @@ from src.schemas.goodwill import (
|
||||
GoodwillTotalsOut,
|
||||
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_source import GoodwillSource
|
||||
|
||||
MAX_SHARE = Decimal(100)
|
||||
BB_RULE_NO = 2
|
||||
|
||||
MESSAGES = {
|
||||
"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)},
|
||||
)
|
||||
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)
|
||||
|
||||
@ -9,7 +9,7 @@ from uuid import UUID
|
||||
|
||||
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.repositories import goodwill as repo
|
||||
from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService
|
||||
@ -71,23 +71,11 @@ class GoodwillSource:
|
||||
self.session, self.osv = session, osv
|
||||
|
||||
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)
|
||||
allowed = {account.id for account in accounts}
|
||||
if pairs is None:
|
||||
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)
|
||||
ctx = await self.osv.build_with_members(version_id)
|
||||
result = Sources(ctx, investments=_investments(ctx, allowed))
|
||||
forms = await repo.forms(
|
||||
self.session, version_id, {subsidiary for _, subsidiary in pairs} if pairs is not None else None
|
||||
)
|
||||
forms = await repo.forms(self.session, version_id)
|
||||
columns, values = await repo.form_data(self.session, [m.id for _, m in forms])
|
||||
month = int(ctx.consolidation.quarter.value[1:]) * 3
|
||||
date = dt.date(ctx.consolidation.year, month, calendar.monthrange(ctx.consolidation.year, month)[1])
|
||||
|
||||
@ -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()
|
||||
181
docs/superpowers/plans/2026-11-06-goodwill-review-back.md
Normal file
181
docs/superpowers/plans/2026-11-06-goodwill-review-back.md
Normal 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 под ревью».
|
||||
@ -42,7 +42,7 @@ GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениям
|
||||
|
||||
### 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
|
||||
|
||||
@ -61,12 +61,3 @@ GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениям
|
||||
Миграция БД не требуется: существующие поля и enum достаточны. Выкладка добавляет маршруты и обновлённый контракт; перед подключением фронта проверить nullable поля. Откат кода оставляет goodwill и аудит в совместимой схеме. Пока фронт отдельно, существующие страницы не изменяются.
|
||||
|
||||
По решению пользователя ФВ пары — сумма только заполненных closing_debit; пустые значения остальных строк не блокируют расчёт P/G и сохранение ручного ввода.
|
||||
|
||||
## Отбор источников для КФО по запросу пользователя
|
||||
|
||||
У таблицы остаётся полный load. contributions проверяет видимость/переключатель, выбирает только сохранённые
|
||||
include_in_kfo=true и вызывает load_for_kfo с направленными парами. Репозиторий ОСВ получает company_pairs и
|
||||
account_ids ветки 58.01, репозиторий ББ — идентификаторы выбранных ДО. Общая нормализация периодов и дорезолв
|
||||
сохраняются: NULL-контрагенты выбранных владельцев/счетов рассматриваются как кандидаты, итог свода после
|
||||
дорезолва фильтруется строго по выбранным парам. Невыбранные сопоставленные пары не загружаются из osv_entry.
|
||||
Без выбранных записей и при выключении нет загрузки ОСВ/ББ.
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
- Найденные пары объединяются с сохранёнными `goodwill`; исчезнувшие остаются с вычисляемым `has_investments=false`, без участия в итогах.
|
||||
- Доля 0–100%, до четырёх знаков; нерассчитанные P/G допускают null. Сохранение одной строки атомарно.
|
||||
- Расчёт по §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; при реализации не подменять ими молча старую редакцию правил.
|
||||
|
||||
## Capabilities
|
||||
|
||||
@ -81,27 +81,9 @@ GET …/goodwill/export SHALL выгружать XLSX с составом и п
|
||||
- **WHEN** выгружается таблица реальной версии с сохранёнными решениями и неактивной парой
|
||||
- **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: Пустые значения конечного дебета
|
||||
По решению пользователя система SHALL суммировать только заполненные closing_debit пары; пустые значения остальных строк MUST NOT блокировать расчёт P/G и сохранение ручного ввода.
|
||||
|
||||
#### Scenario: Заполненная и пустая строки одной пары
|
||||
- **WHEN** у пары есть вложения 269997300.00 и строка с closing_debit=null, доля 100%, УК 100000000.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** вклад пуст, загрузка ОСВ и ББ не выполняется
|
||||
|
||||
@ -25,8 +25,8 @@
|
||||
## 4. Экспорт и граница КФО
|
||||
|
||||
- [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.3 arm-core: описать внутренний контракт вклада и ограничения этапа в описательном контракте API; проверка — документ явно отделяет подготовку от применения, устаревания и снимка КФО, не обещает завершение полного AURORA-1543.
|
||||
- [x] 4.2 перенесено в AURORA-1546 (R-3): подготовка вклада в КФО удалена из этого change.
|
||||
- [x] 4.3 перенесено в AURORA-1546 (R-3).
|
||||
|
||||
## 5. Сквозная проверка и передача
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
## Workflow follow-up
|
||||
|
||||
- Отдельный change ../docs синхронизирует архитектуру, доменные правила, открытые вопросы и общий API-контракт; не назначать новые OQ-номера без проверки реестра.
|
||||
- Следующий change КФО реализует запись результата, устаревание, применение/исключение ББ-2 и фиксацию VERIFIED; AURORA-1543 полностью закрывается после его приёмки.
|
||||
- Следующий change КФО (AURORA-1546) реализует подготовку вклада, запись результата, устаревание, применение/исключение ББ-2 и фиксацию VERIFIED; AURORA-1543 полностью закрывается после его приёмки.
|
||||
- Фронт AURORA-1544 подключается по согласованному обновлённому контракту, отдельной задачей.
|
||||
|
||||
## 6. Исправления по ревью пользователя
|
||||
@ -47,9 +47,8 @@
|
||||
|
||||
## 7. Выбор источников для КФО
|
||||
|
||||
- [x] 7.1 arm-core: отдельный GoodwillSource.load_for_kfo; contributions загружает только goodwill include_in_kfo=true, ОСВ выбранных направленных пар и ББ выбранных ДО. Проверить SQL-отбор и отсутствие полной загрузки при пустом выборе/выключении.
|
||||
- [x] 7.2 arm-core: обновить описание внутреннего контракта и спецификацию; проверить регрессию таблицы/ОСВ, линтеры и типы.
|
||||
|
||||
- [x] 7.1 перенесено в AURORA-1546 (R-3): `load_for_kfo` и фильтры общего свода удалены.
|
||||
- [x] 7.2 перенесено в AURORA-1546 (R-3).
|
||||
|
||||
## 8. Экспорт по экранной таблице
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user