arm-core/api/src/services/goodwill.py
Andrey Epifancev 3e07276a96 Гудвилл: заготовка вклада КФО вынесена в AURORA-1546
Удалены contributions/load_for_kfo и фильтры общего свода ОСВ (load_osv_rows,
build_with_members возвращены к main); OpenSpec change без требований вклада КФО.
План доводки по ревью — docs/superpowers/plans/2026-11-06-goodwill-review-back.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:20:04 +04:00

259 lines
12 KiB
Python

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