Merge remote-tracking branch 'origin/main' into feat/goodWill

This commit is contained in:
Andrey Epifancev 2026-11-06 19:07:28 +04:00
commit c2a52d9ec8
26 changed files with 2325 additions and 1 deletions

View File

@ -1057,7 +1057,10 @@ email без учёта регистра проверяет только сер
| `RESULT_ACCOUNTS_MISSING` | 422 | результирующая таблица ВГО: в плане счетов нет обязательных кодов статей |
| `INVALID_COMPANY_PAIR` | 422 | пары ВГО тройки: компании совпадают или не входят в периметр версии |
| `ACCOUNTS_NOT_ALLOWED` / `INVALID_MODE` | 422 | защита внутреннего вызова сервиса шахматки; маршруты ALL/MANUAL не дают вызвать эти ветки |
| `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) |
| `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже); `PUT …/goodwill` — отмеченная строка без комментария (с ключом пары) |
| `INVALID_SHARE` | 422 | `PUT …/goodwill`: доля не 0–100, больше двух знаков после запятой или пустая у отмеченной строки (`head_legal_entity_id`, `subsidiary_legal_entity_id`) |
| `DUPLICATE_ROW` | 422 | `PUT …/goodwill`: пара дважды в одном пакете (ключ пары) |
| `MONEY_OUT_OF_RANGE` | 422 | `PUT …/goodwill`: сумма снимка строки не помещается в `numeric(18,2)` (ключ пары) |
| `VERSION_NOT_EDITABLE` | 409 | версия не «в работе» (правки) либо `CANCELLED` при «взять в работу» |
| `LOCK_REQUIRED` | 409 | правка версии без своей активной блокировки |
| `LOCKED_BY_OTHER` | 409 | версию держит другой пользователь: из `POST …/lock` — `locked_by`, `locked_by_name`, `locked_at`; из правок (`ensure_version_editable`) — только `locked_by`, `locked_at`; гонка двух взятий (частичный UNIQUE) — то же 409 |
@ -1340,3 +1343,14 @@ 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.
### Таблица гудвилла
Бэкенд таблицы гудвилла версии (§8 «Гудвилл», DOC-259025): пары ГО → ДО по фактическим вложениям 58.01 и потомков
между участниками периметра (`is_head_entity` не используется), УК — 1310 ББ ДО на конец квартала (единицы — словарём
`arm_statements`). `GET …/goodwill` — строки, итоги по ГО (`groups`) и по листу (`totals`); `PUT …/goodwill` — пакет
строк одной транзакцией (ошибка любой строки откатывает пакет и несёт её ключ, неизменённые строки не перезаписываются);
`PUT …/goodwill-settings` — переключатель `goodwill_enabled`; `GET …/goodwill/export?sign=` — xlsx. Деньги в рублях,
доля — до двух знаков, нерассчитанные значения — `null`; исчезнувшие сохранённые пары остаются как «Нет вложений».
Контракт — [`../docs/dev/api-contract-goodwill.md`](../docs/dev/api-contract-goodwill.md). Применение гудвилла в КФО —
AURORA-1546.

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,73 @@
"""Таблица гудвилла, пакетное сохранение, переключатель и XLSX (§8 «Гудвилл»)."""
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 (
GoodwillBatchIn,
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", response_model=GoodwillTableOut, dependencies=EDIT)
async def save(
version_id: UUID, data: GoodwillBatchIn, service: GoodwillServiceDep, audit: AuditDep
) -> GoodwillTableOut:
"""Пакет строк одной транзакцией; ошибка любой строки — 404/422 с её ключом, ничего не сохраняется."""
return await service.save_rows(version_id, data.rows, audit)
@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")},
)

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

@ -0,0 +1,108 @@
"""Чистый расчёт направленных пар гудвилла (§8 «Гудвилл», DOC-259025 §§3, 6); суммы в рублях."""
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 participation(capital: Decimal, share: Decimal) -> Decimal:
"""Доля участия P = УК × доля / 100, до копеек ROUND_HALF_UP (DOC-259025 §6)."""
return (capital * share / 100).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
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 = participation(uk, decision.ownership_share)
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]:
"""ФВ и УК — все действующие строки; P и G — действующие отмеченные; в КФО — применимые положительные G."""
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),
"charter_capital_amount": sum((r.charter_capital_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),
}
def group_totals(rows: Sequence[Row], enabled: bool) -> dict[uuid.UUID, dict[str, Decimal]]:
"""Итоги «Всего» по каждой ГО — те же правила, что у `totals`."""
groups: dict[uuid.UUID, list[Row]] = defaultdict(list)
for row in rows:
groups[row.head_id].append(row)
return {head: totals(items, enabled) for head, items in groups.items()}

View File

@ -0,0 +1,64 @@
"""Пакетное чтение goodwill и привязанных подтверждённых ББ."""
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) -> list[Goodwill]:
stmt = select(Goodwill).where(Goodwill.consolidation_version_id == version_id)
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):
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,
)
)
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,89 @@
"""Контракт гудвилла: денежные суммы в рублях, 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, ".2f"), 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
charter_capital_amount: Money
participation_amount: Money
goodwill_amount: Money
goodwill_in_kfo_amount: Money
class GoodwillGroupOut(BaseModel):
"""Итог «Всего» блока одной ГО."""
head_legal_entity: RefOut
totals: GoodwillTotalsOut
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]
groups: list[GoodwillGroupOut]
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 GoodwillRowKeyIn(GoodwillRowIn):
"""Строка пакетного сохранения: пара ГО → ДО и ручной ввод."""
head_legal_entity_id: UUID
subsidiary_legal_entity_id: UUID
class GoodwillBatchIn(BaseModel):
"""Пакет строк таблицы гудвилла; сохраняется целиком или не сохраняется."""
model_config = ConfigDict(extra="forbid")
rows: list[GoodwillRowKeyIn] = Field(min_length=1)
class GoodwillSettingsIn(BaseModel):
model_config = ConfigDict(extra="forbid")
goodwill_enabled: bool

View File

@ -0,0 +1,312 @@
"""Таблица гудвилла, пакетное сохранение решений и переключатель — в транзакции запроса (§8, DOC-259025)."""
import re
from decimal import Decimal
from http import HTTPStatus
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, DomainError, DomainValidationError
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.directories import LegalEntity
from src.models.enums import AuditAction, AuditSection
from src.models.kfo import Goodwill
from src.repositories import consolidations as versions
from src.repositories import goodwill as repo
from src.schemas.consolidations import RefOut
from src.schemas.goodwill import (
GoodwillGroupOut,
GoodwillRowIn,
GoodwillRowKeyIn,
GoodwillRowOut,
GoodwillSign,
GoodwillTableOut,
GoodwillTotalsOut,
WarningOut,
)
from src.services.consolidation_guard import lock_editable_version
from src.services.goodwill_export import build_workbook, matches_sign
from src.services.goodwill_source import GoodwillSource
MAX_SHARE = Decimal(100)
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{1,3}(?:\.\d{1,2})?", 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,
)
)
groups = domain.group_totals(rows, ctx.version.goodwill_enabled)
heads = sorted(groups, key=lambda head: (entities[head].name, head))
return GoodwillTableOut(
version_id=version_id,
goodwill_enabled=ctx.version.goodwill_enabled,
editable=reason is None,
read_only_reason=reason,
rows=output,
groups=[
GoodwillGroupOut(
head_legal_entity=RefOut(id=head, name=entities[head].name),
totals=GoodwillTotalsOut(**groups[head]),
)
for head in heads
],
totals=GoodwillTotalsOut(**domain.totals(rows, ctx.version.goodwill_enabled)),
)
async def save_rows(self, version_id: UUID, rows: list[GoodwillRowKeyIn], audit: RequestAudit) -> GoodwillTableOut:
"""Пакет строк одной транзакцией (DOC-259025 §5.1 п. 4): любая ошибка — откат всего пакета.
Сначала проверяются все строки, потом пишутся только изменённые; аудит `GOODWILL_EDIT` — по каждой изменённой.
"""
await lock_editable_version(self.session, self.scope, version_id, self.principal.user.id)
table = await self.table(version_id)
current = {(r.head_legal_entity.id, r.subsidiary_legal_entity.id): r for r in table.rows}
saved = {
(r.head_legal_entity_id, r.subsidiary_legal_entity_id): r
for r in await repo.saved(self.session, version_id)
}
_check_duplicates(rows)
planned: list[tuple[GoodwillRowOut, domain.Decision]] = []
for item in rows:
key = item.head_legal_entity_id, item.subsidiary_legal_entity_id
row = current.get(key)
if row is None:
raise DomainError(HTTPStatus.NOT_FOUND, "NOT_FOUND", "Пара не найдена", extra=_key_extra(key))
decision = _decision(row, item, key)
entity = saved.get(key)
previous = (
domain.Decision(entity.ownership_share, entity.include_in_kfo, entity.comment)
if entity
else domain.Decision()
)
if decision != previous:
planned.append((row, decision))
now = utc_now()
written: list[tuple[Goodwill, dict | None]] = []
for row, decision in planned:
key = row.head_legal_entity.id, row.subsidiary_legal_entity.id
entity = saved.get(key)
before = row.model_dump(mode="json") if entity else None
if entity is None:
entity = Goodwill(
id=uuid4(),
consolidation_version_id=version_id,
head_legal_entity_id=key[0],
subsidiary_legal_entity_id=key[1],
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:
for name, amount in _amounts(row, decision).items():
if amount is not None and abs(amount) >= MONEY_LIMIT:
raise DomainValidationError(
"MONEY_OUT_OF_RANGE", "Сумма не помещается в numeric(18,2)", **_key_extra(key)
)
setattr(entity, name, amount)
written.append((entity, before))
if not written:
return table
await self.session.flush()
result = await self.table(version_id)
after = {(r.head_legal_entity.id, r.subsidiary_legal_entity.id): r for r in result.rows}
for entity, before in written:
key = entity.head_legal_entity_id, entity.subsidiary_legal_entity_id
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=after[key].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
MONEY_LIMIT = Decimal("10000000000000000")
def _key_extra(key: tuple[UUID, UUID]) -> dict[str, str]:
return {"head_legal_entity_id": str(key[0]), "subsidiary_legal_entity_id": str(key[1])}
def _check_duplicates(rows: list[GoodwillRowKeyIn]) -> None:
seen: set[tuple[UUID, UUID]] = set()
for item in rows:
key = item.head_legal_entity_id, item.subsidiary_legal_entity_id
if key in seen:
raise DomainValidationError("DUPLICATE_ROW", "Пара встречается в пакете дважды", **_key_extra(key))
seen.add(key)
def _decision(row: GoodwillRowOut, item: GoodwillRowKeyIn, key: tuple[UUID, UUID]) -> domain.Decision:
"""Проверенный ввод строки; ошибка несёт ключ пары."""
share, comment = item.ownership_share, item.comment
if not row.has_investments:
# DOC-259025 §10 п. 9: отсутствующий ввод не затирает сохранённые данные неактивной пары.
if share is None and row.ownership_share is not None:
# numeric(7,4) читается с масштабом 4 («80.1200»): без хвостовых нулей для проверки двух знаков
share = format(row.ownership_share.normalize(), "f")
if comment is None:
comment = row.comment
try:
return validate_decision(
GoodwillRowIn(ownership_share=share, include_in_kfo=item.include_in_kfo, comment=comment)
)
except DomainValidationError as error:
raise DomainValidationError(error.code, error.message, **_key_extra(key)) from error
def _amounts(row: GoodwillRowOut, decision: domain.Decision) -> dict[str, Decimal | None]:
"""Снимок сумм действующей строки при сохранении (поля `goodwill` DBML)."""
assert row.financial_investment_amount is not None
p = (
domain.participation(row.charter_capital_amount, decision.ownership_share)
if decision.include_in_kfo and row.charter_capital_amount is not None and decision.ownership_share is not None
else None
)
return {
"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,
}

View File

@ -0,0 +1,89 @@
"""XLSX с колонками и группировкой экранной таблицы гудвилла; итоги — из ответа таблицы (`groups`, `totals`)."""
from collections import defaultdict
from decimal import Decimal
from io import BytesIO
from openpyxl import Workbook
from src.schemas.goodwill import GoodwillRowOut, GoodwillSign, GoodwillTableOut, GoodwillTotalsOut
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": "Комментарий",
}
NO_INVESTMENTS = "Нет вложений"
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 isinstance(value, str) and key in MONEY:
_put_text(sheet, row, col, value)
elif 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.00"
groups: dict = defaultdict(list)
for item in table.rows:
groups[item.head_legal_entity.id].append(item)
group_totals = {group.head_legal_entity.id: group.totals for group in table.groups}
for head, items in groups.items():
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}
if not item.has_investments:
values["financial_investment_amount"] = NO_INVESTMENTS
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": "Всего", **_money(group_totals[head])})
append({"name": "Итого по листу", **_money(table.totals)})
stream = BytesIO()
book.save(stream)
return stream.getvalue()
def _money(totals: GoodwillTotalsOut) -> dict[str, Decimal]:
return {field: getattr(totals, field) for field in MONEY}

View File

@ -0,0 +1,96 @@
"""Адаптер полного свода ОСВ и привязанных ББ для расчёта гудвилла."""
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
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
from src.services.statements_engine import default_engine
UNIT_FACTORS: dict[str | None, Decimal] = {"руб.": Decimal(1), "тыс. руб.": Decimal(1000), "млн руб.": Decimal(1000000)}
@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 = UNIT_FACTORS.get(default_engine().canonical_units(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:
accounts = await repo.accounts(self.session)
allowed = {account.id for account in accounts}
ctx = await self.osv.build_with_members(version_id)
result = Sources(ctx, investments=_investments(ctx, allowed))
forms = await repo.forms(self.session, version_id)
columns, values = await repo.form_data(self.session, [m.id for _, m in forms])
month = int(ctx.consolidation.quarter.value[1:]) * 3
date = dt.date(ctx.consolidation.year, month, calendar.monthrange(ctx.consolidation.year, month)[1])
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

@ -29,6 +29,7 @@ from arm_statements import (
statements_version,
status_input_from_contract,
)
from arm_statements.text import normalize_caption
__all__ = [
"ArmStatementsEngine",
@ -62,6 +63,7 @@ class StatementsEngine(Protocol):
def editions(self) -> tuple[Edition, ...]: ...
def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: ...
def tolerance(self) -> Decimal: ...
def canonical_units(self, text: str | None) -> str | None: ...
class ArmStatementsEngine:
@ -105,6 +107,13 @@ class ArmStatementsEngine:
def tolerance(self) -> Decimal:
return self._rules.checks.tolerance
def canonical_units(self, text: str | None) -> str | None:
"""Единица формы по словарю пакета («тыс.руб.», «в тысячах рублей» → «тыс. руб.»); не узнали — None."""
if text is None:
return None
caption = normalize_caption(text)
return next((canonical for pattern, canonical in self._rules.units.spellings if pattern.search(caption)), None)
@functools.cache
def default_engine() -> StatementsEngine:

View File

@ -58,6 +58,9 @@ class FakeEngine:
def version(self) -> str | None:
return self._version
def canonical_units(self, text: str | None) -> str | None:
return text
def recompute(self, form: StoredForm) -> RecomputeResult:
self.forms.append(form)
return self.result

View File

@ -0,0 +1,360 @@
"""Реальные вложения 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]
def batch(head, subsidiary, **body) -> dict:
"""Тело пакетного сохранения из одной строки."""
return {"rows": [{"head_legal_entity_id": str(head), "subsidiary_legal_entity_id": str(subsidiary), **body}]}
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
table = response.json()
assert table["totals"]["charter_capital_amount"] == "100000000.00"
assert table["groups"] == [
{
"head_legal_entity": {"id": str(w.a.id), "name": w.a.name},
"totals": {
"financial_investment_amount": "269997300.00",
"charter_capital_amount": "100000000.00",
"participation_amount": "0.00",
"goodwill_amount": "0.00",
"goodwill_in_kfo_amount": "0.00",
},
}
]
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 + "/goodwill",
json=batch(world.a.id, world.b.id, ownership_share="100", include_in_kfo=True, comment="Подтверждено"),
)
assert response.status_code == HTTP_OK, response.text
row = response.json()["rows"][0]
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,265 @@
"""Проверка ввода строки до обращения к хранилищу."""
from decimal import Decimal
from http import HTTPStatus
from uuid import uuid4
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.consolidations import RefOut
from src.schemas.goodwill import GoodwillRowIn, GoodwillRowKeyIn, GoodwillRowOut
from src.services.goodwill import _decision, validate_decision
from tests.test_api_consolidated_osv import KA_RF
from tests.test_api_goodwill import batch, goodwill_world
@pytest.mark.parametrize(
"value", ["-1", "100.01", "100.0001", "80.123", "80,123", "12.3456", "1e2", "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", "7.5", "80.12", "80,12"])
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 + "/goodwill"
body = dict(ownership_share="80.12", include_in_kfo=True, comment="Подтверждено")
response = await client.put(path, json=batch(w.a.id, w.b.id, **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=batch(w.a.id, w.b.id, **body))
assert rejected.status_code == HTTPStatus.CONFLICT
assert rejected.json()["detail"]["code"] == "LOCKED_BY_OTHER"
response = await client.put(path, json=batch(w.a.id, w.b.id, **body))
assert response.status_code == HTTPStatus.OK, response.text
row = response.json()["rows"][0]
assert row["ownership_share"] == "80.12"
assert row["participation_amount"] == "80120000.00"
assert row["goodwill_amount"] == "189877300.00"
again = await client.put(path, json=batch(w.a.id, w.b.id, **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"] == "189877300.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.12"
body["include_in_kfo"] = False
assert (await client.put(path, json=batch(w.a.id, w.b.id, **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=batch(w.a.id, w.b.id, **body))).status_code == HTTPStatus.OK
body["include_in_kfo"] = True
response = await client.put(path, json=batch(w.a.id, w.b.id, **body))
assert response.status_code == HTTPStatus.OK
assert response.json()["rows"][0]["financial_investment_amount"] is None
assert response.json()["rows"][0]["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 + "/goodwill"
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=batch(w.a.id, w.b.id, **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=batch(w.a.id, w.b.id, **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=batch(w.a.id, w.b.id, **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 + "/goodwill", json=batch(w.a.id, w.b.id, 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
async def _locked_world(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
return w, client, base
async def _goodwill_audit_count(db_session) -> int:
return await db_session.scalar(
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.GOODWILL_EDIT)
)
@pytest.mark.integration
@pytest.mark.fixtures
async def test_batch_rolls_back_when_one_row_invalid(db_session, auth_client, fixtures_dir):
w, client, base = await _locked_world(db_session, auth_client, fixtures_dir)
valid = batch(w.a.id, w.b.id, ownership_share="100", include_in_kfo=True, comment="Да")["rows"][0]
invalid = batch(w.b.id, w.a.id, ownership_share="50", include_in_kfo=True, comment=" ")["rows"][0]
# обратная пара Б → А в таблице не существует: сперва проверяем порядок ошибок на существующей паре
response = await client.put(base + "/goodwill", json={"rows": [valid, {**valid, "comment": " "}]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "DUPLICATE_ROW"
response = await client.put(base + "/goodwill", json={"rows": [valid, invalid]})
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json()["detail"]["subsidiary_legal_entity_id"] == str(w.a.id)
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
assert await _goodwill_audit_count(db_session) == 0
@pytest.mark.integration
@pytest.mark.fixtures
async def test_batch_reports_invalid_row_key(db_session, auth_client, fixtures_dir):
w, client, base = await _locked_world(db_session, auth_client, fixtures_dir)
body = batch(w.a.id, w.b.id, ownership_share="100", include_in_kfo=True, comment=" ")
response = await client.put(base + "/goodwill", json=body)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
detail = response.json()["detail"]
assert detail["code"] == "COMMENT_REQUIRED"
assert (detail["head_legal_entity_id"], detail["subsidiary_legal_entity_id"]) == (str(w.a.id), str(w.b.id))
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
assert await _goodwill_audit_count(db_session) == 0
@pytest.mark.integration
@pytest.mark.fixtures
async def test_unchanged_rows_are_not_rewritten(db_session, auth_client, fixtures_dir):
w, client, base = await _locked_world(db_session, auth_client, fixtures_dir)
body = batch(w.a.id, w.b.id, ownership_share="80,5", include_in_kfo=True, comment="Устав")
first = await client.put(base + "/goodwill", json=body)
assert first.status_code == HTTPStatus.OK, first.text
saved_at = first.json()["rows"][0]["updated_at"]
again = await client.put(
base + "/goodwill", json=batch(w.a.id, w.b.id, ownership_share="80.50", include_in_kfo=True, comment="Устав")
)
assert again.status_code == HTTPStatus.OK
assert again.json()["rows"][0]["updated_at"] == saved_at
assert await _goodwill_audit_count(db_session) == 1
@pytest.mark.integration
@pytest.mark.fixtures
async def test_new_row_with_defaults_is_not_created(db_session, auth_client, fixtures_dir):
w, client, base = await _locked_world(db_session, auth_client, fixtures_dir)
body = batch(w.a.id, w.b.id, ownership_share=None, include_in_kfo=False, comment=None)
response = await client.put(base + "/goodwill", json=body)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["rows"][0]["include_in_kfo"] is False
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
assert await _goodwill_audit_count(db_session) == 0
def test_inactive_row_keeps_stored_share_with_db_scale():
"""Колонка numeric(7,4) читается как Decimal("80.1200"): null в пакете не должен давать INVALID_SHARE."""
head, sub = uuid4(), uuid4()
row = GoodwillRowOut(
head_legal_entity=RefOut(id=head, name="ГО"),
subsidiary_legal_entity=RefOut(id=sub, name="ДО"),
ownership_share=Decimal("80.1200"),
financial_investment_amount=None,
charter_capital_amount=None,
additional_capital_amount=None,
participation_amount=None,
goodwill_amount=None,
include_in_kfo=True,
comment="Было",
has_investments=False,
warnings=[],
updated_by=None,
updated_at=None,
)
item = GoodwillRowKeyIn(
head_legal_entity_id=head,
subsidiary_legal_entity_id=sub,
ownership_share=None,
include_in_kfo=True,
comment=None,
)
decision = _decision(row, item, (head, sub))
assert decision.ownership_share == Decimal("80.12")
assert decision.comment == "Было"
row100 = row.model_copy(update={"ownership_share": Decimal("100.0000")})
assert _decision(row100, item, (head, sub)).ownership_share == Decimal(100)

View File

@ -0,0 +1,29 @@
"""Контракт гудвилла в docs (`dev/api-contract-goodwill.openapi.yaml`) совпадает с маршрутами и схемами приложения."""
from decimal import Decimal
from pathlib import Path
import pytest
import yaml
from src.app import app
from src.schemas.goodwill import GoodwillRowIn
from src.services.goodwill import validate_decision
CONTRACT = Path(__file__).resolve().parents[3] / "docs/dev/api-contract-goodwill.openapi.yaml"
def test_contract_matches_application():
if not CONTRACT.exists():
pytest.skip("нет ../docs: контракт гудвилла не проверен")
contract = yaml.safe_load(CONTRACT.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], name
def test_share_with_comma():
data = GoodwillRowIn(ownership_share="80,12", include_in_kfo=True, comment="Доля подтверждена")
assert validate_decision(data).ownership_share == Decimal("80.12")

View File

@ -0,0 +1,91 @@
"""Расчёт и жизненный цикл на реальной сумме вложений Приманыческого."""
from decimal import Decimal
from pathlib import Path
from uuid import UUID
import pytest
from src.domain.goodwill import Decision, Investment, build, group_totals, participation, 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
def test_participation_rounds_half_up() -> None:
assert participation(Decimal("371.17"), Decimal("99.99")) == Decimal("371.13")
assert participation(Decimal("0.05"), Decimal("50")) == Decimal("0.03")
assert participation(Decimal("100000"), Decimal("0")) == Decimal("0.00")
def test_doc_example_totals() -> None:
"""DOC-259025 §11.1: Альфа → Бета (+100 000), Гамма (−30 000), Дельта без доли; плюс пара «Нет вложений»."""
alpha, beta, gamma, delta, gone = (UUID(int=i) for i in (10, 11, 12, 13, 14))
source = [
Investment(alpha, beta, Decimal("180000.00")),
Investment(alpha, gamma, Decimal("50000.00")),
Investment(alpha, delta, Decimal("60000.00")),
]
saved = {
(alpha, beta): Decision(Decimal("80"), True, "Устав"),
(alpha, gamma): Decision(Decimal("80"), True, "Устав"),
(alpha, gone): Decision(Decimal("50"), True, "Было"),
}
capital = {beta: Decimal("100000.00"), gamma: Decimal("100000.00"), delta: Decimal("60000.00")}
rows = {r.subsidiary_id: r for r in build(source, saved, capital)}
assert rows[beta].participation_amount == Decimal("80000.00")
assert rows[beta].goodwill_amount == Decimal("100000.00")
assert rows[gamma].participation_amount == Decimal("80000.00")
assert rows[gamma].goodwill_amount == Decimal("-30000.00")
assert rows[delta].participation_amount is None and rows[delta].goodwill_amount is None
assert not rows[gone].has_investments
expected = {
"financial_investment_amount": Decimal("290000.00"),
"charter_capital_amount": Decimal("260000.00"),
"participation_amount": Decimal("160000.00"),
"goodwill_amount": Decimal("70000.00"),
"goodwill_in_kfo_amount": Decimal("100000.00"),
}
assert totals(list(rows.values()), True) == expected
assert totals(list(rows.values()), False)["goodwill_in_kfo_amount"] == 0
assert group_totals(list(rows.values()), True) == {alpha: expected}

View File

@ -0,0 +1,91 @@
"""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 batch, 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")
assert (
await client.put(base + "/goodwill", json=batch(world.a.id, world.b.id, **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 == "Нет вложений"
assert sheet.cell(3, 7).value is None
assert sheet.cell(5, 4).value == 0
assert sheet.cell(5, 7).value == 0
async def test_export_totals_come_from_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}"
table = (await client.get(base + "/goodwill")).json()
sheet = load_workbook(BytesIO((await client.get(base + "/goodwill/export")).content)).active
assert sheet.cell(4, 2).value == "Всего"
assert sheet.cell(4, 6).value == float(table["groups"][0]["totals"]["charter_capital_amount"])
assert sheet.cell(5, 2).value == "Итого по листу"
assert sheet.cell(5, 6).value == float(table["totals"]["charter_capital_amount"])

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,85 @@
"""Выбор дерева счетов и отчётной колонки без догадок по 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)
@pytest.mark.parametrize(
("units", "factor"),
[
("тыс. руб.", Decimal(1000)),
("тыс.руб.", Decimal(1000)),
("в тысячах рублей", Decimal(1000)),
("руб", Decimal(1)),
("млн. руб.", Decimal(1000000)),
],
)
def test_capital_units_spellings(units: str, factor: Decimal) -> None:
"""Единицы, исправленные аналитиком вручную, читаются словарём единиц arm_statements."""
mid, col = UUID(int=1), UUID(int=2)
columns = [N(id=col, meta_id=mid, period_date=dt.date(2025, 9, 30), is_current=True)]
line = N(id=UUID(int=4), meta_id=mid, line_code="1310", row_index=0, line_status=StatementLineStatus.ok)
values = [(line, col, Decimal("7"))]
assert capital_amounts(mid, units, columns, values, dt.date(2025, 9, 30)) == (Decimal("7") * factor, None)
def test_capital_unknown_units() -> 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=True)]
line = N(id=UUID(int=4), meta_id=mid, line_code="1310", row_index=0, line_status=StatementLineStatus.ok)
assert capital_amounts(mid, "штук", columns, [(line, col, Decimal("7"))], dt.date(2025, 9, 30)) == (None, None)

View File

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

View File

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

View File

@ -0,0 +1,92 @@
# 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. XLSX вместо CSV намеренно отличается от PDF (по контракту). Доля — два знака, сохранение — пакетом (R-1, R-2). 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 …/goodwill`, R-2); не материализуем все строки при чтении или смене переключателя; 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 — проценты с двумя (R-1). Максимум два дробных знака во входе, без автоматического округления; запятая и точка допустимы по 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 — итог по листу, groups — итоги «Всего» по каждой ГО; оба считает бэк (R-4). При goodwill_enabled=false потенциальные G видны, goodwill_in_kfo_amount=0.00.
### 5. Транзакции и безопасность
Маршруты гудвилла используют зависимости по образцу API корректировок. Service использует текущие policy и lock_editable_version. PUT …/goodwill под блокировкой версии загружает актуальные источники, проверяет пары и ввод всех строк, затем upsert изменённых записей и GOODWILL_EDIT before/after по каждой в той же транзакции. Вычисляемые суммы из тела не принимаются. PUT настроек не трогает строки; GOODWILL_EDIT имеет object_type=consolidation_version и changed_field=goodwill_enabled, строки — object_type=goodwill. Новые enum не нужны. Отдельная оптимистическая ревизия не добавляется: существующая блокировка версии сериализует операции, последнее сохранение владельца побеждает.
### 6. Граница КФО
Подготовка вклада в КФО (применимые пары, изменения 1170/1310/GOODWILL, исключение ББ-2), выбор источников только сохранённых выбранных пар и пометка результата КФО устаревшим в этот change не входят — перенесено в AURORA-1546 (R-3, R-6). Этот change не пишет KfoResult и не трогает Correction.
### 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 и сохранение ручного ввода.
## Decisions made without discussion
Ревью arm-core #222 (06.11.2026, владелец: «давай всё сделаем сами»; варианты предложены владельцу, ответа по ним нет —
взяты рекомендованные).
- **R-1 (2026-11-06). Доля владения — не больше двух знаков после запятой.** Обоснование: DOC-259025 §4, §5.2, §9
(«максимум два знака»), фронт вводит два. DBML `numeric(7,4)` не меняется — хранение шире ввода. Цена ошибки: если
методологам нужны 4 знака — расширить регулярку `validate_decision` и сериализацию `Share`, данные не теряются.
- **R-2 (2026-11-06). Построчный `PUT …/goodwill/{head}/{sub}` заменён пакетным `PUT …/goodwill`.** Обоснование:
DOC-259025 §5.1 п. 4 — набор сохраняется целиком или не сохраняется; фронт слал строки `Promise.all` и мог сохранить
часть. Цена ошибки: если понадобится построчное — пакет из одной строки уже его покрывает.
- **R-3 (2026-11-06). Подготовка вклада в КФО (`contributions`, `load_for_kfo`, фильтры общего свода ОСВ) удалена,
перенесена в AURORA-1546.** Обоснование: код не вызывался, а менял общие `load_osv_rows`/`build_with_members`.
Цена ошибки: в 1546 написать заново по DOC-259025 §7, §10 (было ~150 строк с тестами, история — коммит 94895b6).
- **R-4 (2026-11-06). Итоги «Всего» по ГО (`groups`) и итог УК считает бэк.** Обоснование: агрегаты отдаёт бэк
(правило владельца), фронт считал их через `Number`; DOC-259025 §6 требует итоги ФВ и УК. Цена ошибки: лишние поля
ответа, вреда нет.
- **R-5 (2026-11-06). Единицы ББ — словарём единиц `arm_statements` (`StatementsEngine.canonical_units`).**
Обоснование: единицы правятся аналитиком свободным текстом, точное сравнение «тыс. руб.» теряло УК. Цена ошибки:
незнакомая подпись — УК `null` с предупреждением, как раньше.
- **R-6 (2026-11-06). Пометка результата КФО устаревшим при смене переключателя или строк не делается — в AURORA-1546.**
Обоснование: результата КФО (`kfo_result`) ещё не строится, помечать нечего. Цена ошибки: в 1546 добавить сброс
в `save_rows` и `settings`.
- **R-7 (2026-11-06, финальное ревью I-1). Сохранённая доля неактивной строки нормализуется** (`80.1200` → `80.12`)
перед проверкой ввода: колонка `numeric(7,4)` читается с масштабом 4, а ввод ограничен двумя знаками (R-1). Цена ошибки:
если в БД окажется доля с 3–4 значащими знаками (данные до R-1), пакет с этой неактивной строкой даст 422 `INVALID_SHARE`.
- **R-8 (2026-11-06, финальное ревью). В XLSX у строки «Нет вложений» в колонке ФВ — текст «Нет вложений»** (сценарий спеки
«Выгрузка таблицы»). Цена ошибки: нет.

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.
- Подготовка вклада в КФО и исключение ББ-2 — не в этом change, перенесено в AURORA-1546 (R-3).
- Уточнения 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; маршруты GET/PUT …/goodwill, PUT …/goodwill-settings, GET …/goodwill/export; контракт — ../docs/dev/api-contract-goodwill.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,105 @@
# 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 ББ ДО на конец отчётного периода в рублях; единицы формы SHALL распознаваться словарём единиц arm_statements, нераспознанные единицы дают отсутствующий УК. 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** УК переводится в рубли умножением на 1000; единицы «штук» дают УК null и предупреждение CAPITAL_SOURCE_MISSING
#### Scenario: Проверка отчётной колонки
- **WHEN** в привязанном ББ есть несколько сравнительных колонок и единицы «тыс. руб.»
- **THEN** УК берётся по дате конца отчётного периода, а не только is_current; сумма переводится в рубли до расчёта
### Requirement: Получение таблицы
GET /api/v1/consolidations/versions/{version_id}/goodwill SHALL возвращать version_id, goodwill_enabled, editable, read_only_reason, rows, groups, totals. Строки отсортированы по именам ГО/ДО и ID; содержат поля MD-контракта, has_investments и warnings. Деньги — строки с двумя знаками в рублях или null, доли — строки с двумя знаками. groups — итог «Всего» по каждой ГО в порядке строк, totals — итог по листу.
#### 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, сохранение не блокируется; проценты по разным ДО не складываются в общий процент
#### Scenario: Пример DOC-259025 §11.1
- **WHEN** у ГО «Альфа» отмечены Бета (ФВ 180 000, УК 100 000, 80 %) и Гамма (ФВ 50 000, УК 100 000, 80 %), Дельта (ФВ 60 000, УК 60 000) без доли и не отмечена
- **THEN** «Всего» и «Итого по листу»: ФВ 290 000, УК 260 000, P 160 000, G 70 000, в КФО 100 000 (при выключенном переключателе — 0)
### Requirement: Пакетное сохранение
PUT …/goodwill SHALL принимать набор строк {head_legal_entity_id, subsidiary_legal_entity_id, ownership_share, include_in_kfo, comment} и сохранять его в одной транзакции: все строки проверяются до записи, ошибка любой строки откатывает весь пакет и несёт ключ пары. Доля допускает null только при снятом выборе, иначе 0–100, не больше двух знаков после запятой, запятая или точка; превышение точности не округляется. Для выбранной действующей строки обязателен непустой комментарий до 1000 символов. Ошибки — 404 NOT_FOUND (пары нет в таблице), 422 INVALID_SHARE, COMMENT_REQUIRED, MONEY_OUT_OF_RANGE, DUPLICATE_ROW. Строка, ввод которой не изменился, MUST NOT перезаписываться и писать аудит. Ответ — пересчитанная таблица.
#### Scenario: Ошибка одной строки пакета
- **WHEN** пакет содержит корректную строку и строку отмеченной пары без комментария
- **THEN** ответ 422 COMMENT_REQUIRED с head_legal_entity_id и subsidiary_legal_entity_id этой строки; ни одна запись goodwill и ни одно событие GOODWILL_EDIT не созданы
#### Scenario: Пакет без изменений
- **WHEN** повторно отправлена сохранённая строка с той же долей («80,5» после «80.50»), галочкой и комментарием
- **THEN** updated_at строки прежний, событий GOODWILL_EDIT не добавилось
#### 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 суммировать только заполненные 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

View File

@ -0,0 +1,64 @@
# 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, четыре знака (позже — два, R-1), 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, R-2): реализовать 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 перенесено в AURORA-1546 (R-3): подготовка вклада в КФО удалена из этого change.
- [x] 4.3 перенесено в AURORA-1546 (R-3).
## 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 КФО (AURORA-1546) реализует подготовку вклада, запись результата, устаревание, применение/исключение ББ-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 перенесено в AURORA-1546 (R-3): `load_for_kfo` и фильтры общего свода удалены.
- [x] 7.2 перенесено в AURORA-1546 (R-3).
## 8. Экспорт по экранной таблице
- [x] 8.1 arm-core: привести XLSX к составу и порядку восьми колонок фронта со стандартным оформлением. Сохранить прежний контракт ручки и вызов экспорта на фронте. Проверить интеграционные сценарии выгрузки.
## 9. Доводка по ревью (06.11.2026, план docs/superpowers/plans/2026-11-06-goodwill-review-back.md)
- [x] 9.1 arm-core: заготовка вклада КФО удалена, общий свод ОСВ возвращён к main (R-3).
- [x] 9.2 arm-core: доля — два знака, `participation()` в домене, итоги по ГО (`groups`) и УК (R-1, R-4).
- [x] 9.3 arm-core: пакетный `PUT …/goodwill` одной транзакцией вместо построчного (R-2).
- [x] 9.4 arm-core: единицы ББ по словарю arm_statements (R-5).
- [x] 9.5 docs (#204) и arm-core: контракт `../docs/dev/api-contract-goodwill.*`, §8 правил, OQ-10/OQ-37, README.