Удалены 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>
259 lines
12 KiB
Python
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
|