I-1: сохранённая доля неактивной строки нормализуется (numeric(7,4) читается как 80.1200) и не даёт INVALID_SHARE при null в пакете; экспорт помечает строку «Нет вложений» (сценарий спеки); design/tasks/docstring без устаревшего «построчного» PUT, R-7/R-8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
15 KiB
Python
313 lines
15 KiB
Python
"""Таблица гудвилла, пакетное сохранение решений и переключатель — в транзакции запроса (§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,
|
||
}
|