Merge pull request 'feat/goodwillback: бэкенд гудвилл' (#222) from feat/goodwill-back into main
This commit is contained in:
commit
d1e3000dc0
16
README.md
16
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.
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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)
|
||||
|
||||
73
api/src/api/endpoints/consolidations/goodwill.py
Normal file
73
api/src/api/endpoints/consolidations/goodwill.py
Normal file
@ -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")},
|
||||
)
|
||||
108
api/src/domain/goodwill.py
Normal file
108
api/src/domain/goodwill.py
Normal file
@ -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()}
|
||||
64
api/src/repositories/goodwill.py
Normal file
64
api/src/repositories/goodwill.py
Normal file
@ -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
|
||||
89
api/src/schemas/goodwill.py
Normal file
89
api/src/schemas/goodwill.py
Normal file
@ -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
|
||||
312
api/src/services/goodwill.py
Normal file
312
api/src/services/goodwill.py
Normal file
@ -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,
|
||||
}
|
||||
89
api/src/services/goodwill_export.py
Normal file
89
api/src/services/goodwill_export.py
Normal file
@ -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}
|
||||
96
api/src/services/goodwill_source.py
Normal file
96
api/src/services/goodwill_source.py
Normal file
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
360
api/tests/test_api_goodwill.py
Normal file
360
api/tests/test_api_goodwill.py
Normal file
@ -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"] == []
|
||||
265
api/tests/test_api_goodwill_mutations.py
Normal file
265
api/tests/test_api_goodwill_mutations.py
Normal file
@ -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)
|
||||
29
api/tests/test_goodwill_contract.py
Normal file
29
api/tests/test_goodwill_contract.py
Normal file
@ -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")
|
||||
91
api/tests/test_goodwill_domain.py
Normal file
91
api/tests/test_goodwill_domain.py
Normal file
@ -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}
|
||||
91
api/tests/test_goodwill_export.py
Normal file
91
api/tests/test_goodwill_export.py
Normal file
@ -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"])
|
||||
43
api/tests/test_goodwill_golden.py
Normal file
43
api/tests/test_goodwill_golden.py
Normal file
@ -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"}
|
||||
85
api/tests/test_goodwill_source.py
Normal file
85
api/tests/test_goodwill_source.py
Normal file
@ -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)
|
||||
@ -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"},
|
||||
|
||||
181
docs/superpowers/plans/2026-11-06-goodwill-review-back.md
Normal file
181
docs/superpowers/plans/2026-11-06-goodwill-review-back.md
Normal file
@ -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 <noreply@anthropic.com>`.
|
||||
- Решения ревью (дописать в `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 <core> 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 под ревью».
|
||||
2
openspec/changes/goodwill-table/.openspec.yaml
Normal file
2
openspec/changes/goodwill-table/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-11-05
|
||||
92
openspec/changes/goodwill-table/design.md
Normal file
92
openspec/changes/goodwill-table/design.md
Normal file
@ -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 у строки «Нет вложений» в колонке ФВ — текст «Нет вложений»** (сценарий спеки
|
||||
«Выгрузка таблицы»). Цена ошибки: нет.
|
||||
39
openspec/changes/goodwill-table/proposal.md
Normal file
39
openspec/changes/goodwill-table/proposal.md
Normal file
@ -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 пока предварительно согласован.
|
||||
105
openspec/changes/goodwill-table/specs/goodwill-table/spec.md
Normal file
105
openspec/changes/goodwill-table/specs/goodwill-table/spec.md
Normal file
@ -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
|
||||
64
openspec/changes/goodwill-table/tasks.md
Normal file
64
openspec/changes/goodwill-table/tasks.md
Normal file
@ -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.
|
||||
Loading…
x
Reference in New Issue
Block a user