diff --git a/README.md b/README.md index e25604d..fbe094a 100644 --- a/README.md +++ b/README.md @@ -1340,3 +1340,10 @@ 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. + +### Таблица гудвилла + +Бэкенд таблицы гудвилла версии: фактические вложения 58.01 между участниками периметра, построчные решения, +переключатель и XLSX. `is_head_entity` не используется. Деньги в рублях, доля до четырёх знаков, нерассчитанные +значения — `null`; исчезнувшие сохранённые пары остаются как «Нет вложений». +Контракт и ограничения этапа — [контракт API](goodwill/api-contract-goodwill.md). Фронт и построение/фиксация результата КФО — отдельные задачи. diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py index cfb637d..a0c0573 100644 --- a/api/src/api/dependencies/consolidations.py +++ b/api/src/api/dependencies/consolidations.py @@ -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)] diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index 9326fe7..561c29f 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -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) diff --git a/api/src/api/endpoints/consolidations/goodwill.py b/api/src/api/endpoints/consolidations/goodwill.py new file mode 100644 index 0000000..c2537f7 --- /dev/null +++ b/api/src/api/endpoints/consolidations/goodwill.py @@ -0,0 +1,82 @@ +"""Таблица гудвилла, построчное сохранение, переключатель и XLSX.""" + +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 ( + GoodwillRowIn, + GoodwillRowOut, + 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-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")}, + ) + + +@router.put( + "/{version_id}/goodwill/{head_legal_entity_id}/{subsidiary_legal_entity_id}", + response_model=GoodwillRowOut, + dependencies=EDIT, +) +async def save( # noqa: PLR0913, PLR0917 - ключ пары, тело и зависимости FastAPI + version_id: UUID, + head_legal_entity_id: UUID, + subsidiary_legal_entity_id: UUID, + data: GoodwillRowIn, + service: GoodwillServiceDep, + audit: AuditDep, +) -> GoodwillRowOut: + return await service.save(version_id, head_legal_entity_id, subsidiary_legal_entity_id, data, audit) diff --git a/api/src/domain/goodwill.py b/api/src/domain/goodwill.py new file mode 100644 index 0000000..9011240 --- /dev/null +++ b/api/src/domain/goodwill.py @@ -0,0 +1,132 @@ +"""Чистый расчёт направленных пар гудвилла; суммы в рублях.""" + +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 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 = (uk * decision.ownership_share / 100).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + 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]: + 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), + "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), + } + + +@dataclass(frozen=True) +class Contribution: + version_id: uuid.UUID + rule_id: uuid.UUID + head_id: uuid.UUID + subsidiary_id: uuid.UUID + financial_investment_amount: Decimal + participation_amount: Decimal + goodwill_amount: Decimal + # Ключ исключения — balance, rule_id, head_id, subsidiary_id. + deltas: tuple[tuple[uuid.UUID, str, Decimal], ...] + + +def contributions(version_id: uuid.UUID, rule_id: uuid.UUID, rows: Sequence[Row], enabled: bool) -> list[Contribution]: + result = [] + for row in rows: + if not enabled or not row.applicable: + continue + assert row.financial_investment_amount is not None + assert row.participation_amount is not None and row.goodwill_amount is not None + result.append( + Contribution( + version_id, + rule_id, + row.head_id, + row.subsidiary_id, + row.financial_investment_amount, + row.participation_amount, + row.goodwill_amount, + ( + (row.head_id, "1170", -row.financial_investment_amount), + (row.subsidiary_id, "1310", -row.participation_amount), + (row.head_id, "GOODWILL", row.goodwill_amount), + ), + ) + ) + return result diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index ec4d1bb..3d04392 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -306,7 +306,13 @@ async def document_versions(session: AsyncSession, document_id: uuid.UUID) -> li return list((await session.execute(stmt)).scalars()) -async def load_osv_rows(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[OsvEntry, Document, LegalEntity]]: +async def load_osv_rows( + session: AsyncSession, + version_id: uuid.UUID, + *, + company_pairs: Collection[tuple[uuid.UUID, uuid.UUID]] | None = None, + account_ids: Collection[uuid.UUID] | None = None, +) -> list[tuple[OsvEntry, Document, LegalEntity]]: """Исходные строки свода: контрагентские (`group_type = COUNTERPARTY`) строки BU/SUM привязанных документов. `TOTAL` не хранится, строки `ACCOUNT` и данные `NU` отбрасываются. Порядок: имя ЮЛ, код счёта документа, квартал, @@ -326,6 +332,22 @@ async def load_osv_rows(session: AsyncSession, version_id: uuid.UUID) -> list[tu LegalEntity.name, Document.osv_account_code, Document.period_quarter, OsvEntry.row_number, OsvEntry.id ) ) + if company_pairs is not None: + # NULL-контрагент может быть сопоставлен общим дорезолвом; точная пара проверяется после него. + stmt = stmt.where( + or_( + false(), + *( + and_( + OsvEntry.owner_legal_entity_id == head, + or_(OsvEntry.counterparty_id == subsidiary, OsvEntry.counterparty_id.is_(None)), + ) + for head, subsidiary in company_pairs + ), + ) + ) + if account_ids is not None: + stmt = stmt.where(OsvEntry.account_id.in_(account_ids)) return [(row[0], row[1], row[2]) for row in await session.execute(stmt)] diff --git a/api/src/repositories/goodwill.py b/api/src/repositories/goodwill.py new file mode 100644 index 0000000..6dc6a65 --- /dev/null +++ b/api/src/repositories/goodwill.py @@ -0,0 +1,69 @@ +"""Пакетное чтение goodwill и привязанных подтверждённых ББ.""" + +from collections.abc import Collection +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, *, included_only: bool = False) -> list[Goodwill]: + stmt = select(Goodwill).where(Goodwill.consolidation_version_id == version_id) + if included_only: + stmt = stmt.where(Goodwill.include_in_kfo.is_(True)) + 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, company_ids: Collection[UUID] | None = None): + 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, + ) + ) + if company_ids is not None: + stmt = stmt.where(Document.legal_entity_id.in_(company_ids)) + 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 diff --git a/api/src/schemas/goodwill.py b/api/src/schemas/goodwill.py new file mode 100644 index 0000000..0859bcd --- /dev/null +++ b/api/src/schemas/goodwill.py @@ -0,0 +1,66 @@ +"""Контракт гудвилла: денежные суммы в рублях, 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, ".4f"), 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 + participation_amount: Money + goodwill_amount: Money + goodwill_in_kfo_amount: Money + + +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] + 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 GoodwillSettingsIn(BaseModel): + model_config = ConfigDict(extra="forbid") + goodwill_enabled: bool diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index b548d42..1d74dd0 100644 --- a/api/src/services/consolidated_osv.py +++ b/api/src/services/consolidated_osv.py @@ -13,7 +13,7 @@ import uuid from collections.abc import Collection, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Literal from fastapi.concurrency import run_in_threadpool @@ -149,7 +149,13 @@ class ConsolidatedOsvService: self._session = session self._scope = scope - async def build_with_members(self, version_id: uuid.UUID) -> ConsolidatedOsvBuildResult: + async def build_with_members( + self, + version_id: uuid.UUID, + *, + company_pairs: Collection[tuple[uuid.UUID, uuid.UUID]] | None = None, + account_ids: Collection[uuid.UUID] | None = None, + ) -> ConsolidatedOsvBuildResult: """Свод и участники периметра, загруженные при его расчёте; версия вне видимости — 404.""" found = await repo.get_version(self._session, self._scope, version_id) if found is None: @@ -167,7 +173,12 @@ class ConsolidatedOsvService: for document in await consolidation_documents.for_consolidation(self._session, bound, consolidation) ] # ровно то, что отдал `load_osv_rows`: он оставляет `COUNTERPARTY`/`BU`+`SUM`, домен их не перефильтровывает - rows = [_source_row(entry) for entry, _, _ in await repo.load_osv_rows(self._session, version.id)] + rows = [ + _source_row(entry) + for entry, _, _ in await repo.load_osv_rows( + self._session, version.id, company_pairs=company_pairs, account_ids=account_ids + ) + ] index = await CounterpartyIndex.load(self._session) entities = {**await _entities(self._session, _candidate_ids(rows, index) - members.keys()), **members} consolidated = domain.build( @@ -180,6 +191,12 @@ class ConsolidatedOsvService: rows=rows, resolver=index, ) + if company_pairs is not None: + pairs = set(company_pairs) + consolidated = replace( + consolidated, + rows=tuple(row for row in consolidated.rows if (row.legal_entity_id, row.counterparty_id) in pairs), + ) return ConsolidatedOsvBuildResult( consolidated=consolidated, version=version, diff --git a/api/src/services/goodwill.py b/api/src/services/goodwill.py new file mode 100644 index 0000000..b3162d9 --- /dev/null +++ b/api/src/services/goodwill.py @@ -0,0 +1,297 @@ +"""Таблица и построчные решения гудвилла в транзакции запроса.""" + +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.accounting import EliminationRule +from src.models.directories import LegalEntity +from src.models.enums import AuditAction, AuditSection, StatementType +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 VERSION_NOT_FOUND, lock_editable_version +from src.services.goodwill_export import build_workbook, matches_sign +from src.services.goodwill_source import GoodwillSource + +MAX_SHARE = Decimal(100) +BB_RULE_NO = 2 + +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 + + async def contributions(self, version_id: UUID) -> list[domain.Contribution]: + + found = await versions.get_version(self.session, self.scope, version_id) + if found is None: + raise NotFoundError(VERSION_NOT_FOUND) + version, _ = found + if not version.goodwill_enabled: + return [] + saved = await repo.saved(self.session, version_id, included_only=True) + if not saved: + return [] + pairs = {(row.head_legal_entity_id, row.subsidiary_legal_entity_id) for row in saved} + sources = await self.source.load_for_kfo(version_id, pairs) + 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) + if not any(row.applicable for row in rows): + return [] + rules = list( + ( + await self.session.scalars( + select(EliminationRule).where( + EliminationRule.version_no == sources.context.version.elimination_rules_version, + EliminationRule.statement_type == StatementType.balance, + EliminationRule.rule_no == BB_RULE_NO, + ) + ) + ).all() + ) + if len(rules) != 1: + raise ConflictError("GOODWILL_RULE_MISSING", "Нет однозначного ББ-2 закреплённой версии правил") + return domain.contributions(version_id, rules[0].id, rows, sources.context.version.goodwill_enabled) diff --git a/api/src/services/goodwill_export.py b/api/src/services/goodwill_export.py new file mode 100644 index 0000000..887f531 --- /dev/null +++ b/api/src/services/goodwill_export.py @@ -0,0 +1,92 @@ +"""XLSX с колонками и группировкой экранной таблицы гудвилла.""" + +from collections import defaultdict +from decimal import Decimal +from io import BytesIO + +from openpyxl import Workbook + +from src.schemas.goodwill import GoodwillRowOut, GoodwillSign, GoodwillTableOut +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": "Комментарий", +} +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 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.0000" + + groups: dict = defaultdict(list) + for item in table.rows: + groups[item.head_legal_entity.id].append(item) + for items in groups.values(): + 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} + 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": "Всего", **_totals(items)}) + totals = table.totals + values = dict( + name="Итого по листу", + ownership_share=None, + financial_investment_amount=totals.financial_investment_amount, + participation_amount=totals.participation_amount, + goodwill_amount=totals.goodwill_amount, + ) + append(values) + stream = BytesIO() + book.save(stream) + return stream.getvalue() + + +def _totals(rows: list[GoodwillRowOut]) -> dict[str, Decimal]: + active = [r for r in rows if r.has_investments] + return {field: sum((getattr(r, field) or Decimal(0) for r in active), Decimal(0)) for field in MONEY} diff --git a/api/src/services/goodwill_source.py b/api/src/services/goodwill_source.py new file mode 100644 index 0000000..29f721f --- /dev/null +++ b/api/src/services/goodwill_source.py @@ -0,0 +1,105 @@ +"""Адаптер полного свода ОСВ и привязанных ББ для расчёта гудвилла.""" + +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, Pair +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 + + +@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 = {"руб.": Decimal(1), "тыс. руб.": Decimal(1000), "млн руб.": Decimal(1000000)}.get(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: + return await self._load(version_id) + + async def load_for_kfo(self, version_id: UUID, pairs: set[Pair]) -> Sources: + """Источники только сохранённых выбранных пар; таблица использует обычный load.""" + return await self._load(version_id, pairs) + + async def _load(self, version_id: UUID, pairs: set[Pair] | None = None) -> Sources: + accounts = await repo.accounts(self.session) + allowed = {account.id for account in accounts} + if pairs is None: + ctx = await self.osv.build_with_members(version_id) + else: + ctx = await self.osv.build_with_members(version_id, company_pairs=pairs, account_ids=allowed) + result = Sources(ctx, investments=_investments(ctx, allowed)) + forms = await repo.forms( + self.session, version_id, {subsidiary for _, subsidiary in pairs} if pairs is not None else None + ) + 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 diff --git a/api/tests/test_api_goodwill.py b/api/tests/test_api_goodwill.py new file mode 100644 index 0000000..06dfa74 --- /dev/null +++ b/api/tests/test_api_goodwill.py @@ -0,0 +1,341 @@ +"""Реальные вложения 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] + + +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 + 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 + f"/goodwill/{world.a.id}/{world.b.id}", + json=dict(ownership_share="100", include_in_kfo=True, comment="Подтверждено"), + ) + assert response.status_code == HTTP_OK, response.text + row = response.json() + assert row["financial_investment_amount"] == "269997300.00" + assert row["participation_amount"] == "100000000.00" + assert row["goodwill_amount"] == "169997300.00" + assert row["warnings"] == [] diff --git a/api/tests/test_api_goodwill_mutations.py b/api/tests/test_api_goodwill_mutations.py new file mode 100644 index 0000000..c6c23e2 --- /dev/null +++ b/api/tests/test_api_goodwill_mutations.py @@ -0,0 +1,155 @@ +"""Проверка ввода строки до обращения к хранилищу.""" + +from decimal import Decimal +from http import HTTPStatus + +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.goodwill import GoodwillRowIn +from src.services.goodwill import validate_decision +from tests.test_api_consolidated_osv import KA_RF +from tests.test_api_goodwill import goodwill_world + + +@pytest.mark.parametrize("value", ["-1", "100.0001", "0.00001", "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", "12.3456", "12,3456"]) +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 + f"/goodwill/{w.a.id}/{w.b.id}" + body = dict(ownership_share="80.1234", include_in_kfo=True, comment="Подтверждено") + response = await client.put(path, json=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=body) + assert rejected.status_code == HTTPStatus.CONFLICT + assert rejected.json()["detail"]["code"] == "LOCKED_BY_OTHER" + response = await client.put(path, json=body) + assert response.status_code == HTTPStatus.OK, response.text + row = response.json() + assert row["ownership_share"] == "80.1234" + assert row["participation_amount"] == "80123400.00" + assert row["goodwill_amount"] == "189873900.00" + again = await client.put(path, json=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"] == "189873900.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.1234" + body["include_in_kfo"] = False + assert (await client.put(path, json=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=body)).status_code == HTTPStatus.OK + body["include_in_kfo"] = True + response = await client.put(path, json=body) + assert response.status_code == HTTPStatus.OK + assert response.json()["financial_investment_amount"] is None + assert response.json()["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 + f"/goodwill/{w.a.id}/{w.b.id}" + 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=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=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=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 + f"/goodwill/{w.a.id}/{w.b.id}", json=dict(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 diff --git a/api/tests/test_goodwill_contract.py b/api/tests/test_goodwill_contract.py new file mode 100644 index 0000000..6120c45 --- /dev/null +++ b/api/tests/test_goodwill_contract.py @@ -0,0 +1,22 @@ +"""Локальный OpenAPI совпадает с четырьмя маршрутами приложения.""" + +from pathlib import Path + +import yaml + +from src.app import app +from src.schemas.goodwill import GoodwillRowIn +from src.services.goodwill import validate_decision + + +def test_contract_matches_application(): + contract = yaml.safe_load( + (Path(__file__).resolve().parents[2] / "goodwill/api-contract-goodwill.openapi.yaml").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] + data = GoodwillRowIn(ownership_share="80.1234", include_in_kfo=True, comment="Доля подтверждена") + assert str(validate_decision(data).ownership_share) == "80.1234" diff --git a/api/tests/test_goodwill_contribution.py b/api/tests/test_goodwill_contribution.py new file mode 100644 index 0000000..7ab5c6c --- /dev/null +++ b/api/tests/test_goodwill_contribution.py @@ -0,0 +1,227 @@ +"""Гудвилл изменяет актив и пассив на одинаковую сумму, не смешивает пары.""" + +from decimal import Decimal +from http import HTTPStatus +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +from sqlalchemy import func, select + +from src.common.exceptions import ConflictError +from src.domain.goodwill import Decision, Investment, build, contributions +from src.domain.policy import VisibilityScope +from src.models.access import AppUser +from src.models.accounting import ChartOfAccount, EliminationRule +from src.models.consolidation import ConsolidationDocument +from src.models.enums import DocumentType, PeriodQuarter, StatementType +from src.models.files import StoredFile +from src.models.kfo import Correction, Goodwill, KfoResult +from src.models.osv import OsvEntry +from src.models.statements import FinancialStatementMeta +from src.repositories import consolidations as version_repo +from src.repositories import goodwill as goodwill_repo +from src.services.consolidated_osv import ConsolidatedOsvService +from src.services.goodwill import GoodwillService +from src.services.goodwill_source import GoodwillSource +from tests.factories import make_confirmed_osv +from tests.test_api_consolidated_osv import KA_RF +from tests.test_api_goodwill import goodwill_world +from tests.test_goodwill_golden import investment_rows + +BB_RULE_NO = 2 + +pytestmark = pytest.mark.fixtures + + +def test_contribution_balance(fixtures_dir: Path) -> None: + a, b, version, rule = (UUID(int=i) for i in range(1, 5)) + fv = Decimal(investment_rows(fixtures_dir)[0]["closing_debit"]) + saved = {(a, b): Decision(Decimal("80"), True, "Подтверждено")} + rows = build([Investment(a, b, fv)], saved, {b: Decimal("100000000")}) + item = contributions(version, rule, rows, True)[0] + assert item.rule_id == rule and item.head_id == a and item.subsidiary_id == b + assert sum(v for _, code, v in item.deltas if code in ("1170", "GOODWILL")) == -item.participation_amount + assert dict((code, value) for _, code, value in item.deltas)["1310"] == -item.participation_amount + assert contributions(version, rule, rows, False) == [] + assert contributions(version, rule, build([Investment(a, b, fv)], saved, {}), True) == [] + assert contributions(version, rule, build([Investment(a, b, fv)], saved, {b: fv * 2}), True) == [] + + +@pytest.mark.integration +async def test_service_uses_pinned_rule_without_writing_kfo(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}" + assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED + assert ( + await client.put( + base + f"/goodwill/{world.a.id}/{world.b.id}", + json=dict(ownership_share="80", include_in_kfo=True, comment="Да"), + ) + ).status_code == HTTPStatus.OK + assert (await client.put(base + "/goodwill-settings", json={"goodwill_enabled": True})).status_code == HTTPStatus.OK + pinned = ( + await db_session.scalars( + select(EliminationRule).where( + EliminationRule.version_no == 1, + EliminationRule.rule_no == BB_RULE_NO, + EliminationRule.statement_type == StatementType.balance, + ) + ) + ).one() + other = EliminationRule( + **{ + col.name: getattr(pinned, col.name) + for col in EliminationRule.__table__.columns + if col.name not in ("id", "version_no") + }, + version_no=2, + ) + db_session.add(other) + await db_session.flush() + principal_user = (await db_session.scalars(select(AppUser).where(AppUser.email == KA_RF))).one() + scope = VisibilityScope(office_id=None) + service = GoodwillService( + db_session, + scope, + SimpleNamespace(user=principal_user, permissions={"CONSOLIDATION_EDIT"}), + GoodwillSource(db_session, ConsolidatedOsvService(db_session, scope)), + ) + result = await service.contributions(world.version.id) + assert len(result) == 1 and result[0].rule_id == pinned.id + assert await db_session.scalar(select(func.count()).select_from(KfoResult)) == 0 + assert await db_session.scalar(select(func.count()).select_from(Correction)) == 0 + # Дорезолв на чтение сохраняет те же выбранные пары и суммы. + entry = ( + await db_session.scalars( + select(OsvEntry).where(OsvEntry.owner_legal_entity_id == world.a.id, OsvEntry.account_code == "58.01") + ) + ).one() + entry.counterparty_id = None + entry.counterparty_name = world.b.name + await db_session.flush() + assert await service.contributions(world.version.id) == result + pinned.rule_no = 99 + await db_session.flush() + with pytest.raises(ConflictError) as error: + await service.contributions(world.version.id) + assert error.value.code == "GOODWILL_RULE_MISSING" + + world.version.goodwill_enabled = False + await db_session.flush() + assert await service.contributions(world.version.id) == [] + + +@pytest.mark.integration +async def test_kfo_loads_only_saved_selected_pairs(db_session, auth_client, fixtures_dir, monkeypatch): + world = await goodwill_world(db_session, fixtures_dir) + client = await auth_client(KA_RF) + base = f"/consolidations/versions/{world.version.id}" + await client.post(base + "/lock") + await client.put( + base + f"/goodwill/{world.a.id}/{world.b.id}", + json=dict(ownership_share="80", include_in_kfo=True, comment="Да"), + ) + await client.put(base + "/goodwill-settings", json={"goodwill_enabled": True}) + db_session.add( + Goodwill( + consolidation_version_id=world.version.id, + head_legal_entity_id=world.a.id, + subsidiary_legal_entity_id=world.chkf.id, + ownership_share=Decimal("100"), + include_in_kfo=False, + ) + ) + account = (await db_session.scalars(select(ChartOfAccount).where(ChartOfAccount.code == "58.01"))).one() + for owner, other in ((world.a, world.chkf), (world.b, world.a)): + doc = await make_confirmed_osv( + db_session, + entity=owner, + account="58.01", + year=2024, + quarter=PeriodQuarter.Q3, + is_cumulative=True, + rows=[ + dict( + name=other.name, + counterparty_id=other.id, + account_id=account.id, + closing_debit=Decimal("269997300.00"), + ) + ], + ) + db_session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=doc.id)) + extra = await make_confirmed_osv( + db_session, entity=world.a, account="80", year=2024, quarter=PeriodQuarter.Q3, is_cumulative=True, rows=[] + ) + extra.document_type = DocumentType.BALANCE + extra.osv_account_code = None + file = await db_session.get(StoredFile, extra.stored_file_id) + meta = FinancialStatementMeta( + document_id=extra.id, + recognition_job_id=file.current_recognition_job_id, + statement_type=StatementType.balance, + units="тыс. руб.", + ) + db_session.add(meta) + db_session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=extra.id)) + await db_session.flush() + original_rows, original_forms = version_repo.load_osv_rows, goodwill_repo.forms + loaded_pairs, loaded_form_owners = [], [] + + async def capture_rows(*args, **kwargs): + assert kwargs["company_pairs"] == {(world.a.id, world.b.id)} + assert account.id in kwargs["account_ids"] + rows = await original_rows(*args, **kwargs) + loaded_pairs.extend((e.owner_legal_entity_id, e.counterparty_id) for e, _, _ in rows) + return rows + + async def capture_forms(*args, **kwargs): + rows = await original_forms(*args, **kwargs) + loaded_form_owners.extend(doc.legal_entity_id for doc, _ in rows) + return rows + + monkeypatch.setattr(version_repo, "load_osv_rows", capture_rows) + monkeypatch.setattr(goodwill_repo, "forms", capture_forms) + user = (await db_session.scalars(select(AppUser).where(AppUser.email == KA_RF))).one() + scope = VisibilityScope(office_id=None) + source = GoodwillSource(db_session, ConsolidatedOsvService(db_session, scope)) + source.load = AsyncMock(side_effect=AssertionError("Полная таблица для КФО не нужна")) + service = GoodwillService(db_session, scope, SimpleNamespace(user=user), source) + result = await service.contributions(world.version.id) + assert len(result) == 1 + assert {(r.head_id, r.subsidiary_id) for r in result} == {(world.a.id, world.b.id)} + assert loaded_pairs and set(loaded_pairs) == {(world.a.id, world.b.id)} + assert loaded_form_owners == [world.b.id] + source.load.assert_not_awaited() + + +@pytest.mark.integration +async def test_kfo_without_selected_rows_does_not_load_sources(db_session, auth_client, fixtures_dir): + world = await goodwill_world(db_session, fixtures_dir) + world.version.goodwill_enabled = True + await db_session.flush() + user = (await db_session.scalars(select(AppUser).where(AppUser.email == KA_RF))).one() + scope = VisibilityScope(office_id=None) + source = GoodwillSource(db_session, ConsolidatedOsvService(db_session, scope)) + source.load_for_kfo = AsyncMock(side_effect=AssertionError("Нет выбранных пар — нет источников")) + service = GoodwillService(db_session, scope, SimpleNamespace(user=user), source) + assert await service.contributions(world.version.id) == [] + source.load_for_kfo.assert_not_awaited() + db_session.add( + Goodwill( + consolidation_version_id=world.version.id, + head_legal_entity_id=world.a.id, + subsidiary_legal_entity_id=world.b.id, + ownership_share=Decimal("100"), + include_in_kfo=True, + comment="Да", + ) + ) + world.version.goodwill_enabled = False + await db_session.flush() + assert await service.contributions(world.version.id) == [] + source.load_for_kfo.assert_not_awaited() diff --git a/api/tests/test_goodwill_domain.py b/api/tests/test_goodwill_domain.py new file mode 100644 index 0000000..1a540d3 --- /dev/null +++ b/api/tests/test_goodwill_domain.py @@ -0,0 +1,52 @@ +"""Расчёт и жизненный цикл на реальной сумме вложений Приманыческого.""" + +from decimal import Decimal +from pathlib import Path +from uuid import UUID + +import pytest + +from src.domain.goodwill import Decision, Investment, build, 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 diff --git a/api/tests/test_goodwill_export.py b/api/tests/test_goodwill_export.py new file mode 100644 index 0000000..b7e40bf --- /dev/null +++ b/api/tests/test_goodwill_export.py @@ -0,0 +1,78 @@ +"""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 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") + path = base + f"/goodwill/{world.a.id}/{world.b.id}" + assert (await client.put(path, json=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 is None + assert sheet.cell(3, 7).value is None + assert sheet.cell(5, 4).value == 0 + assert sheet.cell(5, 7).value == 0 diff --git a/api/tests/test_goodwill_golden.py b/api/tests/test_goodwill_golden.py new file mode 100644 index 0000000..ed59d11 --- /dev/null +++ b/api/tests/test_goodwill_golden.py @@ -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"} diff --git a/api/tests/test_goodwill_source.py b/api/tests/test_goodwill_source.py new file mode 100644 index 0000000..1fa7bc0 --- /dev/null +++ b/api/tests/test_goodwill_source.py @@ -0,0 +1,59 @@ +"""Выбор дерева счетов и отчётной колонки без догадок по 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) diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index bf47e58..053e62c 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -82,6 +82,13 @@ 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"}, + "/api/v1/consolidations/versions/{version_id}/goodwill-settings": {"put"}, + "/api/v1/consolidations/versions/{version_id}/goodwill/{head_legal_entity_id}/{subsidiary_legal_entity_id}": { + "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"}, diff --git a/openspec/changes/goodwill-table/.openspec.yaml b/openspec/changes/goodwill-table/.openspec.yaml new file mode 100644 index 0000000..1491b92 --- /dev/null +++ b/openspec/changes/goodwill-table/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-05 diff --git a/openspec/changes/goodwill-table/design.md b/openspec/changes/goodwill-table/design.md new file mode 100644 index 0000000..2a1d5a5 --- /dev/null +++ b/openspec/changes/goodwill-table/design.md @@ -0,0 +1,72 @@ +# 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. Четыре знака доли, построчное PUT и XLSX намеренно отличаются от PDF. Сохраняем четыре пути MD. 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 строки; не материализуем все строки при чтении или смене переключателя. Это обычное сохранение по PUT из MD §3.3; 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 — проценты с четырьмя. Максимум четыре дробных знака во входе, без автоматического округления; запятая и точка допустимы по 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 — поля MD; в этом этапе без отдельного API групповых итогов: они выводимы из всех строк. При goodwill_enabled=false потенциальные G видны, goodwill_in_kfo_amount=0.00. + +### 5. Транзакции и безопасность + +Маршруты гудвилла используют зависимости по образцу API корректировок. Service использует текущие policy и lock_editable_version. PUT строки под блокировкой версии повторно загружает актуальные источники, проверяет пару и ввод, upsert существующей записи, записывает GOODWILL_EDIT before/after в той же транзакции. Вычисляемые суммы из тела не принимаются. PUT настроек не трогает строки; GOODWILL_EDIT имеет object_type=consolidation_version и changed_field=goodwill_enabled, строки — object_type=goodwill. Новые enum не нужны. Отдельная оптимистическая ревизия не добавляется: существующая блокировка версии сериализует операции, последнее сохранение владельца побеждает. + +### 6. Граница КФО + +Внутренний DTO/чистая функция возвращает применимые пары с version_id, head_id, subsidiary_id, FV/P/G, дельтами и ключом исключения (balance, rule_id ББ-2 закреплённой elimination_rules_version, head_id, subsidiary_id). Переключатель — дополнительное обязательное условие. При выключенном переключателе или отсутствии применимых пар вклад пуст и правило не запрашивается. Для непустого вклада нет правила ББ-2 в закреплённом наборе — подготовка вклада даёт явный конфликт GOODWILL_RULE_MISSING, не подставляет старое правило №7. GET таблицы может работать без подготовки вклада. Не записывать KfoResult и не менять статусы/суммы Correction. Не создавать фиктивный механизм инвалидирования: требование AURORA-1543 о stale и фиксация VERIFIED перенесены в будущий change КФО с явной зависимостью на этот контракт. + +### 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 и сохранение ручного ввода. + +## Отбор источников для КФО по запросу пользователя + +У таблицы остаётся полный load. contributions проверяет видимость/переключатель, выбирает только сохранённые +include_in_kfo=true и вызывает load_for_kfo с направленными парами. Репозиторий ОСВ получает company_pairs и +account_ids ветки 58.01, репозиторий ББ — идентификаторы выбранных ДО. Общая нормализация периодов и дорезолв +сохраняются: NULL-контрагенты выбранных владельцев/счетов рассматриваются как кандидаты, итог свода после +дорезолва фильтруется строго по выбранным парам. Невыбранные сопоставленные пары не загружаются из osv_entry. +Без выбранных записей и при выключении нет загрузки ОСВ/ББ. diff --git a/openspec/changes/goodwill-table/proposal.md b/openspec/changes/goodwill-table/proposal.md new file mode 100644 index 0000000..e596a3f --- /dev/null +++ b/openspec/changes/goodwill-table/proposal.md @@ -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. +- Подготовка чистого результата для будущего КФО: применимые пары, изменения 1170/1310/GOODWILL и исключаемое направление ББ-2. Запись КФО не выполняется. +- Уточнения 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; четыре маршрута из MD. Изменение чернового контракта: новые признаки состояния и nullable P/G, обновлённая семантика пар и итогов. Используются существующие guard, Decimal и openpyxl. + +## Non-goals + +- Фронт AURORA-1544, пакетное сохранение и CSV. +- Построение и запись kfo_result, применение корректировок, экран отметки «Учтено в гудвилле». +- Механизм устаревания и фиксации снимка КФО: отдельный change; не добавлять несуществующий флаг БД. +- ОСВ 58.03 как замена 58.01; автоматический перенос разницы в 1370. +- Новая модель данных и окончательное решение методологов по источнику УК; XLSX пока предварительно согласован. diff --git a/openspec/changes/goodwill-table/specs/goodwill-table/spec.md b/openspec/changes/goodwill-table/specs/goodwill-table/spec.md new file mode 100644 index 0000000..72ba810 --- /dev/null +++ b/openspec/changes/goodwill-table/specs/goodwill-table/spec.md @@ -0,0 +1,107 @@ +# 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 ББ ДО на конец отчётного периода в рублях: 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** УК берётся по дате конца отчётного периода, а не только is_current; сумма переводится в рубли до расчёта + +### Requirement: Получение таблицы +GET /api/v1/consolidations/versions/{version_id}/goodwill SHALL возвращать version_id, goodwill_enabled, editable, read_only_reason, rows, totals. Строки отсортированы по именам ГО/ДО и ID; содержат поля MD-контракта, has_investments и warnings. Деньги — строки с двумя знаками в рублях или null, доли — строки с четырьмя. + +#### 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, сохранение не блокируется; проценты по разным ДО не складываются в общий процент + +### Requirement: Построчное сохранение +PUT …/goodwill/{head_id}/{subsidiary_id} SHALL атомарно сохранять ownership_share, include_in_kfo, comment и расчётные суммы только одной пары. Доля допускает null только при снятом выборе, иначе 0–100 с точностью до четырёх знаков; превышение точности не округляется. Для выбранной действующей строки обязателен непустой комментарий до 1000 символов. Ошибки — INVALID_SHARE и COMMENT_REQUIRED (422). + +#### 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 готовить данные только из сохранённого ввода и актуальных источников: при включённом переключателе, действующей выбранной паре, заполненных доле/комментарии и G>0 — 1170 ГО −ФВ, 1310 ДО −P, GOODWILL ГО +G и направление исключения ББ-2 закреплённой версии правил. Запись КФО и изменение корректировок MUST NOT выполняться. + +#### Scenario: Неположительный гудвилл +- **WHEN** пересчитанная сохранённая пара имеет G≤0, нет УК либо выключен переключатель +- **THEN** её специальный вклад пуст, ББ-2 не исключается; отрицательное G не переносится в 1370 и не уменьшает положительный вклад других пар + +### 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 + +### Requirement: Источники КФО только выбранных сохранённых пар +Подготовка КФО SHALL брать только сохранённые goodwill выбранной версии с include_in_kfo=true, ОСВ этих направленных пар и ББ выбранных ДО. При выключенном переключателе или пустом выборе источники ОСВ/ББ MUST NOT загружаться. Дорезолв SHALL сохранять соответствие расчёту таблицы. + +#### Scenario: Выбранная и невыбранная пары +- **WHEN** сохранены А→Б с include_in_kfo=true и А→В с false, а для Б→А goodwill отсутствует +- **THEN** загружаются ОСВ А→Б и ББ Б; А→В и Б→А не дают источников и вклада, полный load таблицы не вызывается + +#### Scenario: Пустой выбор +- **WHEN** выбранных сохранённых строк нет либо goodwill_enabled=false +- **THEN** вклад пуст, загрузка ОСВ и ББ не выполняется diff --git a/openspec/changes/goodwill-table/tasks.md b/openspec/changes/goodwill-table/tasks.md new file mode 100644 index 0000000..f6e4cbe --- /dev/null +++ b/openspec/changes/goodwill-table/tasks.md @@ -0,0 +1,56 @@ +# 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, четыре знака, 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 строки в сервисе и репозитории гудвилла через 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 arm-core: добавить чистую подготовку вклада в доменном модуле расчёта гудвилла и адаптер сохранённого ввода в сервисе гудвилла; тестами подготовки вклада гудвилла проверить баланс −ФВ+G=−P, только положительные G, выключенный режим, отсутствие капитала, закреплённое ББ-2 и направление исключения. Проверить, что KfoResult и Correction не записываются. +- [x] 4.3 arm-core: описать внутренний контракт вклада и ограничения этапа в описательном контракте API; проверка — документ явно отделяет подготовку от применения, устаревания и снимка КФО, не обещает завершение полного AURORA-1543. + +## 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 КФО реализует запись результата, устаревание, применение/исключение ББ-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 arm-core: отдельный GoodwillSource.load_for_kfo; contributions загружает только goodwill include_in_kfo=true, ОСВ выбранных направленных пар и ББ выбранных ДО. Проверить SQL-отбор и отсутствие полной загрузки при пустом выборе/выключении. +- [x] 7.2 arm-core: обновить описание внутреннего контракта и спецификацию; проверить регрессию таблицы/ОСВ, линтеры и типы. + + +## 8. Экспорт по экранной таблице + +- [x] 8.1 arm-core: привести XLSX к составу и порядку восьми колонок фронта со стандартным оформлением. Сохранить прежний контракт ручки и вызов экспорта на фронте. Проверить интеграционные сценарии выгрузки.