"""Таблица и построчные решения гудвилла в транзакции запроса.""" 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