From 94895b6de71de8f64f5e3589e125ae65fb44a549 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Thu, 5 Nov 2026 15:36:14 +0300 Subject: [PATCH 1/7] =?UTF-8?q?feat/goodwillback:=20=D0=B1=D1=8D=D0=BA?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=20=D0=B3=D1=83=D0=B4=D0=B2=D0=B8=D0=BB=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 + api/src/api/dependencies/consolidations.py | 14 + .../api/endpoints/consolidations/__init__.py | 2 + .../api/endpoints/consolidations/goodwill.py | 82 +++++ api/src/domain/goodwill.py | 132 +++++++ api/src/repositories/consolidations.py | 24 +- api/src/repositories/goodwill.py | 69 ++++ api/src/schemas/goodwill.py | 66 ++++ api/src/services/consolidated_osv.py | 23 +- api/src/services/goodwill.py | 297 +++++++++++++++ api/src/services/goodwill_export.py | 92 +++++ api/src/services/goodwill_source.py | 105 ++++++ api/tests/test_api_goodwill.py | 341 ++++++++++++++++++ api/tests/test_api_goodwill_mutations.py | 155 ++++++++ api/tests/test_goodwill_contract.py | 22 ++ api/tests/test_goodwill_contribution.py | 227 ++++++++++++ api/tests/test_goodwill_domain.py | 52 +++ api/tests/test_goodwill_export.py | 78 ++++ api/tests/test_goodwill_golden.py | 43 +++ api/tests/test_goodwill_source.py | 59 +++ api/tests/test_openapi_routes.py | 7 + .../changes/goodwill-table/.openspec.yaml | 2 + openspec/changes/goodwill-table/design.md | 72 ++++ openspec/changes/goodwill-table/proposal.md | 39 ++ .../specs/goodwill-table/spec.md | 107 ++++++ openspec/changes/goodwill-table/tasks.md | 56 +++ 26 files changed, 2169 insertions(+), 4 deletions(-) create mode 100644 api/src/api/endpoints/consolidations/goodwill.py create mode 100644 api/src/domain/goodwill.py create mode 100644 api/src/repositories/goodwill.py create mode 100644 api/src/schemas/goodwill.py create mode 100644 api/src/services/goodwill.py create mode 100644 api/src/services/goodwill_export.py create mode 100644 api/src/services/goodwill_source.py create mode 100644 api/tests/test_api_goodwill.py create mode 100644 api/tests/test_api_goodwill_mutations.py create mode 100644 api/tests/test_goodwill_contract.py create mode 100644 api/tests/test_goodwill_contribution.py create mode 100644 api/tests/test_goodwill_domain.py create mode 100644 api/tests/test_goodwill_export.py create mode 100644 api/tests/test_goodwill_golden.py create mode 100644 api/tests/test_goodwill_source.py create mode 100644 openspec/changes/goodwill-table/.openspec.yaml create mode 100644 openspec/changes/goodwill-table/design.md create mode 100644 openspec/changes/goodwill-table/proposal.md create mode 100644 openspec/changes/goodwill-table/specs/goodwill-table/spec.md create mode 100644 openspec/changes/goodwill-table/tasks.md 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 к составу и порядку восьми колонок фронта со стандартным оформлением. Сохранить прежний контракт ручки и вызов экспорта на фронте. Проверить интеграционные сценарии выгрузки. -- 2.47.2 From 3e07276a96f4a7f0ce7ee451d43c1ca28acc987b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 18:20:04 +0400 Subject: [PATCH 2/7] =?UTF-8?q?=D0=93=D1=83=D0=B4=D0=B2=D0=B8=D0=BB=D0=BB:?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=B0=20=D0=9A=D0=A4=D0=9E=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0=20=D0=B2=20AUR?= =?UTF-8?q?ORA-1546?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удалены contributions/load_for_kfo и фильтры общего свода ОСВ (load_osv_rows, build_with_members возвращены к main); OpenSpec change без требований вклада КФО. План доводки по ревью — docs/superpowers/plans/2026-11-06-goodwill-review-back.md. Co-Authored-By: Claude Opus 5.5 --- api/src/domain/goodwill.py | 39 --- api/src/repositories/consolidations.py | 24 +- api/src/repositories/goodwill.py | 9 +- api/src/services/consolidated_osv.py | 23 +- api/src/services/goodwill.py | 43 +--- api/src/services/goodwill_source.py | 18 +- api/tests/test_goodwill_contribution.py | 227 ------------------ .../plans/2026-11-06-goodwill-review-back.md | 181 ++++++++++++++ openspec/changes/goodwill-table/design.md | 11 +- openspec/changes/goodwill-table/proposal.md | 2 +- .../specs/goodwill-table/spec.md | 18 -- openspec/changes/goodwill-table/tasks.md | 11 +- 12 files changed, 199 insertions(+), 407 deletions(-) delete mode 100644 api/tests/test_goodwill_contribution.py create mode 100644 docs/superpowers/plans/2026-11-06-goodwill-review-back.md diff --git a/api/src/domain/goodwill.py b/api/src/domain/goodwill.py index 9011240..3d5c226 100644 --- a/api/src/domain/goodwill.py +++ b/api/src/domain/goodwill.py @@ -91,42 +91,3 @@ def totals(rows: Sequence[Row], enabled: bool) -> dict[str, Decimal]: "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 3d04392..ec4d1bb 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -306,13 +306,7 @@ 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, - *, - company_pairs: Collection[tuple[uuid.UUID, uuid.UUID]] | None = None, - account_ids: Collection[uuid.UUID] | None = None, -) -> list[tuple[OsvEntry, Document, LegalEntity]]: +async def load_osv_rows(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[OsvEntry, Document, LegalEntity]]: """Исходные строки свода: контрагентские (`group_type = COUNTERPARTY`) строки BU/SUM привязанных документов. `TOTAL` не хранится, строки `ACCOUNT` и данные `NU` отбрасываются. Порядок: имя ЮЛ, код счёта документа, квартал, @@ -332,22 +326,6 @@ async def load_osv_rows( 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 index 6dc6a65..f5b354a 100644 --- a/api/src/repositories/goodwill.py +++ b/api/src/repositories/goodwill.py @@ -1,6 +1,5 @@ """Пакетное чтение goodwill и привязанных подтверждённых ББ.""" -from collections.abc import Collection from uuid import UUID from sqlalchemy import select @@ -25,10 +24,8 @@ from src.models.statements import ( ) -async def saved(session: AsyncSession, version_id: UUID, *, included_only: bool = False) -> list[Goodwill]: +async def saved(session: AsyncSession, version_id: UUID) -> 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()) @@ -39,7 +36,7 @@ async def accounts(session: AsyncSession) -> list[ChartOfAccount]: 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): +async def forms(session: AsyncSession, version_id: UUID): stmt = ( select(Document, Meta) .join(Meta, Meta.document_id == Document.id) @@ -50,8 +47,6 @@ async def forms(session: AsyncSession, version_id: UUID, company_ids: Collection 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()) diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index 1d74dd0..b548d42 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, replace +from dataclasses import dataclass from typing import Literal from fastapi.concurrency import run_in_threadpool @@ -149,13 +149,7 @@ class ConsolidatedOsvService: self._session = session self._scope = scope - 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: + async def build_with_members(self, version_id: uuid.UUID) -> ConsolidatedOsvBuildResult: """Свод и участники периметра, загруженные при его расчёте; версия вне видимости — 404.""" found = await repo.get_version(self._session, self._scope, version_id) if found is None: @@ -173,12 +167,7 @@ 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, company_pairs=company_pairs, account_ids=account_ids - ) - ] + rows = [_source_row(entry) for entry, _, _ in await repo.load_osv_rows(self._session, version.id)] index = await CounterpartyIndex.load(self._session) entities = {**await _entities(self._session, _candidate_ids(rows, index) - members.keys()), **members} consolidated = domain.build( @@ -191,12 +180,6 @@ 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 index b3162d9..b2f6579 100644 --- a/api/src/services/goodwill.py +++ b/api/src/services/goodwill.py @@ -15,9 +15,8 @@ from src.common.exceptions import ConflictError, DomainValidationError, NotFound 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.enums import AuditAction, AuditSection from src.models.kfo import Goodwill from src.repositories import consolidations as versions from src.repositories import goodwill as repo @@ -30,12 +29,11 @@ from src.schemas.goodwill import ( GoodwillTotalsOut, WarningOut, ) -from src.services.consolidation_guard import VERSION_NOT_FOUND, lock_editable_version +from src.services.consolidation_guard import lock_editable_version from src.services.goodwill_export import build_workbook, matches_sign from src.services.goodwill_source import GoodwillSource MAX_SHARE = Decimal(100) -BB_RULE_NO = 2 MESSAGES = { "CAPITAL_SOURCE_MISSING": "Нет однозначного значения 1310 на конец отчётного периода", @@ -258,40 +256,3 @@ class GoodwillService: 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_source.py b/api/src/services/goodwill_source.py index 29f721f..f5f2b20 100644 --- a/api/src/services/goodwill_source.py +++ b/api/src/services/goodwill_source.py @@ -9,7 +9,7 @@ from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession -from src.domain.goodwill import Investment, Pair +from src.domain.goodwill import Investment 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 @@ -71,23 +71,11 @@ class GoodwillSource: 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) + ctx = await self.osv.build_with_members(version_id) 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 - ) + forms = await repo.forms(self.session, version_id) 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]) diff --git a/api/tests/test_goodwill_contribution.py b/api/tests/test_goodwill_contribution.py deleted file mode 100644 index 7ab5c6c..0000000 --- a/api/tests/test_goodwill_contribution.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Гудвилл изменяет актив и пассив на одинаковую сумму, не смешивает пары.""" - -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/docs/superpowers/plans/2026-11-06-goodwill-review-back.md b/docs/superpowers/plans/2026-11-06-goodwill-review-back.md new file mode 100644 index 0000000..a6b95fe --- /dev/null +++ b/docs/superpowers/plans/2026-11-06-goodwill-review-back.md @@ -0,0 +1,181 @@ +# Гудвилл: доводка бэка по ревью (arm-core #222) — план + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** довести PR arm-core #222 (ветка `feat/goodwill-back`, AURORA-1543) до мержа: убрать невызываемую заготовку КФО, +итоги по ГО и УК отдать с бэка, долю ограничить двумя знаками, сохранение сделать пакетным в одной транзакции, единицы ББ +читать через словарь `arm_statements`, привести контракт в docs (ветка `feat/204-goodwill-contract`, issue docs #204), +§8 правил и OpenSpec change к фактическому API. + +**Architecture:** чистый расчёт — `api/src/domain/goodwill.py`; загрузка источников — `services/goodwill_source.py` + +`repositories/goodwill.py`; сервис/маршруты — `services/goodwill.py`, `api/endpoints/consolidations/goodwill.py`; экспорт — +`services/goodwill_export.py`. Общий свод ОСВ (`repositories/consolidations.load_osv_rows`, +`services/consolidated_osv.build_with_members`) возвращается к состоянию `main`. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, pydantic v2, openpyxl, pytest (маркеры `integration`, `fixtures`). + +**Spec:** `openspec/changes/goodwill-table/` (этого репозитория) + страница аналитики DOC-259025 «Гудвилл» (версия 03.11.2026, +`mcp__sdlc-wiki__page code=DOC-259025`) + решения ревью ниже. + +Рабочие каталоги: arm-core — `/Users/andrey.epifancev/projects/awp-ca/.wt/review-222/arm-core` (ветка `feat/goodwill-back`), +docs — `/Users/andrey.epifancev/projects/awp-ca/.wt/review-222/docs` (ветка `feat/204-goodwill-contract`). Git — только +`git -C <абсолютный путь worktree>`. + +## Global Constraints + +- Деньги — `Decimal`, `ROUND_HALF_UP` до 0.01, в API — строки с двумя знаками в рублях; `null` ≠ `0.00`. +- Доля владения: строка `0`…`100`, не больше **двух** знаков после запятой, запятая или точка; лишние знаки — 422 + `INVALID_SHARE`, без округления и обрезки. В ответе — строка с двумя знаками (`"80.00"`). +- `is_head_entity` не используется; ГО — владелец строки 58.01 (и потомков) с контрагентом из периметра. +- Аудит — `GOODWILL_EDIT` (строки: `object_type=goodwill`; переключатель: `object_type=consolidation_version`, + `changed_field=goodwill_enabled`), выгрузка — `EXPORT`; новых enum и колонок нет, миграций нет. +- Коммиты — по-русски, в конце `Co-Authored-By: Claude Opus 5.5 `. +- Решения ревью (дописать в `openspec/changes/goodwill-table/design.md`, раздел `## Decisions made without discussion`): + R-1 доля — 2 знака (DOC-259025 §4, §5.2, §9; DBML `numeric(7,4)` хранит без изменений); R-2 построчный `PUT …/goodwill/{head}/{sub}` + заменён пакетным `PUT …/goodwill` в одной транзакции (DOC-259025 §5.1 п. 4); R-3 заготовка вклада КФО (`contributions`, + `load_for_kfo`, фильтры общего свода) вынесена в AURORA-1546; R-4 итоги по ГО и итог УК считает бэк; R-5 единицы ББ — + через словарь единиц `arm_statements`; R-6 пометка результата КФО устаревшим — в AURORA-1546 (результата КФО ещё нет). + +## Review Focus + +- Пакет, где одна строка невалидна: ничего не сохраняется, ни одной записи `goodwill` и `audit_event`, в 422 есть ключ строки. +- Повторное сохранение без изменений: `updated_by/updated_at` не меняются, аудита нет (фронт шлёт весь набор отмеченных строк). +- Единицы ББ, исправленные аналитиком вручную («тыс.руб.», «в тысячах рублей»): УК всё равно переводится в рубли. +- Итог «Всего» блока: ФВ и УК — по всем действующим строкам блока, P и G — только отмеченным; «Нет вложений» не входит. +- Доля `"80,123"` — 422, а не молча `80.12`. + +--- + +### Task 1: Убрать заготовку вклада КФО + +**Files:** +- Modify: `api/src/domain/goodwill.py` (удалить `Contribution`, `contributions`) +- Modify: `api/src/services/goodwill.py` (удалить `contributions`, `BB_RULE_NO`, импорт `EliminationRule`, `StatementType`) +- Modify: `api/src/services/goodwill_source.py` (удалить `load_for_kfo`, параметр `pairs` у `_load`) +- Modify: `api/src/repositories/goodwill.py` (удалить `included_only` у `saved`, `company_ids` у `forms`) +- Restore from `origin/main`: `api/src/repositories/consolidations.py`, `api/src/services/consolidated_osv.py` +- Delete: `api/tests/test_goodwill_contribution.py` +- Modify: `openspec/changes/goodwill-table/{proposal,design,tasks}.md`, `specs/goodwill-table/spec.md` + +**Interfaces:** +- Produces: `GoodwillSource.load(version_id: UUID) -> Sources` (единственный метод загрузки); `repo.saved(session, version_id) -> list[Goodwill]`; + `repo.forms(session, version_id)`. + +- [ ] **Step 1:** `git -C checkout origin/main -- api/src/repositories/consolidations.py api/src/services/consolidated_osv.py`; + убедиться `git diff origin/main -- <эти два файла>` пуст. +- [ ] **Step 2:** удалить перечисленный код и тест; `grep -rn "contributions\|load_for_kfo\|company_pairs\|included_only" api/src api/tests` — пусто. +- [ ] **Step 3:** в OpenSpec удалить требования «Подготовка вклада в КФО» и «Источники КФО только выбранных сохранённых пар», + в design §6 и разделе «Отбор источников для КФО» оставить одну фразу «перенесено в AURORA-1546 (R-3)», в tasks.md пункты + 4.2, 4.3, 7.x — пометить «перенесено в AURORA-1546». +- [ ] **Step 4:** `make lint` и `cd api && .venv/bin/pytest -m integration tests/test_api_goodwill.py tests/test_api_goodwill_mutations.py tests/test_goodwill_export.py tests/test_api_consolidated_osv.py -q` — зелёные. +- [ ] **Step 5:** коммит «Гудвилл: заготовка вклада КФО вынесена в AURORA-1546». + +### Task 2: Доля — два знака, расчёт в домене, итоги по ГО и УК + +**Files:** +- Modify: `api/src/domain/goodwill.py`, `api/src/schemas/goodwill.py`, `api/src/services/goodwill.py`, `api/src/services/goodwill_export.py` +- Test: `api/tests/test_goodwill_domain.py`, `api/tests/test_api_goodwill.py`, `api/tests/test_goodwill_export.py`, `api/tests/test_api_goodwill_mutations.py` + +**Interfaces:** +- Produces (домен): `participation(capital: Decimal, share: Decimal) -> Decimal` (`capital*share/100`, HALF_UP до 0.01) — единственное + место формулы, `build` и сервис используют его; `totals(rows, enabled) -> dict[str, Decimal]` с ключами + `financial_investment_amount, charter_capital_amount, participation_amount, goodwill_amount, goodwill_in_kfo_amount` + (ФВ и УК — все действующие строки; P/G — действующие отмеченные; в КФО — `applicable` и `enabled`); + `group_totals(rows, enabled) -> dict[UUID, dict[str, Decimal]]` = `totals` по строкам каждой ГО (`head_id`). +- Produces (схемы): `GoodwillTotalsOut` + поле `charter_capital_amount: Money`; `GoodwillGroupOut {head_legal_entity: RefOut, totals: GoodwillTotalsOut}`; + `GoodwillTableOut.groups: list[GoodwillGroupOut]` — порядок как у строк (имя ГО, id); `Share` сериализуется `format(v, ".2f")`. +- Produces (сервис): `validate_decision(data) -> domain.Decision` принимает `^\d{1,3}(?:[.,]\d{1,2})?$` и `≤ 100`. + +- [ ] **Step 1: тест домена на примере DOC-259025 §11.1** — `test_goodwill_domain.py::test_doc_example_totals`: ГО «Альфа»; + Бета ФВ 180000 УК 100000 доля 80 отмечена, комментарий → P 80000.00, G 100000.00; Гамма ФВ 50000 УК 100000 доля 80 отмечена → P 80000.00, G −30000.00; + Дельта ФВ 60000 УК 60000 без доли, не отмечена → P/G None. `totals(rows, True)` = ФВ 290000.00, УК 260000.00, P 160000.00, + G 70000.00, в КФО 100000.00; `totals(rows, False)["goodwill_in_kfo_amount"] == 0`; `group_totals` для «Альфа» совпадает с `totals`; + строка «Нет вложений» (сохранённое решение без ФВ) не меняет ни одной суммы. +- [ ] **Step 2: тест доли** — `test_api_goodwill_mutations.py`: параметры `test_invalid_share` дополнить `"80.123"`, `"80,123"`, `"100.01"`, `"-1"`, `"1e2"`; + `test_valid_share` — `"0"`, `"100"`, `"80,12"`, `"7.5"`; ответ строки отдаёт `"80.12"`. +- [ ] **Step 3:** прогнать — FAIL (нет `participation`, `group_totals`, `charter_capital_amount`, регулярка пропускает 4 знака). +- [ ] **Step 4:** реализовать по Interfaces; в `service.save` (до Task 3) и `build` убрать собственные формулы P в пользу `participation`; + `table()` заполняет `groups` из `group_totals`; сообщение `INVALID_SHARE` — «Доля — число от 0 до 100, не больше двух знаков после запятой». +- [ ] **Step 5: экспорт** — «Всего» блока брать из `table.groups` (удалить `_totals`), в «Итого по листу» добавить УК, формат доли `"0.00"`; + `test_goodwill_export.py`: значение УК в строке «Итого по листу» равно `totals.charter_capital_amount`. +- [ ] **Step 6:** `test_api_goodwill.py`: в ответе таблицы есть `groups[0].totals` и `totals.charter_capital_amount` с ожидаемыми строками. +- [ ] **Step 7:** `make lint`, интеграционные тесты гудвилла — PASS; коммит «Гудвилл: доля до двух знаков, итоги по ГО и УК с бэка». + +### Task 3: Пакетное сохранение в одной транзакции + +**Files:** +- Modify: `api/src/schemas/goodwill.py`, `api/src/services/goodwill.py`, `api/src/api/endpoints/consolidations/goodwill.py` +- Test: `api/tests/test_api_goodwill_mutations.py`, `api/tests/test_api_goodwill.py`, `api/tests/test_goodwill_export.py`, `api/tests/test_openapi_routes.py` + +**Interfaces:** +- Consumes: `validate_decision`, `participation` (Task 2). +- Produces: маршрут `PUT /api/v1/consolidations/versions/{version_id}/goodwill`, право `CONSOLIDATION_EDIT`, тело + `GoodwillBatchIn {rows: list[GoodwillRowKeyIn]}` (`min_length=1`, `extra="forbid"`), где + `GoodwillRowKeyIn = {head_legal_entity_id: UUID, subsidiary_legal_entity_id: UUID, ownership_share: str | None, include_in_kfo: bool, comment: str | None (≤1000)}`; + ответ 200 `GoodwillTableOut`. Маршрут `PUT …/goodwill/{head}/{sub}` и `GoodwillService.save` удаляются; новый + `GoodwillService.save_rows(version_id: UUID, rows: list[GoodwillRowKeyIn], audit: RequestAudit) -> GoodwillTableOut`. +- Ошибки (все — откат всего пакета): 404 `NOT_FOUND` «Пара не найдена» и 422 `INVALID_SHARE` / `COMMENT_REQUIRED` / + `MONEY_OUT_OF_RANGE` с доп. полями `head_legal_entity_id`, `subsidiary_legal_entity_id` (для `NotFoundError` — создать + `DomainError(404, "NOT_FOUND", …, extra=…)`); пара дважды в пакете — 422 `DUPLICATE_ROW` с теми же полями; 409 правок версии — как раньше. + +- [ ] **Step 1: тесты** (`test_api_goodwill_mutations.py`, переписать вызовы на пакет): + `test_batch_rolls_back_when_one_row_invalid` — две пары, вторая с `include_in_kfo=true` без комментария → 422 `COMMENT_REQUIRED`, + `detail.subsidiary_legal_entity_id` = id второй ДО, в БД 0 записей `goodwill` и 0 `audit_event(GOODWILL_EDIT)`; + `test_unchanged_rows_are_not_rewritten` — сохранить строку, повторить тот же пакет → `updated_at` прежний, событий `GOODWILL_EDIT` по-прежнему одно; + `test_new_row_with_defaults_is_not_created` — пакет `{share: null, include: false, comment: null}` по паре без записи → записей 0; + `test_duplicate_pair_in_batch` → 422 `DUPLICATE_ROW`; `test_unknown_pair` → 404 с ключом пары. Сценарии прежнего + `test_save_settings_and_disappearing_pair` и `test_money_overflow_keeps_database_unchanged` сохранить, переведя на пакет. +- [ ] **Step 2:** `test_openapi_routes.py` — путь `…/goodwill`: `{"get", "put"}`, путь `…/goodwill/{head_legal_entity_id}/{subsidiary_legal_entity_id}` удалён. +- [ ] **Step 3:** прогнать — FAIL. +- [ ] **Step 4:** реализовать `save_rows`: `lock_editable_version` → одна сборка таблицы → проверка дублей, пар и ввода всех + строк до любой записи → upsert только изменённых (сравнение `ownership_share`, `include_in_kfo`, `comment` с сохранённым; + для пары без записи «изменение» — отличие от `Decision()`) с пересчётом сумм как сейчас → `flush` → одна сборка таблицы → + `GOODWILL_EDIT` по каждой изменённой строке (`before` — строка ответа до, `null` для новой; `after` — строка ответа после) → + вернуть таблицу. Поведение «отсутствующий ввод не затирает сохранённое у неактивной пары» сохранить. +- [ ] **Step 5:** PASS; `make lint`; коммит «Гудвилл: пакетное сохранение таблицы одной транзакцией». + +### Task 4: Единицы ББ через словарь arm_statements + +**Files:** +- Modify: `api/src/services/statements_engine.py` (Protocol `StatementsEngine` и `ArmStatementsEngine`), `api/src/services/goodwill_source.py` +- Test: `api/tests/test_goodwill_source.py` + +**Interfaces:** +- Produces: `StatementsEngine.canonical_units(text: str | None) -> str | None` — `normalize_caption(text)` (из `arm_statements.text`) + и первый совпавший шаблон `self._rules.units.spellings` → каноническая единица («руб.», «тыс. руб.», «млн руб.»), иначе `None`. + `capital_amounts` берёт множитель по `default_engine().canonical_units(units)`. + +- [ ] **Step 1: тест** `test_capital_units_spellings` (параметры): `"тыс.руб."`, `"в тысячах рублей"`, `"тыс. руб."` → УК = значение × 1000; + `"руб"` → × 1; `"млн. руб."` → × 1000000; `"штук"` и `None` → `(None, None)`. +- [ ] **Step 2:** FAIL → реализовать → PASS; если пакетный словарь в ядре подменяется заглушкой в тестах (`StatementsEngine` фейки в `api/tests`), + добавить метод и туда (`grep -rn "class .*Engine" api/tests`). +- [ ] **Step 3:** `make lint`; коммит «Гудвилл: единицы ББ по словарю arm_statements». + +### Task 5: Контракт, §8, OpenSpec, README + +**Files:** +- docs: `dev/api-contract-goodwill.md`, `dev/api-contract-goodwill.openapi.yaml`, `context/05-domain-rules.md` (§8 «Гудвилл») +- arm-core: `api/tests/test_goodwill_contract.py`, `README.md` (раздел «Таблица гудвилла», таблица «Ошибки API»), + `openspec/changes/goodwill-table/{proposal,design,tasks}.md`, `specs/goodwill-table/spec.md` + +**Interfaces:** +- Consumes: фактический `app.openapi()` после Tasks 1–4. + +- [ ] **Step 1: тест контракта** — читать `Path(__file__).resolve().parents[3] / "docs/dev/api-contract-goodwill.openapi.yaml"`, + при отсутствии файла — `pytest.skip("нет ../docs: контракт гудвилла не проверен")`; сравнение путей `/goodwill` и всех схем + контракта с `app.openapi()` — как сейчас; проверку `validate_decision` перевести на `"80,12"` → `Decimal("80.12")`. +- [ ] **Step 2: OpenAPI-копия** — выгрузить из `app.openapi()` пути с `/goodwill` и все схемы, на которые они ссылаются + (транзитивно, включая `RefOut`, `WarningOut`, `HTTPValidationError`), в `docs/dev/api-contract-goodwill.openapi.yaml` (`openapi`, `info` + с версией `1.0`, `paths`, `components.schemas`); тест Step 1 — PASS. +- [ ] **Step 3: MD-контракт** — переписать `docs/dev/api-contract-goodwill.md` под факт: источники (DOC-259025 03.11 первым), понятия + (ГО по вложениям, «Нет вложений», P/G только у отмеченных, предупреждения `CAPITAL_SOURCE_MISSING`/`CAPITAL_SOURCE_AMBIGUOUS`/ + `OWNERSHIP_TOTAL_EXCEEDED`), эндпоинты (GET, PUT пакет, PUT settings, GET export с `sign=all|positive|negative|zero`), схемы, ошибки, + пример ответа с `groups`, открытые вопросы OQ-10/OQ-37; раздел «Что изменилось против черновика 30.10» — для фронта. +- [ ] **Step 4: §8** — заменить абзац «Гудвилл» в `05-domain-rules.md` на выжимку DOC-259025 §§1–8 с решениями R-1, R-5 и + пометкой «применение в КФО и исключение ББ-2 — AURORA-1546, не реализовано». +- [ ] **Step 5: arm-core** — README: ссылка на `../docs/dev/api-contract-goodwill.md`, пакетный PUT, коды `INVALID_SHARE`, + `COMMENT_REQUIRED`, `MONEY_OUT_OF_RANGE`, `DUPLICATE_ROW` в «Ошибки API»; OpenSpec — требования «Построчное сохранение» → «Пакетное сохранение», + доля — два знака, итоги по ГО и УК, единицы; раздел `## Decisions made without discussion` с R-1…R-6 (дата 2026-11-06, решение, + обоснование, цена ошибки); `openspec validate goodwill-table --strict` — valid. +- [ ] **Step 6:** `make check` (весь unit + lint) и `make test-integration` в arm-core — зелёные; `make dbml-check` — без расхождений. +- [ ] **Step 7:** коммиты: docs — «Гудвилл: контракт API и §8 под DOC-259025 и ревью core #222 (#204)»; arm-core — «Гудвилл: контракт в docs, README, OpenSpec под ревью». diff --git a/openspec/changes/goodwill-table/design.md b/openspec/changes/goodwill-table/design.md index 2a1d5a5..a961051 100644 --- a/openspec/changes/goodwill-table/design.md +++ b/openspec/changes/goodwill-table/design.md @@ -42,7 +42,7 @@ GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениям ### 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 КФО с явной зависимостью на этот контракт. +Подготовка вклада в КФО (применимые пары, изменения 1170/1310/GOODWILL, исключение ББ-2), выбор источников только сохранённых выбранных пар и пометка результата КФО устаревшим в этот change не входят — перенесено в AURORA-1546 (R-3, R-6). Этот change не пишет KfoResult и не трогает Correction. ### 7. XLSX @@ -61,12 +61,3 @@ GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениям Миграция БД не требуется: существующие поля и 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 index e596a3f..25a6b85 100644 --- a/openspec/changes/goodwill-table/proposal.md +++ b/openspec/changes/goodwill-table/proposal.md @@ -11,7 +11,7 @@ - Найденные пары объединяются с сохранёнными `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. Запись КФО не выполняется. +- Подготовка вклада в КФО и исключение ББ-2 — не в этом change, перенесено в AURORA-1546 (R-3). - Уточнения PDF, отсутствующие в §8 (неактивные пары, выборка по дереву, итоги, исключение ББ-2), требуют отдельной синхронизации в docs; при реализации не подменять ими молча старую редакцию правил. ## Capabilities diff --git a/openspec/changes/goodwill-table/specs/goodwill-table/spec.md b/openspec/changes/goodwill-table/specs/goodwill-table/spec.md index 72ba810..3668cf2 100644 --- a/openspec/changes/goodwill-table/specs/goodwill-table/spec.md +++ b/openspec/changes/goodwill-table/specs/goodwill-table/spec.md @@ -81,27 +81,9 @@ GET …/goodwill/export SHALL выгружать XLSX с составом и п - **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 index f6e4cbe..73cf0c5 100644 --- a/openspec/changes/goodwill-table/tasks.md +++ b/openspec/changes/goodwill-table/tasks.md @@ -25,8 +25,8 @@ ## 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. +- [x] 4.2 перенесено в AURORA-1546 (R-3): подготовка вклада в КФО удалена из этого change. +- [x] 4.3 перенесено в AURORA-1546 (R-3). ## 5. Сквозная проверка и передача @@ -36,7 +36,7 @@ ## Workflow follow-up - Отдельный change ../docs синхронизирует архитектуру, доменные правила, открытые вопросы и общий API-контракт; не назначать новые OQ-номера без проверки реестра. -- Следующий change КФО реализует запись результата, устаревание, применение/исключение ББ-2 и фиксацию VERIFIED; AURORA-1543 полностью закрывается после его приёмки. +- Следующий change КФО (AURORA-1546) реализует подготовку вклада, запись результата, устаревание, применение/исключение ББ-2 и фиксацию VERIFIED; AURORA-1543 полностью закрывается после его приёмки. - Фронт AURORA-1544 подключается по согласованному обновлённому контракту, отдельной задачей. ## 6. Исправления по ревью пользователя @@ -47,9 +47,8 @@ ## 7. Выбор источников для КФО -- [x] 7.1 arm-core: отдельный GoodwillSource.load_for_kfo; contributions загружает только goodwill include_in_kfo=true, ОСВ выбранных направленных пар и ББ выбранных ДО. Проверить SQL-отбор и отсутствие полной загрузки при пустом выборе/выключении. -- [x] 7.2 arm-core: обновить описание внутреннего контракта и спецификацию; проверить регрессию таблицы/ОСВ, линтеры и типы. - +- [x] 7.1 перенесено в AURORA-1546 (R-3): `load_for_kfo` и фильтры общего свода удалены. +- [x] 7.2 перенесено в AURORA-1546 (R-3). ## 8. Экспорт по экранной таблице -- 2.47.2 From a086c39a78148b7d8abe219d9714e5513150293e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 18:22:26 +0400 Subject: [PATCH 3/7] =?UTF-8?q?=D0=93=D1=83=D0=B4=D0=B2=D0=B8=D0=BB=D0=BB:?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=BB=D1=8F=20=D0=B4=D0=BE=20=D0=B4=D0=B2=D1=83?= =?UTF-8?q?=D1=85=20=D0=B7=D0=BD=D0=B0=D0=BA=D0=BE=D0=B2,=20=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D0=B8=20=D0=BF=D0=BE=20=D0=93=D0=9E=20=D0=B8=20?= =?UTF-8?q?=D0=A3=D0=9A=20=D1=81=20=D0=B1=D1=8D=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit participation() — единственное место формулы доли участия; totals с УК, group_totals по ГО, GoodwillTableOut.groups; экспорт берёт «Всего» и «Итого по листу» из ответа таблицы; доля 0–100 не больше двух знаков (DOC-259025 §5.2), ответ — два знака. Co-Authored-By: Claude Opus 5.5 --- api/src/domain/goodwill.py | 19 +++++++++-- api/src/schemas/goodwill.py | 11 ++++++- api/src/services/goodwill.py | 20 ++++++++---- api/src/services/goodwill_export.py | 26 ++++++--------- api/tests/test_api_goodwill.py | 14 ++++++++ api/tests/test_api_goodwill_mutations.py | 18 ++++++----- api/tests/test_goodwill_domain.py | 41 +++++++++++++++++++++++- api/tests/test_goodwill_export.py | 12 +++++++ 8 files changed, 126 insertions(+), 35 deletions(-) diff --git a/api/src/domain/goodwill.py b/api/src/domain/goodwill.py index 3d5c226..49b6e3c 100644 --- a/api/src/domain/goodwill.py +++ b/api/src/domain/goodwill.py @@ -1,4 +1,4 @@ -"""Чистый расчёт направленных пар гудвилла; суммы в рублях.""" +"""Чистый расчёт направленных пар гудвилла (§8 «Гудвилл», DOC-259025 §§3, 6); суммы в рублях.""" import uuid from collections import defaultdict @@ -48,6 +48,11 @@ class Row: ) +def participation(capital: Decimal, share: Decimal) -> Decimal: + """Доля участия P = УК × доля / 100, до копеек ROUND_HALF_UP (DOC-259025 §6).""" + return (capital * share / 100).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + def build( investments: Sequence[Investment], decisions: Mapping[Pair, Decision], @@ -73,7 +78,7 @@ def build( 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) + p = participation(uk, decision.ownership_share) g = fv - p if active and shares[subsidiary] > MAX_SHARE: warnings.append("OWNERSHIP_TOTAL_EXCEEDED") @@ -83,11 +88,21 @@ def build( def totals(rows: Sequence[Row], enabled: bool) -> dict[str, Decimal]: + """ФВ и УК — все действующие строки; P и G — действующие отмеченные; в КФО — применимые положительные G.""" 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), + "charter_capital_amount": sum((r.charter_capital_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), } + + +def group_totals(rows: Sequence[Row], enabled: bool) -> dict[uuid.UUID, dict[str, Decimal]]: + """Итоги «Всего» по каждой ГО — те же правила, что у `totals`.""" + groups: dict[uuid.UUID, list[Row]] = defaultdict(list) + for row in rows: + groups[row.head_id].append(row) + return {head: totals(items, enabled) for head, items in groups.items()} diff --git a/api/src/schemas/goodwill.py b/api/src/schemas/goodwill.py index 0859bcd..d55059b 100644 --- a/api/src/schemas/goodwill.py +++ b/api/src/schemas/goodwill.py @@ -10,7 +10,7 @@ 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")] +Share = Annotated[Decimal, PlainSerializer(lambda v: format(v, ".2f"), return_type=str, when_used="json")] GoodwillSign = Literal["all", "positive", "negative", "zero"] @@ -40,17 +40,26 @@ class GoodwillRowOut(BaseModel): class GoodwillTotalsOut(BaseModel): financial_investment_amount: Money + charter_capital_amount: Money participation_amount: Money goodwill_amount: Money goodwill_in_kfo_amount: Money +class GoodwillGroupOut(BaseModel): + """Итог «Всего» блока одной ГО.""" + + head_legal_entity: RefOut + totals: GoodwillTotalsOut + + 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] + groups: list[GoodwillGroupOut] totals: GoodwillTotalsOut diff --git a/api/src/services/goodwill.py b/api/src/services/goodwill.py index b2f6579..cae44d7 100644 --- a/api/src/services/goodwill.py +++ b/api/src/services/goodwill.py @@ -1,7 +1,7 @@ """Таблица и построчные решения гудвилла в транзакции запроса.""" import re -from decimal import ROUND_HALF_UP, Decimal +from decimal import Decimal from uuid import UUID, uuid4 from fastapi.concurrency import run_in_threadpool @@ -22,6 +22,7 @@ from src.repositories import consolidations as versions from src.repositories import goodwill as repo from src.schemas.consolidations import RefOut from src.schemas.goodwill import ( + GoodwillGroupOut, GoodwillRowIn, GoodwillRowOut, GoodwillSign, @@ -46,8 +47,8 @@ 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, до четырёх знаков") + elif not re.fullmatch(r"\d{1,3}(?:\.\d{1,2})?", value) or Decimal(value) > MAX_SHARE: + raise DomainValidationError("INVALID_SHARE", "Доля — число от 0 до 100, не больше двух знаков после запятой") else: share = Decimal(value) if data.include_in_kfo and share is None: @@ -131,12 +132,21 @@ class GoodwillService: r.subsidiary_legal_entity.id, ) ) + groups = domain.group_totals(rows, ctx.version.goodwill_enabled) + heads = sorted(groups, key=lambda head: (entities[head].name, head)) return GoodwillTableOut( version_id=version_id, goodwill_enabled=ctx.version.goodwill_enabled, editable=reason is None, read_only_reason=reason, rows=output, + groups=[ + GoodwillGroupOut( + head_legal_entity=RefOut(id=head, name=entities[head].name), + totals=GoodwillTotalsOut(**groups[head]), + ) + for head in heads + ], totals=GoodwillTotalsOut(**domain.totals(rows, ctx.version.goodwill_enabled)), ) @@ -189,9 +199,7 @@ class GoodwillService: 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 - ) + domain.participation(row.charter_capital_amount, decision.ownership_share) if decision.include_in_kfo and row.charter_capital_amount is not None and decision.ownership_share is not None diff --git a/api/src/services/goodwill_export.py b/api/src/services/goodwill_export.py index 887f531..44ebc12 100644 --- a/api/src/services/goodwill_export.py +++ b/api/src/services/goodwill_export.py @@ -1,4 +1,4 @@ -"""XLSX с колонками и группировкой экранной таблицы гудвилла.""" +"""XLSX с колонками и группировкой экранной таблицы гудвилла; итоги — из ответа таблицы (`groups`, `totals`).""" from collections import defaultdict from decimal import Decimal @@ -6,7 +6,7 @@ from io import BytesIO from openpyxl import Workbook -from src.schemas.goodwill import GoodwillRowOut, GoodwillSign, GoodwillTableOut +from src.schemas.goodwill import GoodwillRowOut, GoodwillSign, GoodwillTableOut, GoodwillTotalsOut from src.services.osv_export import _put_money, _put_text COLUMNS = { @@ -48,12 +48,13 @@ def build_workbook(table: GoodwillTableOut, sign: GoodwillSign = "all") -> bytes else: _put_text(sheet, row, col, value) if key == "ownership_share": - sheet.cell(row, col).number_format = "0.0000" + sheet.cell(row, col).number_format = "0.00" groups: dict = defaultdict(list) for item in table.rows: groups[item.head_legal_entity.id].append(item) - for items in groups.values(): + group_totals = {group.head_legal_entity.id: group.totals for group in table.groups} + for head, items in groups.items(): visible = [item for item in items if matches_sign(item, sign)] if not visible: continue @@ -72,21 +73,12 @@ def build_workbook(table: GoodwillTableOut, sign: GoodwillSign = "all") -> bytes 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) + append({"name": "Всего", **_money(group_totals[head])}) + append({"name": "Итого по листу", **_money(table.totals)}) 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} +def _money(totals: GoodwillTotalsOut) -> dict[str, Decimal]: + return {field: getattr(totals, field) for field in MONEY} diff --git a/api/tests/test_api_goodwill.py b/api/tests/test_api_goodwill.py index 06dfa74..2ee22af 100644 --- a/api/tests/test_api_goodwill.py +++ b/api/tests/test_api_goodwill.py @@ -97,6 +97,20 @@ async def test_table_no_write_and_visibility(db_session, auth_client, fixtures_d assert row["charter_capital_amount"] == "100000000.00" assert row["goodwill_amount"] is None assert row["has_investments"] is True + table = response.json() + assert table["totals"]["charter_capital_amount"] == "100000000.00" + assert table["groups"] == [ + { + "head_legal_entity": {"id": str(w.a.id), "name": w.a.name}, + "totals": { + "financial_investment_amount": "269997300.00", + "charter_capital_amount": "100000000.00", + "participation_amount": "0.00", + "goodwill_amount": "0.00", + "goodwill_in_kfo_amount": "0.00", + }, + } + ] 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 diff --git a/api/tests/test_api_goodwill_mutations.py b/api/tests/test_api_goodwill_mutations.py index c6c23e2..fca4028 100644 --- a/api/tests/test_api_goodwill_mutations.py +++ b/api/tests/test_api_goodwill_mutations.py @@ -19,14 +19,16 @@ 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", ""]) +@pytest.mark.parametrize( + "value", ["-1", "100.01", "100.0001", "80.123", "80,123", "12.3456", "1e2", "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"]) +@pytest.mark.parametrize("value", ["0", "100", "7.5", "80.12", "80,12"]) def test_valid_share(value): result = validate_decision(GoodwillRowIn(ownership_share=value, include_in_kfo=True, comment=" Обоснование ")) assert str(result.ownership_share) == value.replace(",", ".") @@ -51,7 +53,7 @@ async def test_save_settings_and_disappearing_pair(db_session, auth_client, fixt 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="Подтверждено") + body = dict(ownership_share="80.12", 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 @@ -62,15 +64,15 @@ async def test_save_settings_and_disappearing_pair(db_session, auth_client, fixt 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" + assert row["ownership_share"] == "80.12" + assert row["participation_amount"] == "80120000.00" + assert row["goodwill_amount"] == "189877300.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" + assert on.json()["totals"]["goodwill_in_kfo_amount"] == "189877300.00" audit_count = await db_session.scalar( select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.GOODWILL_EDIT) ) @@ -82,7 +84,7 @@ async def test_save_settings_and_disappearing_pair(db_session, auth_client, fixt ) 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" + assert off.json()["rows"][0]["ownership_share"] == "80.12" 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" diff --git a/api/tests/test_goodwill_domain.py b/api/tests/test_goodwill_domain.py index 1a540d3..817836c 100644 --- a/api/tests/test_goodwill_domain.py +++ b/api/tests/test_goodwill_domain.py @@ -6,7 +6,7 @@ from uuid import UUID import pytest -from src.domain.goodwill import Decision, Investment, build, totals +from src.domain.goodwill import Decision, Investment, build, group_totals, participation, totals from tests.test_goodwill_golden import investment_rows pytestmark = pytest.mark.fixtures @@ -50,3 +50,42 @@ def test_rounding_and_unselected(fixtures_dir: Path) -> None: assert selected.participation_amount == Decimal("0.01") assert totals([row], True)["goodwill_amount"] == 0 assert totals([row], True)["financial_investment_amount"] == fv + + +def test_participation_rounds_half_up() -> None: + assert participation(Decimal("371.17"), Decimal("99.99")) == Decimal("371.13") + assert participation(Decimal("0.05"), Decimal("50")) == Decimal("0.03") + assert participation(Decimal("100000"), Decimal("0")) == Decimal("0.00") + + +def test_doc_example_totals() -> None: + """DOC-259025 §11.1: Альфа → Бета (+100 000), Гамма (−30 000), Дельта без доли; плюс пара «Нет вложений».""" + alpha, beta, gamma, delta, gone = (UUID(int=i) for i in (10, 11, 12, 13, 14)) + source = [ + Investment(alpha, beta, Decimal("180000.00")), + Investment(alpha, gamma, Decimal("50000.00")), + Investment(alpha, delta, Decimal("60000.00")), + ] + saved = { + (alpha, beta): Decision(Decimal("80"), True, "Устав"), + (alpha, gamma): Decision(Decimal("80"), True, "Устав"), + (alpha, gone): Decision(Decimal("50"), True, "Было"), + } + capital = {beta: Decimal("100000.00"), gamma: Decimal("100000.00"), delta: Decimal("60000.00")} + rows = {r.subsidiary_id: r for r in build(source, saved, capital)} + assert rows[beta].participation_amount == Decimal("80000.00") + assert rows[beta].goodwill_amount == Decimal("100000.00") + assert rows[gamma].participation_amount == Decimal("80000.00") + assert rows[gamma].goodwill_amount == Decimal("-30000.00") + assert rows[delta].participation_amount is None and rows[delta].goodwill_amount is None + assert not rows[gone].has_investments + expected = { + "financial_investment_amount": Decimal("290000.00"), + "charter_capital_amount": Decimal("260000.00"), + "participation_amount": Decimal("160000.00"), + "goodwill_amount": Decimal("70000.00"), + "goodwill_in_kfo_amount": Decimal("100000.00"), + } + assert totals(list(rows.values()), True) == expected + assert totals(list(rows.values()), False)["goodwill_in_kfo_amount"] == 0 + assert group_totals(list(rows.values()), True) == {alpha: expected} diff --git a/api/tests/test_goodwill_export.py b/api/tests/test_goodwill_export.py index b7e40bf..54529b3 100644 --- a/api/tests/test_goodwill_export.py +++ b/api/tests/test_goodwill_export.py @@ -76,3 +76,15 @@ async def test_export_matches_server_table(db_session, auth_client, fixtures_dir assert sheet.cell(3, 7).value is None assert sheet.cell(5, 4).value == 0 assert sheet.cell(5, 7).value == 0 + + +async def test_export_totals_come_from_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}" + table = (await client.get(base + "/goodwill")).json() + sheet = load_workbook(BytesIO((await client.get(base + "/goodwill/export")).content)).active + assert sheet.cell(4, 2).value == "Всего" + assert sheet.cell(4, 6).value == float(table["groups"][0]["totals"]["charter_capital_amount"]) + assert sheet.cell(5, 2).value == "Итого по листу" + assert sheet.cell(5, 6).value == float(table["totals"]["charter_capital_amount"]) -- 2.47.2 From 46fba8875be0d3f89eacb30d3afe15be4b97335b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 18:24:49 +0400 Subject: [PATCH 4/7] =?UTF-8?q?=D0=93=D1=83=D0=B4=D0=B2=D0=B8=D0=BB=D0=BB:?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=BD=D0=BE=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20=D0=BE=D0=B4=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT …/goodwill {rows: [...]} вместо построчного PUT …/goodwill/{head}/{sub}: все строки проверяются до записи, ошибка любой (NOT_FOUND, INVALID_SHARE, COMMENT_REQUIRED, MONEY_OUT_OF_RANGE, DUPLICATE_ROW) несёт ключ пары и откатывает пакет; неизменённые строки не перезаписываются и аудит не пишут (DOC-259025 §5.1 п. 4). Co-Authored-By: Claude Opus 5.5 --- .../api/endpoints/consolidations/goodwill.py | 27 +-- api/src/schemas/goodwill.py | 14 ++ api/src/services/goodwill.py | 199 +++++++++++------- api/tests/test_api_goodwill.py | 11 +- api/tests/test_api_goodwill_mutations.py | 107 ++++++++-- api/tests/test_goodwill_export.py | 7 +- api/tests/test_openapi_routes.py | 5 +- 7 files changed, 248 insertions(+), 122 deletions(-) diff --git a/api/src/api/endpoints/consolidations/goodwill.py b/api/src/api/endpoints/consolidations/goodwill.py index c2537f7..4a53a8a 100644 --- a/api/src/api/endpoints/consolidations/goodwill.py +++ b/api/src/api/endpoints/consolidations/goodwill.py @@ -10,8 +10,7 @@ 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, + GoodwillBatchIn, GoodwillSettingsIn, GoodwillSign, GoodwillTableOut, @@ -28,6 +27,14 @@ async def table(version_id: UUID, service: GoodwillServiceDep) -> GoodwillTableO return await service.table(version_id) +@router.put("/{version_id}/goodwill", response_model=GoodwillTableOut, dependencies=EDIT) +async def save( + version_id: UUID, data: GoodwillBatchIn, service: GoodwillServiceDep, audit: AuditDep +) -> GoodwillTableOut: + """Пакет строк одной транзакцией; ошибка любой строки — 404/422 с её ключом, ничего не сохраняется.""" + return await service.save_rows(version_id, data.rows, audit) + + @router.put("/{version_id}/goodwill-settings", response_model=GoodwillTableOut, dependencies=EDIT) async def settings( version_id: UUID, data: GoodwillSettingsIn, service: GoodwillServiceDep, audit: AuditDep @@ -64,19 +71,3 @@ async def export( 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/schemas/goodwill.py b/api/src/schemas/goodwill.py index d55059b..8410669 100644 --- a/api/src/schemas/goodwill.py +++ b/api/src/schemas/goodwill.py @@ -70,6 +70,20 @@ class GoodwillRowIn(BaseModel): comment: str | None = Field(default=None, max_length=1000) +class GoodwillRowKeyIn(GoodwillRowIn): + """Строка пакетного сохранения: пара ГО → ДО и ручной ввод.""" + + head_legal_entity_id: UUID + subsidiary_legal_entity_id: UUID + + +class GoodwillBatchIn(BaseModel): + """Пакет строк таблицы гудвилла; сохраняется целиком или не сохраняется.""" + + model_config = ConfigDict(extra="forbid") + rows: list[GoodwillRowKeyIn] = Field(min_length=1) + + class GoodwillSettingsIn(BaseModel): model_config = ConfigDict(extra="forbid") goodwill_enabled: bool diff --git a/api/src/services/goodwill.py b/api/src/services/goodwill.py index cae44d7..7cfef8d 100644 --- a/api/src/services/goodwill.py +++ b/api/src/services/goodwill.py @@ -1,7 +1,8 @@ -"""Таблица и построчные решения гудвилла в транзакции запроса.""" +"""Таблица гудвилла, пакетное сохранение решений и переключатель — в транзакции запроса (§8, DOC-259025).""" import re from decimal import Decimal +from http import HTTPStatus from uuid import UUID, uuid4 from fastapi.concurrency import run_in_threadpool @@ -11,7 +12,7 @@ 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.common.exceptions import ConflictError, DomainError, DomainValidationError from src.domain import goodwill as domain from src.domain.policy import VisibilityScope, ensure_version_editable from src.models.access import AppUser @@ -24,6 +25,7 @@ from src.schemas.consolidations import RefOut from src.schemas.goodwill import ( GoodwillGroupOut, GoodwillRowIn, + GoodwillRowKeyIn, GoodwillRowOut, GoodwillSign, GoodwillTableOut, @@ -150,87 +152,80 @@ class GoodwillService: 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: + async def save_rows(self, version_id: UUID, rows: list[GoodwillRowKeyIn], audit: RequestAudit) -> GoodwillTableOut: + """Пакет строк одной транзакцией (DOC-259025 §5.1 п. 4): любая ошибка — откат всего пакета. + + Сначала проверяются все строки, потом пишутся только изменённые; аудит `GOODWILL_EDIT` — по каждой изменённой. + """ await lock_editable_version(self.session, self.scope, version_id, self.principal.user.id) table = await self.table(version_id) - 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, - } + current = {(r.head_legal_entity.id, r.subsidiary_legal_entity.id): r for r in table.rows} + saved = { + (r.head_legal_entity_id, r.subsidiary_legal_entity_id): r + for r in await repo.saved(self.session, version_id) + } + _check_duplicates(rows) + planned: list[tuple[GoodwillRowOut, domain.Decision]] = [] + for item in rows: + key = item.head_legal_entity_id, item.subsidiary_legal_entity_id + row = current.get(key) + if row is None: + raise DomainError(HTTPStatus.NOT_FOUND, "NOT_FOUND", "Пара не найдена", extra=_key_extra(key)) + decision = _decision(row, item, key) + entity = saved.get(key) + previous = ( + domain.Decision(entity.ownership_share, entity.include_in_kfo, entity.comment) + if entity + else domain.Decision() ) - 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 + if decision != previous: + planned.append((row, decision)) 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, + written: list[tuple[Goodwill, dict | None]] = [] + for row, decision in planned: + key = row.head_legal_entity.id, row.subsidiary_legal_entity.id + entity = saved.get(key) + before = row.model_dump(mode="json") if entity else None + if entity is None: + entity = Goodwill( + id=uuid4(), + consolidation_version_id=version_id, + head_legal_entity_id=key[0], + subsidiary_legal_entity_id=key[1], + created_by=self.principal.user.id, + created_at=now, + ) + self.session.add(entity) + entity.ownership_share, entity.include_in_kfo, entity.comment = ( + decision.ownership_share, + decision.include_in_kfo, + decision.comment, ) - 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 = ( - domain.participation(row.charter_capital_amount, decision.ownership_share) - if decision.include_in_kfo - and row.charter_capital_amount is not None - and decision.ownership_share is not None - else None - ) - 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) + entity.updated_by, entity.updated_at = self.principal.user.id, now + if row.has_investments: + for name, amount in _amounts(row, decision).items(): + if amount is not None and abs(amount) >= MONEY_LIMIT: + raise DomainValidationError( + "MONEY_OUT_OF_RANGE", "Сумма не помещается в numeric(18,2)", **_key_extra(key) + ) + setattr(entity, name, amount) + written.append((entity, before)) + if not written: + return table await self.session.flush() - result = 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"), - ) + result = await self.table(version_id) + after = {(r.head_legal_entity.id, r.subsidiary_legal_entity.id): r for r in result.rows} + for entity, before in written: + key = entity.head_legal_entity_id, entity.subsidiary_legal_entity_id + await audit.record( + action=AuditAction.GOODWILL_EDIT, + section=AuditSection.CONSOLIDATION, + object_type="goodwill", + object_id=entity.id, + consolidation_version_id=version_id, + before_value=before, + after_value=after[key].model_dump(mode="json"), + ) return result async def settings(self, version_id: UUID, enabled: bool, audit: RequestAudit) -> GoodwillTableOut: @@ -264,3 +259,53 @@ class GoodwillService: after_value={"report": "goodwill", "rows": sum(matches_sign(row, sign) for row in table.rows)}, ) return content + + +MONEY_LIMIT = Decimal("10000000000000000") + + +def _key_extra(key: tuple[UUID, UUID]) -> dict[str, str]: + return {"head_legal_entity_id": str(key[0]), "subsidiary_legal_entity_id": str(key[1])} + + +def _check_duplicates(rows: list[GoodwillRowKeyIn]) -> None: + seen: set[tuple[UUID, UUID]] = set() + for item in rows: + key = item.head_legal_entity_id, item.subsidiary_legal_entity_id + if key in seen: + raise DomainValidationError("DUPLICATE_ROW", "Пара встречается в пакете дважды", **_key_extra(key)) + seen.add(key) + + +def _decision(row: GoodwillRowOut, item: GoodwillRowKeyIn, key: tuple[UUID, UUID]) -> domain.Decision: + """Проверенный ввод строки; ошибка несёт ключ пары.""" + share, comment = item.ownership_share, item.comment + if not row.has_investments: + # DOC-259025 §10 п. 9: отсутствующий ввод не затирает сохранённые данные неактивной пары. + if share is None and row.ownership_share is not None: + share = str(row.ownership_share) + if comment is None: + comment = row.comment + try: + return validate_decision( + GoodwillRowIn(ownership_share=share, include_in_kfo=item.include_in_kfo, comment=comment) + ) + except DomainValidationError as error: + raise DomainValidationError(error.code, error.message, **_key_extra(key)) from error + + +def _amounts(row: GoodwillRowOut, decision: domain.Decision) -> dict[str, Decimal | None]: + """Снимок сумм действующей строки при сохранении (поля `goodwill` DBML).""" + assert row.financial_investment_amount is not None + p = ( + domain.participation(row.charter_capital_amount, decision.ownership_share) + if decision.include_in_kfo and row.charter_capital_amount is not None and decision.ownership_share is not None + else None + ) + return { + "financial_investment_amount": row.financial_investment_amount, + "charter_capital_amount": row.charter_capital_amount, + "additional_capital_amount": row.additional_capital_amount, + "participation_amount": p, + "goodwill_amount": row.financial_investment_amount - p if p is not None else None, + } diff --git a/api/tests/test_api_goodwill.py b/api/tests/test_api_goodwill.py index 2ee22af..943d07d 100644 --- a/api/tests/test_api_goodwill.py +++ b/api/tests/test_api_goodwill.py @@ -39,6 +39,11 @@ HTTP_CREATED = 201 pytestmark = [pytest.mark.integration, pytest.mark.fixtures] +def batch(head, subsidiary, **body) -> dict: + """Тело пакетного сохранения из одной строки.""" + return {"rows": [{"head_legal_entity_id": str(head), "subsidiary_legal_entity_id": str(subsidiary), **body}]} + + async def goodwill_world(session, fixtures_dir: Path): w = await _world(session) raw = investment_rows(fixtures_dir)[0] @@ -344,11 +349,11 @@ async def test_null_closing_debit_does_not_block_pair_calculation(db_session, au 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="Подтверждено"), + base + "/goodwill", + json=batch(world.a.id, world.b.id, ownership_share="100", include_in_kfo=True, comment="Подтверждено"), ) assert response.status_code == HTTP_OK, response.text - row = response.json() + row = response.json()["rows"][0] assert row["financial_investment_amount"] == "269997300.00" assert row["participation_amount"] == "100000000.00" assert row["goodwill_amount"] == "169997300.00" diff --git a/api/tests/test_api_goodwill_mutations.py b/api/tests/test_api_goodwill_mutations.py index fca4028..85e5638 100644 --- a/api/tests/test_api_goodwill_mutations.py +++ b/api/tests/test_api_goodwill_mutations.py @@ -16,7 +16,7 @@ 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 +from tests.test_api_goodwill import batch, goodwill_world @pytest.mark.parametrize( @@ -52,22 +52,22 @@ async def test_save_settings_and_disappearing_pair(db_session, auth_client, fixt 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}" + path = base + "/goodwill" body = dict(ownership_share="80.12", include_in_kfo=True, comment="Подтверждено") - response = await client.put(path, json=body) + response = await client.put(path, json=batch(w.a.id, w.b.id, **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) + rejected = await other.put(path, json=batch(w.a.id, w.b.id, **body)) assert rejected.status_code == HTTPStatus.CONFLICT assert rejected.json()["detail"]["code"] == "LOCKED_BY_OTHER" - response = await client.put(path, json=body) + response = await client.put(path, json=batch(w.a.id, w.b.id, **body)) assert response.status_code == HTTPStatus.OK, response.text - row = response.json() + row = response.json()["rows"][0] assert row["ownership_share"] == "80.12" assert row["participation_amount"] == "80120000.00" assert row["goodwill_amount"] == "189877300.00" - again = await client.put(path, json=body) + again = await client.put(path, json=batch(w.a.id, w.b.id, **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)) @@ -86,7 +86,7 @@ async def test_save_settings_and_disappearing_pair(db_session, auth_client, fixt assert off.json()["totals"]["goodwill_in_kfo_amount"] == "0.00" assert off.json()["rows"][0]["ownership_share"] == "80.12" body["include_in_kfo"] = False - assert (await client.put(path, json=body)).status_code == HTTPStatus.OK + assert (await client.put(path, json=batch(w.a.id, w.b.id, **body))).status_code == HTTPStatus.OK assert (await client.get(base + "/goodwill")).json()["totals"]["goodwill_amount"] == "0.00" entries = list( ( @@ -101,12 +101,12 @@ async def test_save_settings_and_disappearing_pair(db_session, auth_client, fixt 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 + assert (await client.put(path, json=batch(w.a.id, w.b.id, **body))).status_code == HTTPStatus.OK body["include_in_kfo"] = True - response = await client.put(path, json=body) + response = await client.put(path, json=batch(w.a.id, w.b.id, **body)) assert response.status_code == HTTPStatus.OK - assert response.json()["financial_investment_amount"] is None - assert response.json()["goodwill_amount"] is None + assert response.json()["rows"][0]["financial_investment_amount"] is None + assert response.json()["rows"][0]["goodwill_amount"] is None assert await db_session.scalar(select(func.count()).select_from(KfoResult)) == 0 @@ -117,16 +117,16 @@ async def test_mutation_validation_and_rollback(db_session, auth_client, fixture 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}" + path = base + "/goodwill" 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) + response = await client.put(path, json=batch(w.a.id, w.b.id, **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 client.put(path, json=batch(w.a.id, w.b.id, **body))).json()["detail"]["code"] == "INVALID_SHARE" assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0 async def reject_audit(*args, **kwargs): @@ -134,7 +134,7 @@ async def test_mutation_validation_and_rollback(db_session, auth_client, fixture monkeypatch.setattr(RequestAudit, "record", reject_audit) body["ownership_share"] = "100" - response = await client.put(path, json=body) + response = await client.put(path, json=batch(w.a.id, w.b.id, **body)) assert response.status_code == HTTPStatus.CONFLICT assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0 @@ -150,8 +150,81 @@ async def test_money_overflow_keeps_database_unchanged(db_session, auth_client, 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="Проверка") + base + "/goodwill", json=batch(w.a.id, w.b.id, 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 + + +async def _locked_world(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 + return w, client, base + + +async def _goodwill_audit_count(db_session) -> int: + return await db_session.scalar( + select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.GOODWILL_EDIT) + ) + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_batch_rolls_back_when_one_row_invalid(db_session, auth_client, fixtures_dir): + w, client, base = await _locked_world(db_session, auth_client, fixtures_dir) + valid = batch(w.a.id, w.b.id, ownership_share="100", include_in_kfo=True, comment="Да")["rows"][0] + invalid = batch(w.b.id, w.a.id, ownership_share="50", include_in_kfo=True, comment=" ")["rows"][0] + # обратная пара Б → А в таблице не существует: сперва проверяем порядок ошибок на существующей паре + response = await client.put(base + "/goodwill", json={"rows": [valid, {**valid, "comment": " "}]}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "DUPLICATE_ROW" + response = await client.put(base + "/goodwill", json={"rows": [valid, invalid]}) + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["detail"]["subsidiary_legal_entity_id"] == str(w.a.id) + assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0 + assert await _goodwill_audit_count(db_session) == 0 + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_batch_reports_invalid_row_key(db_session, auth_client, fixtures_dir): + w, client, base = await _locked_world(db_session, auth_client, fixtures_dir) + body = batch(w.a.id, w.b.id, ownership_share="100", include_in_kfo=True, comment=" ") + response = await client.put(base + "/goodwill", json=body) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + detail = response.json()["detail"] + assert detail["code"] == "COMMENT_REQUIRED" + assert (detail["head_legal_entity_id"], detail["subsidiary_legal_entity_id"]) == (str(w.a.id), str(w.b.id)) + assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0 + assert await _goodwill_audit_count(db_session) == 0 + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_unchanged_rows_are_not_rewritten(db_session, auth_client, fixtures_dir): + w, client, base = await _locked_world(db_session, auth_client, fixtures_dir) + body = batch(w.a.id, w.b.id, ownership_share="80,5", include_in_kfo=True, comment="Устав") + first = await client.put(base + "/goodwill", json=body) + assert first.status_code == HTTPStatus.OK, first.text + saved_at = first.json()["rows"][0]["updated_at"] + again = await client.put( + base + "/goodwill", json=batch(w.a.id, w.b.id, ownership_share="80.50", include_in_kfo=True, comment="Устав") + ) + assert again.status_code == HTTPStatus.OK + assert again.json()["rows"][0]["updated_at"] == saved_at + assert await _goodwill_audit_count(db_session) == 1 + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_new_row_with_defaults_is_not_created(db_session, auth_client, fixtures_dir): + w, client, base = await _locked_world(db_session, auth_client, fixtures_dir) + body = batch(w.a.id, w.b.id, ownership_share=None, include_in_kfo=False, comment=None) + response = await client.put(base + "/goodwill", json=body) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["rows"][0]["include_in_kfo"] is False + assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0 + assert await _goodwill_audit_count(db_session) == 0 diff --git a/api/tests/test_goodwill_export.py b/api/tests/test_goodwill_export.py index 54529b3..72def3f 100644 --- a/api/tests/test_goodwill_export.py +++ b/api/tests/test_goodwill_export.py @@ -11,7 +11,7 @@ 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 +from tests.test_api_goodwill import batch, goodwill_world INVESTMENT_AMOUNT = 269997300 FRONTEND_COLUMN_COUNT = 8 @@ -51,8 +51,9 @@ async def test_export_matches_server_table(db_session, auth_client, fixtures_dir 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 + assert ( + await client.put(base + "/goodwill", json=batch(world.a.id, world.b.id, **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" diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 053e62c..5114654 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -83,11 +83,8 @@ 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": {"get", "put"}, "/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"}, -- 2.47.2 From eeb0ce48da01d52a28abcc840d7bb1ea1712a2b1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 18:26:17 +0400 Subject: [PATCH 5/7] =?UTF-8?q?=D0=93=D1=83=D0=B4=D0=B2=D0=B8=D0=BB=D0=BB:?= =?UTF-8?q?=20=D0=B5=D0=B4=D0=B8=D0=BD=D0=B8=D1=86=D1=8B=20=D0=91=D0=91=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8E=20arm?= =?UTF-8?q?=5Fstatements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StatementsEngine.canonical_units — нормализация подписи единиц шаблонами пакета; УК ДО переводится в рубли и при единицах, исправленных аналитиком вручную. Co-Authored-By: Claude Opus 5.5 --- api/src/services/goodwill_source.py | 5 ++++- api/src/services/statements_engine.py | 9 +++++++++ api/tests/fake_engine.py | 3 +++ api/tests/test_goodwill_source.py | 26 ++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/api/src/services/goodwill_source.py b/api/src/services/goodwill_source.py index f5f2b20..762eaeb 100644 --- a/api/src/services/goodwill_source.py +++ b/api/src/services/goodwill_source.py @@ -13,6 +13,9 @@ from src.domain.goodwill import Investment 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 +from src.services.statements_engine import default_engine + +UNIT_FACTORS: dict[str | None, Decimal] = {"руб.": Decimal(1), "тыс. руб.": Decimal(1000), "млн руб.": Decimal(1000000)} @dataclass @@ -26,7 +29,7 @@ class Sources: 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) + factor = UNIT_FACTORS.get(default_engine().canonical_units(units)) if len(selected) != 1 or factor is None: return None, None lines = {} diff --git a/api/src/services/statements_engine.py b/api/src/services/statements_engine.py index 0ca01bd..7ca839a 100644 --- a/api/src/services/statements_engine.py +++ b/api/src/services/statements_engine.py @@ -29,6 +29,7 @@ from arm_statements import ( statements_version, status_input_from_contract, ) +from arm_statements.text import normalize_caption __all__ = [ "ArmStatementsEngine", @@ -62,6 +63,7 @@ class StatementsEngine(Protocol): def editions(self) -> tuple[Edition, ...]: ... def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: ... def tolerance(self) -> Decimal: ... + def canonical_units(self, text: str | None) -> str | None: ... class ArmStatementsEngine: @@ -105,6 +107,13 @@ class ArmStatementsEngine: def tolerance(self) -> Decimal: return self._rules.checks.tolerance + def canonical_units(self, text: str | None) -> str | None: + """Единица формы по словарю пакета («тыс.руб.», «в тысячах рублей» → «тыс. руб.»); не узнали — None.""" + if text is None: + return None + caption = normalize_caption(text) + return next((canonical for pattern, canonical in self._rules.units.spellings if pattern.search(caption)), None) + @functools.cache def default_engine() -> StatementsEngine: diff --git a/api/tests/fake_engine.py b/api/tests/fake_engine.py index 84fa0fd..3e840de 100644 --- a/api/tests/fake_engine.py +++ b/api/tests/fake_engine.py @@ -58,6 +58,9 @@ class FakeEngine: def version(self) -> str | None: return self._version + def canonical_units(self, text: str | None) -> str | None: + return text + def recompute(self, form: StoredForm) -> RecomputeResult: self.forms.append(form) return self.result diff --git a/api/tests/test_goodwill_source.py b/api/tests/test_goodwill_source.py index 1fa7bc0..b72bb9e 100644 --- a/api/tests/test_goodwill_source.py +++ b/api/tests/test_goodwill_source.py @@ -57,3 +57,29 @@ def test_capital_aggregates_duplicates_at_period_end() -> None: 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) + + +@pytest.mark.parametrize( + ("units", "factor"), + [ + ("тыс. руб.", Decimal(1000)), + ("тыс.руб.", Decimal(1000)), + ("в тысячах рублей", Decimal(1000)), + ("руб", Decimal(1)), + ("млн. руб.", Decimal(1000000)), + ], +) +def test_capital_units_spellings(units: str, factor: Decimal) -> None: + """Единицы, исправленные аналитиком вручную, читаются словарём единиц arm_statements.""" + mid, col = UUID(int=1), UUID(int=2) + columns = [N(id=col, meta_id=mid, period_date=dt.date(2025, 9, 30), is_current=True)] + line = N(id=UUID(int=4), meta_id=mid, line_code="1310", row_index=0, line_status=StatementLineStatus.ok) + values = [(line, col, Decimal("7"))] + assert capital_amounts(mid, units, columns, values, dt.date(2025, 9, 30)) == (Decimal("7") * factor, None) + + +def test_capital_unknown_units() -> 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=True)] + line = N(id=UUID(int=4), meta_id=mid, line_code="1310", row_index=0, line_status=StatementLineStatus.ok) + assert capital_amounts(mid, "штук", columns, [(line, col, Decimal("7"))], dt.date(2025, 9, 30)) == (None, None) -- 2.47.2 From 3bfcc2e2aa566092ec161c6303d60615929ef69f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 18:41:20 +0400 Subject: [PATCH 6/7] =?UTF-8?q?=D0=93=D1=83=D0=B4=D0=B2=D0=B8=D0=BB=D0=BB:?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20=D0=B2=20?= =?UTF-8?q?docs,=20README,=20OpenSpec=20=D0=BF=D0=BE=D0=B4=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тест контракта читает ../docs/dev/api-contract-goodwill.openapi.yaml (без docs — skip); README: раздел гудвилла и коды INVALID_SHARE/DUPLICATE_ROW/MONEY_OUT_OF_RANGE; OpenSpec goodwill-table: пакетное сохранение, доля два знака, итоги по ГО и УК, единицы, Decisions made without discussion R-1…R-6. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 +++++++---- api/tests/test_goodwill_contract.py | 21 ++++++++----- openspec/changes/goodwill-table/design.md | 30 +++++++++++++++++-- openspec/changes/goodwill-table/proposal.md | 6 ++-- .../specs/goodwill-table/spec.md | 28 +++++++++++++---- openspec/changes/goodwill-table/tasks.md | 9 ++++++ 6 files changed, 87 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index fbe094a..96ec2ee 100644 --- a/README.md +++ b/README.md @@ -1057,7 +1057,10 @@ email без учёта регистра проверяет только сер | `RESULT_ACCOUNTS_MISSING` | 422 | результирующая таблица ВГО: в плане счетов нет обязательных кодов статей | | `INVALID_COMPANY_PAIR` | 422 | пары ВГО тройки: компании совпадают или не входят в периметр версии | | `ACCOUNTS_NOT_ALLOWED` / `INVALID_MODE` | 422 | защита внутреннего вызова сервиса шахматки; маршруты ALL/MANUAL не дают вызвать эти ветки | -| `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) | +| `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже); `PUT …/goodwill` — отмеченная строка без комментария (с ключом пары) | +| `INVALID_SHARE` | 422 | `PUT …/goodwill`: доля не 0–100, больше двух знаков после запятой или пустая у отмеченной строки (`head_legal_entity_id`, `subsidiary_legal_entity_id`) | +| `DUPLICATE_ROW` | 422 | `PUT …/goodwill`: пара дважды в одном пакете (ключ пары) | +| `MONEY_OUT_OF_RANGE` | 422 | `PUT …/goodwill`: сумма снимка строки не помещается в `numeric(18,2)` (ключ пары) | | `VERSION_NOT_EDITABLE` | 409 | версия не «в работе» (правки) либо `CANCELLED` при «взять в работу» | | `LOCK_REQUIRED` | 409 | правка версии без своей активной блокировки | | `LOCKED_BY_OTHER` | 409 | версию держит другой пользователь: из `POST …/lock` — `locked_by`, `locked_by_name`, `locked_at`; из правок (`ensure_version_editable`) — только `locked_by`, `locked_at`; гонка двух взятий (частичный UNIQUE) — то же 409 | @@ -1343,7 +1346,11 @@ Dockerfile только для локального compose ### Таблица гудвилла -Бэкенд таблицы гудвилла версии: фактические вложения 58.01 между участниками периметра, построчные решения, -переключатель и XLSX. `is_head_entity` не используется. Деньги в рублях, доля до четырёх знаков, нерассчитанные -значения — `null`; исчезнувшие сохранённые пары остаются как «Нет вложений». -Контракт и ограничения этапа — [контракт API](goodwill/api-contract-goodwill.md). Фронт и построение/фиксация результата КФО — отдельные задачи. +Бэкенд таблицы гудвилла версии (§8 «Гудвилл», DOC-259025): пары ГО → ДО по фактическим вложениям 58.01 и потомков +между участниками периметра (`is_head_entity` не используется), УК — 1310 ББ ДО на конец квартала (единицы — словарём +`arm_statements`). `GET …/goodwill` — строки, итоги по ГО (`groups`) и по листу (`totals`); `PUT …/goodwill` — пакет +строк одной транзакцией (ошибка любой строки откатывает пакет и несёт её ключ, неизменённые строки не перезаписываются); +`PUT …/goodwill-settings` — переключатель `goodwill_enabled`; `GET …/goodwill/export?sign=` — xlsx. Деньги в рублях, +доля — до двух знаков, нерассчитанные значения — `null`; исчезнувшие сохранённые пары остаются как «Нет вложений». +Контракт — [`../docs/dev/api-contract-goodwill.md`](../docs/dev/api-contract-goodwill.md). Применение гудвилла в КФО — +AURORA-1546. diff --git a/api/tests/test_goodwill_contract.py b/api/tests/test_goodwill_contract.py index 6120c45..662896d 100644 --- a/api/tests/test_goodwill_contract.py +++ b/api/tests/test_goodwill_contract.py @@ -1,22 +1,29 @@ -"""Локальный OpenAPI совпадает с четырьмя маршрутами приложения.""" +"""Контракт гудвилла в docs (`dev/api-contract-goodwill.openapi.yaml`) совпадает с маршрутами и схемами приложения.""" +from decimal import Decimal from pathlib import Path +import pytest import yaml from src.app import app from src.schemas.goodwill import GoodwillRowIn from src.services.goodwill import validate_decision +CONTRACT = Path(__file__).resolve().parents[3] / "docs/dev/api-contract-goodwill.openapi.yaml" + def test_contract_matches_application(): - contract = yaml.safe_load( - (Path(__file__).resolve().parents[2] / "goodwill/api-contract-goodwill.openapi.yaml").read_text() - ) + if not CONTRACT.exists(): + pytest.skip("нет ../docs: контракт гудвилла не проверен") + contract = yaml.safe_load(CONTRACT.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" + assert schema == actual["components"]["schemas"][name], name + + +def test_share_with_comma(): + data = GoodwillRowIn(ownership_share="80,12", include_in_kfo=True, comment="Доля подтверждена") + assert validate_decision(data).ownership_share == Decimal("80.12") diff --git a/openspec/changes/goodwill-table/design.md b/openspec/changes/goodwill-table/design.md index a961051..2ae7676 100644 --- a/openspec/changes/goodwill-table/design.md +++ b/openspec/changes/goodwill-table/design.md @@ -14,7 +14,7 @@ ### 1. Приоритет требований -Явные решения пользователя имеют приоритет над PDF, затем PDF, затем MD/OpenAPI. Четыре знака доли, построчное PUT и XLSX намеренно отличаются от PDF. Сохраняем четыре пути MD. is_head_entity полностью исключается. ASSUMPTION(OQ-10): источник УК — только ББ 1310; 1350 можно показывать справочно, но не включать в P. ASSUMPTION(OQ-37): вклад использует GOODWILL. Подтверждение другого источника УК или формата экспорта требует обновления этого change перед соответствующей реализацией. +Явные решения пользователя имеют приоритет над PDF, затем PDF, затем MD/OpenAPI. XLSX вместо CSV намеренно отличается от PDF (по контракту). Доля — два знака, сохранение — пакетом (R-1, R-2). is_head_entity полностью исключается. ASSUMPTION(OQ-10): источник УК — только ББ 1310; 1350 можно показывать справочно, но не включать в P. ASSUMPTION(OQ-37): вклад использует GOODWILL. Подтверждение другого источника УК или формата экспорта требует обновления этого change перед соответствующей реализацией. ### 2. Слои и источники @@ -32,13 +32,13 @@ has_investments — вычисляемый DTO-флаг, не колонка. П ### 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. +Decimal, P.quantize(0.01, ROUND_HALF_UP), затем G=ФВ−P. Ограничение numeric(18,2) проверять до записи, переполнение отклонять 422 MONEY_OUT_OF_RANGE без частичного сохранения; float не использовать. Значения в API — рубли с двумя знаками, ownership_share — проценты с двумя (R-1). Максимум два дробных знака во входе, без автоматического округления; запятая и точка допустимы по 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 не нужны. Отдельная оптимистическая ревизия не добавляется: существующая блокировка версии сериализует операции, последнее сохранение владельца побеждает. +Маршруты гудвилла используют зависимости по образцу 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 не нужны. PUT …/goodwill принимает набор строк и сохраняет его одной транзакцией (R-2): сначала проверяются все строки, потом пишутся изменённые. Отдельная оптимистическая ревизия не добавляется: существующая блокировка версии сериализует операции, последнее сохранение владельца побеждает. ### 6. Граница КФО @@ -61,3 +61,27 @@ GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениям Миграция БД не требуется: существующие поля и enum достаточны. Выкладка добавляет маршруты и обновлённый контракт; перед подключением фронта проверить nullable поля. Откат кода оставляет goodwill и аудит в совместимой схеме. Пока фронт отдельно, существующие страницы не изменяются. По решению пользователя ФВ пары — сумма только заполненных closing_debit; пустые значения остальных строк не блокируют расчёт P/G и сохранение ручного ввода. + +## Decisions made without discussion + +Ревью arm-core #222 (06.11.2026, владелец: «давай всё сделаем сами»; варианты предложены владельцу, ответа по ним нет — +взяты рекомендованные). + +- **R-1 (2026-11-06). Доля владения — не больше двух знаков после запятой.** Обоснование: DOC-259025 §4, §5.2, §9 + («максимум два знака»), фронт вводит два. DBML `numeric(7,4)` не меняется — хранение шире ввода. Цена ошибки: если + методологам нужны 4 знака — расширить регулярку `validate_decision` и сериализацию `Share`, данные не теряются. +- **R-2 (2026-11-06). Построчный `PUT …/goodwill/{head}/{sub}` заменён пакетным `PUT …/goodwill`.** Обоснование: + DOC-259025 §5.1 п. 4 — набор сохраняется целиком или не сохраняется; фронт слал строки `Promise.all` и мог сохранить + часть. Цена ошибки: если понадобится построчное — пакет из одной строки уже его покрывает. +- **R-3 (2026-11-06). Подготовка вклада в КФО (`contributions`, `load_for_kfo`, фильтры общего свода ОСВ) удалена, + перенесена в AURORA-1546.** Обоснование: код не вызывался, а менял общие `load_osv_rows`/`build_with_members`. + Цена ошибки: в 1546 написать заново по DOC-259025 §7, §10 (было ~150 строк с тестами, история — коммит 94895b6). +- **R-4 (2026-11-06). Итоги «Всего» по ГО (`groups`) и итог УК считает бэк.** Обоснование: агрегаты отдаёт бэк + (правило владельца), фронт считал их через `Number`; DOC-259025 §6 требует итоги ФВ и УК. Цена ошибки: лишние поля + ответа, вреда нет. +- **R-5 (2026-11-06). Единицы ББ — словарём единиц `arm_statements` (`StatementsEngine.canonical_units`).** + Обоснование: единицы правятся аналитиком свободным текстом, точное сравнение «тыс. руб.» теряло УК. Цена ошибки: + незнакомая подпись — УК `null` с предупреждением, как раньше. +- **R-6 (2026-11-06). Пометка результата КФО устаревшим при смене переключателя или строк не делается — в AURORA-1546.** + Обоснование: результата КФО (`kfo_result`) ещё не строится, помечать нечего. Цена ошибки: в 1546 добавить сброс + в `save_rows` и `settings`. diff --git a/openspec/changes/goodwill-table/proposal.md b/openspec/changes/goodwill-table/proposal.md index 25a6b85..6b35b7b 100644 --- a/openspec/changes/goodwill-table/proposal.md +++ b/openspec/changes/goodwill-table/proposal.md @@ -6,10 +6,10 @@ ## What Changes -- Срез 6: бэкенд таблицы, построчное сохранение, переключатель версии и экспорт XLSX. +- Срез 6: бэкенд таблицы, пакетное сохранение, переключатель версии и экспорт XLSX. - ГО определяется по фактическим вложениям 58.01 и дочерних счетов в другие компании периметра; `is_head_entity` не используется. - Найденные пары объединяются с сохранёнными `goodwill`; исчезнувшие остаются с вычисляемым `has_investments=false`, без участия в итогах. -- Доля 0–100%, до четырёх знаков; нерассчитанные P/G допускают null. Сохранение одной строки атомарно. +- Доля 0–100%, до двух знаков; нерассчитанные P/G допускают null. Пакет строк сохраняется одной транзакцией; итоги по ГО и УК отдаёт бэк. - Расчёт по §8 domain-rules с уточнениями PDF §§3–10 и решениями пользователя: ASSUMPTION(OQ-10) — УК только из 1310 ББ, 1350 не входит; ASSUMPTION(OQ-37) — подготовленный вклад имеет технический код GOODWILL. - Подготовка вклада в КФО и исключение ББ-2 — не в этом change, перенесено в AURORA-1546 (R-3). - Уточнения PDF, отсутствующие в §8 (неактивные пары, выборка по дереву, итоги, исключение ББ-2), требуют отдельной синхронизации в docs; при реализации не подменять ими молча старую редакцию правил. @@ -28,7 +28,7 @@ Читаются таблицы 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. +Затрагиваются domain/, repositories/, schemas/, services/ и endpoints/consolidations; маршруты GET/PUT …/goodwill, PUT …/goodwill-settings, GET …/goodwill/export; контракт — ../docs/dev/api-contract-goodwill.md. Изменение чернового контракта: новые признаки состояния и nullable P/G, обновлённая семантика пар и итогов. Используются существующие guard, Decimal и openpyxl. ## Non-goals diff --git a/openspec/changes/goodwill-table/specs/goodwill-table/spec.md b/openspec/changes/goodwill-table/specs/goodwill-table/spec.md index 3668cf2..fd354b8 100644 --- a/openspec/changes/goodwill-table/specs/goodwill-table/spec.md +++ b/openspec/changes/goodwill-table/specs/goodwill-table/spec.md @@ -25,36 +25,52 @@ - **THEN** строка остаётся с has_investments=false, прежними долей, комментарием и галочкой, но без вклада в итоги; повторное появление пары восстанавливает расчёт по текущим источникам ### Requirement: Источник капитала и расчёт -Система SHALL использовать 1310 ББ ДО на конец отчётного периода в рублях: P=round(УК×доля/100,2), G=ФВ−P. Дубликаты 1310 обрабатываются агрегированием формы. Ноль допустим; отсутствие доли или достоверного УК даёт null P/G. 1350 не участвует: ASSUMPTION(OQ-10). +Система SHALL использовать 1310 ББ ДО на конец отчётного периода в рублях; единицы формы SHALL распознаваться словарём единиц arm_statements, нераспознанные единицы дают отсутствующий УК. 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** УК переводится в рубли умножением на 1000; единицы «штук» дают УК null и предупреждение CAPITAL_SOURCE_MISSING + #### 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, доли — строки с четырьмя. +GET /api/v1/consolidations/versions/{version_id}/goodwill SHALL возвращать version_id, goodwill_enabled, editable, read_only_reason, rows, groups, totals. Строки отсортированы по именам ГО/ДО и ID; содержат поля MD-контракта, has_investments и warnings. Деньги — строки с двумя знаками в рублях или null, доли — строки с двумя знаками. groups — итог «Всего» по каждой ГО в порядке строк, totals — итог по листу. #### Scenario: Пустая таблица - **WHEN** нет подходящих вложений и сохранённых пар - **THEN** rows пуст, денежные итоги 0.00; запрос не создаёт goodwill ### Requirement: Итоги и предупреждения -Система SHALL суммировать ФВ действующих строк независимо от выбора; P/G — только действующих выбранных рассчитанных строк. goodwill_in_kfo_amount включает только положительные применимые G и равен нулю при выключенном goodwill_enabled. Сумма заполненных долей действующих ГО в одной ДО свыше 100% даёт предупреждение, не ошибку. +Система 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: Пример DOC-259025 §11.1 +- **WHEN** у ГО «Альфа» отмечены Бета (ФВ 180 000, УК 100 000, 80 %) и Гамма (ФВ 50 000, УК 100 000, 80 %), Дельта (ФВ 60 000, УК 60 000) без доли и не отмечена +- **THEN** «Всего» и «Итого по листу»: ФВ 290 000, УК 260 000, P 160 000, G 70 000, в КФО 100 000 (при выключенном переключателе — 0) + +### Requirement: Пакетное сохранение +PUT …/goodwill SHALL принимать набор строк {head_legal_entity_id, subsidiary_legal_entity_id, ownership_share, include_in_kfo, comment} и сохранять его в одной транзакции: все строки проверяются до записи, ошибка любой строки откатывает весь пакет и несёт ключ пары. Доля допускает null только при снятом выборе, иначе 0–100, не больше двух знаков после запятой, запятая или точка; превышение точности не округляется. Для выбранной действующей строки обязателен непустой комментарий до 1000 символов. Ошибки — 404 NOT_FOUND (пары нет в таблице), 422 INVALID_SHARE, COMMENT_REQUIRED, MONEY_OUT_OF_RANGE, DUPLICATE_ROW. Строка, ввод которой не изменился, MUST NOT перезаписываться и писать аудит. Ответ — пересчитанная таблица. + +#### Scenario: Ошибка одной строки пакета +- **WHEN** пакет содержит корректную строку и строку отмеченной пары без комментария +- **THEN** ответ 422 COMMENT_REQUIRED с head_legal_entity_id и subsidiary_legal_entity_id этой строки; ни одна запись goodwill и ни одно событие GOODWILL_EDIT не созданы + +#### Scenario: Пакет без изменений +- **WHEN** повторно отправлена сохранённая строка с той же долей («80,5» после «80.50»), галочкой и комментарием +- **THEN** updated_at строки прежний, событий GOODWILL_EDIT не добавилось #### Scenario: Снятие выбора - **WHEN** сохраняется действующая строка с include_in_kfo=false и прежними долей и комментарием -- **THEN** ввод сохраняется, строка исключается из итогов P/G и вклада КФО; ответ содержит пересчитанную строку +- **THEN** ввод сохраняется, строка исключается из итогов P/G и вклада КФО; ответ содержит пересчитанную таблицу #### Scenario: Повторное сохранение и неактивная строка - **WHEN** повторно сохраняется существующая пара diff --git a/openspec/changes/goodwill-table/tasks.md b/openspec/changes/goodwill-table/tasks.md index 73cf0c5..27d3c1c 100644 --- a/openspec/changes/goodwill-table/tasks.md +++ b/openspec/changes/goodwill-table/tasks.md @@ -53,3 +53,12 @@ ## 8. Экспорт по экранной таблице - [x] 8.1 arm-core: привести XLSX к составу и порядку восьми колонок фронта со стандартным оформлением. Сохранить прежний контракт ручки и вызов экспорта на фронте. Проверить интеграционные сценарии выгрузки. + + +## 9. Доводка по ревью (06.11.2026, план docs/superpowers/plans/2026-11-06-goodwill-review-back.md) + +- [x] 9.1 arm-core: заготовка вклада КФО удалена, общий свод ОСВ возвращён к main (R-3). +- [x] 9.2 arm-core: доля — два знака, `participation()` в домене, итоги по ГО (`groups`) и УК (R-1, R-4). +- [x] 9.3 arm-core: пакетный `PUT …/goodwill` одной транзакцией вместо построчного (R-2). +- [x] 9.4 arm-core: единицы ББ по словарю arm_statements (R-5). +- [x] 9.5 docs (#204) и arm-core: контракт `../docs/dev/api-contract-goodwill.*`, §8 правил, OQ-10/OQ-37, README. -- 2.47.2 From 8485b2c53ec4b1e467210116044e0b7477330a44 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 18:52:59 +0400 Subject: [PATCH 7/7] =?UTF-8?q?=D0=93=D1=83=D0=B4=D0=B2=D0=B8=D0=BB=D0=BB:?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=D1=83=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1: сохранённая доля неактивной строки нормализуется (numeric(7,4) читается как 80.1200) и не даёт INVALID_SHARE при null в пакете; экспорт помечает строку «Нет вложений» (сценарий спеки); design/tasks/docstring без устаревшего «построчного» PUT, R-7/R-8. Co-Authored-By: Claude Opus 5.5 --- .../api/endpoints/consolidations/goodwill.py | 2 +- api/src/services/goodwill.py | 3 +- api/src/services/goodwill_export.py | 7 +++- api/tests/test_api_goodwill_mutations.py | 39 ++++++++++++++++++- api/tests/test_goodwill_export.py | 2 +- openspec/changes/goodwill-table/design.md | 11 ++++-- openspec/changes/goodwill-table/tasks.md | 4 +- 7 files changed, 57 insertions(+), 11 deletions(-) diff --git a/api/src/api/endpoints/consolidations/goodwill.py b/api/src/api/endpoints/consolidations/goodwill.py index 4a53a8a..93dfdda 100644 --- a/api/src/api/endpoints/consolidations/goodwill.py +++ b/api/src/api/endpoints/consolidations/goodwill.py @@ -1,4 +1,4 @@ -"""Таблица гудвилла, построчное сохранение, переключатель и XLSX.""" +"""Таблица гудвилла, пакетное сохранение, переключатель и XLSX (§8 «Гудвилл»).""" from typing import Annotated from uuid import UUID diff --git a/api/src/services/goodwill.py b/api/src/services/goodwill.py index 7cfef8d..bd6301b 100644 --- a/api/src/services/goodwill.py +++ b/api/src/services/goodwill.py @@ -283,7 +283,8 @@ def _decision(row: GoodwillRowOut, item: GoodwillRowKeyIn, key: tuple[UUID, UUID if not row.has_investments: # DOC-259025 §10 п. 9: отсутствующий ввод не затирает сохранённые данные неактивной пары. if share is None and row.ownership_share is not None: - share = str(row.ownership_share) + # numeric(7,4) читается с масштабом 4 («80.1200»): без хвостовых нулей для проверки двух знаков + share = format(row.ownership_share.normalize(), "f") if comment is None: comment = row.comment try: diff --git a/api/src/services/goodwill_export.py b/api/src/services/goodwill_export.py index 44ebc12..a03fad2 100644 --- a/api/src/services/goodwill_export.py +++ b/api/src/services/goodwill_export.py @@ -19,6 +19,7 @@ COLUMNS = { "goodwill_amount": "Гудвилл (+/-)", "comment": "Комментарий", } +NO_INVESTMENTS = "Нет вложений" MONEY = ("financial_investment_amount", "participation_amount", "charter_capital_amount", "goodwill_amount") @@ -43,7 +44,9 @@ def build_workbook(table: GoodwillTableOut, sign: GoodwillSign = "all") -> bytes row = sheet.max_row + 1 for col, key in enumerate(keys, 1): value = values.get(key) - if key in MONEY or key == "ownership_share": + if isinstance(value, str) and key in MONEY: + _put_text(sheet, row, col, value) + elif key in MONEY or key == "ownership_share": _put_money(sheet, row, col, value) else: _put_text(sheet, row, col, value) @@ -66,6 +69,8 @@ def build_workbook(table: GoodwillTableOut, sign: GoodwillSign = "all") -> bytes ) for item in visible: values = {key: getattr(item, key) for key in MONEY} + if not item.has_investments: + values["financial_investment_amount"] = NO_INVESTMENTS values.update( name=item.subsidiary_legal_entity.name, include_in_kfo="☑" if item.include_in_kfo else "☐", diff --git a/api/tests/test_api_goodwill_mutations.py b/api/tests/test_api_goodwill_mutations.py index 85e5638..7747041 100644 --- a/api/tests/test_api_goodwill_mutations.py +++ b/api/tests/test_api_goodwill_mutations.py @@ -2,6 +2,7 @@ from decimal import Decimal from http import HTTPStatus +from uuid import uuid4 import pytest from sqlalchemy import func, select @@ -13,8 +14,9 @@ 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 src.schemas.consolidations import RefOut +from src.schemas.goodwill import GoodwillRowIn, GoodwillRowKeyIn, GoodwillRowOut +from src.services.goodwill import _decision, validate_decision from tests.test_api_consolidated_osv import KA_RF from tests.test_api_goodwill import batch, goodwill_world @@ -228,3 +230,36 @@ async def test_new_row_with_defaults_is_not_created(db_session, auth_client, fix assert response.json()["rows"][0]["include_in_kfo"] is False assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0 assert await _goodwill_audit_count(db_session) == 0 + + +def test_inactive_row_keeps_stored_share_with_db_scale(): + """Колонка numeric(7,4) читается как Decimal("80.1200"): null в пакете не должен давать INVALID_SHARE.""" + head, sub = uuid4(), uuid4() + row = GoodwillRowOut( + head_legal_entity=RefOut(id=head, name="ГО"), + subsidiary_legal_entity=RefOut(id=sub, name="ДО"), + ownership_share=Decimal("80.1200"), + financial_investment_amount=None, + charter_capital_amount=None, + additional_capital_amount=None, + participation_amount=None, + goodwill_amount=None, + include_in_kfo=True, + comment="Было", + has_investments=False, + warnings=[], + updated_by=None, + updated_at=None, + ) + item = GoodwillRowKeyIn( + head_legal_entity_id=head, + subsidiary_legal_entity_id=sub, + ownership_share=None, + include_in_kfo=True, + comment=None, + ) + decision = _decision(row, item, (head, sub)) + assert decision.ownership_share == Decimal("80.12") + assert decision.comment == "Было" + row100 = row.model_copy(update={"ownership_share": Decimal("100.0000")}) + assert _decision(row100, item, (head, sub)).ownership_share == Decimal(100) diff --git a/api/tests/test_goodwill_export.py b/api/tests/test_goodwill_export.py index 72def3f..1e9d0a6 100644 --- a/api/tests/test_goodwill_export.py +++ b/api/tests/test_goodwill_export.py @@ -73,7 +73,7 @@ async def test_export_matches_server_table(db_session, auth_client, fixtures_dir ) 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, 4).value == "Нет вложений" 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/openspec/changes/goodwill-table/design.md b/openspec/changes/goodwill-table/design.md index 2ae7676..a0c2035 100644 --- a/openspec/changes/goodwill-table/design.md +++ b/openspec/changes/goodwill-table/design.md @@ -26,7 +26,7 @@ ### 3. Ручной ввод и отсутствие вложений -Ключ — (version_id, head_id, subsidiary_id), существующий UNIQUE. GET объединяет найденные пары с goodwill и ничего не пишет. Впервые запись создаётся при PUT строки; не материализуем все строки при чтении или смене переключателя. Это обычное сохранение по PUT из MD §3.3; GET формирует ответ без записи. +Ключ — (version_id, head_id, subsidiary_id), существующий UNIQUE. GET объединяет найденные пары с goodwill и ничего не пишет. Впервые запись создаётся при сохранении строки пакетом (`PUT …/goodwill`, R-2); не материализуем все строки при чтении или смене переключателя; GET формирует ответ без записи. has_investments — вычисляемый DTO-флаг, не колонка. По прямому решению пользователя для «Нет вложений» все денежные колонки ответа равны null; сохранённый ввод и последний денежный снимок в БД не удаляются. Строка исключается из итогов и применения в КФО. Собственной ошибки GOODWILL_PAIR_INACTIVE нет. Отсутствующие доля и комментарий не затирают сохранённый ввод неактивной пары (PDF §10). @@ -34,11 +34,11 @@ has_investments — вычисляемый DTO-флаг, не колонка. П Decimal, P.quantize(0.01, ROUND_HALF_UP), затем G=ФВ−P. Ограничение numeric(18,2) проверять до записи, переполнение отклонять 422 MONEY_OUT_OF_RANGE без частичного сохранения; float не использовать. Значения в API — рубли с двумя знаками, ownership_share — проценты с двумя (R-1). Максимум два дробных знака во входе, без автоматического округления; запятая и точка допустимы по 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. +GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениями spec: has_investments, warnings[] из {code, message}; charter_capital_amount и additional_capital_amount тоже nullable, чтобы отсутствие ББ не маскировать нулём. totals — итог по листу, groups — итоги «Всего» по каждой ГО; оба считает бэк (R-4). При 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 не нужны. PUT …/goodwill принимает набор строк и сохраняет его одной транзакцией (R-2): сначала проверяются все строки, потом пишутся изменённые. Отдельная оптимистическая ревизия не добавляется: существующая блокировка версии сериализует операции, последнее сохранение владельца побеждает. +Маршруты гудвилла используют зависимости по образцу API корректировок. Service использует текущие policy и lock_editable_version. PUT …/goodwill под блокировкой версии загружает актуальные источники, проверяет пары и ввод всех строк, затем upsert изменённых записей и GOODWILL_EDIT before/after по каждой в той же транзакции. Вычисляемые суммы из тела не принимаются. PUT настроек не трогает строки; GOODWILL_EDIT имеет object_type=consolidation_version и changed_field=goodwill_enabled, строки — object_type=goodwill. Новые enum не нужны. Отдельная оптимистическая ревизия не добавляется: существующая блокировка версии сериализует операции, последнее сохранение владельца побеждает. ### 6. Граница КФО @@ -85,3 +85,8 @@ GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениям - **R-6 (2026-11-06). Пометка результата КФО устаревшим при смене переключателя или строк не делается — в AURORA-1546.** Обоснование: результата КФО (`kfo_result`) ещё не строится, помечать нечего. Цена ошибки: в 1546 добавить сброс в `save_rows` и `settings`. +- **R-7 (2026-11-06, финальное ревью I-1). Сохранённая доля неактивной строки нормализуется** (`80.1200` → `80.12`) + перед проверкой ввода: колонка `numeric(7,4)` читается с масштабом 4, а ввод ограничен двумя знаками (R-1). Цена ошибки: + если в БД окажется доля с 3–4 значащими знаками (данные до R-1), пакет с этой неактивной строкой даст 422 `INVALID_SHARE`. +- **R-8 (2026-11-06, финальное ревью). В XLSX у строки «Нет вложений» в колонке ФВ — текст «Нет вложений»** (сценарий спеки + «Выгрузка таблицы»). Цена ошибки: нет. diff --git a/openspec/changes/goodwill-table/tasks.md b/openspec/changes/goodwill-table/tasks.md index 27d3c1c..f8ead7a 100644 --- a/openspec/changes/goodwill-table/tasks.md +++ b/openspec/changes/goodwill-table/tasks.md @@ -5,7 +5,7 @@ ## 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.2 arm-core: добавить доменные тесты гудвилла и кейсы состояний на реальных исходных строках: направление, неиспользование is_head_entity, исчезновение/возврат пары, ноль/null, четыре знака (позже — два, R-1), Decimal-округление, итоги, сумма долей >100. Проверка — воспроизводимый набор ожидаемых расчётов до реализации. - [x] 1.3 arm-core: реализовать доменный модуль расчёта гудвилла: пары по дереву счетов, объединение сохранённого ввода, формулы и предупреждения; проверить тесты 1.1–1.2, отсутствие ORM/float и записи вычисляемых пар при чтении. ## 2. Загрузка и API чтения @@ -18,7 +18,7 @@ ## 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.2 arm-core (позже заменено пакетным PUT, R-2): реализовать 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. -- 2.47.2