diff --git a/README.md b/README.md index 8f268f7..76e24a7 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 | @@ -1340,3 +1343,14 @@ 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. + +### Таблица гудвилла + +Бэкенд таблицы гудвилла версии (§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/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..93dfdda --- /dev/null +++ b/api/src/api/endpoints/consolidations/goodwill.py @@ -0,0 +1,73 @@ +"""Таблица гудвилла, пакетное сохранение, переключатель и XLSX (§8 «Гудвилл»).""" + +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 ( + GoodwillBatchIn, + 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", 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 +) -> 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")}, + ) diff --git a/api/src/domain/goodwill.py b/api/src/domain/goodwill.py new file mode 100644 index 0000000..49b6e3c --- /dev/null +++ b/api/src/domain/goodwill.py @@ -0,0 +1,108 @@ +"""Чистый расчёт направленных пар гудвилла (§8 «Гудвилл», DOC-259025 §§3, 6); суммы в рублях.""" + +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 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], + 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 = participation(uk, decision.ownership_share) + 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]: + """ФВ и УК — все действующие строки; 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/repositories/goodwill.py b/api/src/repositories/goodwill.py new file mode 100644 index 0000000..f5b354a --- /dev/null +++ b/api/src/repositories/goodwill.py @@ -0,0 +1,64 @@ +"""Пакетное чтение goodwill и привязанных подтверждённых ББ.""" + +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) -> list[Goodwill]: + stmt = select(Goodwill).where(Goodwill.consolidation_version_id == version_id) + 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): + 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, + ) + ) + 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..8410669 --- /dev/null +++ b/api/src/schemas/goodwill.py @@ -0,0 +1,89 @@ +"""Контракт гудвилла: денежные суммы в рублях, 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, ".2f"), 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 + 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 + + +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 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 new file mode 100644 index 0000000..bd6301b --- /dev/null +++ b/api/src/services/goodwill.py @@ -0,0 +1,312 @@ +"""Таблица гудвилла, пакетное сохранение решений и переключатель — в транзакции запроса (§8, DOC-259025).""" + +import re +from decimal import Decimal +from http import HTTPStatus +from uuid import UUID, uuid4 + +from fastapi.concurrency import run_in_threadpool +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.api.dependencies.auth import Principal +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, DomainError, DomainValidationError +from src.domain import goodwill as domain +from src.domain.policy import VisibilityScope, ensure_version_editable +from src.models.access import AppUser +from src.models.directories import LegalEntity +from src.models.enums import AuditAction, AuditSection +from src.models.kfo import Goodwill +from src.repositories import consolidations as versions +from src.repositories import goodwill as repo +from src.schemas.consolidations import RefOut +from src.schemas.goodwill import ( + GoodwillGroupOut, + GoodwillRowIn, + GoodwillRowKeyIn, + GoodwillRowOut, + GoodwillSign, + GoodwillTableOut, + GoodwillTotalsOut, + WarningOut, +) +from src.services.consolidation_guard import lock_editable_version +from src.services.goodwill_export import build_workbook, matches_sign +from src.services.goodwill_source import GoodwillSource + +MAX_SHARE = Decimal(100) + +MESSAGES = { + "CAPITAL_SOURCE_MISSING": "Нет однозначного значения 1310 на конец отчётного периода", + "CAPITAL_SOURCE_AMBIGUOUS": "Несколько подходящих балансов дочерней организации", + "OWNERSHIP_TOTAL_EXCEEDED": "Сумма долей в дочерней организации превышает 100%", +} + + +def validate_decision(data: GoodwillRowIn) -> domain.Decision: + value = data.ownership_share.replace(",", ".") if data.ownership_share is not None else None + if value is None: + share = None + elif not re.fullmatch(r"\d{1,3}(?:\.\d{1,2})?", value) or Decimal(value) > MAX_SHARE: + raise DomainValidationError("INVALID_SHARE", "Доля — число от 0 до 100, не больше двух знаков после запятой") + else: + share = Decimal(value) + if data.include_in_kfo and share is None: + raise DomainValidationError("INVALID_SHARE", "Заполните долю владения") + comment = data.comment.strip() if data.comment is not None else None + if data.include_in_kfo and not comment: + raise DomainValidationError("COMMENT_REQUIRED", "Укажите комментарий") + return domain.Decision(share, data.include_in_kfo, comment) + + +class GoodwillService: + def __init__(self, session: AsyncSession, scope: VisibilityScope, principal: Principal, source: GoodwillSource): + self.session, self.scope, self.principal, self.source = session, scope, principal, source + + async def table(self, version_id: UUID) -> GoodwillTableOut: + sources = await self.source.load(version_id) + ctx = sources.context + saved = await repo.saved(self.session, version_id) + decisions = { + (r.head_legal_entity_id, r.subsidiary_legal_entity_id): domain.Decision( + r.ownership_share, r.include_in_kfo, r.comment + ) + for r in saved + } + rows = domain.build(sources.investments, decisions, sources.capital) + old = {(r.head_legal_entity_id, r.subsidiary_legal_entity_id): r for r in saved} + ids = {i for r in rows for i in (r.head_id, r.subsidiary_id)} + entities = { + e.id: e for e in (await self.session.scalars(select(LegalEntity).where(LegalEntity.id.in_(ids)))).all() + } + users = { + u.id: u + for u in ( + await self.session.scalars( + select(AppUser).where(AppUser.id.in_({r.updated_by for r in saved if r.updated_by})) + ) + ).all() + } + reason = None + if "CONSOLIDATION_EDIT" not in self.principal.permissions: + reason = "VERSION_NOT_EDITABLE" + else: + try: + ensure_version_editable( + ctx.version, await versions.active_lock(self.session, version_id), self.principal.user.id + ) + except ConflictError as error: + if error.code not in ("VERSION_NOT_EDITABLE", "LOCK_REQUIRED", "LOCKED_BY_OTHER"): + raise + reason = error.code + output = [] + for r in rows: + previous = old.get((r.head_id, r.subsidiary_id)) + user = users.get(previous.updated_by) if previous and previous.updated_by else None + codes = list(r.warnings) + if r.has_investments and r.subsidiary_id in sources.warnings: + codes = [c for c in codes if c != "CAPITAL_SOURCE_MISSING"] + [sources.warnings[r.subsidiary_id]] + output.append( + GoodwillRowOut( + head_legal_entity=RefOut(id=r.head_id, name=entities[r.head_id].name), + subsidiary_legal_entity=RefOut(id=r.subsidiary_id, name=entities[r.subsidiary_id].name), + ownership_share=r.decision.ownership_share, + financial_investment_amount=r.financial_investment_amount, + charter_capital_amount=r.charter_capital_amount, + additional_capital_amount=sources.additional.get(r.subsidiary_id) if r.has_investments else None, + participation_amount=r.participation_amount, + goodwill_amount=r.goodwill_amount, + include_in_kfo=r.decision.include_in_kfo, + comment=r.decision.comment, + has_investments=r.has_investments, + warnings=[WarningOut(code=c, message=MESSAGES[c]) for c in codes], + updated_by=RefOut(id=user.id, name=user.full_name) if user else None, + updated_at=previous.updated_at if previous else None, + ) + ) + output.sort( + key=lambda r: ( + r.head_legal_entity.name, + r.subsidiary_legal_entity.name, + r.head_legal_entity.id, + r.subsidiary_legal_entity.id, + ) + ) + groups = domain.group_totals(rows, ctx.version.goodwill_enabled) + heads = sorted(groups, key=lambda head: (entities[head].name, head)) + return GoodwillTableOut( + version_id=version_id, + goodwill_enabled=ctx.version.goodwill_enabled, + editable=reason is None, + read_only_reason=reason, + rows=output, + groups=[ + GoodwillGroupOut( + head_legal_entity=RefOut(id=head, name=entities[head].name), + totals=GoodwillTotalsOut(**groups[head]), + ) + for head in heads + ], + totals=GoodwillTotalsOut(**domain.totals(rows, ctx.version.goodwill_enabled)), + ) + + async def save_rows(self, version_id: UUID, rows: list[GoodwillRowKeyIn], audit: RequestAudit) -> GoodwillTableOut: + """Пакет строк одной транзакцией (DOC-259025 §5.1 п. 4): любая ошибка — откат всего пакета. + + Сначала проверяются все строки, потом пишутся только изменённые; аудит `GOODWILL_EDIT` — по каждой изменённой. + """ + await lock_editable_version(self.session, self.scope, version_id, self.principal.user.id) + table = await self.table(version_id) + current = {(r.head_legal_entity.id, r.subsidiary_legal_entity.id): r for r in table.rows} + saved = { + (r.head_legal_entity_id, r.subsidiary_legal_entity_id): r + for r in await repo.saved(self.session, version_id) + } + _check_duplicates(rows) + planned: list[tuple[GoodwillRowOut, domain.Decision]] = [] + for item in rows: + key = item.head_legal_entity_id, item.subsidiary_legal_entity_id + row = current.get(key) + if row is None: + raise DomainError(HTTPStatus.NOT_FOUND, "NOT_FOUND", "Пара не найдена", extra=_key_extra(key)) + decision = _decision(row, item, key) + entity = saved.get(key) + previous = ( + domain.Decision(entity.ownership_share, entity.include_in_kfo, entity.comment) + if entity + else domain.Decision() + ) + if decision != previous: + planned.append((row, decision)) + now = utc_now() + written: list[tuple[Goodwill, dict | None]] = [] + for row, decision in planned: + key = row.head_legal_entity.id, row.subsidiary_legal_entity.id + entity = saved.get(key) + before = row.model_dump(mode="json") if entity else None + if entity is None: + entity = Goodwill( + id=uuid4(), + consolidation_version_id=version_id, + head_legal_entity_id=key[0], + subsidiary_legal_entity_id=key[1], + created_by=self.principal.user.id, + created_at=now, + ) + self.session.add(entity) + entity.ownership_share, entity.include_in_kfo, entity.comment = ( + decision.ownership_share, + decision.include_in_kfo, + decision.comment, + ) + entity.updated_by, entity.updated_at = self.principal.user.id, now + if row.has_investments: + for name, amount in _amounts(row, decision).items(): + if amount is not None and abs(amount) >= MONEY_LIMIT: + raise DomainValidationError( + "MONEY_OUT_OF_RANGE", "Сумма не помещается в numeric(18,2)", **_key_extra(key) + ) + setattr(entity, name, amount) + written.append((entity, before)) + if not written: + return table + await self.session.flush() + result = await self.table(version_id) + after = {(r.head_legal_entity.id, r.subsidiary_legal_entity.id): r for r in result.rows} + for entity, before in written: + key = entity.head_legal_entity_id, entity.subsidiary_legal_entity_id + await audit.record( + action=AuditAction.GOODWILL_EDIT, + section=AuditSection.CONSOLIDATION, + object_type="goodwill", + object_id=entity.id, + consolidation_version_id=version_id, + before_value=before, + after_value=after[key].model_dump(mode="json"), + ) + return result + + async def settings(self, version_id: UUID, enabled: bool, audit: RequestAudit) -> GoodwillTableOut: + version, _ = await lock_editable_version(self.session, self.scope, version_id, self.principal.user.id) + if version.goodwill_enabled != enabled: + before = version.goodwill_enabled + version.goodwill_enabled = enabled + await self.session.flush() + await audit.record( + action=AuditAction.GOODWILL_EDIT, + section=AuditSection.CONSOLIDATION, + object_type="consolidation_version", + object_id=version_id, + consolidation_version_id=version_id, + changed_field="goodwill_enabled", + before_value={"goodwill_enabled": before}, + after_value={"goodwill_enabled": enabled}, + ) + return await self.table(version_id) + + async def export(self, version_id: UUID, audit: RequestAudit, sign: GoodwillSign = "all") -> bytes: + + table = await self.table(version_id) + content = await run_in_threadpool(build_workbook, table, sign) + await audit.record( + action=AuditAction.EXPORT, + section=AuditSection.EXPORT, + object_type="consolidation_version", + object_id=version_id, + consolidation_version_id=version_id, + after_value={"report": "goodwill", "rows": sum(matches_sign(row, sign) for row in table.rows)}, + ) + return content + + +MONEY_LIMIT = Decimal("10000000000000000") + + +def _key_extra(key: tuple[UUID, UUID]) -> dict[str, str]: + return {"head_legal_entity_id": str(key[0]), "subsidiary_legal_entity_id": str(key[1])} + + +def _check_duplicates(rows: list[GoodwillRowKeyIn]) -> None: + seen: set[tuple[UUID, UUID]] = set() + for item in rows: + key = item.head_legal_entity_id, item.subsidiary_legal_entity_id + if key in seen: + raise DomainValidationError("DUPLICATE_ROW", "Пара встречается в пакете дважды", **_key_extra(key)) + seen.add(key) + + +def _decision(row: GoodwillRowOut, item: GoodwillRowKeyIn, key: tuple[UUID, UUID]) -> domain.Decision: + """Проверенный ввод строки; ошибка несёт ключ пары.""" + share, comment = item.ownership_share, item.comment + if not row.has_investments: + # DOC-259025 §10 п. 9: отсутствующий ввод не затирает сохранённые данные неактивной пары. + if share is None and row.ownership_share is not None: + # numeric(7,4) читается с масштабом 4 («80.1200»): без хвостовых нулей для проверки двух знаков + share = format(row.ownership_share.normalize(), "f") + if comment is None: + comment = row.comment + try: + return validate_decision( + GoodwillRowIn(ownership_share=share, include_in_kfo=item.include_in_kfo, comment=comment) + ) + except DomainValidationError as error: + raise DomainValidationError(error.code, error.message, **_key_extra(key)) from error + + +def _amounts(row: GoodwillRowOut, decision: domain.Decision) -> dict[str, Decimal | None]: + """Снимок сумм действующей строки при сохранении (поля `goodwill` DBML).""" + assert row.financial_investment_amount is not None + p = ( + domain.participation(row.charter_capital_amount, decision.ownership_share) + if decision.include_in_kfo and row.charter_capital_amount is not None and decision.ownership_share is not None + else None + ) + return { + "financial_investment_amount": row.financial_investment_amount, + "charter_capital_amount": row.charter_capital_amount, + "additional_capital_amount": row.additional_capital_amount, + "participation_amount": p, + "goodwill_amount": row.financial_investment_amount - p if p is not None else None, + } diff --git a/api/src/services/goodwill_export.py b/api/src/services/goodwill_export.py new file mode 100644 index 0000000..a03fad2 --- /dev/null +++ b/api/src/services/goodwill_export.py @@ -0,0 +1,89 @@ +"""XLSX с колонками и группировкой экранной таблицы гудвилла; итоги — из ответа таблицы (`groups`, `totals`).""" + +from collections import defaultdict +from decimal import Decimal +from io import BytesIO + +from openpyxl import Workbook + +from src.schemas.goodwill import GoodwillRowOut, GoodwillSign, GoodwillTableOut, GoodwillTotalsOut +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": "Комментарий", +} +NO_INVESTMENTS = "Нет вложений" +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 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) + if key == "ownership_share": + 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) + 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 + 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} + 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 "☐", + ownership_share=item.ownership_share, + comment=item.comment, + ) + append(values) + append({"name": "Всего", **_money(group_totals[head])}) + append({"name": "Итого по листу", **_money(table.totals)}) + stream = BytesIO() + book.save(stream) + return stream.getvalue() + + +def _money(totals: GoodwillTotalsOut) -> dict[str, Decimal]: + return {field: getattr(totals, field) 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..762eaeb --- /dev/null +++ b/api/src/services/goodwill_source.py @@ -0,0 +1,96 @@ +"""Адаптер полного свода ОСВ и привязанных ББ для расчёта гудвилла.""" + +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 +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 +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 = UNIT_FACTORS.get(default_engine().canonical_units(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: + accounts = await repo.accounts(self.session) + allowed = {account.id for account in accounts} + ctx = await self.osv.build_with_members(version_id) + result = Sources(ctx, investments=_investments(ctx, allowed)) + 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]) + 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/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_api_goodwill.py b/api/tests/test_api_goodwill.py new file mode 100644 index 0000000..943d07d --- /dev/null +++ b/api/tests/test_api_goodwill.py @@ -0,0 +1,360 @@ +"""Реальные вложения 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] + + +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] + 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 + 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 + + +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 + "/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()["rows"][0] + 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..7747041 --- /dev/null +++ b/api/tests/test_api_goodwill_mutations.py @@ -0,0 +1,265 @@ +"""Проверка ввода строки до обращения к хранилищу.""" + +from decimal import Decimal +from http import HTTPStatus +from uuid import uuid4 + +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.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 + + +@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", "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(",", ".") + 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 + "/goodwill" + body = dict(ownership_share="80.12", include_in_kfo=True, comment="Подтверждено") + 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=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=batch(w.a.id, w.b.id, **body)) + assert response.status_code == HTTPStatus.OK, response.text + 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=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)) + assert on.status_code == HTTPStatus.OK + 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) + ) + 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.12" + body["include_in_kfo"] = False + 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( + ( + 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=batch(w.a.id, w.b.id, **body))).status_code == HTTPStatus.OK + body["include_in_kfo"] = True + response = await client.put(path, json=batch(w.a.id, w.b.id, **body)) + assert response.status_code == HTTPStatus.OK + 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 + + +@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 + "/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=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=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): + raise ConflictError("TEST_AUDIT_FAILURE", "Ошибка тестового аудита") + + monkeypatch.setattr(RequestAudit, "record", reject_audit) + body["ownership_share"] = "100" + 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 + + +@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 + "/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 + + +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_contract.py b/api/tests/test_goodwill_contract.py new file mode 100644 index 0000000..662896d --- /dev/null +++ b/api/tests/test_goodwill_contract.py @@ -0,0 +1,29 @@ +"""Контракт гудвилла в 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(): + 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], 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/api/tests/test_goodwill_domain.py b/api/tests/test_goodwill_domain.py new file mode 100644 index 0000000..817836c --- /dev/null +++ b/api/tests/test_goodwill_domain.py @@ -0,0 +1,91 @@ +"""Расчёт и жизненный цикл на реальной сумме вложений Приманыческого.""" + +from decimal import Decimal +from pathlib import Path +from uuid import UUID + +import pytest + +from src.domain.goodwill import Decision, Investment, build, group_totals, participation, 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 + + +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 new file mode 100644 index 0000000..1e9d0a6 --- /dev/null +++ b/api/tests/test_goodwill_export.py @@ -0,0 +1,91 @@ +"""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 batch, 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") + 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" + 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 == "Нет вложений" + 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"]) 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..b72bb9e --- /dev/null +++ b/api/tests/test_goodwill_source.py @@ -0,0 +1,85 @@ +"""Выбор дерева счетов и отчётной колонки без догадок по 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) + + +@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) diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 32a2f1a..cb63651 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -82,6 +82,10 @@ 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", "put"}, + "/api/v1/consolidations/versions/{version_id}/goodwill-settings": {"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/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/.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..a0c2035 --- /dev/null +++ b/openspec/changes/goodwill-table/design.md @@ -0,0 +1,92 @@ +# 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. XLSX вместо CSV намеренно отличается от PDF (по контракту). Доля — два знака, сохранение — пакетом (R-1, R-2). 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 …/goodwill`, R-2); не материализуем все строки при чтении или смене переключателя; 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 — проценты с двумя (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 — итог по листу, groups — итоги «Всего» по каждой ГО; оба считает бэк (R-4). При goodwill_enabled=false потенциальные G видны, goodwill_in_kfo_amount=0.00. + +### 5. Транзакции и безопасность + +Маршруты гудвилла используют зависимости по образцу 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. Граница КФО + +Подготовка вклада в КФО (применимые пары, изменения 1170/1310/GOODWILL, исключение ББ-2), выбор источников только сохранённых выбранных пар и пометка результата КФО устаревшим в этот change не входят — перенесено в AURORA-1546 (R-3, R-6). Этот change не пишет KfoResult и не трогает Correction. + +### 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 и сохранение ручного ввода. + +## 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`. +- **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/proposal.md b/openspec/changes/goodwill-table/proposal.md new file mode 100644 index 0000000..6b35b7b --- /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. +- Подготовка вклада в КФО и исключение ББ-2 — не в этом change, перенесено в AURORA-1546 (R-3). +- Уточнения 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; маршруты GET/PUT …/goodwill, PUT …/goodwill-settings, GET …/goodwill/export; контракт — ../docs/dev/api-contract-goodwill.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..fd354b8 --- /dev/null +++ b/openspec/changes/goodwill-table/specs/goodwill-table/spec.md @@ -0,0 +1,105 @@ +# 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 ББ ДО на конец отчётного периода в рублях; единицы формы 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, 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% даёт предупреждение, не ошибку. + +#### Scenario: Несколько ГО одной ДО +- **WHEN** у действующих пар одной ДО заполнены доли 70% и 40% +- **THEN** строки содержат предупреждение OWNERSHIP_TOTAL_EXCEEDED, сохранение не блокируется; проценты по разным ДО не складываются в общий процент + +#### 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 и вклада КФО; ответ содержит пересчитанную таблицу + +#### 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 суммировать только заполненные 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 diff --git a/openspec/changes/goodwill-table/tasks.md b/openspec/changes/goodwill-table/tasks.md new file mode 100644 index 0000000..f8ead7a --- /dev/null +++ b/openspec/changes/goodwill-table/tasks.md @@ -0,0 +1,64 @@ +# 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, четыре знака (позже — два, R-1), 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, 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. + +## 4. Экспорт и граница КФО + +- [x] 4.1 arm-core: добавить тесты экспорта гудвилла и сервис экспорта гудвилла, маршрут экспорта; проверить содержимое XLSX, групповые/общие итоги, пустые ячейки null, текстовую безопасность, имя кириллицей и EXPORT с report=goodwill. Документировать экспорт в локальном MD/OpenAPI. +- [x] 4.2 перенесено в AURORA-1546 (R-3): подготовка вклада в КФО удалена из этого change. +- [x] 4.3 перенесено в AURORA-1546 (R-3). + +## 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 КФО (AURORA-1546) реализует подготовку вклада, запись результата, устаревание, применение/исключение ББ-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 перенесено в AURORA-1546 (R-3): `load_for_kfo` и фильтры общего свода удалены. +- [x] 7.2 перенесено в AURORA-1546 (R-3). + +## 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.