feat/goodwillback: бэкенд гудвилл

This commit is contained in:
tsygankoviva 2026-11-05 15:36:14 +03:00
parent 967fa01fb6
commit 94895b6de7
26 changed files with 2169 additions and 4 deletions

View File

@ -1340,3 +1340,10 @@ Dockerfile только для локального compose
- [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов;
- [`../docs/contracts/`](../docs/contracts/) — JSON-схемы обмена с сервисами распознавания;
- `openspec/changes/slice-0-skeleton/` — спецификация и решения Среза 0.
### Таблица гудвилла
Бэкенд таблицы гудвилла версии: фактические вложения 58.01 между участниками периметра, построчные решения,
переключатель и XLSX. `is_head_entity` не используется. Деньги в рублях, доля до четырёх знаков, нерассчитанные
значения — `null`; исчезнувшие сохранённые пары остаются как «Нет вложений».
Контракт и ограничения этапа — [контракт API](goodwill/api-contract-goodwill.md). Фронт и построение/фиксация результата КФО — отдельные задачи.

View File

@ -18,6 +18,8 @@ from src.services.correction_source import CorrectionAggregateSource, VgoCorrect
from src.services.corrections import CorrectionService
from src.services.counterparty_mapping import CounterpartyMappingService
from src.services.elimination_rules import EliminationRuleService
from src.services.goodwill import GoodwillService
from src.services.goodwill_source import GoodwillSource
from src.services.intercompany_matrix import IntercompanyMatrixService
from src.services.vgo import VgoAssignmentCrudService, VgoService
@ -157,3 +159,15 @@ def get_vgo_assignment_crud_service(
VgoAssignmentCrudServiceDep = Annotated[VgoAssignmentCrudService, Depends(get_vgo_assignment_crud_service)]
def get_goodwill_service(
session: SessionDep,
scope: Annotated[VisibilityScope, Depends(get_scope)],
principal: Annotated[Principal, Depends(current_user)],
osv: ConsolidatedOsvServiceDep,
) -> GoodwillService:
return GoodwillService(session, scope, principal, GoodwillSource(session, osv))
GoodwillServiceDep = Annotated[GoodwillService, Depends(get_goodwill_service)]

View File

@ -7,6 +7,7 @@ from src.api.endpoints.consolidations import (
corrections,
elimination_rule,
export,
goodwill,
intercompany_matrix,
locks,
mapping,
@ -26,6 +27,7 @@ router.include_router(status.router)
router.include_router(members.router)
router.include_router(checklist.router)
router.include_router(corrections.router)
router.include_router(goodwill.router)
router.include_router(osv.router)
router.include_router(export.router)
router.include_router(mapping.router)

View File

@ -0,0 +1,82 @@
"""Таблица гудвилла, построчное сохранение, переключатель и XLSX."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Response
from src.api.dependencies.audit import RequestAudit, get_request_audit
from src.api.dependencies.auth import require
from src.api.dependencies.consolidations import ConsolidationServiceDep, GoodwillServiceDep
from src.common.filenames import content_disposition
from src.schemas.goodwill import (
GoodwillRowIn,
GoodwillRowOut,
GoodwillSettingsIn,
GoodwillSign,
GoodwillTableOut,
)
router = APIRouter(prefix="/consolidations/versions")
VIEW = [Depends(require("CONSOLIDATION_VIEW"))]
EDIT = [Depends(require("CONSOLIDATION_EDIT"))]
AuditDep = Annotated[RequestAudit, Depends(get_request_audit)]
@router.get("/{version_id}/goodwill", response_model=GoodwillTableOut, dependencies=VIEW)
async def table(version_id: UUID, service: GoodwillServiceDep) -> GoodwillTableOut:
return await service.table(version_id)
@router.put("/{version_id}/goodwill-settings", response_model=GoodwillTableOut, dependencies=EDIT)
async def settings(
version_id: UUID, data: GoodwillSettingsIn, service: GoodwillServiceDep, audit: AuditDep
) -> GoodwillTableOut:
return await service.settings(version_id, data.goodwill_enabled, audit)
@router.get(
"/{version_id}/goodwill/export",
response_class=Response,
dependencies=VIEW,
responses={
200: {
"content": {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
"schema": {"type": "string", "format": "binary"}
}
}
}
},
)
async def export(
version_id: UUID,
service: GoodwillServiceDep,
consolidation: ConsolidationServiceDep,
audit: AuditDep,
sign: GoodwillSign = "all",
) -> Response:
content = await service.export(version_id, audit, sign)
name, year, quarter = await consolidation.version_title(version_id)
filename = f"Гудвилл_{name}_{year}_{quarter}.xlsx"
return Response(
content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": content_disposition(filename, ascii_fallback="goodwill.xlsx")},
)
@router.put(
"/{version_id}/goodwill/{head_legal_entity_id}/{subsidiary_legal_entity_id}",
response_model=GoodwillRowOut,
dependencies=EDIT,
)
async def save( # noqa: PLR0913, PLR0917 - ключ пары, тело и зависимости FastAPI
version_id: UUID,
head_legal_entity_id: UUID,
subsidiary_legal_entity_id: UUID,
data: GoodwillRowIn,
service: GoodwillServiceDep,
audit: AuditDep,
) -> GoodwillRowOut:
return await service.save(version_id, head_legal_entity_id, subsidiary_legal_entity_id, data, audit)

132
api/src/domain/goodwill.py Normal file
View File

@ -0,0 +1,132 @@
"""Чистый расчёт направленных пар гудвилла; суммы в рублях."""
import uuid
from collections import defaultdict
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
MAX_SHARE = Decimal(100)
ZERO = Decimal("0.00")
Pair = tuple[uuid.UUID, uuid.UUID]
@dataclass(frozen=True)
class Decision:
ownership_share: Decimal | None = None
include_in_kfo: bool = False
comment: str | None = None
@dataclass(frozen=True)
class Investment:
head_id: uuid.UUID
subsidiary_id: uuid.UUID
amount: Decimal
@dataclass(frozen=True)
class Row:
head_id: uuid.UUID
subsidiary_id: uuid.UUID
financial_investment_amount: Decimal | None
charter_capital_amount: Decimal | None
participation_amount: Decimal | None
goodwill_amount: Decimal | None
has_investments: bool
decision: Decision
warnings: tuple[str, ...] = ()
@property
def applicable(self) -> bool:
return (
self.has_investments
and self.decision.include_in_kfo
and bool((self.decision.comment or "").strip())
and self.goodwill_amount is not None
and self.goodwill_amount > 0
)
def build(
investments: Sequence[Investment],
decisions: Mapping[Pair, Decision],
capital: Mapping[uuid.UUID, Decimal | None],
) -> list[Row]:
amounts: dict[Pair, Decimal] = defaultdict(lambda: ZERO)
for item in investments:
key = item.head_id, item.subsidiary_id
amounts[key] += item.amount
shares: dict[uuid.UUID, Decimal] = defaultdict(lambda: ZERO)
for key, decision in decisions.items():
if amounts.get(key, ZERO) != 0 and decision.ownership_share is not None:
shares[key[1]] += decision.ownership_share
result = []
for head, subsidiary in sorted(amounts.keys() | decisions.keys()):
key = head, subsidiary
fv = amounts.get(key, ZERO)
active = fv != 0
decision = decisions.get(key, Decision())
uk = capital.get(subsidiary) if active else None
p = g = None
warnings = []
if active and uk is None:
warnings.append("CAPITAL_SOURCE_MISSING")
if active and decision.include_in_kfo and uk is not None and decision.ownership_share is not None:
p = (uk * decision.ownership_share / 100).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
g = fv - p
if active and shares[subsidiary] > MAX_SHARE:
warnings.append("OWNERSHIP_TOTAL_EXCEEDED")
if active or key in decisions:
result.append(Row(head, subsidiary, fv if active else None, uk, p, g, active, decision, tuple(warnings)))
return result
def totals(rows: Sequence[Row], enabled: bool) -> dict[str, Decimal]:
active = [r for r in rows if r.has_investments]
selected = [r for r in active if r.decision.include_in_kfo]
return {
"financial_investment_amount": sum((r.financial_investment_amount or ZERO for r in active), ZERO),
"participation_amount": sum((r.participation_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),
}
@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,7 +306,13 @@ 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) -> list[tuple[OsvEntry, Document, LegalEntity]]:
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]]:
"""Исходные строки свода: контрагентские (`group_type = COUNTERPARTY`) строки BU/SUM привязанных документов.
`TOTAL` не хранится, строки `ACCOUNT` и данные `NU` отбрасываются. Порядок: имя ЮЛ, код счёта документа, квартал,
@ -326,6 +332,22 @@ async def load_osv_rows(session: AsyncSession, version_id: uuid.UUID) -> list[tu
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)]

View File

@ -0,0 +1,69 @@
"""Пакетное чтение goodwill и привязанных подтверждённых ББ."""
from collections.abc import Collection
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.accounting import ChartOfAccount
from src.models.consolidation import ConsolidationDocument
from src.models.enums import DocumentStatus, StatementType
from src.models.files import Document
from src.models.kfo import Goodwill
from src.models.statements import (
FinancialStatementColumn as Column,
)
from src.models.statements import (
FinancialStatementLine as Line,
)
from src.models.statements import (
FinancialStatementMeta as Meta,
)
from src.models.statements import (
FinancialStatementValue as Value,
)
async def saved(session: AsyncSession, version_id: UUID, *, included_only: bool = False) -> 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())
async def accounts(session: AsyncSession) -> list[ChartOfAccount]:
"""Только 58.01 и потомки по parent_id; полный справочник в память не загружается."""
tree = select(ChartOfAccount.id).where(ChartOfAccount.code == "58.01").cte("goodwill_accounts", recursive=True)
tree = tree.union(select(ChartOfAccount.id).join(tree, ChartOfAccount.parent_id == tree.c.id))
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):
stmt = (
select(Document, Meta)
.join(Meta, Meta.document_id == Document.id)
.join(ConsolidationDocument, ConsolidationDocument.document_id == Document.id)
.where(
ConsolidationDocument.consolidation_version_id == version_id,
Document.status == DocumentStatus.CONFIRMED,
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())
async def form_data(session: AsyncSession, ids: list[UUID]):
columns = list((await session.scalars(select(Column).where(Column.meta_id.in_(ids)))).all())
values = list(
(
await session.execute(
select(Line, Value.column_id, Value.amount)
.outerjoin(Value, Value.line_id == Line.id)
.where(Line.meta_id.in_(ids), Line.line_code.in_(["1310", "1350"]))
)
).all()
)
return columns, values

View File

@ -0,0 +1,66 @@
"""Контракт гудвилла: денежные суммы в рублях, nullable расчёты."""
import datetime
from decimal import Decimal
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer
from src.schemas.consolidations import RefOut
from src.schemas.corrections import Money
Share = Annotated[Decimal, PlainSerializer(lambda v: format(v, ".4f"), return_type=str, when_used="json")]
GoodwillSign = Literal["all", "positive", "negative", "zero"]
class WarningOut(BaseModel):
code: str
message: str
class GoodwillRowOut(BaseModel):
head_legal_entity: RefOut
subsidiary_legal_entity: RefOut
ownership_share: Share | None
financial_investment_amount: Money | None
charter_capital_amount: Money | None
additional_capital_amount: Money | None
participation_amount: Money | None
goodwill_amount: Money | None
include_in_kfo: bool
comment: str | None
has_investments: bool
warnings: list[WarningOut]
updated_by: RefOut | None
updated_at: datetime.datetime | None
class GoodwillTotalsOut(BaseModel):
financial_investment_amount: Money
participation_amount: Money
goodwill_amount: Money
goodwill_in_kfo_amount: Money
class GoodwillTableOut(BaseModel):
version_id: UUID
goodwill_enabled: bool
editable: bool
read_only_reason: Literal["VERSION_NOT_EDITABLE", "LOCK_REQUIRED", "LOCKED_BY_OTHER"] | None
rows: list[GoodwillRowOut]
totals: GoodwillTotalsOut
class GoodwillRowIn(BaseModel):
model_config = ConfigDict(extra="forbid")
ownership_share: str | None
include_in_kfo: bool
comment: str | None = Field(default=None, max_length=1000)
class GoodwillSettingsIn(BaseModel):
model_config = ConfigDict(extra="forbid")
goodwill_enabled: bool

View File

@ -13,7 +13,7 @@
import uuid
from collections.abc import Collection, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Literal
from fastapi.concurrency import run_in_threadpool
@ -149,7 +149,13 @@ class ConsolidatedOsvService:
self._session = session
self._scope = scope
async def build_with_members(self, version_id: uuid.UUID) -> ConsolidatedOsvBuildResult:
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:
"""Свод и участники периметра, загруженные при его расчёте; версия вне видимости — 404."""
found = await repo.get_version(self._session, self._scope, version_id)
if found is None:
@ -167,7 +173,12 @@ 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)]
rows = [
_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)
entities = {**await _entities(self._session, _candidate_ids(rows, index) - members.keys()), **members}
consolidated = domain.build(
@ -180,6 +191,12 @@ 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,

View File

@ -0,0 +1,297 @@
"""Таблица и построчные решения гудвилла в транзакции запроса."""
import re
from decimal import ROUND_HALF_UP, Decimal
from uuid import UUID, uuid4
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.api.dependencies.auth import Principal
from src.common.clock import utc_now
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
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.kfo import Goodwill
from src.repositories import consolidations as versions
from src.repositories import goodwill as repo
from src.schemas.consolidations import RefOut
from src.schemas.goodwill import (
GoodwillRowIn,
GoodwillRowOut,
GoodwillSign,
GoodwillTableOut,
GoodwillTotalsOut,
WarningOut,
)
from src.services.consolidation_guard import VERSION_NOT_FOUND, 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 на конец отчётного периода",
"CAPITAL_SOURCE_AMBIGUOUS": "Несколько подходящих балансов дочерней организации",
"OWNERSHIP_TOTAL_EXCEEDED": "Сумма долей в дочерней организации превышает 100%",
}
def validate_decision(data: GoodwillRowIn) -> domain.Decision:
value = data.ownership_share.replace(",", ".") if data.ownership_share is not None else None
if value is None:
share = None
elif not re.fullmatch(r"\d+(?:\.\d{1,4})?", value) or Decimal(value) > MAX_SHARE:
raise DomainValidationError("INVALID_SHARE", "Доля должна быть от 0 до 100, до четырёх знаков")
else:
share = Decimal(value)
if data.include_in_kfo and share is None:
raise DomainValidationError("INVALID_SHARE", "Заполните долю владения")
comment = data.comment.strip() if data.comment is not None else None
if data.include_in_kfo and not comment:
raise DomainValidationError("COMMENT_REQUIRED", "Укажите комментарий")
return domain.Decision(share, data.include_in_kfo, comment)
class GoodwillService:
def __init__(self, session: AsyncSession, scope: VisibilityScope, principal: Principal, source: GoodwillSource):
self.session, self.scope, self.principal, self.source = session, scope, principal, source
async def table(self, version_id: UUID) -> GoodwillTableOut:
sources = await self.source.load(version_id)
ctx = sources.context
saved = await repo.saved(self.session, version_id)
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)
old = {(r.head_legal_entity_id, r.subsidiary_legal_entity_id): r for r in saved}
ids = {i for r in rows for i in (r.head_id, r.subsidiary_id)}
entities = {
e.id: e for e in (await self.session.scalars(select(LegalEntity).where(LegalEntity.id.in_(ids)))).all()
}
users = {
u.id: u
for u in (
await self.session.scalars(
select(AppUser).where(AppUser.id.in_({r.updated_by for r in saved if r.updated_by}))
)
).all()
}
reason = None
if "CONSOLIDATION_EDIT" not in self.principal.permissions:
reason = "VERSION_NOT_EDITABLE"
else:
try:
ensure_version_editable(
ctx.version, await versions.active_lock(self.session, version_id), self.principal.user.id
)
except ConflictError as error:
if error.code not in ("VERSION_NOT_EDITABLE", "LOCK_REQUIRED", "LOCKED_BY_OTHER"):
raise
reason = error.code
output = []
for r in rows:
previous = old.get((r.head_id, r.subsidiary_id))
user = users.get(previous.updated_by) if previous and previous.updated_by else None
codes = list(r.warnings)
if r.has_investments and r.subsidiary_id in sources.warnings:
codes = [c for c in codes if c != "CAPITAL_SOURCE_MISSING"] + [sources.warnings[r.subsidiary_id]]
output.append(
GoodwillRowOut(
head_legal_entity=RefOut(id=r.head_id, name=entities[r.head_id].name),
subsidiary_legal_entity=RefOut(id=r.subsidiary_id, name=entities[r.subsidiary_id].name),
ownership_share=r.decision.ownership_share,
financial_investment_amount=r.financial_investment_amount,
charter_capital_amount=r.charter_capital_amount,
additional_capital_amount=sources.additional.get(r.subsidiary_id) if r.has_investments else None,
participation_amount=r.participation_amount,
goodwill_amount=r.goodwill_amount,
include_in_kfo=r.decision.include_in_kfo,
comment=r.decision.comment,
has_investments=r.has_investments,
warnings=[WarningOut(code=c, message=MESSAGES[c]) for c in codes],
updated_by=RefOut(id=user.id, name=user.full_name) if user else None,
updated_at=previous.updated_at if previous else None,
)
)
output.sort(
key=lambda r: (
r.head_legal_entity.name,
r.subsidiary_legal_entity.name,
r.head_legal_entity.id,
r.subsidiary_legal_entity.id,
)
)
return GoodwillTableOut(
version_id=version_id,
goodwill_enabled=ctx.version.goodwill_enabled,
editable=reason is None,
read_only_reason=reason,
rows=output,
totals=GoodwillTotalsOut(**domain.totals(rows, ctx.version.goodwill_enabled)),
)
async def save(
self, version_id: UUID, head: UUID, subsidiary: UUID, data: GoodwillRowIn, audit: RequestAudit
) -> GoodwillRowOut:
await lock_editable_version(self.session, self.scope, version_id, self.principal.user.id)
table = await self.table(version_id)
row = next(
(r for r in table.rows if (r.head_legal_entity.id, r.subsidiary_legal_entity.id) == (head, subsidiary)),
None,
)
if row is None:
raise NotFoundError("Пара не найдена")
if not row.has_investments:
# PDF §10: отсутствующий ввод не затирает сохранённые данные неактивной пары.
data = data.model_copy(
update={
"ownership_share": data.ownership_share
if data.ownership_share is not None
else str(row.ownership_share)
if row.ownership_share is not None
else None,
"comment": data.comment if data.comment is not None else row.comment,
}
)
decision = validate_decision(data)
saved = await repo.saved(self.session, version_id)
entity = next(
(r for r in saved if (r.head_legal_entity_id, r.subsidiary_legal_entity_id) == (head, subsidiary)), None
)
before = row.model_dump(mode="json") if entity else None
now = utc_now()
if entity is None:
entity = Goodwill(
id=uuid4(),
consolidation_version_id=version_id,
head_legal_entity_id=head,
subsidiary_legal_entity_id=subsidiary,
created_by=self.principal.user.id,
created_at=now,
)
self.session.add(entity)
entity.ownership_share, entity.include_in_kfo, entity.comment = (
decision.ownership_share,
decision.include_in_kfo,
decision.comment,
)
entity.updated_by, entity.updated_at = self.principal.user.id, now
if row.has_investments:
assert row.financial_investment_amount is not None
p = (
(row.charter_capital_amount * decision.ownership_share / 100).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
if decision.include_in_kfo
and row.charter_capital_amount is not None
and decision.ownership_share is not None
else None
)
amounts = dict(
financial_investment_amount=row.financial_investment_amount,
charter_capital_amount=row.charter_capital_amount,
additional_capital_amount=row.additional_capital_amount,
participation_amount=p,
goodwill_amount=row.financial_investment_amount - p if p is not None else None,
)
for name, amount in amounts.items():
if amount is not None and abs(amount) >= Decimal("10000000000000000"):
raise DomainValidationError("MONEY_OUT_OF_RANGE", "Сумма не помещается в numeric(18,2)")
setattr(entity, name, amount)
await self.session.flush()
result = next(
r
for r in (await self.table(version_id)).rows
if (r.head_legal_entity.id, r.subsidiary_legal_entity.id) == (head, subsidiary)
)
await audit.record(
action=AuditAction.GOODWILL_EDIT,
section=AuditSection.CONSOLIDATION,
object_type="goodwill",
object_id=entity.id,
consolidation_version_id=version_id,
before_value=before,
after_value=result.model_dump(mode="json"),
)
return result
async def settings(self, version_id: UUID, enabled: bool, audit: RequestAudit) -> GoodwillTableOut:
version, _ = await lock_editable_version(self.session, self.scope, version_id, self.principal.user.id)
if version.goodwill_enabled != enabled:
before = version.goodwill_enabled
version.goodwill_enabled = enabled
await self.session.flush()
await audit.record(
action=AuditAction.GOODWILL_EDIT,
section=AuditSection.CONSOLIDATION,
object_type="consolidation_version",
object_id=version_id,
consolidation_version_id=version_id,
changed_field="goodwill_enabled",
before_value={"goodwill_enabled": before},
after_value={"goodwill_enabled": enabled},
)
return await self.table(version_id)
async def export(self, version_id: UUID, audit: RequestAudit, sign: GoodwillSign = "all") -> bytes:
table = await self.table(version_id)
content = await run_in_threadpool(build_workbook, table, sign)
await audit.record(
action=AuditAction.EXPORT,
section=AuditSection.EXPORT,
object_type="consolidation_version",
object_id=version_id,
consolidation_version_id=version_id,
after_value={"report": "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)

View File

@ -0,0 +1,92 @@
"""XLSX с колонками и группировкой экранной таблицы гудвилла."""
from collections import defaultdict
from decimal import Decimal
from io import BytesIO
from openpyxl import Workbook
from src.schemas.goodwill import GoodwillRowOut, GoodwillSign, GoodwillTableOut
from src.services.osv_export import _put_money, _put_text
COLUMNS = {
"include_in_kfo": "Учет в отчете",
"name": "Наименование дочерней организации",
"ownership_share": "Доля владения",
"financial_investment_amount": "Фондовые вложения головной организации",
"participation_amount": "Доля участия головной организации в уставном капитале",
"charter_capital_amount": "Уставный капитал",
"goodwill_amount": "Гудвилл (+/-)",
"comment": "Комментарий",
}
MONEY = ("financial_investment_amount", "participation_amount", "charter_capital_amount", "goodwill_amount")
def matches_sign(row: GoodwillRowOut, sign: GoodwillSign) -> bool:
if sign == "all":
return True
amount = row.goodwill_amount
if amount is None:
return False
return {"positive": amount > 0, "negative": amount < 0, "zero": amount == 0}[sign]
def build_workbook(table: GoodwillTableOut, sign: GoodwillSign = "all") -> bytes:
keys = list(COLUMNS)
book = Workbook()
sheet = book.active
assert sheet is not None
sheet.title = "Гудвилл"
sheet.append([COLUMNS[key] for key in keys])
def append(values):
row = sheet.max_row + 1
for col, key in enumerate(keys, 1):
value = values.get(key)
if key in MONEY or key == "ownership_share":
_put_money(sheet, row, col, value)
else:
_put_text(sheet, row, col, value)
if key == "ownership_share":
sheet.cell(row, col).number_format = "0.0000"
groups: dict = defaultdict(list)
for item in table.rows:
groups[item.head_legal_entity.id].append(item)
for items in groups.values():
visible = [item for item in items if matches_sign(item, sign)]
if not visible:
continue
append(
{
"name": items[0].head_legal_entity.name,
"include_in_kfo": "☑" if all(i.include_in_kfo for i in visible) else "☐",
},
)
for item in visible:
values = {key: getattr(item, key) for key in MONEY}
values.update(
name=item.subsidiary_legal_entity.name,
include_in_kfo="☑" if item.include_in_kfo else "☐",
ownership_share=item.ownership_share,
comment=item.comment,
)
append(values)
append({"name": "Всего", **_totals(items)})
totals = table.totals
values = dict(
name="Итого по листу",
ownership_share=None,
financial_investment_amount=totals.financial_investment_amount,
participation_amount=totals.participation_amount,
goodwill_amount=totals.goodwill_amount,
)
append(values)
stream = BytesIO()
book.save(stream)
return stream.getvalue()
def _totals(rows: list[GoodwillRowOut]) -> dict[str, Decimal]:
active = [r for r in rows if r.has_investments]
return {field: sum((getattr(r, field) or Decimal(0) for r in active), Decimal(0)) for field in MONEY}

View File

@ -0,0 +1,105 @@
"""Адаптер полного свода ОСВ и привязанных ББ для расчёта гудвилла."""
import calendar
import datetime as dt
from collections import defaultdict
from dataclasses import dataclass, field
from decimal import Decimal
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.goodwill import Investment, Pair
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
@dataclass
class Sources:
context: ConsolidatedOsvBuildResult
investments: list[Investment] = field(default_factory=list)
capital: dict[UUID, Decimal | None] = field(default_factory=dict)
additional: dict[UUID, Decimal | None] = field(default_factory=dict)
warnings: dict[UUID, str] = field(default_factory=dict)
def capital_amounts(meta_id, units, columns, values, date) -> tuple[Decimal | None, Decimal | None]:
selected = [c.id for c in columns if c.meta_id == meta_id and c.period_date == date]
factor = {"руб.": Decimal(1), "тыс. руб.": Decimal(1000), "млн руб.": Decimal(1000000)}.get(units)
if len(selected) != 1 or factor is None:
return None, None
lines = {}
amounts_by_line: dict[UUID, dict[UUID, Decimal | None]] = {}
for line, column_id, raw_amount in values:
if line.meta_id == meta_id:
if line.id not in lines:
amounts_by_line[line.id] = {}
lines[line.id] = AggLine(
line.id, line.row_index, line.line_status, line.line_code, amounts_by_line[line.id]
)
amounts_by_line[line.id][column_id] = raw_amount
aggregated = aggregate_by_code(lines.values())
def amount(code):
agg = aggregated.get(code)
raw = agg.amounts.get(selected[0]) if agg else None
return raw * factor if raw is not None else None
return amount("1310"), amount("1350")
def _investments(ctx, allowed) -> list[Investment]:
amounts: dict[tuple[UUID, UUID], Decimal] = defaultdict(lambda: Decimal(0))
for row in ctx.consolidated.rows:
if (
row.account_id not in allowed
or row.legal_entity_id not in ctx.members
or row.counterparty_id not in ctx.members
or row.legal_entity_id == row.counterparty_id
):
continue
assert row.counterparty_id is not None
pair = row.legal_entity_id, row.counterparty_id
if row.closing_debit is not None:
amounts[pair] += row.closing_debit
return [Investment(h, s, amount) for (h, s), amount in amounts.items()]
class GoodwillSource:
def __init__(self, session: AsyncSession, osv: ConsolidatedOsvService) -> None:
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)
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
)
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])
by_owner = defaultdict(list)
for doc, meta in forms:
if doc.legal_entity_id in ctx.members:
by_owner[doc.legal_entity_id].append(meta)
for owner, metas in by_owner.items():
if len(metas) != 1:
result.warnings[owner] = "CAPITAL_SOURCE_AMBIGUOUS"
continue
meta = metas[0]
uk, additional = capital_amounts(meta.id, meta.units, columns, values, date)
result.capital[owner], result.additional[owner] = uk, additional
return result

View File

@ -0,0 +1,341 @@
"""Реальные вложения 58.01 в API с настоящей БД и стандартными правами."""
import datetime as dt
from decimal import Decimal
from pathlib import Path
from uuid import uuid4
import pytest
from sqlalchemy import delete, func, select
from src.models.accounting import ChartOfAccount
from src.models.consolidation import Consolidation, ConsolidationDocument
from src.models.enums import (
ConsolidationStatus,
DocumentStatus,
DocumentType,
OsvDataType,
PeriodQuarter,
StatementType,
)
from src.models.files import Document, StoredFile
from src.models.kfo import Goodwill
from src.models.osv import OsvEntry
from src.models.statements import (
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
from src.repositories import consolidations as consolidation_repo
from tests.factories import make_confirmed_osv
from tests.fixtures_helpers import GOLDEN_STATEMENTS, golden_result_v1, map_golden_snapshot
from tests.test_api_consolidated_osv import FOREIGN, KA_RF, _world
from tests.test_goodwill_golden import investment_rows
HTTP_OK, HTTP_NOT_FOUND, HTTP_CONFLICT, HTTP_FORBIDDEN = 200, 404, 409, 403
HTTP_CREATED = 201
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
async def goodwill_world(session, fixtures_dir: Path):
w = await _world(session)
raw = investment_rows(fixtures_dir)[0]
account = (await session.scalars(select(ChartOfAccount).where(ChartOfAccount.code == "58.01"))).one()
doc = await make_confirmed_osv(
session,
entity=w.a,
account="58.01",
year=2024,
quarter=PeriodQuarter.Q3,
is_cumulative=True,
rows=[
dict(
name=raw["counterparty_name"],
counterparty_id=w.b.id,
account_id=account.id,
closing_debit=Decimal(raw["closing_debit"]),
)
],
)
session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=doc.id))
balance = await make_confirmed_osv(
session, entity=w.b, account="80", year=2024, quarter=PeriodQuarter.Q3, is_cumulative=True, rows=[]
)
balance.document_type = DocumentType.BALANCE
balance.osv_account_code = None
balance.status = DocumentStatus.CONFIRMED
meta = FinancialStatementMeta(
document_id=balance.id,
recognition_job_id=(await session.get(StoredFile, balance.stored_file_id)).current_recognition_job_id,
statement_type=StatementType.balance,
units="тыс. руб.",
)
session.add(meta)
await session.flush()
column = FinancialStatementColumn(
meta_id=meta.id, column_index=0, period_date=dt.date(2024, 9, 30), is_current=True
)
line = FinancialStatementLine(meta_id=meta.id, line_code="1310", row_index=0)
session.add_all([column, line])
await session.flush()
session.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=Decimal("100000")))
session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=balance.id))
await session.flush()
return w
async def test_table_no_write_and_visibility(db_session, auth_client, fixtures_dir):
w = await goodwill_world(db_session, fixtures_dir)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{w.version.id}"
response = await client.get(base + "/goodwill")
assert response.status_code == HTTP_OK, response.text
row = response.json()["rows"][0]
assert row["financial_investment_amount"] == "269997300.00"
assert row["charter_capital_amount"] == "100000000.00"
assert row["goodwill_amount"] is None
assert row["has_investments"] is True
assert (await db_session.scalar(select(func.count()).select_from(Goodwill))) == 0
foreign = await auth_client(FOREIGN)
assert (await foreign.get(base + "/goodwill")).status_code == HTTP_NOT_FOUND
async def test_empty_and_readonly(db_session, auth_client, fixtures_dir):
w = await _world(db_session)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{w.version.id}"
table = (await client.get(base + "/goodwill")).json()
assert table["rows"] == [] and table["totals"]["financial_investment_amount"] == "0.00"
w.version.status = ConsolidationStatus.VERIFIED
await db_session.flush()
table = (await client.get(base + "/goodwill")).json()
assert table["editable"] is False
response = await client.put(base + "/goodwill-settings", json={"goodwill_enabled": True})
assert response.status_code == HTTP_CONFLICT and response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
async def test_capital_missing_duplicate_and_zero(db_session, auth_client, fixtures_dir):
w = await goodwill_world(db_session, fixtures_dir)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{w.version.id}"
meta = (await db_session.scalars(select(FinancialStatementMeta))).one()
column = (await db_session.scalars(select(FinancialStatementColumn))).one()
# Дубликаты 1310 суммируются штатным агрегатом формы.
line = FinancialStatementLine(meta_id=meta.id, line_code="1310", row_index=1)
db_session.add(line)
await db_session.flush()
value = FinancialStatementValue(line_id=line.id, column_id=column.id, amount=Decimal("1"))
db_session.add(value)
await db_session.flush()
row = (await client.get(base + "/goodwill")).json()["rows"][0]
assert row["charter_capital_amount"] == "100001000.00"
column.period_date = dt.date(2023, 9, 30)
await db_session.flush()
row = (await client.get(base + "/goodwill")).json()["rows"][0]
assert row["charter_capital_amount"] is None
assert row["warnings"][0]["code"] == "CAPITAL_SOURCE_MISSING"
column.period_date = dt.date(2024, 9, 30)
for val in (await db_session.scalars(select(FinancialStatementValue))).all():
val.amount = 0
await db_session.flush()
assert (await client.get(base + "/goodwill")).json()["rows"][0]["charter_capital_amount"] == "0.00"
async def test_investments_do_not_apply_custom_document_or_account_priority(db_session, auth_client, fixtures_dir):
w = await goodwill_world(db_session, fixtures_dir)
client = await auth_client(KA_RF)
amount = Decimal(investment_rows(fixtures_dir)[0]["closing_debit"])
account = (await db_session.scalars(select(ChartOfAccount).where(ChartOfAccount.code == "58.01"))).one()
broad = await make_confirmed_osv(
db_session,
entity=w.a,
account="58",
year=2024,
quarter=PeriodQuarter.Q3,
is_cumulative=True,
rows=[
dict(
name="СЕМИКАРАКОРСКИЙ ЭЛЕВАТОР ООО", counterparty_id=w.b.id, account_id=account.id, closing_debit=amount
)
],
)
db_session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=broad.id))
await db_session.flush()
base = f"/consolidations/versions/{w.version.id}"
assert (await client.get(base + "/goodwill")).json()["totals"]["financial_investment_amount"] == "539994600.00"
child = ChartOfAccount(
code="58.01.99", name="Тестовая детализация", parent_id=account.id, row_type=account.row_type, kind=account.kind
)
db_session.add(child)
await db_session.flush()
detail = await make_confirmed_osv(
db_session,
entity=w.a,
account="58.01.99",
year=2024,
quarter=PeriodQuarter.Q3,
is_cumulative=True,
rows=[
dict(name="СЕМИКАРАКОРСКИЙ ЭЛЕВАТОР ООО", counterparty_id=w.b.id, account_id=child.id, closing_debit=amount)
],
)
db_session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=detail.id))
await db_session.flush()
assert (await client.get(base + "/goodwill")).json()["totals"]["financial_investment_amount"] == "809991900.00"
async def test_view_and_edit_permissions(auth_client):
client = await auth_client("admin@armka.local")
base = f"/consolidations/versions/{uuid4()}"
for path in ("/goodwill", "/goodwill/export"):
assert (await client.get(base + path)).status_code == HTTP_FORBIDDEN
response = await client.put(base + "/goodwill-settings", json={"goodwill_enabled": True})
assert response.status_code == HTTP_FORBIDDEN
async def test_real_balance_and_ambiguous_forms(db_session, auth_client, fixtures_dir):
w = await goodwill_world(db_session, fixtures_dir)
consolidation = await db_session.get(Consolidation, w.version.consolidation_id)
consolidation.year = 2025
# Реальный ББ НПЗ, строки и суммы не меняются.
path = next((fixtures_dir / GOLDEN_STATEMENTS / "npz/2025-Q3").glob("*.json"))
documents = await map_golden_snapshot(
db_session, entity=w.b, path=path, uploaded_by=None, result_builder=golden_result_v1
)
previous = (
await db_session.scalars(
select(Document)
.join(ConsolidationDocument)
.where(
ConsolidationDocument.consolidation_version_id == w.version.id,
Document.document_type == DocumentType.BALANCE,
)
)
).one()
await db_session.execute(
delete(ConsolidationDocument).where(
ConsolidationDocument.consolidation_version_id == w.version.id,
ConsolidationDocument.document_id == previous.id,
)
)
for doc in documents:
if doc.document_type == DocumentType.BALANCE:
doc.status = DocumentStatus.CONFIRMED
db_session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=doc.id))
for doc in (
await db_session.scalars(
select(Document)
.join(ConsolidationDocument)
.where(
ConsolidationDocument.consolidation_version_id == w.version.id,
Document.document_type == DocumentType.OSV,
)
)
).all():
doc.period_year = 2025
await db_session.flush()
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{w.version.id}/goodwill"
row = (await client.get(base)).json()["rows"][0]
assert row["charter_capital_amount"] == "100000.00"
db_session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=previous.id))
await db_session.flush()
row = (await client.get(base)).json()["rows"][0]
assert row["charter_capital_amount"] is None
assert row["warnings"][0]["code"] == "CAPITAL_SOURCE_AMBIGUOUS"
async def test_quarterly_bu_and_sum_are_preserved(db_session, auth_client, fixtures_dir):
w = await goodwill_world(db_session, fixtures_dir)
# Заменяем нарастающий 58.01 тремя квартальными источниками с двумя видами учёта.
await db_session.execute(
delete(ConsolidationDocument).where(
ConsolidationDocument.consolidation_version_id == w.version.id,
ConsolidationDocument.document_id.in_(select(Document.id).where(Document.osv_account_code == "58.01")),
)
)
account = (await db_session.scalars(select(ChartOfAccount).where(ChartOfAccount.code == "58.01"))).one()
raw = investment_rows(fixtures_dir)[0]
for quarter in (PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3):
doc = await make_confirmed_osv(
db_session,
entity=w.a,
account="58.01",
year=2024,
quarter=quarter,
is_cumulative=False,
rows=[
dict(
name=raw["counterparty_name"],
counterparty_id=w.b.id,
account_id=account.id,
closing_debit=Decimal(raw["closing_debit"]),
data_type=kind,
)
for kind in (OsvDataType.BU, OsvDataType.SUM)
],
)
db_session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=doc.id))
await db_session.flush()
client = await auth_client(KA_RF)
table = (await client.get(f"/consolidations/versions/{w.version.id}/goodwill")).json()
assert table["totals"]["financial_investment_amount"] == "539994600.00"
async def test_table_loads_osv_rows_once(db_session, auth_client, fixtures_dir, monkeypatch):
world = await goodwill_world(db_session, fixtures_dir)
original = consolidation_repo.load_osv_rows
calls = 0
async def counted(*args, **kwargs):
nonlocal calls
calls += 1
return await original(*args, **kwargs)
monkeypatch.setattr(consolidation_repo, "load_osv_rows", counted)
client = await auth_client(KA_RF)
response = await client.get(f"/consolidations/versions/{world.version.id}/goodwill")
assert response.status_code == HTTP_OK, response.text
assert response.json()["rows"][0]["financial_investment_amount"] == "269997300.00"
assert calls == 1
async def test_null_closing_debit_does_not_block_pair_calculation(db_session, auth_client, fixtures_dir):
world = await goodwill_world(db_session, fixtures_dir)
entry = (
await db_session.scalars(
select(OsvEntry).where(OsvEntry.owner_legal_entity_id == world.a.id, OsvEntry.account_code == "58.01")
)
).one()
db_session.add(
OsvEntry(
document_id=entry.document_id,
recognition_job_id=entry.recognition_job_id,
owner_legal_entity_id=entry.owner_legal_entity_id,
row_number=entry.row_number + 1,
account_id=entry.account_id,
account_code=entry.account_code,
account_root=entry.account_root,
group_type=entry.group_type,
data_type=entry.data_type,
counterparty_id=entry.counterparty_id,
counterparty_name=entry.counterparty_name,
closing_debit=None,
)
)
await db_session.flush()
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{world.version.id}"
assert (await client.post(base + "/lock")).status_code == HTTP_CREATED
response = await client.put(
base + f"/goodwill/{world.a.id}/{world.b.id}",
json=dict(ownership_share="100", include_in_kfo=True, comment="Подтверждено"),
)
assert response.status_code == HTTP_OK, response.text
row = response.json()
assert row["financial_investment_amount"] == "269997300.00"
assert row["participation_amount"] == "100000000.00"
assert row["goodwill_amount"] == "169997300.00"
assert row["warnings"] == []

View File

@ -0,0 +1,155 @@
"""Проверка ввода строки до обращения к хранилищу."""
from decimal import Decimal
from http import HTTPStatus
import pytest
from sqlalchemy import func, select
from src.api.dependencies.audit import RequestAudit
from src.common.exceptions import ConflictError, DomainValidationError
from src.models.audit_ex import AuditEvent
from src.models.enums import AuditAction
from src.models.kfo import Goodwill, KfoResult
from src.models.osv import OsvEntry
from src.models.statements import FinancialStatementValue
from src.schemas.goodwill import GoodwillRowIn
from src.services.goodwill import validate_decision
from tests.test_api_consolidated_osv import KA_RF
from tests.test_api_goodwill import goodwill_world
@pytest.mark.parametrize("value", ["-1", "100.0001", "0.00001", "NaN", "Infinity", "abc", ""])
def test_invalid_share(value):
with pytest.raises(DomainValidationError) as error:
validate_decision(GoodwillRowIn(ownership_share=value, include_in_kfo=False))
assert error.value.code == "INVALID_SHARE"
@pytest.mark.parametrize("value", ["0", "100", "12.3456", "12,3456"])
def test_valid_share(value):
result = validate_decision(GoodwillRowIn(ownership_share=value, include_in_kfo=True, comment=" Обоснование "))
assert str(result.ownership_share) == value.replace(",", ".")
assert result.comment == "Обоснование"
def test_required_fields():
assert validate_decision(GoodwillRowIn(ownership_share=None, include_in_kfo=False)).ownership_share is None
with pytest.raises(DomainValidationError) as error:
validate_decision(GoodwillRowIn(ownership_share=None, include_in_kfo=True, comment="Да"))
assert error.value.code == "INVALID_SHARE"
with pytest.raises(DomainValidationError) as error:
validate_decision(GoodwillRowIn(ownership_share="100", include_in_kfo=True, comment=" "))
assert error.value.code == "COMMENT_REQUIRED"
@pytest.mark.integration
@pytest.mark.fixtures
async def test_save_settings_and_disappearing_pair(db_session, auth_client, fixtures_dir):
w = await goodwill_world(db_session, fixtures_dir)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{w.version.id}"
path = base + f"/goodwill/{w.a.id}/{w.b.id}"
body = dict(ownership_share="80.1234", include_in_kfo=True, comment="Подтверждено")
response = await client.put(path, json=body)
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "LOCK_REQUIRED"
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
other = await auth_client("rka@armka.local")
rejected = await other.put(path, json=body)
assert rejected.status_code == HTTPStatus.CONFLICT
assert rejected.json()["detail"]["code"] == "LOCKED_BY_OTHER"
response = await client.put(path, json=body)
assert response.status_code == HTTPStatus.OK, response.text
row = response.json()
assert row["ownership_share"] == "80.1234"
assert row["participation_amount"] == "80123400.00"
assert row["goodwill_amount"] == "189873900.00"
again = await client.put(path, json=body)
assert again.status_code == HTTPStatus.OK
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 1
on = await client.put(base + "/goodwill-settings", json=dict(goodwill_enabled=True))
assert on.status_code == HTTPStatus.OK
assert on.json()["totals"]["goodwill_in_kfo_amount"] == "189873900.00"
audit_count = await db_session.scalar(
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.GOODWILL_EDIT)
)
assert (
await client.put(base + "/goodwill-settings", json=dict(goodwill_enabled=True))
).status_code == HTTPStatus.OK
assert audit_count == await db_session.scalar(
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.GOODWILL_EDIT)
)
off = await client.put(base + "/goodwill-settings", json=dict(goodwill_enabled=False))
assert off.json()["totals"]["goodwill_in_kfo_amount"] == "0.00"
assert off.json()["rows"][0]["ownership_share"] == "80.1234"
body["include_in_kfo"] = False
assert (await client.put(path, json=body)).status_code == HTTPStatus.OK
assert (await client.get(base + "/goodwill")).json()["totals"]["goodwill_amount"] == "0.00"
entries = list(
(
await db_session.scalars(
select(OsvEntry).where(OsvEntry.account_code == "58.01", OsvEntry.owner_legal_entity_id == w.a.id)
)
).all()
)
for entry in entries:
entry.closing_debit = 0
await db_session.flush()
row = (await client.get(base + "/goodwill")).json()["rows"][0]
assert not row["has_investments"] and row["goodwill_amount"] is None
assert row["comment"] == "Подтверждено"
assert (await client.put(path, json=body)).status_code == HTTPStatus.OK
body["include_in_kfo"] = True
response = await client.put(path, json=body)
assert response.status_code == HTTPStatus.OK
assert response.json()["financial_investment_amount"] is None
assert response.json()["goodwill_amount"] is None
assert await db_session.scalar(select(func.count()).select_from(KfoResult)) == 0
@pytest.mark.integration
@pytest.mark.fixtures
async def test_mutation_validation_and_rollback(db_session, auth_client, fixtures_dir, monkeypatch):
w = await goodwill_world(db_session, fixtures_dir)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{w.version.id}"
path = base + f"/goodwill/{w.a.id}/{w.b.id}"
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
body = dict(ownership_share="100", include_in_kfo=True, comment="")
response = await client.put(path, json=body)
assert (
response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
and response.json()["detail"]["code"] == "COMMENT_REQUIRED"
)
body.update(ownership_share="100.00001", comment="Да")
assert (await client.put(path, json=body)).json()["detail"]["code"] == "INVALID_SHARE"
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
async def reject_audit(*args, **kwargs):
raise ConflictError("TEST_AUDIT_FAILURE", "Ошибка тестового аудита")
monkeypatch.setattr(RequestAudit, "record", reject_audit)
body["ownership_share"] = "100"
response = await client.put(path, json=body)
assert response.status_code == HTTPStatus.CONFLICT
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
@pytest.mark.integration
@pytest.mark.fixtures
async def test_money_overflow_keeps_database_unchanged(db_session, auth_client, fixtures_dir):
w = await goodwill_world(db_session, fixtures_dir)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{w.version.id}"
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
value = (await db_session.scalars(select(FinancialStatementValue))).one()
value.amount = Decimal("9999999999999999.99")
await db_session.flush()
response = await client.put(
base + f"/goodwill/{w.a.id}/{w.b.id}", json=dict(ownership_share="100", include_in_kfo=True, comment="Проверка")
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "MONEY_OUT_OF_RANGE"
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0

View File

@ -0,0 +1,22 @@
"""Локальный OpenAPI совпадает с четырьмя маршрутами приложения."""
from pathlib import Path
import yaml
from src.app import app
from src.schemas.goodwill import GoodwillRowIn
from src.services.goodwill import validate_decision
def test_contract_matches_application():
contract = yaml.safe_load(
(Path(__file__).resolve().parents[2] / "goodwill/api-contract-goodwill.openapi.yaml").read_text()
)
actual = app.openapi()
paths = {key: value for key, value in actual["paths"].items() if "/goodwill" in key}
assert contract["paths"] == paths
for name, schema in contract["components"]["schemas"].items():
assert schema == actual["components"]["schemas"][name]
data = GoodwillRowIn(ownership_share="80.1234", include_in_kfo=True, comment="Доля подтверждена")
assert str(validate_decision(data).ownership_share) == "80.1234"

View File

@ -0,0 +1,227 @@
"""Гудвилл изменяет актив и пассив на одинаковую сумму, не смешивает пары."""
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,52 @@
"""Расчёт и жизненный цикл на реальной сумме вложений Приманыческого."""
from decimal import Decimal
from pathlib import Path
from uuid import UUID
import pytest
from src.domain.goodwill import Decision, Investment, build, totals
from tests.test_goodwill_golden import investment_rows
pytestmark = pytest.mark.fixtures
A, B, C = (UUID(int=i) for i in (1, 2, 3))
def test_calculation_and_lifecycle(fixtures_dir: Path) -> None:
fv = Decimal(investment_rows(fixtures_dir)[0]["closing_debit"])
source = [Investment(A, B, fv)]
rows = build(source, {}, {B: Decimal("100000.00")})
assert rows[0].goodwill_amount is None
saved = {(A, B): Decision(Decimal("12.3456"), True, "Обоснование")}
row = build(source, saved, {B: Decimal("100000.00")})[0]
assert row.participation_amount == Decimal("12345.60")
assert row.goodwill_amount == fv - Decimal("12345.60")
gone = build([], saved, {})[0]
assert not gone.has_investments and gone.goodwill_amount is None
assert gone.financial_investment_amount is None
assert gone.decision == saved[A, B]
assert totals([gone], True)["financial_investment_amount"] == 0
assert build(source, saved, {B: Decimal("100000.00")})[0] == row
def test_zero_missing_capital_and_warning(fixtures_dir: Path) -> None:
fv = Decimal(investment_rows(fixtures_dir)[0]["closing_debit"])
source = [Investment(A, B, fv), Investment(C, B, fv)]
saved = {(A, B): Decision(Decimal("70"), True, "A"), (C, B): Decision(Decimal("40"), True, "C")}
rows = build(source, saved, {B: Decimal("0")})
assert all(r.participation_amount == 0 and r.goodwill_amount == fv for r in rows)
assert all("OWNERSHIP_TOTAL_EXCEEDED" in r.warnings for r in rows)
assert totals(rows, False)["goodwill_in_kfo_amount"] == 0
assert all(r.goodwill_amount is None for r in build(source, saved, {}))
def test_rounding_and_unselected(fixtures_dir: Path) -> None:
fv = Decimal(investment_rows(fixtures_dir)[0]["closing_debit"])
source = [Investment(A, B, fv)]
row = build(source, {(A, B): Decision(Decimal("0.0001"), False, None)}, {B: Decimal("5000")})[0]
assert row.participation_amount is None
selected = build(source, {(A, B): Decision(Decimal("0.0001"), True, "Подтверждено")}, {B: Decimal("5000")})[0]
assert selected.participation_amount == Decimal("0.01")
assert totals([row], True)["goodwill_amount"] == 0
assert totals([row], True)["financial_investment_amount"] == fv

View File

@ -0,0 +1,78 @@
"""XLSX: пустые расчёты, группировка, итоги и аудит через настоящий API."""
from http import HTTPStatus
from io import BytesIO
import pytest
from openpyxl import load_workbook
from sqlalchemy import select, update
from src.models.audit_ex import AuditEvent
from src.models.enums import AuditAction
from src.models.osv import OsvEntry
from tests.test_api_consolidated_osv import KA_RF
from tests.test_api_goodwill import goodwill_world
INVESTMENT_AMOUNT = 269997300
FRONTEND_COLUMN_COUNT = 8
EMPTY_EXPORT_ROW_COUNT = 2
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
async def test_export_matches_server_table(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}"
response = await client.get(base + "/goodwill/export")
assert response.status_code == HTTPStatus.OK, response.text
assert "filename*=" in response.headers["content-disposition"]
sheet = load_workbook(BytesIO(response.content)).active
assert sheet.max_column == FRONTEND_COLUMN_COUNT
assert sheet.cell(1, 1).value == "Учет в отчете"
assert sheet.cell(1, 2).value == "Наименование дочерней организации"
assert sheet.cell(2, 2).value == world.a.name
assert sheet.cell(3, 2).value == world.b.name
assert sheet.cell(3, 4).value == INVESTMENT_AMOUNT
assert sheet.cell(3, 5).value is None
assert sheet.cell(3, 7).value is None
assert sheet.cell(4, 2).value == "Всего"
assert sheet.cell(5, 2).value == "Итого по листу"
assert sheet.cell(5, 4).value == sheet.cell(4, 4).value == sheet.cell(3, 4).value
assert sheet.cell(5, 4).fill.patternType is None
event = (await db_session.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
assert event.after_value == {"report": "goodwill", "rows": 1}
# Нерассчитанные строки не считаются нулевыми; итоги фильтр не меняет (PDF §4).
filtered = await client.get(base + "/goodwill/export?sign=zero")
filtered_sheet = load_workbook(BytesIO(filtered.content)).active
assert filtered_sheet.max_row == EMPTY_EXPORT_ROW_COUNT
assert filtered_sheet.cell(2, 2).value == "Итого по листу"
assert filtered_sheet.cell(2, 4).value == INVESTMENT_AMOUNT
assert (await client.get(base + "/goodwill/export?sign=unknown")).status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
body = dict(ownership_share="100", include_in_kfo=True, comment="=1+1")
path = base + f"/goodwill/{world.a.id}/{world.b.id}"
assert (await client.put(path, json=body)).status_code == HTTPStatus.OK
response = await client.get(base + "/goodwill/export")
sheet = load_workbook(BytesIO(response.content)).active
assert sheet.cell(3, 8).data_type == "s"
assert sheet.cell(3, 8).value == "=1+1"
assert sheet.cell(3, 7).value == sheet.cell(5, 7).value
filtered = await client.get(base + "/goodwill/export?sign=negative")
filtered_sheet = load_workbook(BytesIO(filtered.content)).active
assert filtered_sheet.max_row == EMPTY_EXPORT_ROW_COUNT
assert filtered_sheet.cell(2, 2).value == "Итого по листу"
assert filtered_sheet.cell(2, 4).value == INVESTMENT_AMOUNT
assert filtered_sheet.cell(2, 7).value == sheet.cell(5, 7).value
await db_session.execute(
update(OsvEntry)
.where(OsvEntry.owner_legal_entity_id == world.a.id, OsvEntry.account_code == "58.01")
.values(closing_debit=0)
)
response = await client.get(base + "/goodwill/export")
sheet = load_workbook(BytesIO(response.content)).active
assert sheet.cell(3, 4).value is None
assert sheet.cell(3, 7).value is None
assert sheet.cell(5, 4).value == 0
assert sheet.cell(5, 7).value == 0

View File

@ -0,0 +1,43 @@
"""Неизменённые источники 58.01 и отрицательные примеры 58.03."""
import json
from decimal import Decimal
from pathlib import Path
import pytest
pytestmark = pytest.mark.fixtures
def investment_rows(fixtures_dir: Path) -> list[dict]:
path = next((fixtures_dir / "golden/expected_osv_parse/osv-dkb/primanych/2025-Q3").glob("*58*.json"))
return [
r
for d in json.loads(path.read_text())["documents"]
for r in d["rows"]
if r["group_type"] == "COUNTERPARTY"
and r["data_type"] in ("BU", "SUM")
and (r.get("account_code_raw") or "").startswith("58.01")
]
def test_real_equity_investment(fixtures_dir: Path) -> None:
rows = investment_rows(fixtures_dir)
assert len(rows) == 1
assert rows[0]["counterparty_name"] == "СЕМИКАРАКОРСКИЙ ЭЛЕВАТОР ООО"
assert Decimal(rows[0]["closing_debit"]) == Decimal("269997300.00")
@pytest.mark.parametrize("period", ["2025-Q1", "2024-Q3"])
def test_biscuit_contains_loans_only(fixtures_dir: Path, period: str) -> None:
paths = list((fixtures_dir / "golden/expected_osv_parse/rb" / period).glob("*58*.json"))
assert paths
rows = [
r
for p in paths
for d in json.loads(p.read_text())["documents"]
for r in d["rows"]
if r["group_type"] == "COUNTERPARTY"
]
assert rows
assert {r["account_code_raw"] for r in rows} == {"58.03"}

View File

@ -0,0 +1,59 @@
"""Выбор дерева счетов и отчётной колонки без догадок по is_current."""
import datetime as dt
from decimal import Decimal
from types import SimpleNamespace as N
from uuid import UUID
import pytest
from sqlalchemy import select
from src.models.accounting import ChartOfAccount
from src.models.enums import StatementLineStatus
from src.repositories.goodwill import accounts
from src.services.goodwill_source import capital_amounts
@pytest.mark.integration
async def test_accounts_query_loads_only_investment_subtree(seeded) -> None:
root = (await seeded.scalars(select(ChartOfAccount).where(ChartOfAccount.code == "58.01"))).one()
child = ChartOfAccount(
code="GOODWILL_CHILD",
name="Потомок 58.01",
parent_id=root.id,
kind=root.kind,
row_type=root.row_type,
)
sibling = ChartOfAccount(
code="58.0199",
name="Похожий код вне ветки",
parent_id=root.parent_id,
kind=root.kind,
row_type=root.row_type,
)
seeded.add_all([child, sibling])
await seeded.flush()
selected = await accounts(seeded)
ids = {account.id for account in selected}
assert {root.id, child.id} <= ids
assert sibling.id not in ids
assert all(account.id == root.id or account.parent_id in ids for account in selected)
def test_capital_aggregates_duplicates_at_period_end() -> None:
mid, col = UUID(int=1), UUID(int=2)
columns = [
N(id=col, meta_id=mid, period_date=dt.date(2025, 9, 30), is_current=False),
N(id=UUID(int=3), meta_id=mid, period_date=dt.date(2024, 12, 31), is_current=True),
]
values = [
(
N(id=UUID(int=i), meta_id=mid, line_code="1310", row_index=i, line_status=StatementLineStatus.ok),
col,
Decimal(amount),
)
for i, amount in [(4, "100"), (5, "20")]
]
assert capital_amounts(mid, "тыс. руб.", columns, values, dt.date(2025, 9, 30)) == (Decimal("120000"), None)
assert capital_amounts(mid, "тыс. руб.", columns, values, dt.date(2025, 6, 30)) == (None, None)
assert capital_amounts(mid, None, columns, values, dt.date(2025, 9, 30)) == (None, None)

View File

@ -82,6 +82,13 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
# Корректировки, первый этап
"/api/v1/consolidations/versions/{version_id}/corrections": {"get"},
"/api/v1/consolidations/versions/{version_id}/corrections/export": {"get"},
# Таблица гудвилла (goodwill-table)
"/api/v1/consolidations/versions/{version_id}/goodwill": {"get"},
"/api/v1/consolidations/versions/{version_id}/goodwill-settings": {"put"},
"/api/v1/consolidations/versions/{version_id}/goodwill/{head_legal_entity_id}/{subsidiary_legal_entity_id}": {
"put"
},
"/api/v1/consolidations/versions/{version_id}/goodwill/export": {"get"},
# шахматка ВГО и правила элиминации
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
"/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"},

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-05

View File

@ -0,0 +1,72 @@
# Design
## Context
Проверены модели Goodwill и KfoResult, ConsolidatedOsvService, CorrectionService, consolidation_guard, текущая DBML v5.24, §8 domain-rules и PDF DOC-259025. Таблица goodwill уже содержит nullable суммы и numeric(7,4); audit_action содержит GOODWILL_EDIT. Расчётного сервиса КФО и признака устаревания нет. Контекст взят из конфигурации проекта и общей документации.
## Goals / Non-Goals
**Goals:** отделить чистые вычисления от загрузки источников и ручного ввода; повторно использовать существующие правила выбора ОСВ и guard; обеспечить единый расчёт для чтения, сохранения, выгрузки и будущего КФО.
**Non-Goals:** новый движок КФО, изменение его таблиц и корректировок, UI, изменение workflow OpenSpec. Историческая фиксация VERIFIED не реализована этим этапом. Это ограничение реализации относительно PDF, а не новое правило: не объявлять текущие источники историческим снимком.
## Decisions
### 1. Приоритет требований
Явные решения пользователя имеют приоритет над PDF, затем PDF, затем MD/OpenAPI. Четыре знака доли, построчное PUT и XLSX намеренно отличаются от PDF. Сохраняем четыре пути MD. is_head_entity полностью исключается. ASSUMPTION(OQ-10): источник УК — только ББ 1310; 1350 можно показывать справочно, но не включать в P. ASSUMPTION(OQ-37): вклад использует GOODWILL. Подтверждение другого источника УК или формата экспорта требует обновления этого change перед соответствующей реализацией.
### 2. Слои и источники
Добавить доменный модуль расчёта гудвилла с типами без ORM: пара, источники, ручной ввод, рассчитанная строка, итоги и вклад КФО. Репозиторий гудвилла загружает сохранённые строки, дерево счетов и привязанные ББ пакетно. Адаптер источников гудвилла преобразует полный результат ConsolidatedOsvService, без пагинации: использовать его выбор периодов и нормализацию денежных строк, а не разнесение VGO. Альтернатива — брать только назначенные ББ-2 позиции — отвергнута: PDF определяет вложения независимо от ручного назначения ВГО.
Отбор по chart_of_account.parent_id выполняется рекурсивным SQL от 58.01. Финансовые вложения — сумма closing_debit подходящих строк готового свода по владельцу и контрагенту внутри периметра. Собственных приоритетов документов, исключения родительских счетов по наличию потомков и предпочтения BU перед SUM нет. Используются только существующие правила сводной ОСВ; фраза PDF об исключении повторного суммирования не определяет дополнительного алгоритма между перекрывающимися файлами.
ББ — только подтверждённый документ, привязанный к версии, владелец ДО. Дата колонки — конец квартала (reporting_date существующей консолидации может быть первым днём следующего квартала), is_current — недостаточно. Агрегировать повтор кода 1310 общим механизмом формы и перевести units в рубли. По прямому решению пользователя отсутствие пригодной 1310 или неоднозначность ББ не блокируют сохранение ручного ввода. Нет подходящего ББ/1310/даты или неоднозначные подходящие формы — charter_capital_amount=null и warning CAPITAL_SOURCE_MISSING/AMBIGUOUS; P/G=null. Не выбирать документ случайно. Числовой 0 — полноценный источник.
### 3. Ручной ввод и отсутствие вложений
Ключ — (version_id, head_id, subsidiary_id), существующий UNIQUE. GET объединяет найденные пары с goodwill и ничего не пишет. Впервые запись создаётся при PUT строки; не материализуем все строки при чтении или смене переключателя. Это обычное сохранение по PUT из MD §3.3; GET формирует ответ без записи.
has_investments — вычисляемый DTO-флаг, не колонка. По прямому решению пользователя для «Нет вложений» все денежные колонки ответа равны null; сохранённый ввод и последний денежный снимок в БД не удаляются. Строка исключается из итогов и применения в КФО. Собственной ошибки GOODWILL_PAIR_INACTIVE нет. Отсутствующие доля и комментарий не затирают сохранённый ввод неактивной пары (PDF §10).
### 4. Деньги и DTO
Decimal, P.quantize(0.01, ROUND_HALF_UP), затем G=ФВ−P. Ограничение numeric(18,2) проверять до записи, переполнение отклонять 422 MONEY_OUT_OF_RANGE без частичного сохранения; float не использовать. Значения в API — рубли с двумя знаками, ownership_share — проценты с четырьмя. Максимум четыре дробных знака во входе, без автоматического округления; запятая и точка допустимы по PDF §5.2; null допускается для снятой галочки. P/G рассчитываются только у действующей отмеченной строки при наличии доли и капитала (PDF §6); итоги P/G учитывают только выбранные действующие строки. Для первоначальной строки include_in_kfo=false, доля/comment/updated_* = null.
GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениями spec: has_investments, warnings[] из {code, message}; charter_capital_amount и additional_capital_amount тоже nullable, чтобы отсутствие ББ не маскировать нулём. totals — поля MD; в этом этапе без отдельного API групповых итогов: они выводимы из всех строк. При goodwill_enabled=false потенциальные G видны, goodwill_in_kfo_amount=0.00.
### 5. Транзакции и безопасность
Маршруты гудвилла используют зависимости по образцу API корректировок. Service использует текущие policy и lock_editable_version. PUT строки под блокировкой версии повторно загружает актуальные источники, проверяет пару и ввод, upsert существующей записи, записывает GOODWILL_EDIT before/after в той же транзакции. Вычисляемые суммы из тела не принимаются. PUT настроек не трогает строки; GOODWILL_EDIT имеет object_type=consolidation_version и changed_field=goodwill_enabled, строки — object_type=goodwill. Новые enum не нужны. Отдельная оптимистическая ревизия не добавляется: существующая блокировка версии сериализует операции, последнее сохранение владельца побеждает.
### 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 КФО с явной зависимостью на этот контракт.
### 7. XLSX
Сервис экспорта гудвилла использует DTO таблицы, openpyxl и безопасную запись текстов из osv_export. Экспорт текущего серверного состояния с сохранённым вводом. Параметр sign=all|positive|negative|zero выбирает строки по текущему фильтру (PDF §4); нерассчитанные строки не являются нулевыми. Итоги сохраняются по полному составу блока и листа независимо от фильтра. По запросу пользователя экспорт использует состав и порядок восьми колонок экранной таблицы; цветовое оформление стандартное. Изменения фронта и параметров API не требуются. Денежные значения выгружаются в рублях. Добавочный капитал и статус отдельными колонками не выгружаются. null — пустая ячейка, а не 0. Аудит EXPORT по образцу corrections_export, filename* для кириллицы.
## Risks / Trade-offs
- Старые MD/OpenAPI обещают все пары ГО и обязательные денежные суммы → при реализации обновить оба локальных контракта согласованно и передать совместимый контракт фронту до подключения.
- В РБ 2024 Q3 присутствует 58.03, который не является инвестицией 58.01 → отрицательный тест исключения, не переименовывать данные ради критерия старого XML. Положительные тесты брать из реальных файлов 58 других периодов/групп.
- Отсутствие пригодной 1310 и неоднозначность ББ → решение пользователя: null, сохранение ручного ввода разрешено.
- Фиксация исторических источников и stale отсутствуют → текущий этап нельзя выдавать за завершённый полный AURORA-1543; приёмка интеграции КФО отдельная.
- Новые уточнения §8 отсутствуют в общем docs → отдельный docs-change должен записать решения и нерешённые пункты в реестр открытых вопросов без выдуманных OQ-номеров.
## Migration Plan
Миграция БД не требуется: существующие поля и 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.
Без выбранных записей и при выключении нет загрузки ОСВ/ББ.

View File

@ -0,0 +1,39 @@
# Proposal
## Why
Для AURORA-1543 нужна таблица гудвилла версии: реальные вложения между компаниями периметра, ручная доля владения и проверяемый расчёт. Существуют ORM и черновик контракта, но нет API и расчётного сервиса; PDF уточняет жизненный цикл и расходится с контрактом.
## What Changes
- Срез 6: бэкенд таблицы, построчное сохранение, переключатель версии и экспорт XLSX.
- ГО определяется по фактическим вложениям 58.01 и дочерних счетов в другие компании периметра; `is_head_entity` не используется.
- Найденные пары объединяются с сохранёнными `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. Запись КФО не выполняется.
- Уточнения PDF, отсутствующие в §8 (неактивные пары, выборка по дереву, итоги, исключение ББ-2), требуют отдельной синхронизации в docs; при реализации не подменять ими молча старую редакцию правил.
## Capabilities
### New Capabilities
- `goodwill-table`: источники, расчёт, сохранение, доступ, экспорт и подготовка вклада в КФО.
### Modified Capabilities
Нет: существующие API и требования к сводной ОСВ остаются прежними.
## Impact
Репозиторий реализации — arm-core, только api. Документация общего домена и общий контракт — отдельный согласованный change в ../docs; текущий change не изменяет соседний репозиторий.
Читаются таблицы DBML v5.24: consolidation, consolidation_version, consolidation_member, consolidation_document, document, osv_entry, chart_of_account, financial_statement_meta/line/column/value, legal_entity, app_user, elimination_rule. Изменяются goodwill, consolidation_version.goodwill_enabled и audit_event. Для аудита использовать существующий GOODWILL_EDIT, для выгрузки EXPORT; новых enum и колонок нет. `has_investments`, предупреждения и вклад КФО — вычисляемые DTO.
Затрагиваются domain/, repositories/, schemas/, services/ и endpoints/consolidations; четыре маршрута из MD. Изменение чернового контракта: новые признаки состояния и nullable P/G, обновлённая семантика пар и итогов. Используются существующие guard, Decimal и openpyxl.
## Non-goals
- Фронт AURORA-1544, пакетное сохранение и CSV.
- Построение и запись kfo_result, применение корректировок, экран отметки «Учтено в гудвилле».
- Механизм устаревания и фиксации снимка КФО: отдельный change; не добавлять несуществующий флаг БД.
- ОСВ 58.03 как замена 58.01; автоматический перенос разницы в 1370.
- Новая модель данных и окончательное решение методологов по источнику УК; XLSX пока предварительно согласован.

View File

@ -0,0 +1,107 @@
# Spec Delta
## Purpose
Таблица гудвилла позволяет аналитику сохранить долю и обоснование для фактических вложений между компаниями версии и получить проверяемые суммы для последующего расчёта КФО.
## ADDED Requirements
### Requirement: Фактические направленные пары
Система SHALL находить пары ГО→ДО по ненулевой сумме closing_debit счёта 58.01 и его потомков по дереву счетов. Обе компании входят в периметр и различаются; контрагент сопоставлен. is_head_entity MUST NOT влиять на результат. Источники подчиняются правилам актуальности, периодов и исключения двойного суммирования сводной ОСВ.
#### Scenario: Реальная ОСВ счёта 58
- **WHEN** строится таблица по привязанной ОСВ `fixtures/osv-dkb/primanych/2025-Q3/Оборотно-сальдовая ведомость по счету 58 за 9 месяцев 2025 г. ООО Приманыческий.xls`
- **THEN** учитываются только подходящие строки 58.01 и потомков с контрагентами периметра; 58.03 не включается, строки ACCOUNT не включаются; собственные приоритеты перекрывающихся файлов не применяются
#### Scenario: Направление не зависит от признака головы
- **WHEN** подходящие вложения найдены у двух участников периметра с любыми значениями is_head_entity
- **THEN** каждый владелец становится ГО своих пар, включая встречные направления; пустой контрагент, самовложение и внешний контрагент не создают пары
### Requirement: Сохранение исчезнувших пар
Таблица SHALL объединять текущие пары с ранее сохранёнными этой версии. has_investments=false обозначает «Нет вложений»: пара отсутствует, её сальдо нулевое или участник вышел из периметра. По решению пользователя денежные поля неактивной строки SHALL быть null. Сохранённый ввод MUST оставаться; неактивная строка не входит в итоги и вклад КФО. Чтение MUST NOT создавать или изменять записи goodwill.
#### Scenario: Замена источника
- **WHEN** после сохранения пары из реальной ОСВ документ заменён и подходящих вложений больше нет
- **THEN** строка остаётся с has_investments=false, прежними долей, комментарием и галочкой, но без вклада в итоги; повторное появление пары восстанавливает расчёт по текущим источникам
### Requirement: Источник капитала и расчёт
Система SHALL использовать 1310 ББ ДО на конец отчётного периода в рублях: P=round(УК×доля/100,2), G=ФВ−P. Дубликаты 1310 обрабатываются агрегированием формы. Ноль допустим; отсутствие доли или достоверного УК даёт null P/G. 1350 не участвует: ASSUMPTION(OQ-10).
#### Scenario: Ноль отличается от отсутствия расчёта
- **WHEN** из привязанного реального ББ получен УК U, доля ещё не введена
- **THEN** P и G равны null; при доле 0 P равен 0.00 и G равен ФВ; отсутствующая 1310 не подменяется нулём
#### Scenario: Проверка отчётной колонки
- **WHEN** в привязанном ББ есть несколько сравнительных колонок и единицы «тыс. руб.»
- **THEN** УК берётся по дате конца отчётного периода, а не только is_current; сумма переводится в рубли до расчёта
### Requirement: Получение таблицы
GET /api/v1/consolidations/versions/{version_id}/goodwill SHALL возвращать version_id, goodwill_enabled, editable, read_only_reason, rows, totals. Строки отсортированы по именам ГО/ДО и ID; содержат поля MD-контракта, has_investments и warnings. Деньги — строки с двумя знаками в рублях или null, доли — строки с четырьмя.
#### Scenario: Пустая таблица
- **WHEN** нет подходящих вложений и сохранённых пар
- **THEN** rows пуст, денежные итоги 0.00; запрос не создаёт goodwill
### Requirement: Итоги и предупреждения
Система SHALL суммировать ФВ действующих строк независимо от выбора; P/G — только действующих выбранных рассчитанных строк. goodwill_in_kfo_amount включает только положительные применимые G и равен нулю при выключенном goodwill_enabled. Сумма заполненных долей действующих ГО в одной ДО свыше 100% даёт предупреждение, не ошибку.
#### Scenario: Несколько ГО одной ДО
- **WHEN** у действующих пар одной ДО заполнены доли 70% и 40%
- **THEN** строки содержат предупреждение OWNERSHIP_TOTAL_EXCEEDED, сохранение не блокируется; проценты по разным ДО не складываются в общий процент
### Requirement: Построчное сохранение
PUT …/goodwill/{head_id}/{subsidiary_id} SHALL атомарно сохранять ownership_share, include_in_kfo, comment и расчётные суммы только одной пары. Доля допускает null только при снятом выборе, иначе 0–100 с точностью до четырёх знаков; превышение точности не округляется. Для выбранной действующей строки обязателен непустой комментарий до 1000 символов. Ошибки — INVALID_SHARE и COMMENT_REQUIRED (422).
#### Scenario: Снятие выбора
- **WHEN** сохраняется действующая строка с include_in_kfo=false и прежними долей и комментарием
- **THEN** ввод сохраняется, строка исключается из итогов P/G и вклада КФО; ответ содержит пересчитанную строку
#### Scenario: Повторное сохранение и неактивная строка
- **WHEN** повторно сохраняется существующая пара
- **THEN** новая запись не создаётся; произвольная неизвестная пара даёт 404, сохранённая неактивная пара не получает дополнительного запрета GOODWILL_PAIR_INACTIVE; отсутствующий ввод не затирает сохранённые данные
### Requirement: Переключатель версии
PUT …/goodwill-settings SHALL принимать goodwill_enabled и возвращать таблицу. Выключение MUST NOT очищать строки и ручной ввод; оно отключает вклад в КФО. Изменение значения и сохранение строки SHALL записывать GOODWILL_EDIT с before/after, автором и версией в одной транзакции; повтор прежнего значения переключателя не создаёт изменение.
#### Scenario: Выключение и включение
- **WHEN** пользователь выключает гудвилл и затем включает обратно
- **THEN** доли, комментарии и галочки сохранены; вклад вновь вычисляется из актуальных данных, записи КФО не изменяются
### Requirement: Общие права и блокировки
Чтение и экспорт SHALL требовать CONSOLIDATION_VIEW и видимость версии. Изменения SHALL требовать CONSOLIDATION_EDIT, редактируемый статус и собственную активную блокировку. Невидимая версия возвращает 404 NOT_FOUND; изменения без доступа — 403, без редактируемости/блокировки — 409 VERSION_NOT_EDITABLE, LOCK_REQUIRED или LOCKED_BY_OTHER.
#### Scenario: Просмотр завершённой версии
- **WHEN** видимая версия VERIFIED или CANCELLED открывается либо изменяется
- **THEN** чтение доступно с editable=false, изменение отклоняется без записи goodwill или аудита изменения
### Requirement: Экспорт XLSX
GET …/goodwill/export SHALL выгружать XLSX с составом и порядком восьми колонок экранной таблицы. Параметры API остаются прежними: sign выбирает фильтр знака без изменения общих итогов. Деньги выгружаются в рублях; экспорт использует сохранённый ввод, оставляет нерассчитанные значения пустыми и записывает EXPORT. Цветовое оформление стандартное.
#### Scenario: Выгрузка таблицы
- **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** вклад пуст, загрузка ОСВ и ББ не выполняется

View File

@ -0,0 +1,56 @@
# Tasks
Каждая задача рассчитана не более чем на половину дня. Реализация — arm-core/api; фронт и запись КФО отдельно.
## 1. Источники и контракт расчёта
- [x] 1.1 arm-core: добавить тесты на реальных исходных данных с загрузкой неизменённых реальных fixtures счёта 58.01 «Приманыческий» 2025 Q3 и 58.03 РБ 2025 Q1 / 2024 Q3; проверить реальные счета, контрагентов и суммы, зафиксировать положительный набор 58.01 и отрицательный 58.03. Не подменять отсутствующий реальный источник синтетической суммой.
- [x] 1.2 arm-core: добавить доменные тесты гудвилла и кейсы состояний на реальных исходных строках: направление, неиспользование is_head_entity, исчезновение/возврат пары, ноль/null, четыре знака, Decimal-округление, итоги, сумма долей >100. Проверка — воспроизводимый набор ожидаемых расчётов до реализации.
- [x] 1.3 arm-core: реализовать доменный модуль расчёта гудвилла: пары по дереву счетов, объединение сохранённого ввода, формулы и предупреждения; проверить тесты 1.1–1.2, отсутствие ORM/float и записи вычисляемых пар при чтении.
## 2. Загрузка и API чтения
- [x] 2.1 arm-core: добавить тесты загрузки источников гудвилла на реальных ОСВ/ББ: источник периода, parent_id, дубли уровней и перекрывающиеся документы, колонка конца периода, units, повторы 1310, отсутствующий/неоднозначный ББ; проверить, что отсутствующий УК не становится нулём.
- [x] 2.2 arm-core: реализовать репозиторий гудвилла и адаптер источников гудвилла через полный ConsolidatedOsvService и пакетную загрузку форм; проверить тесты 2.1 и отсутствие запроса ББ на каждую строку.
- [x] 2.3 arm-core: добавить схемы, сервис и API-маршруты гудвилла и регистрацию роутера; реализовать GET таблицы. Добавить тесты API гудвилла: сортировка, пустой ответ, null, has_investments, права, чужая версия, отсутствие записей при GET; тесты должны проходить.
- [x] 2.4 arm-core: обновить описательный контракт API и OpenAPI-контракт для чтения, nullable капиталов/P/G, вычисляемых состояний и новых правил пар; проверить соответствие OpenAPI фактическому ответу и перечислить предварительные OQ-10/OQ-37.
## 3. Сохранение и переключатель
- [x] 3.1 arm-core: добавить тесты изменения данных гудвилла для создания/повторного сохранения, снятия выбора, доли null/0/100/лишней точности, COMMENT_REQUIRED, неактивной пары, переполнения денег и отката аудита вместе с записью; добавить проверки статусов/блокировок и двух сериализованных сохранений.
- [x] 3.2 arm-core: реализовать PUT строки в сервисе и репозитории гудвилла через consolidation_guard; запись goodwill и GOODWILL_EDIT атомарна. Проверка — тесты 3.1, одна запись на естественный ключ и неизменность остальных строк.
- [x] 3.3 arm-core: реализовать PUT goodwill-settings, добавить тесты изменения/повтора значения, сохранности ручного ввода и нулевого вклада при выключении; проверить единое событие GOODWILL_EDIT только при изменении переключателя и отсутствие изменений KfoResult.
- [x] 3.4 arm-core: обновить описательный контракт API и OpenAPI-контракт для PUT, ошибок и null доли снятой строки; проверить примеры запросов/ответов контрактными тестами и одинаковую семантику MD/OpenAPI.
## 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.
## 5. Сквозная проверка и передача
- [x] 5.1 arm-core: выполнить pytest новых тестов и регрессии consolidated-osv/corrections, штатные линтеры и проверку типов API по конфигурации репозитория; проверить четыре маршрута через OpenAPI и неизменность схемы БД. Зафиксировать результаты и ограничения реальных фикстур в описании реализации.
- [x] 5.2 arm-core: обновить обзор проекта и подготовить описание изменений для общей архитектуры, доменных правил, реестра открытых вопросов и общих контрактов. Проверить отражение источника 1310, предварительного XLSX, неактивных пар, границы КФО и различий с PDF. Изменения самого ../docs провести отдельным согласованным docs-change; этот пункт завершает только локальную документацию и передачу.
## Workflow follow-up
- Отдельный change ../docs синхронизирует архитектуру, доменные правила, открытые вопросы и общий API-контракт; не назначать новые OQ-номера без проверки реестра.
- Следующий change КФО реализует запись результата, устаревание, применение/исключение ББ-2 и фиксацию VERIFIED; AURORA-1543 полностью закрывается после его приёмки.
- Фронт AURORA-1544 подключается по согласованному обновлённому контракту, отдельной задачей.
## 6. Исправления по ревью пользователя
- [x] 6.1 arm-core: убрать собственные приоритеты документов и уровней счетов, лишние метаданные consolidated_osv; проверить сохранение всех допустимых строк и единственную загрузку ОСВ.
- [x] 6.2 arm-core: привести расчёт отмеченных строк, ввод с запятой, null неактивных денежных колонок и экспорт с фильтром к PDF/решениям пользователя; проверить API, XLSX и OpenAPI-контракт.
- [x] 6.3 arm-core: записать ответы пользователя о неопределённых источниках, удалить несогласованные ограничения и синхронизировать design/specs/контракты; проверить тесты и явно оставить за рамками запись/фиксацию КФО по согласованной границе этапа.
## 7. Выбор источников для КФО
- [x] 7.1 arm-core: отдельный GoodwillSource.load_for_kfo; contributions загружает только goodwill include_in_kfo=true, ОСВ выбранных направленных пар и ББ выбранных ДО. Проверить SQL-отбор и отсутствие полной загрузки при пустом выборе/выключении.
- [x] 7.2 arm-core: обновить описание внутреннего контракта и спецификацию; проверить регрессию таблицы/ОСВ, линтеры и типы.
## 8. Экспорт по экранной таблице
- [x] 8.1 arm-core: привести XLSX к составу и порядку восьми колонок фронта со стандартным оформлением. Сохранить прежний контракт ручки и вызов экспорта на фронте. Проверить интеграционные сценарии выгрузки.