feat/goodwillback: бэкенд гудвилл
This commit is contained in:
parent
967fa01fb6
commit
94895b6de7
@ -1340,3 +1340,10 @@ Dockerfile только для локального compose
|
||||
- [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов;
|
||||
- [`../docs/contracts/`](../docs/contracts/) — JSON-схемы обмена с сервисами распознавания;
|
||||
- `openspec/changes/slice-0-skeleton/` — спецификация и решения Среза 0.
|
||||
|
||||
### Таблица гудвилла
|
||||
|
||||
Бэкенд таблицы гудвилла версии: фактические вложения 58.01 между участниками периметра, построчные решения,
|
||||
переключатель и XLSX. `is_head_entity` не используется. Деньги в рублях, доля до четырёх знаков, нерассчитанные
|
||||
значения — `null`; исчезнувшие сохранённые пары остаются как «Нет вложений».
|
||||
Контракт и ограничения этапа — [контракт API](goodwill/api-contract-goodwill.md). Фронт и построение/фиксация результата КФО — отдельные задачи.
|
||||
|
||||
@ -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)
|
||||
|
||||
82
api/src/api/endpoints/consolidations/goodwill.py
Normal file
82
api/src/api/endpoints/consolidations/goodwill.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""Таблица гудвилла, построчное сохранение, переключатель и XLSX."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit, get_request_audit
|
||||
from src.api.dependencies.auth import require
|
||||
from src.api.dependencies.consolidations import ConsolidationServiceDep, GoodwillServiceDep
|
||||
from src.common.filenames import content_disposition
|
||||
from src.schemas.goodwill import (
|
||||
GoodwillRowIn,
|
||||
GoodwillRowOut,
|
||||
GoodwillSettingsIn,
|
||||
GoodwillSign,
|
||||
GoodwillTableOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/consolidations/versions")
|
||||
VIEW = [Depends(require("CONSOLIDATION_VIEW"))]
|
||||
EDIT = [Depends(require("CONSOLIDATION_EDIT"))]
|
||||
AuditDep = Annotated[RequestAudit, Depends(get_request_audit)]
|
||||
|
||||
|
||||
@router.get("/{version_id}/goodwill", response_model=GoodwillTableOut, dependencies=VIEW)
|
||||
async def table(version_id: UUID, service: GoodwillServiceDep) -> GoodwillTableOut:
|
||||
return await service.table(version_id)
|
||||
|
||||
|
||||
@router.put("/{version_id}/goodwill-settings", response_model=GoodwillTableOut, dependencies=EDIT)
|
||||
async def settings(
|
||||
version_id: UUID, data: GoodwillSettingsIn, service: GoodwillServiceDep, audit: AuditDep
|
||||
) -> GoodwillTableOut:
|
||||
return await service.settings(version_id, data.goodwill_enabled, audit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{version_id}/goodwill/export",
|
||||
response_class=Response,
|
||||
dependencies=VIEW,
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
|
||||
"schema": {"type": "string", "format": "binary"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
async def export(
|
||||
version_id: UUID,
|
||||
service: GoodwillServiceDep,
|
||||
consolidation: ConsolidationServiceDep,
|
||||
audit: AuditDep,
|
||||
sign: GoodwillSign = "all",
|
||||
) -> Response:
|
||||
content = await service.export(version_id, audit, sign)
|
||||
name, year, quarter = await consolidation.version_title(version_id)
|
||||
filename = f"Гудвилл_{name}_{year}_{quarter}.xlsx"
|
||||
return Response(
|
||||
content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": content_disposition(filename, ascii_fallback="goodwill.xlsx")},
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{version_id}/goodwill/{head_legal_entity_id}/{subsidiary_legal_entity_id}",
|
||||
response_model=GoodwillRowOut,
|
||||
dependencies=EDIT,
|
||||
)
|
||||
async def save( # noqa: PLR0913, PLR0917 - ключ пары, тело и зависимости FastAPI
|
||||
version_id: UUID,
|
||||
head_legal_entity_id: UUID,
|
||||
subsidiary_legal_entity_id: UUID,
|
||||
data: GoodwillRowIn,
|
||||
service: GoodwillServiceDep,
|
||||
audit: AuditDep,
|
||||
) -> GoodwillRowOut:
|
||||
return await service.save(version_id, head_legal_entity_id, subsidiary_legal_entity_id, data, audit)
|
||||
132
api/src/domain/goodwill.py
Normal file
132
api/src/domain/goodwill.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""Чистый расчёт направленных пар гудвилла; суммы в рублях."""
|
||||
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
MAX_SHARE = Decimal(100)
|
||||
ZERO = Decimal("0.00")
|
||||
Pair = tuple[uuid.UUID, uuid.UUID]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
ownership_share: Decimal | None = None
|
||||
include_in_kfo: bool = False
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Investment:
|
||||
head_id: uuid.UUID
|
||||
subsidiary_id: uuid.UUID
|
||||
amount: Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Row:
|
||||
head_id: uuid.UUID
|
||||
subsidiary_id: uuid.UUID
|
||||
financial_investment_amount: Decimal | None
|
||||
charter_capital_amount: Decimal | None
|
||||
participation_amount: Decimal | None
|
||||
goodwill_amount: Decimal | None
|
||||
has_investments: bool
|
||||
decision: Decision
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def applicable(self) -> bool:
|
||||
return (
|
||||
self.has_investments
|
||||
and self.decision.include_in_kfo
|
||||
and bool((self.decision.comment or "").strip())
|
||||
and self.goodwill_amount is not None
|
||||
and self.goodwill_amount > 0
|
||||
)
|
||||
|
||||
|
||||
def build(
|
||||
investments: Sequence[Investment],
|
||||
decisions: Mapping[Pair, Decision],
|
||||
capital: Mapping[uuid.UUID, Decimal | None],
|
||||
) -> list[Row]:
|
||||
amounts: dict[Pair, Decimal] = defaultdict(lambda: ZERO)
|
||||
for item in investments:
|
||||
key = item.head_id, item.subsidiary_id
|
||||
amounts[key] += item.amount
|
||||
shares: dict[uuid.UUID, Decimal] = defaultdict(lambda: ZERO)
|
||||
for key, decision in decisions.items():
|
||||
if amounts.get(key, ZERO) != 0 and decision.ownership_share is not None:
|
||||
shares[key[1]] += decision.ownership_share
|
||||
result = []
|
||||
for head, subsidiary in sorted(amounts.keys() | decisions.keys()):
|
||||
key = head, subsidiary
|
||||
fv = amounts.get(key, ZERO)
|
||||
active = fv != 0
|
||||
decision = decisions.get(key, Decision())
|
||||
uk = capital.get(subsidiary) if active else None
|
||||
p = g = None
|
||||
warnings = []
|
||||
if active and uk is None:
|
||||
warnings.append("CAPITAL_SOURCE_MISSING")
|
||||
if active and decision.include_in_kfo and uk is not None and decision.ownership_share is not None:
|
||||
p = (uk * decision.ownership_share / 100).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
g = fv - p
|
||||
if active and shares[subsidiary] > MAX_SHARE:
|
||||
warnings.append("OWNERSHIP_TOTAL_EXCEEDED")
|
||||
if active or key in decisions:
|
||||
result.append(Row(head, subsidiary, fv if active else None, uk, p, g, active, decision, tuple(warnings)))
|
||||
return result
|
||||
|
||||
|
||||
def totals(rows: Sequence[Row], enabled: bool) -> dict[str, Decimal]:
|
||||
active = [r for r in rows if r.has_investments]
|
||||
selected = [r for r in active if r.decision.include_in_kfo]
|
||||
return {
|
||||
"financial_investment_amount": sum((r.financial_investment_amount or ZERO for r in active), ZERO),
|
||||
"participation_amount": sum((r.participation_amount or ZERO for r in selected), ZERO),
|
||||
"goodwill_amount": sum((r.goodwill_amount or ZERO for r in selected), ZERO),
|
||||
"goodwill_in_kfo_amount": sum((r.goodwill_amount or ZERO for r in active if enabled and r.applicable), ZERO),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Contribution:
|
||||
version_id: uuid.UUID
|
||||
rule_id: uuid.UUID
|
||||
head_id: uuid.UUID
|
||||
subsidiary_id: uuid.UUID
|
||||
financial_investment_amount: Decimal
|
||||
participation_amount: Decimal
|
||||
goodwill_amount: Decimal
|
||||
# Ключ исключения — balance, rule_id, head_id, subsidiary_id.
|
||||
deltas: tuple[tuple[uuid.UUID, str, Decimal], ...]
|
||||
|
||||
|
||||
def contributions(version_id: uuid.UUID, rule_id: uuid.UUID, rows: Sequence[Row], enabled: bool) -> list[Contribution]:
|
||||
result = []
|
||||
for row in rows:
|
||||
if not enabled or not row.applicable:
|
||||
continue
|
||||
assert row.financial_investment_amount is not None
|
||||
assert row.participation_amount is not None and row.goodwill_amount is not None
|
||||
result.append(
|
||||
Contribution(
|
||||
version_id,
|
||||
rule_id,
|
||||
row.head_id,
|
||||
row.subsidiary_id,
|
||||
row.financial_investment_amount,
|
||||
row.participation_amount,
|
||||
row.goodwill_amount,
|
||||
(
|
||||
(row.head_id, "1170", -row.financial_investment_amount),
|
||||
(row.subsidiary_id, "1310", -row.participation_amount),
|
||||
(row.head_id, "GOODWILL", row.goodwill_amount),
|
||||
),
|
||||
)
|
||||
)
|
||||
return result
|
||||
@ -306,7 +306,13 @@ async def document_versions(session: AsyncSession, document_id: uuid.UUID) -> li
|
||||
return list((await session.execute(stmt)).scalars())
|
||||
|
||||
|
||||
async def load_osv_rows(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[OsvEntry, Document, LegalEntity]]:
|
||||
async def load_osv_rows(
|
||||
session: AsyncSession,
|
||||
version_id: uuid.UUID,
|
||||
*,
|
||||
company_pairs: Collection[tuple[uuid.UUID, uuid.UUID]] | None = None,
|
||||
account_ids: Collection[uuid.UUID] | None = None,
|
||||
) -> list[tuple[OsvEntry, Document, LegalEntity]]:
|
||||
"""Исходные строки свода: контрагентские (`group_type = COUNTERPARTY`) строки BU/SUM привязанных документов.
|
||||
|
||||
`TOTAL` не хранится, строки `ACCOUNT` и данные `NU` отбрасываются. Порядок: имя ЮЛ, код счёта документа, квартал,
|
||||
@ -326,6 +332,22 @@ async def load_osv_rows(session: AsyncSession, version_id: uuid.UUID) -> list[tu
|
||||
LegalEntity.name, Document.osv_account_code, Document.period_quarter, OsvEntry.row_number, OsvEntry.id
|
||||
)
|
||||
)
|
||||
if company_pairs is not None:
|
||||
# NULL-контрагент может быть сопоставлен общим дорезолвом; точная пара проверяется после него.
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
false(),
|
||||
*(
|
||||
and_(
|
||||
OsvEntry.owner_legal_entity_id == head,
|
||||
or_(OsvEntry.counterparty_id == subsidiary, OsvEntry.counterparty_id.is_(None)),
|
||||
)
|
||||
for head, subsidiary in company_pairs
|
||||
),
|
||||
)
|
||||
)
|
||||
if account_ids is not None:
|
||||
stmt = stmt.where(OsvEntry.account_id.in_(account_ids))
|
||||
return [(row[0], row[1], row[2]) for row in await session.execute(stmt)]
|
||||
|
||||
|
||||
|
||||
69
api/src/repositories/goodwill.py
Normal file
69
api/src/repositories/goodwill.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""Пакетное чтение goodwill и привязанных подтверждённых ББ."""
|
||||
|
||||
from collections.abc import Collection
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.accounting import ChartOfAccount
|
||||
from src.models.consolidation import ConsolidationDocument
|
||||
from src.models.enums import DocumentStatus, StatementType
|
||||
from src.models.files import Document
|
||||
from src.models.kfo import Goodwill
|
||||
from src.models.statements import (
|
||||
FinancialStatementColumn as Column,
|
||||
)
|
||||
from src.models.statements import (
|
||||
FinancialStatementLine as Line,
|
||||
)
|
||||
from src.models.statements import (
|
||||
FinancialStatementMeta as Meta,
|
||||
)
|
||||
from src.models.statements import (
|
||||
FinancialStatementValue as Value,
|
||||
)
|
||||
|
||||
|
||||
async def saved(session: AsyncSession, version_id: UUID, *, included_only: bool = False) -> list[Goodwill]:
|
||||
stmt = select(Goodwill).where(Goodwill.consolidation_version_id == version_id)
|
||||
if included_only:
|
||||
stmt = stmt.where(Goodwill.include_in_kfo.is_(True))
|
||||
return list((await session.scalars(stmt)).all())
|
||||
|
||||
|
||||
async def accounts(session: AsyncSession) -> list[ChartOfAccount]:
|
||||
"""Только 58.01 и потомки по parent_id; полный справочник в память не загружается."""
|
||||
tree = select(ChartOfAccount.id).where(ChartOfAccount.code == "58.01").cte("goodwill_accounts", recursive=True)
|
||||
tree = tree.union(select(ChartOfAccount.id).join(tree, ChartOfAccount.parent_id == tree.c.id))
|
||||
return list((await session.scalars(select(ChartOfAccount).join(tree, ChartOfAccount.id == tree.c.id))).all())
|
||||
|
||||
|
||||
async def forms(session: AsyncSession, version_id: UUID, company_ids: Collection[UUID] | None = None):
|
||||
stmt = (
|
||||
select(Document, Meta)
|
||||
.join(Meta, Meta.document_id == Document.id)
|
||||
.join(ConsolidationDocument, ConsolidationDocument.document_id == Document.id)
|
||||
.where(
|
||||
ConsolidationDocument.consolidation_version_id == version_id,
|
||||
Document.status == DocumentStatus.CONFIRMED,
|
||||
Meta.statement_type == StatementType.balance,
|
||||
)
|
||||
)
|
||||
if company_ids is not None:
|
||||
stmt = stmt.where(Document.legal_entity_id.in_(company_ids))
|
||||
return list((await session.execute(stmt)).all())
|
||||
|
||||
|
||||
async def form_data(session: AsyncSession, ids: list[UUID]):
|
||||
columns = list((await session.scalars(select(Column).where(Column.meta_id.in_(ids)))).all())
|
||||
values = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Line, Value.column_id, Value.amount)
|
||||
.outerjoin(Value, Value.line_id == Line.id)
|
||||
.where(Line.meta_id.in_(ids), Line.line_code.in_(["1310", "1350"]))
|
||||
)
|
||||
).all()
|
||||
)
|
||||
return columns, values
|
||||
66
api/src/schemas/goodwill.py
Normal file
66
api/src/schemas/goodwill.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Контракт гудвилла: денежные суммы в рублях, nullable расчёты."""
|
||||
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer
|
||||
|
||||
from src.schemas.consolidations import RefOut
|
||||
from src.schemas.corrections import Money
|
||||
|
||||
Share = Annotated[Decimal, PlainSerializer(lambda v: format(v, ".4f"), return_type=str, when_used="json")]
|
||||
|
||||
|
||||
GoodwillSign = Literal["all", "positive", "negative", "zero"]
|
||||
|
||||
|
||||
class WarningOut(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
class GoodwillRowOut(BaseModel):
|
||||
head_legal_entity: RefOut
|
||||
subsidiary_legal_entity: RefOut
|
||||
ownership_share: Share | None
|
||||
financial_investment_amount: Money | None
|
||||
charter_capital_amount: Money | None
|
||||
additional_capital_amount: Money | None
|
||||
participation_amount: Money | None
|
||||
goodwill_amount: Money | None
|
||||
include_in_kfo: bool
|
||||
comment: str | None
|
||||
has_investments: bool
|
||||
warnings: list[WarningOut]
|
||||
updated_by: RefOut | None
|
||||
updated_at: datetime.datetime | None
|
||||
|
||||
|
||||
class GoodwillTotalsOut(BaseModel):
|
||||
financial_investment_amount: Money
|
||||
participation_amount: Money
|
||||
goodwill_amount: Money
|
||||
goodwill_in_kfo_amount: Money
|
||||
|
||||
|
||||
class GoodwillTableOut(BaseModel):
|
||||
version_id: UUID
|
||||
goodwill_enabled: bool
|
||||
editable: bool
|
||||
read_only_reason: Literal["VERSION_NOT_EDITABLE", "LOCK_REQUIRED", "LOCKED_BY_OTHER"] | None
|
||||
rows: list[GoodwillRowOut]
|
||||
totals: GoodwillTotalsOut
|
||||
|
||||
|
||||
class GoodwillRowIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
ownership_share: str | None
|
||||
include_in_kfo: bool
|
||||
comment: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class GoodwillSettingsIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
goodwill_enabled: bool
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
import uuid
|
||||
from collections.abc import Collection, Sequence
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Literal
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
@ -149,7 +149,13 @@ class ConsolidatedOsvService:
|
||||
self._session = session
|
||||
self._scope = scope
|
||||
|
||||
async def build_with_members(self, version_id: uuid.UUID) -> ConsolidatedOsvBuildResult:
|
||||
async def build_with_members(
|
||||
self,
|
||||
version_id: uuid.UUID,
|
||||
*,
|
||||
company_pairs: Collection[tuple[uuid.UUID, uuid.UUID]] | None = None,
|
||||
account_ids: Collection[uuid.UUID] | None = None,
|
||||
) -> ConsolidatedOsvBuildResult:
|
||||
"""Свод и участники периметра, загруженные при его расчёте; версия вне видимости — 404."""
|
||||
found = await repo.get_version(self._session, self._scope, version_id)
|
||||
if found is None:
|
||||
@ -167,7 +173,12 @@ class ConsolidatedOsvService:
|
||||
for document in await consolidation_documents.for_consolidation(self._session, bound, consolidation)
|
||||
]
|
||||
# ровно то, что отдал `load_osv_rows`: он оставляет `COUNTERPARTY`/`BU`+`SUM`, домен их не перефильтровывает
|
||||
rows = [_source_row(entry) for entry, _, _ in await repo.load_osv_rows(self._session, version.id)]
|
||||
rows = [
|
||||
_source_row(entry)
|
||||
for entry, _, _ in await repo.load_osv_rows(
|
||||
self._session, version.id, company_pairs=company_pairs, account_ids=account_ids
|
||||
)
|
||||
]
|
||||
index = await CounterpartyIndex.load(self._session)
|
||||
entities = {**await _entities(self._session, _candidate_ids(rows, index) - members.keys()), **members}
|
||||
consolidated = domain.build(
|
||||
@ -180,6 +191,12 @@ class ConsolidatedOsvService:
|
||||
rows=rows,
|
||||
resolver=index,
|
||||
)
|
||||
if company_pairs is not None:
|
||||
pairs = set(company_pairs)
|
||||
consolidated = replace(
|
||||
consolidated,
|
||||
rows=tuple(row for row in consolidated.rows if (row.legal_entity_id, row.counterparty_id) in pairs),
|
||||
)
|
||||
return ConsolidatedOsvBuildResult(
|
||||
consolidated=consolidated,
|
||||
version=version,
|
||||
|
||||
297
api/src/services/goodwill.py
Normal file
297
api/src/services/goodwill.py
Normal file
@ -0,0 +1,297 @@
|
||||
"""Таблица и построчные решения гудвилла в транзакции запроса."""
|
||||
|
||||
import re
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit
|
||||
from src.api.dependencies.auth import Principal
|
||||
from src.common.clock import utc_now
|
||||
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
|
||||
from src.domain import goodwill as domain
|
||||
from src.domain.policy import VisibilityScope, ensure_version_editable
|
||||
from src.models.access import AppUser
|
||||
from src.models.accounting import EliminationRule
|
||||
from src.models.directories import LegalEntity
|
||||
from src.models.enums import AuditAction, AuditSection, StatementType
|
||||
from src.models.kfo import Goodwill
|
||||
from src.repositories import consolidations as versions
|
||||
from src.repositories import goodwill as repo
|
||||
from src.schemas.consolidations import RefOut
|
||||
from src.schemas.goodwill import (
|
||||
GoodwillRowIn,
|
||||
GoodwillRowOut,
|
||||
GoodwillSign,
|
||||
GoodwillTableOut,
|
||||
GoodwillTotalsOut,
|
||||
WarningOut,
|
||||
)
|
||||
from src.services.consolidation_guard import VERSION_NOT_FOUND, lock_editable_version
|
||||
from src.services.goodwill_export import build_workbook, matches_sign
|
||||
from src.services.goodwill_source import GoodwillSource
|
||||
|
||||
MAX_SHARE = Decimal(100)
|
||||
BB_RULE_NO = 2
|
||||
|
||||
MESSAGES = {
|
||||
"CAPITAL_SOURCE_MISSING": "Нет однозначного значения 1310 на конец отчётного периода",
|
||||
"CAPITAL_SOURCE_AMBIGUOUS": "Несколько подходящих балансов дочерней организации",
|
||||
"OWNERSHIP_TOTAL_EXCEEDED": "Сумма долей в дочерней организации превышает 100%",
|
||||
}
|
||||
|
||||
|
||||
def validate_decision(data: GoodwillRowIn) -> domain.Decision:
|
||||
value = data.ownership_share.replace(",", ".") if data.ownership_share is not None else None
|
||||
if value is None:
|
||||
share = None
|
||||
elif not re.fullmatch(r"\d+(?:\.\d{1,4})?", value) or Decimal(value) > MAX_SHARE:
|
||||
raise DomainValidationError("INVALID_SHARE", "Доля должна быть от 0 до 100, до четырёх знаков")
|
||||
else:
|
||||
share = Decimal(value)
|
||||
if data.include_in_kfo and share is None:
|
||||
raise DomainValidationError("INVALID_SHARE", "Заполните долю владения")
|
||||
comment = data.comment.strip() if data.comment is not None else None
|
||||
if data.include_in_kfo and not comment:
|
||||
raise DomainValidationError("COMMENT_REQUIRED", "Укажите комментарий")
|
||||
return domain.Decision(share, data.include_in_kfo, comment)
|
||||
|
||||
|
||||
class GoodwillService:
|
||||
def __init__(self, session: AsyncSession, scope: VisibilityScope, principal: Principal, source: GoodwillSource):
|
||||
self.session, self.scope, self.principal, self.source = session, scope, principal, source
|
||||
|
||||
async def table(self, version_id: UUID) -> GoodwillTableOut:
|
||||
sources = await self.source.load(version_id)
|
||||
ctx = sources.context
|
||||
saved = await repo.saved(self.session, version_id)
|
||||
decisions = {
|
||||
(r.head_legal_entity_id, r.subsidiary_legal_entity_id): domain.Decision(
|
||||
r.ownership_share, r.include_in_kfo, r.comment
|
||||
)
|
||||
for r in saved
|
||||
}
|
||||
rows = domain.build(sources.investments, decisions, sources.capital)
|
||||
old = {(r.head_legal_entity_id, r.subsidiary_legal_entity_id): r for r in saved}
|
||||
ids = {i for r in rows for i in (r.head_id, r.subsidiary_id)}
|
||||
entities = {
|
||||
e.id: e for e in (await self.session.scalars(select(LegalEntity).where(LegalEntity.id.in_(ids)))).all()
|
||||
}
|
||||
users = {
|
||||
u.id: u
|
||||
for u in (
|
||||
await self.session.scalars(
|
||||
select(AppUser).where(AppUser.id.in_({r.updated_by for r in saved if r.updated_by}))
|
||||
)
|
||||
).all()
|
||||
}
|
||||
reason = None
|
||||
if "CONSOLIDATION_EDIT" not in self.principal.permissions:
|
||||
reason = "VERSION_NOT_EDITABLE"
|
||||
else:
|
||||
try:
|
||||
ensure_version_editable(
|
||||
ctx.version, await versions.active_lock(self.session, version_id), self.principal.user.id
|
||||
)
|
||||
except ConflictError as error:
|
||||
if error.code not in ("VERSION_NOT_EDITABLE", "LOCK_REQUIRED", "LOCKED_BY_OTHER"):
|
||||
raise
|
||||
reason = error.code
|
||||
output = []
|
||||
for r in rows:
|
||||
previous = old.get((r.head_id, r.subsidiary_id))
|
||||
user = users.get(previous.updated_by) if previous and previous.updated_by else None
|
||||
codes = list(r.warnings)
|
||||
if r.has_investments and r.subsidiary_id in sources.warnings:
|
||||
codes = [c for c in codes if c != "CAPITAL_SOURCE_MISSING"] + [sources.warnings[r.subsidiary_id]]
|
||||
output.append(
|
||||
GoodwillRowOut(
|
||||
head_legal_entity=RefOut(id=r.head_id, name=entities[r.head_id].name),
|
||||
subsidiary_legal_entity=RefOut(id=r.subsidiary_id, name=entities[r.subsidiary_id].name),
|
||||
ownership_share=r.decision.ownership_share,
|
||||
financial_investment_amount=r.financial_investment_amount,
|
||||
charter_capital_amount=r.charter_capital_amount,
|
||||
additional_capital_amount=sources.additional.get(r.subsidiary_id) if r.has_investments else None,
|
||||
participation_amount=r.participation_amount,
|
||||
goodwill_amount=r.goodwill_amount,
|
||||
include_in_kfo=r.decision.include_in_kfo,
|
||||
comment=r.decision.comment,
|
||||
has_investments=r.has_investments,
|
||||
warnings=[WarningOut(code=c, message=MESSAGES[c]) for c in codes],
|
||||
updated_by=RefOut(id=user.id, name=user.full_name) if user else None,
|
||||
updated_at=previous.updated_at if previous else None,
|
||||
)
|
||||
)
|
||||
output.sort(
|
||||
key=lambda r: (
|
||||
r.head_legal_entity.name,
|
||||
r.subsidiary_legal_entity.name,
|
||||
r.head_legal_entity.id,
|
||||
r.subsidiary_legal_entity.id,
|
||||
)
|
||||
)
|
||||
return GoodwillTableOut(
|
||||
version_id=version_id,
|
||||
goodwill_enabled=ctx.version.goodwill_enabled,
|
||||
editable=reason is None,
|
||||
read_only_reason=reason,
|
||||
rows=output,
|
||||
totals=GoodwillTotalsOut(**domain.totals(rows, ctx.version.goodwill_enabled)),
|
||||
)
|
||||
|
||||
async def save(
|
||||
self, version_id: UUID, head: UUID, subsidiary: UUID, data: GoodwillRowIn, audit: RequestAudit
|
||||
) -> GoodwillRowOut:
|
||||
await lock_editable_version(self.session, self.scope, version_id, self.principal.user.id)
|
||||
table = await self.table(version_id)
|
||||
row = next(
|
||||
(r for r in table.rows if (r.head_legal_entity.id, r.subsidiary_legal_entity.id) == (head, subsidiary)),
|
||||
None,
|
||||
)
|
||||
if row is None:
|
||||
raise NotFoundError("Пара не найдена")
|
||||
if not row.has_investments:
|
||||
# PDF §10: отсутствующий ввод не затирает сохранённые данные неактивной пары.
|
||||
data = data.model_copy(
|
||||
update={
|
||||
"ownership_share": data.ownership_share
|
||||
if data.ownership_share is not None
|
||||
else str(row.ownership_share)
|
||||
if row.ownership_share is not None
|
||||
else None,
|
||||
"comment": data.comment if data.comment is not None else row.comment,
|
||||
}
|
||||
)
|
||||
decision = validate_decision(data)
|
||||
saved = await repo.saved(self.session, version_id)
|
||||
entity = next(
|
||||
(r for r in saved if (r.head_legal_entity_id, r.subsidiary_legal_entity_id) == (head, subsidiary)), None
|
||||
)
|
||||
before = row.model_dump(mode="json") if entity else None
|
||||
now = utc_now()
|
||||
if entity is None:
|
||||
entity = Goodwill(
|
||||
id=uuid4(),
|
||||
consolidation_version_id=version_id,
|
||||
head_legal_entity_id=head,
|
||||
subsidiary_legal_entity_id=subsidiary,
|
||||
created_by=self.principal.user.id,
|
||||
created_at=now,
|
||||
)
|
||||
self.session.add(entity)
|
||||
entity.ownership_share, entity.include_in_kfo, entity.comment = (
|
||||
decision.ownership_share,
|
||||
decision.include_in_kfo,
|
||||
decision.comment,
|
||||
)
|
||||
entity.updated_by, entity.updated_at = self.principal.user.id, now
|
||||
if row.has_investments:
|
||||
assert row.financial_investment_amount is not None
|
||||
p = (
|
||||
(row.charter_capital_amount * decision.ownership_share / 100).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
if decision.include_in_kfo
|
||||
and row.charter_capital_amount is not None
|
||||
and decision.ownership_share is not None
|
||||
else None
|
||||
)
|
||||
amounts = dict(
|
||||
financial_investment_amount=row.financial_investment_amount,
|
||||
charter_capital_amount=row.charter_capital_amount,
|
||||
additional_capital_amount=row.additional_capital_amount,
|
||||
participation_amount=p,
|
||||
goodwill_amount=row.financial_investment_amount - p if p is not None else None,
|
||||
)
|
||||
for name, amount in amounts.items():
|
||||
if amount is not None and abs(amount) >= Decimal("10000000000000000"):
|
||||
raise DomainValidationError("MONEY_OUT_OF_RANGE", "Сумма не помещается в numeric(18,2)")
|
||||
setattr(entity, name, amount)
|
||||
await self.session.flush()
|
||||
result = next(
|
||||
r
|
||||
for r in (await self.table(version_id)).rows
|
||||
if (r.head_legal_entity.id, r.subsidiary_legal_entity.id) == (head, subsidiary)
|
||||
)
|
||||
await audit.record(
|
||||
action=AuditAction.GOODWILL_EDIT,
|
||||
section=AuditSection.CONSOLIDATION,
|
||||
object_type="goodwill",
|
||||
object_id=entity.id,
|
||||
consolidation_version_id=version_id,
|
||||
before_value=before,
|
||||
after_value=result.model_dump(mode="json"),
|
||||
)
|
||||
return result
|
||||
|
||||
async def settings(self, version_id: UUID, enabled: bool, audit: RequestAudit) -> GoodwillTableOut:
|
||||
version, _ = await lock_editable_version(self.session, self.scope, version_id, self.principal.user.id)
|
||||
if version.goodwill_enabled != enabled:
|
||||
before = version.goodwill_enabled
|
||||
version.goodwill_enabled = enabled
|
||||
await self.session.flush()
|
||||
await audit.record(
|
||||
action=AuditAction.GOODWILL_EDIT,
|
||||
section=AuditSection.CONSOLIDATION,
|
||||
object_type="consolidation_version",
|
||||
object_id=version_id,
|
||||
consolidation_version_id=version_id,
|
||||
changed_field="goodwill_enabled",
|
||||
before_value={"goodwill_enabled": before},
|
||||
after_value={"goodwill_enabled": enabled},
|
||||
)
|
||||
return await self.table(version_id)
|
||||
|
||||
async def export(self, version_id: UUID, audit: RequestAudit, sign: GoodwillSign = "all") -> bytes:
|
||||
|
||||
table = await self.table(version_id)
|
||||
content = await run_in_threadpool(build_workbook, table, sign)
|
||||
await audit.record(
|
||||
action=AuditAction.EXPORT,
|
||||
section=AuditSection.EXPORT,
|
||||
object_type="consolidation_version",
|
||||
object_id=version_id,
|
||||
consolidation_version_id=version_id,
|
||||
after_value={"report": "goodwill", "rows": sum(matches_sign(row, sign) for row in table.rows)},
|
||||
)
|
||||
return content
|
||||
|
||||
async def contributions(self, version_id: UUID) -> list[domain.Contribution]:
|
||||
|
||||
found = await versions.get_version(self.session, self.scope, version_id)
|
||||
if found is None:
|
||||
raise NotFoundError(VERSION_NOT_FOUND)
|
||||
version, _ = found
|
||||
if not version.goodwill_enabled:
|
||||
return []
|
||||
saved = await repo.saved(self.session, version_id, included_only=True)
|
||||
if not saved:
|
||||
return []
|
||||
pairs = {(row.head_legal_entity_id, row.subsidiary_legal_entity_id) for row in saved}
|
||||
sources = await self.source.load_for_kfo(version_id, pairs)
|
||||
decisions = {
|
||||
(r.head_legal_entity_id, r.subsidiary_legal_entity_id): domain.Decision(
|
||||
r.ownership_share, r.include_in_kfo, r.comment
|
||||
)
|
||||
for r in saved
|
||||
}
|
||||
rows = domain.build(sources.investments, decisions, sources.capital)
|
||||
if not any(row.applicable for row in rows):
|
||||
return []
|
||||
rules = list(
|
||||
(
|
||||
await self.session.scalars(
|
||||
select(EliminationRule).where(
|
||||
EliminationRule.version_no == sources.context.version.elimination_rules_version,
|
||||
EliminationRule.statement_type == StatementType.balance,
|
||||
EliminationRule.rule_no == BB_RULE_NO,
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if len(rules) != 1:
|
||||
raise ConflictError("GOODWILL_RULE_MISSING", "Нет однозначного ББ-2 закреплённой версии правил")
|
||||
return domain.contributions(version_id, rules[0].id, rows, sources.context.version.goodwill_enabled)
|
||||
92
api/src/services/goodwill_export.py
Normal file
92
api/src/services/goodwill_export.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""XLSX с колонками и группировкой экранной таблицы гудвилла."""
|
||||
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
from src.schemas.goodwill import GoodwillRowOut, GoodwillSign, GoodwillTableOut
|
||||
from src.services.osv_export import _put_money, _put_text
|
||||
|
||||
COLUMNS = {
|
||||
"include_in_kfo": "Учет в отчете",
|
||||
"name": "Наименование дочерней организации",
|
||||
"ownership_share": "Доля владения",
|
||||
"financial_investment_amount": "Фондовые вложения головной организации",
|
||||
"participation_amount": "Доля участия головной организации в уставном капитале",
|
||||
"charter_capital_amount": "Уставный капитал",
|
||||
"goodwill_amount": "Гудвилл (+/-)",
|
||||
"comment": "Комментарий",
|
||||
}
|
||||
MONEY = ("financial_investment_amount", "participation_amount", "charter_capital_amount", "goodwill_amount")
|
||||
|
||||
|
||||
def matches_sign(row: GoodwillRowOut, sign: GoodwillSign) -> bool:
|
||||
if sign == "all":
|
||||
return True
|
||||
amount = row.goodwill_amount
|
||||
if amount is None:
|
||||
return False
|
||||
return {"positive": amount > 0, "negative": amount < 0, "zero": amount == 0}[sign]
|
||||
|
||||
|
||||
def build_workbook(table: GoodwillTableOut, sign: GoodwillSign = "all") -> bytes:
|
||||
keys = list(COLUMNS)
|
||||
book = Workbook()
|
||||
sheet = book.active
|
||||
assert sheet is not None
|
||||
sheet.title = "Гудвилл"
|
||||
sheet.append([COLUMNS[key] for key in keys])
|
||||
|
||||
def append(values):
|
||||
row = sheet.max_row + 1
|
||||
for col, key in enumerate(keys, 1):
|
||||
value = values.get(key)
|
||||
if key in MONEY or key == "ownership_share":
|
||||
_put_money(sheet, row, col, value)
|
||||
else:
|
||||
_put_text(sheet, row, col, value)
|
||||
if key == "ownership_share":
|
||||
sheet.cell(row, col).number_format = "0.0000"
|
||||
|
||||
groups: dict = defaultdict(list)
|
||||
for item in table.rows:
|
||||
groups[item.head_legal_entity.id].append(item)
|
||||
for items in groups.values():
|
||||
visible = [item for item in items if matches_sign(item, sign)]
|
||||
if not visible:
|
||||
continue
|
||||
append(
|
||||
{
|
||||
"name": items[0].head_legal_entity.name,
|
||||
"include_in_kfo": "☑" if all(i.include_in_kfo for i in visible) else "☐",
|
||||
},
|
||||
)
|
||||
for item in visible:
|
||||
values = {key: getattr(item, key) for key in MONEY}
|
||||
values.update(
|
||||
name=item.subsidiary_legal_entity.name,
|
||||
include_in_kfo="☑" if item.include_in_kfo else "☐",
|
||||
ownership_share=item.ownership_share,
|
||||
comment=item.comment,
|
||||
)
|
||||
append(values)
|
||||
append({"name": "Всего", **_totals(items)})
|
||||
totals = table.totals
|
||||
values = dict(
|
||||
name="Итого по листу",
|
||||
ownership_share=None,
|
||||
financial_investment_amount=totals.financial_investment_amount,
|
||||
participation_amount=totals.participation_amount,
|
||||
goodwill_amount=totals.goodwill_amount,
|
||||
)
|
||||
append(values)
|
||||
stream = BytesIO()
|
||||
book.save(stream)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
def _totals(rows: list[GoodwillRowOut]) -> dict[str, Decimal]:
|
||||
active = [r for r in rows if r.has_investments]
|
||||
return {field: sum((getattr(r, field) or Decimal(0) for r in active), Decimal(0)) for field in MONEY}
|
||||
105
api/src/services/goodwill_source.py
Normal file
105
api/src/services/goodwill_source.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""Адаптер полного свода ОСВ и привязанных ББ для расчёта гудвилла."""
|
||||
|
||||
import calendar
|
||||
import datetime as dt
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domain.goodwill import Investment, Pair
|
||||
from src.domain.statement_aggregate import AggLine, aggregate_by_code
|
||||
from src.repositories import goodwill as repo
|
||||
from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sources:
|
||||
context: ConsolidatedOsvBuildResult
|
||||
investments: list[Investment] = field(default_factory=list)
|
||||
capital: dict[UUID, Decimal | None] = field(default_factory=dict)
|
||||
additional: dict[UUID, Decimal | None] = field(default_factory=dict)
|
||||
warnings: dict[UUID, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def capital_amounts(meta_id, units, columns, values, date) -> tuple[Decimal | None, Decimal | None]:
|
||||
selected = [c.id for c in columns if c.meta_id == meta_id and c.period_date == date]
|
||||
factor = {"руб.": Decimal(1), "тыс. руб.": Decimal(1000), "млн руб.": Decimal(1000000)}.get(units)
|
||||
if len(selected) != 1 or factor is None:
|
||||
return None, None
|
||||
lines = {}
|
||||
amounts_by_line: dict[UUID, dict[UUID, Decimal | None]] = {}
|
||||
for line, column_id, raw_amount in values:
|
||||
if line.meta_id == meta_id:
|
||||
if line.id not in lines:
|
||||
amounts_by_line[line.id] = {}
|
||||
lines[line.id] = AggLine(
|
||||
line.id, line.row_index, line.line_status, line.line_code, amounts_by_line[line.id]
|
||||
)
|
||||
amounts_by_line[line.id][column_id] = raw_amount
|
||||
aggregated = aggregate_by_code(lines.values())
|
||||
|
||||
def amount(code):
|
||||
agg = aggregated.get(code)
|
||||
raw = agg.amounts.get(selected[0]) if agg else None
|
||||
return raw * factor if raw is not None else None
|
||||
|
||||
return amount("1310"), amount("1350")
|
||||
|
||||
|
||||
def _investments(ctx, allowed) -> list[Investment]:
|
||||
amounts: dict[tuple[UUID, UUID], Decimal] = defaultdict(lambda: Decimal(0))
|
||||
for row in ctx.consolidated.rows:
|
||||
if (
|
||||
row.account_id not in allowed
|
||||
or row.legal_entity_id not in ctx.members
|
||||
or row.counterparty_id not in ctx.members
|
||||
or row.legal_entity_id == row.counterparty_id
|
||||
):
|
||||
continue
|
||||
assert row.counterparty_id is not None
|
||||
pair = row.legal_entity_id, row.counterparty_id
|
||||
if row.closing_debit is not None:
|
||||
amounts[pair] += row.closing_debit
|
||||
return [Investment(h, s, amount) for (h, s), amount in amounts.items()]
|
||||
|
||||
|
||||
class GoodwillSource:
|
||||
def __init__(self, session: AsyncSession, osv: ConsolidatedOsvService) -> None:
|
||||
self.session, self.osv = session, osv
|
||||
|
||||
async def load(self, version_id: UUID) -> Sources:
|
||||
return await self._load(version_id)
|
||||
|
||||
async def load_for_kfo(self, version_id: UUID, pairs: set[Pair]) -> Sources:
|
||||
"""Источники только сохранённых выбранных пар; таблица использует обычный load."""
|
||||
return await self._load(version_id, pairs)
|
||||
|
||||
async def _load(self, version_id: UUID, pairs: set[Pair] | None = None) -> Sources:
|
||||
accounts = await repo.accounts(self.session)
|
||||
allowed = {account.id for account in accounts}
|
||||
if pairs is None:
|
||||
ctx = await self.osv.build_with_members(version_id)
|
||||
else:
|
||||
ctx = await self.osv.build_with_members(version_id, company_pairs=pairs, account_ids=allowed)
|
||||
result = Sources(ctx, investments=_investments(ctx, allowed))
|
||||
forms = await repo.forms(
|
||||
self.session, version_id, {subsidiary for _, subsidiary in pairs} if pairs is not None else None
|
||||
)
|
||||
columns, values = await repo.form_data(self.session, [m.id for _, m in forms])
|
||||
month = int(ctx.consolidation.quarter.value[1:]) * 3
|
||||
date = dt.date(ctx.consolidation.year, month, calendar.monthrange(ctx.consolidation.year, month)[1])
|
||||
by_owner = defaultdict(list)
|
||||
for doc, meta in forms:
|
||||
if doc.legal_entity_id in ctx.members:
|
||||
by_owner[doc.legal_entity_id].append(meta)
|
||||
for owner, metas in by_owner.items():
|
||||
if len(metas) != 1:
|
||||
result.warnings[owner] = "CAPITAL_SOURCE_AMBIGUOUS"
|
||||
continue
|
||||
meta = metas[0]
|
||||
uk, additional = capital_amounts(meta.id, meta.units, columns, values, date)
|
||||
result.capital[owner], result.additional[owner] = uk, additional
|
||||
return result
|
||||
341
api/tests/test_api_goodwill.py
Normal file
341
api/tests/test_api_goodwill.py
Normal file
@ -0,0 +1,341 @@
|
||||
"""Реальные вложения 58.01 в API с настоящей БД и стандартными правами."""
|
||||
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from src.models.accounting import ChartOfAccount
|
||||
from src.models.consolidation import Consolidation, ConsolidationDocument
|
||||
from src.models.enums import (
|
||||
ConsolidationStatus,
|
||||
DocumentStatus,
|
||||
DocumentType,
|
||||
OsvDataType,
|
||||
PeriodQuarter,
|
||||
StatementType,
|
||||
)
|
||||
from src.models.files import Document, StoredFile
|
||||
from src.models.kfo import Goodwill
|
||||
from src.models.osv import OsvEntry
|
||||
from src.models.statements import (
|
||||
FinancialStatementColumn,
|
||||
FinancialStatementLine,
|
||||
FinancialStatementMeta,
|
||||
FinancialStatementValue,
|
||||
)
|
||||
from src.repositories import consolidations as consolidation_repo
|
||||
from tests.factories import make_confirmed_osv
|
||||
from tests.fixtures_helpers import GOLDEN_STATEMENTS, golden_result_v1, map_golden_snapshot
|
||||
from tests.test_api_consolidated_osv import FOREIGN, KA_RF, _world
|
||||
from tests.test_goodwill_golden import investment_rows
|
||||
|
||||
HTTP_OK, HTTP_NOT_FOUND, HTTP_CONFLICT, HTTP_FORBIDDEN = 200, 404, 409, 403
|
||||
HTTP_CREATED = 201
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
|
||||
|
||||
|
||||
async def goodwill_world(session, fixtures_dir: Path):
|
||||
w = await _world(session)
|
||||
raw = investment_rows(fixtures_dir)[0]
|
||||
account = (await session.scalars(select(ChartOfAccount).where(ChartOfAccount.code == "58.01"))).one()
|
||||
doc = await make_confirmed_osv(
|
||||
session,
|
||||
entity=w.a,
|
||||
account="58.01",
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q3,
|
||||
is_cumulative=True,
|
||||
rows=[
|
||||
dict(
|
||||
name=raw["counterparty_name"],
|
||||
counterparty_id=w.b.id,
|
||||
account_id=account.id,
|
||||
closing_debit=Decimal(raw["closing_debit"]),
|
||||
)
|
||||
],
|
||||
)
|
||||
session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=doc.id))
|
||||
balance = await make_confirmed_osv(
|
||||
session, entity=w.b, account="80", year=2024, quarter=PeriodQuarter.Q3, is_cumulative=True, rows=[]
|
||||
)
|
||||
balance.document_type = DocumentType.BALANCE
|
||||
balance.osv_account_code = None
|
||||
balance.status = DocumentStatus.CONFIRMED
|
||||
meta = FinancialStatementMeta(
|
||||
document_id=balance.id,
|
||||
recognition_job_id=(await session.get(StoredFile, balance.stored_file_id)).current_recognition_job_id,
|
||||
statement_type=StatementType.balance,
|
||||
units="тыс. руб.",
|
||||
)
|
||||
session.add(meta)
|
||||
await session.flush()
|
||||
column = FinancialStatementColumn(
|
||||
meta_id=meta.id, column_index=0, period_date=dt.date(2024, 9, 30), is_current=True
|
||||
)
|
||||
line = FinancialStatementLine(meta_id=meta.id, line_code="1310", row_index=0)
|
||||
session.add_all([column, line])
|
||||
await session.flush()
|
||||
session.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=Decimal("100000")))
|
||||
session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=balance.id))
|
||||
await session.flush()
|
||||
return w
|
||||
|
||||
|
||||
async def test_table_no_write_and_visibility(db_session, auth_client, fixtures_dir):
|
||||
w = await goodwill_world(db_session, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{w.version.id}"
|
||||
response = await client.get(base + "/goodwill")
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
row = response.json()["rows"][0]
|
||||
assert row["financial_investment_amount"] == "269997300.00"
|
||||
assert row["charter_capital_amount"] == "100000000.00"
|
||||
assert row["goodwill_amount"] is None
|
||||
assert row["has_investments"] is True
|
||||
assert (await db_session.scalar(select(func.count()).select_from(Goodwill))) == 0
|
||||
foreign = await auth_client(FOREIGN)
|
||||
assert (await foreign.get(base + "/goodwill")).status_code == HTTP_NOT_FOUND
|
||||
|
||||
|
||||
async def test_empty_and_readonly(db_session, auth_client, fixtures_dir):
|
||||
w = await _world(db_session)
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{w.version.id}"
|
||||
table = (await client.get(base + "/goodwill")).json()
|
||||
assert table["rows"] == [] and table["totals"]["financial_investment_amount"] == "0.00"
|
||||
w.version.status = ConsolidationStatus.VERIFIED
|
||||
await db_session.flush()
|
||||
table = (await client.get(base + "/goodwill")).json()
|
||||
assert table["editable"] is False
|
||||
response = await client.put(base + "/goodwill-settings", json={"goodwill_enabled": True})
|
||||
assert response.status_code == HTTP_CONFLICT and response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
|
||||
|
||||
|
||||
async def test_capital_missing_duplicate_and_zero(db_session, auth_client, fixtures_dir):
|
||||
w = await goodwill_world(db_session, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{w.version.id}"
|
||||
meta = (await db_session.scalars(select(FinancialStatementMeta))).one()
|
||||
column = (await db_session.scalars(select(FinancialStatementColumn))).one()
|
||||
# Дубликаты 1310 суммируются штатным агрегатом формы.
|
||||
line = FinancialStatementLine(meta_id=meta.id, line_code="1310", row_index=1)
|
||||
db_session.add(line)
|
||||
await db_session.flush()
|
||||
value = FinancialStatementValue(line_id=line.id, column_id=column.id, amount=Decimal("1"))
|
||||
db_session.add(value)
|
||||
await db_session.flush()
|
||||
row = (await client.get(base + "/goodwill")).json()["rows"][0]
|
||||
assert row["charter_capital_amount"] == "100001000.00"
|
||||
column.period_date = dt.date(2023, 9, 30)
|
||||
await db_session.flush()
|
||||
row = (await client.get(base + "/goodwill")).json()["rows"][0]
|
||||
assert row["charter_capital_amount"] is None
|
||||
assert row["warnings"][0]["code"] == "CAPITAL_SOURCE_MISSING"
|
||||
column.period_date = dt.date(2024, 9, 30)
|
||||
for val in (await db_session.scalars(select(FinancialStatementValue))).all():
|
||||
val.amount = 0
|
||||
await db_session.flush()
|
||||
assert (await client.get(base + "/goodwill")).json()["rows"][0]["charter_capital_amount"] == "0.00"
|
||||
|
||||
|
||||
async def test_investments_do_not_apply_custom_document_or_account_priority(db_session, auth_client, fixtures_dir):
|
||||
w = await goodwill_world(db_session, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
amount = Decimal(investment_rows(fixtures_dir)[0]["closing_debit"])
|
||||
account = (await db_session.scalars(select(ChartOfAccount).where(ChartOfAccount.code == "58.01"))).one()
|
||||
broad = await make_confirmed_osv(
|
||||
db_session,
|
||||
entity=w.a,
|
||||
account="58",
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q3,
|
||||
is_cumulative=True,
|
||||
rows=[
|
||||
dict(
|
||||
name="СЕМИКАРАКОРСКИЙ ЭЛЕВАТОР ООО", counterparty_id=w.b.id, account_id=account.id, closing_debit=amount
|
||||
)
|
||||
],
|
||||
)
|
||||
db_session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=broad.id))
|
||||
await db_session.flush()
|
||||
base = f"/consolidations/versions/{w.version.id}"
|
||||
assert (await client.get(base + "/goodwill")).json()["totals"]["financial_investment_amount"] == "539994600.00"
|
||||
child = ChartOfAccount(
|
||||
code="58.01.99", name="Тестовая детализация", parent_id=account.id, row_type=account.row_type, kind=account.kind
|
||||
)
|
||||
db_session.add(child)
|
||||
await db_session.flush()
|
||||
detail = await make_confirmed_osv(
|
||||
db_session,
|
||||
entity=w.a,
|
||||
account="58.01.99",
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q3,
|
||||
is_cumulative=True,
|
||||
rows=[
|
||||
dict(name="СЕМИКАРАКОРСКИЙ ЭЛЕВАТОР ООО", counterparty_id=w.b.id, account_id=child.id, closing_debit=amount)
|
||||
],
|
||||
)
|
||||
db_session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=detail.id))
|
||||
await db_session.flush()
|
||||
assert (await client.get(base + "/goodwill")).json()["totals"]["financial_investment_amount"] == "809991900.00"
|
||||
|
||||
|
||||
async def test_view_and_edit_permissions(auth_client):
|
||||
client = await auth_client("admin@armka.local")
|
||||
base = f"/consolidations/versions/{uuid4()}"
|
||||
for path in ("/goodwill", "/goodwill/export"):
|
||||
assert (await client.get(base + path)).status_code == HTTP_FORBIDDEN
|
||||
response = await client.put(base + "/goodwill-settings", json={"goodwill_enabled": True})
|
||||
assert response.status_code == HTTP_FORBIDDEN
|
||||
|
||||
|
||||
async def test_real_balance_and_ambiguous_forms(db_session, auth_client, fixtures_dir):
|
||||
w = await goodwill_world(db_session, fixtures_dir)
|
||||
consolidation = await db_session.get(Consolidation, w.version.consolidation_id)
|
||||
consolidation.year = 2025
|
||||
# Реальный ББ НПЗ, строки и суммы не меняются.
|
||||
path = next((fixtures_dir / GOLDEN_STATEMENTS / "npz/2025-Q3").glob("*.json"))
|
||||
documents = await map_golden_snapshot(
|
||||
db_session, entity=w.b, path=path, uploaded_by=None, result_builder=golden_result_v1
|
||||
)
|
||||
previous = (
|
||||
await db_session.scalars(
|
||||
select(Document)
|
||||
.join(ConsolidationDocument)
|
||||
.where(
|
||||
ConsolidationDocument.consolidation_version_id == w.version.id,
|
||||
Document.document_type == DocumentType.BALANCE,
|
||||
)
|
||||
)
|
||||
).one()
|
||||
await db_session.execute(
|
||||
delete(ConsolidationDocument).where(
|
||||
ConsolidationDocument.consolidation_version_id == w.version.id,
|
||||
ConsolidationDocument.document_id == previous.id,
|
||||
)
|
||||
)
|
||||
for doc in documents:
|
||||
if doc.document_type == DocumentType.BALANCE:
|
||||
doc.status = DocumentStatus.CONFIRMED
|
||||
db_session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=doc.id))
|
||||
for doc in (
|
||||
await db_session.scalars(
|
||||
select(Document)
|
||||
.join(ConsolidationDocument)
|
||||
.where(
|
||||
ConsolidationDocument.consolidation_version_id == w.version.id,
|
||||
Document.document_type == DocumentType.OSV,
|
||||
)
|
||||
)
|
||||
).all():
|
||||
doc.period_year = 2025
|
||||
await db_session.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{w.version.id}/goodwill"
|
||||
row = (await client.get(base)).json()["rows"][0]
|
||||
assert row["charter_capital_amount"] == "100000.00"
|
||||
db_session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=previous.id))
|
||||
await db_session.flush()
|
||||
row = (await client.get(base)).json()["rows"][0]
|
||||
assert row["charter_capital_amount"] is None
|
||||
assert row["warnings"][0]["code"] == "CAPITAL_SOURCE_AMBIGUOUS"
|
||||
|
||||
|
||||
async def test_quarterly_bu_and_sum_are_preserved(db_session, auth_client, fixtures_dir):
|
||||
w = await goodwill_world(db_session, fixtures_dir)
|
||||
# Заменяем нарастающий 58.01 тремя квартальными источниками с двумя видами учёта.
|
||||
await db_session.execute(
|
||||
delete(ConsolidationDocument).where(
|
||||
ConsolidationDocument.consolidation_version_id == w.version.id,
|
||||
ConsolidationDocument.document_id.in_(select(Document.id).where(Document.osv_account_code == "58.01")),
|
||||
)
|
||||
)
|
||||
account = (await db_session.scalars(select(ChartOfAccount).where(ChartOfAccount.code == "58.01"))).one()
|
||||
raw = investment_rows(fixtures_dir)[0]
|
||||
for quarter in (PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3):
|
||||
doc = await make_confirmed_osv(
|
||||
db_session,
|
||||
entity=w.a,
|
||||
account="58.01",
|
||||
year=2024,
|
||||
quarter=quarter,
|
||||
is_cumulative=False,
|
||||
rows=[
|
||||
dict(
|
||||
name=raw["counterparty_name"],
|
||||
counterparty_id=w.b.id,
|
||||
account_id=account.id,
|
||||
closing_debit=Decimal(raw["closing_debit"]),
|
||||
data_type=kind,
|
||||
)
|
||||
for kind in (OsvDataType.BU, OsvDataType.SUM)
|
||||
],
|
||||
)
|
||||
db_session.add(ConsolidationDocument(consolidation_version_id=w.version.id, document_id=doc.id))
|
||||
await db_session.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
table = (await client.get(f"/consolidations/versions/{w.version.id}/goodwill")).json()
|
||||
assert table["totals"]["financial_investment_amount"] == "539994600.00"
|
||||
|
||||
|
||||
async def test_table_loads_osv_rows_once(db_session, auth_client, fixtures_dir, monkeypatch):
|
||||
world = await goodwill_world(db_session, fixtures_dir)
|
||||
original = consolidation_repo.load_osv_rows
|
||||
calls = 0
|
||||
|
||||
async def counted(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return await original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(consolidation_repo, "load_osv_rows", counted)
|
||||
client = await auth_client(KA_RF)
|
||||
response = await client.get(f"/consolidations/versions/{world.version.id}/goodwill")
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
assert response.json()["rows"][0]["financial_investment_amount"] == "269997300.00"
|
||||
assert calls == 1
|
||||
|
||||
|
||||
async def test_null_closing_debit_does_not_block_pair_calculation(db_session, auth_client, fixtures_dir):
|
||||
world = await goodwill_world(db_session, fixtures_dir)
|
||||
entry = (
|
||||
await db_session.scalars(
|
||||
select(OsvEntry).where(OsvEntry.owner_legal_entity_id == world.a.id, OsvEntry.account_code == "58.01")
|
||||
)
|
||||
).one()
|
||||
db_session.add(
|
||||
OsvEntry(
|
||||
document_id=entry.document_id,
|
||||
recognition_job_id=entry.recognition_job_id,
|
||||
owner_legal_entity_id=entry.owner_legal_entity_id,
|
||||
row_number=entry.row_number + 1,
|
||||
account_id=entry.account_id,
|
||||
account_code=entry.account_code,
|
||||
account_root=entry.account_root,
|
||||
group_type=entry.group_type,
|
||||
data_type=entry.data_type,
|
||||
counterparty_id=entry.counterparty_id,
|
||||
counterparty_name=entry.counterparty_name,
|
||||
closing_debit=None,
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{world.version.id}"
|
||||
assert (await client.post(base + "/lock")).status_code == HTTP_CREATED
|
||||
response = await client.put(
|
||||
base + f"/goodwill/{world.a.id}/{world.b.id}",
|
||||
json=dict(ownership_share="100", include_in_kfo=True, comment="Подтверждено"),
|
||||
)
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
row = response.json()
|
||||
assert row["financial_investment_amount"] == "269997300.00"
|
||||
assert row["participation_amount"] == "100000000.00"
|
||||
assert row["goodwill_amount"] == "169997300.00"
|
||||
assert row["warnings"] == []
|
||||
155
api/tests/test_api_goodwill_mutations.py
Normal file
155
api/tests/test_api_goodwill_mutations.py
Normal file
@ -0,0 +1,155 @@
|
||||
"""Проверка ввода строки до обращения к хранилищу."""
|
||||
|
||||
from decimal import Decimal
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit
|
||||
from src.common.exceptions import ConflictError, DomainValidationError
|
||||
from src.models.audit_ex import AuditEvent
|
||||
from src.models.enums import AuditAction
|
||||
from src.models.kfo import Goodwill, KfoResult
|
||||
from src.models.osv import OsvEntry
|
||||
from src.models.statements import FinancialStatementValue
|
||||
from src.schemas.goodwill import GoodwillRowIn
|
||||
from src.services.goodwill import validate_decision
|
||||
from tests.test_api_consolidated_osv import KA_RF
|
||||
from tests.test_api_goodwill import goodwill_world
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["-1", "100.0001", "0.00001", "NaN", "Infinity", "abc", ""])
|
||||
def test_invalid_share(value):
|
||||
with pytest.raises(DomainValidationError) as error:
|
||||
validate_decision(GoodwillRowIn(ownership_share=value, include_in_kfo=False))
|
||||
assert error.value.code == "INVALID_SHARE"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "100", "12.3456", "12,3456"])
|
||||
def test_valid_share(value):
|
||||
result = validate_decision(GoodwillRowIn(ownership_share=value, include_in_kfo=True, comment=" Обоснование "))
|
||||
assert str(result.ownership_share) == value.replace(",", ".")
|
||||
assert result.comment == "Обоснование"
|
||||
|
||||
|
||||
def test_required_fields():
|
||||
assert validate_decision(GoodwillRowIn(ownership_share=None, include_in_kfo=False)).ownership_share is None
|
||||
with pytest.raises(DomainValidationError) as error:
|
||||
validate_decision(GoodwillRowIn(ownership_share=None, include_in_kfo=True, comment="Да"))
|
||||
assert error.value.code == "INVALID_SHARE"
|
||||
with pytest.raises(DomainValidationError) as error:
|
||||
validate_decision(GoodwillRowIn(ownership_share="100", include_in_kfo=True, comment=" "))
|
||||
assert error.value.code == "COMMENT_REQUIRED"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.fixtures
|
||||
async def test_save_settings_and_disappearing_pair(db_session, auth_client, fixtures_dir):
|
||||
|
||||
w = await goodwill_world(db_session, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{w.version.id}"
|
||||
path = base + f"/goodwill/{w.a.id}/{w.b.id}"
|
||||
body = dict(ownership_share="80.1234", include_in_kfo=True, comment="Подтверждено")
|
||||
response = await client.put(path, json=body)
|
||||
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "LOCK_REQUIRED"
|
||||
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
|
||||
other = await auth_client("rka@armka.local")
|
||||
rejected = await other.put(path, json=body)
|
||||
assert rejected.status_code == HTTPStatus.CONFLICT
|
||||
assert rejected.json()["detail"]["code"] == "LOCKED_BY_OTHER"
|
||||
response = await client.put(path, json=body)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
row = response.json()
|
||||
assert row["ownership_share"] == "80.1234"
|
||||
assert row["participation_amount"] == "80123400.00"
|
||||
assert row["goodwill_amount"] == "189873900.00"
|
||||
again = await client.put(path, json=body)
|
||||
assert again.status_code == HTTPStatus.OK
|
||||
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 1
|
||||
on = await client.put(base + "/goodwill-settings", json=dict(goodwill_enabled=True))
|
||||
assert on.status_code == HTTPStatus.OK
|
||||
assert on.json()["totals"]["goodwill_in_kfo_amount"] == "189873900.00"
|
||||
audit_count = await db_session.scalar(
|
||||
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.GOODWILL_EDIT)
|
||||
)
|
||||
assert (
|
||||
await client.put(base + "/goodwill-settings", json=dict(goodwill_enabled=True))
|
||||
).status_code == HTTPStatus.OK
|
||||
assert audit_count == await db_session.scalar(
|
||||
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.GOODWILL_EDIT)
|
||||
)
|
||||
off = await client.put(base + "/goodwill-settings", json=dict(goodwill_enabled=False))
|
||||
assert off.json()["totals"]["goodwill_in_kfo_amount"] == "0.00"
|
||||
assert off.json()["rows"][0]["ownership_share"] == "80.1234"
|
||||
body["include_in_kfo"] = False
|
||||
assert (await client.put(path, json=body)).status_code == HTTPStatus.OK
|
||||
assert (await client.get(base + "/goodwill")).json()["totals"]["goodwill_amount"] == "0.00"
|
||||
entries = list(
|
||||
(
|
||||
await db_session.scalars(
|
||||
select(OsvEntry).where(OsvEntry.account_code == "58.01", OsvEntry.owner_legal_entity_id == w.a.id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for entry in entries:
|
||||
entry.closing_debit = 0
|
||||
await db_session.flush()
|
||||
row = (await client.get(base + "/goodwill")).json()["rows"][0]
|
||||
assert not row["has_investments"] and row["goodwill_amount"] is None
|
||||
assert row["comment"] == "Подтверждено"
|
||||
assert (await client.put(path, json=body)).status_code == HTTPStatus.OK
|
||||
body["include_in_kfo"] = True
|
||||
response = await client.put(path, json=body)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["financial_investment_amount"] is None
|
||||
assert response.json()["goodwill_amount"] is None
|
||||
assert await db_session.scalar(select(func.count()).select_from(KfoResult)) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.fixtures
|
||||
async def test_mutation_validation_and_rollback(db_session, auth_client, fixtures_dir, monkeypatch):
|
||||
|
||||
w = await goodwill_world(db_session, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{w.version.id}"
|
||||
path = base + f"/goodwill/{w.a.id}/{w.b.id}"
|
||||
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
|
||||
body = dict(ownership_share="100", include_in_kfo=True, comment="")
|
||||
response = await client.put(path, json=body)
|
||||
assert (
|
||||
response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
and response.json()["detail"]["code"] == "COMMENT_REQUIRED"
|
||||
)
|
||||
body.update(ownership_share="100.00001", comment="Да")
|
||||
assert (await client.put(path, json=body)).json()["detail"]["code"] == "INVALID_SHARE"
|
||||
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
|
||||
|
||||
async def reject_audit(*args, **kwargs):
|
||||
raise ConflictError("TEST_AUDIT_FAILURE", "Ошибка тестового аудита")
|
||||
|
||||
monkeypatch.setattr(RequestAudit, "record", reject_audit)
|
||||
body["ownership_share"] = "100"
|
||||
response = await client.put(path, json=body)
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.fixtures
|
||||
async def test_money_overflow_keeps_database_unchanged(db_session, auth_client, fixtures_dir):
|
||||
w = await goodwill_world(db_session, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{w.version.id}"
|
||||
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
|
||||
value = (await db_session.scalars(select(FinancialStatementValue))).one()
|
||||
value.amount = Decimal("9999999999999999.99")
|
||||
await db_session.flush()
|
||||
response = await client.put(
|
||||
base + f"/goodwill/{w.a.id}/{w.b.id}", json=dict(ownership_share="100", include_in_kfo=True, comment="Проверка")
|
||||
)
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert response.json()["detail"]["code"] == "MONEY_OUT_OF_RANGE"
|
||||
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
|
||||
22
api/tests/test_goodwill_contract.py
Normal file
22
api/tests/test_goodwill_contract.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""Локальный OpenAPI совпадает с четырьмя маршрутами приложения."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from src.app import app
|
||||
from src.schemas.goodwill import GoodwillRowIn
|
||||
from src.services.goodwill import validate_decision
|
||||
|
||||
|
||||
def test_contract_matches_application():
|
||||
contract = yaml.safe_load(
|
||||
(Path(__file__).resolve().parents[2] / "goodwill/api-contract-goodwill.openapi.yaml").read_text()
|
||||
)
|
||||
actual = app.openapi()
|
||||
paths = {key: value for key, value in actual["paths"].items() if "/goodwill" in key}
|
||||
assert contract["paths"] == paths
|
||||
for name, schema in contract["components"]["schemas"].items():
|
||||
assert schema == actual["components"]["schemas"][name]
|
||||
data = GoodwillRowIn(ownership_share="80.1234", include_in_kfo=True, comment="Доля подтверждена")
|
||||
assert str(validate_decision(data).ownership_share) == "80.1234"
|
||||
227
api/tests/test_goodwill_contribution.py
Normal file
227
api/tests/test_goodwill_contribution.py
Normal file
@ -0,0 +1,227 @@
|
||||
"""Гудвилл изменяет актив и пассив на одинаковую сумму, не смешивает пары."""
|
||||
|
||||
from decimal import Decimal
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from src.common.exceptions import ConflictError
|
||||
from src.domain.goodwill import Decision, Investment, build, contributions
|
||||
from src.domain.policy import VisibilityScope
|
||||
from src.models.access import AppUser
|
||||
from src.models.accounting import ChartOfAccount, EliminationRule
|
||||
from src.models.consolidation import ConsolidationDocument
|
||||
from src.models.enums import DocumentType, PeriodQuarter, StatementType
|
||||
from src.models.files import StoredFile
|
||||
from src.models.kfo import Correction, Goodwill, KfoResult
|
||||
from src.models.osv import OsvEntry
|
||||
from src.models.statements import FinancialStatementMeta
|
||||
from src.repositories import consolidations as version_repo
|
||||
from src.repositories import goodwill as goodwill_repo
|
||||
from src.services.consolidated_osv import ConsolidatedOsvService
|
||||
from src.services.goodwill import GoodwillService
|
||||
from src.services.goodwill_source import GoodwillSource
|
||||
from tests.factories import make_confirmed_osv
|
||||
from tests.test_api_consolidated_osv import KA_RF
|
||||
from tests.test_api_goodwill import goodwill_world
|
||||
from tests.test_goodwill_golden import investment_rows
|
||||
|
||||
BB_RULE_NO = 2
|
||||
|
||||
pytestmark = pytest.mark.fixtures
|
||||
|
||||
|
||||
def test_contribution_balance(fixtures_dir: Path) -> None:
|
||||
a, b, version, rule = (UUID(int=i) for i in range(1, 5))
|
||||
fv = Decimal(investment_rows(fixtures_dir)[0]["closing_debit"])
|
||||
saved = {(a, b): Decision(Decimal("80"), True, "Подтверждено")}
|
||||
rows = build([Investment(a, b, fv)], saved, {b: Decimal("100000000")})
|
||||
item = contributions(version, rule, rows, True)[0]
|
||||
assert item.rule_id == rule and item.head_id == a and item.subsidiary_id == b
|
||||
assert sum(v for _, code, v in item.deltas if code in ("1170", "GOODWILL")) == -item.participation_amount
|
||||
assert dict((code, value) for _, code, value in item.deltas)["1310"] == -item.participation_amount
|
||||
assert contributions(version, rule, rows, False) == []
|
||||
assert contributions(version, rule, build([Investment(a, b, fv)], saved, {}), True) == []
|
||||
assert contributions(version, rule, build([Investment(a, b, fv)], saved, {b: fv * 2}), True) == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_service_uses_pinned_rule_without_writing_kfo(db_session, auth_client, fixtures_dir):
|
||||
world = await goodwill_world(db_session, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{world.version.id}"
|
||||
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
|
||||
assert (
|
||||
await client.put(
|
||||
base + f"/goodwill/{world.a.id}/{world.b.id}",
|
||||
json=dict(ownership_share="80", include_in_kfo=True, comment="Да"),
|
||||
)
|
||||
).status_code == HTTPStatus.OK
|
||||
assert (await client.put(base + "/goodwill-settings", json={"goodwill_enabled": True})).status_code == HTTPStatus.OK
|
||||
pinned = (
|
||||
await db_session.scalars(
|
||||
select(EliminationRule).where(
|
||||
EliminationRule.version_no == 1,
|
||||
EliminationRule.rule_no == BB_RULE_NO,
|
||||
EliminationRule.statement_type == StatementType.balance,
|
||||
)
|
||||
)
|
||||
).one()
|
||||
other = EliminationRule(
|
||||
**{
|
||||
col.name: getattr(pinned, col.name)
|
||||
for col in EliminationRule.__table__.columns
|
||||
if col.name not in ("id", "version_no")
|
||||
},
|
||||
version_no=2,
|
||||
)
|
||||
db_session.add(other)
|
||||
await db_session.flush()
|
||||
principal_user = (await db_session.scalars(select(AppUser).where(AppUser.email == KA_RF))).one()
|
||||
scope = VisibilityScope(office_id=None)
|
||||
service = GoodwillService(
|
||||
db_session,
|
||||
scope,
|
||||
SimpleNamespace(user=principal_user, permissions={"CONSOLIDATION_EDIT"}),
|
||||
GoodwillSource(db_session, ConsolidatedOsvService(db_session, scope)),
|
||||
)
|
||||
result = await service.contributions(world.version.id)
|
||||
assert len(result) == 1 and result[0].rule_id == pinned.id
|
||||
assert await db_session.scalar(select(func.count()).select_from(KfoResult)) == 0
|
||||
assert await db_session.scalar(select(func.count()).select_from(Correction)) == 0
|
||||
# Дорезолв на чтение сохраняет те же выбранные пары и суммы.
|
||||
entry = (
|
||||
await db_session.scalars(
|
||||
select(OsvEntry).where(OsvEntry.owner_legal_entity_id == world.a.id, OsvEntry.account_code == "58.01")
|
||||
)
|
||||
).one()
|
||||
entry.counterparty_id = None
|
||||
entry.counterparty_name = world.b.name
|
||||
await db_session.flush()
|
||||
assert await service.contributions(world.version.id) == result
|
||||
pinned.rule_no = 99
|
||||
await db_session.flush()
|
||||
with pytest.raises(ConflictError) as error:
|
||||
await service.contributions(world.version.id)
|
||||
assert error.value.code == "GOODWILL_RULE_MISSING"
|
||||
|
||||
world.version.goodwill_enabled = False
|
||||
await db_session.flush()
|
||||
assert await service.contributions(world.version.id) == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_kfo_loads_only_saved_selected_pairs(db_session, auth_client, fixtures_dir, monkeypatch):
|
||||
world = await goodwill_world(db_session, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{world.version.id}"
|
||||
await client.post(base + "/lock")
|
||||
await client.put(
|
||||
base + f"/goodwill/{world.a.id}/{world.b.id}",
|
||||
json=dict(ownership_share="80", include_in_kfo=True, comment="Да"),
|
||||
)
|
||||
await client.put(base + "/goodwill-settings", json={"goodwill_enabled": True})
|
||||
db_session.add(
|
||||
Goodwill(
|
||||
consolidation_version_id=world.version.id,
|
||||
head_legal_entity_id=world.a.id,
|
||||
subsidiary_legal_entity_id=world.chkf.id,
|
||||
ownership_share=Decimal("100"),
|
||||
include_in_kfo=False,
|
||||
)
|
||||
)
|
||||
account = (await db_session.scalars(select(ChartOfAccount).where(ChartOfAccount.code == "58.01"))).one()
|
||||
for owner, other in ((world.a, world.chkf), (world.b, world.a)):
|
||||
doc = await make_confirmed_osv(
|
||||
db_session,
|
||||
entity=owner,
|
||||
account="58.01",
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q3,
|
||||
is_cumulative=True,
|
||||
rows=[
|
||||
dict(
|
||||
name=other.name,
|
||||
counterparty_id=other.id,
|
||||
account_id=account.id,
|
||||
closing_debit=Decimal("269997300.00"),
|
||||
)
|
||||
],
|
||||
)
|
||||
db_session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=doc.id))
|
||||
extra = await make_confirmed_osv(
|
||||
db_session, entity=world.a, account="80", year=2024, quarter=PeriodQuarter.Q3, is_cumulative=True, rows=[]
|
||||
)
|
||||
extra.document_type = DocumentType.BALANCE
|
||||
extra.osv_account_code = None
|
||||
file = await db_session.get(StoredFile, extra.stored_file_id)
|
||||
meta = FinancialStatementMeta(
|
||||
document_id=extra.id,
|
||||
recognition_job_id=file.current_recognition_job_id,
|
||||
statement_type=StatementType.balance,
|
||||
units="тыс. руб.",
|
||||
)
|
||||
db_session.add(meta)
|
||||
db_session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=extra.id))
|
||||
await db_session.flush()
|
||||
original_rows, original_forms = version_repo.load_osv_rows, goodwill_repo.forms
|
||||
loaded_pairs, loaded_form_owners = [], []
|
||||
|
||||
async def capture_rows(*args, **kwargs):
|
||||
assert kwargs["company_pairs"] == {(world.a.id, world.b.id)}
|
||||
assert account.id in kwargs["account_ids"]
|
||||
rows = await original_rows(*args, **kwargs)
|
||||
loaded_pairs.extend((e.owner_legal_entity_id, e.counterparty_id) for e, _, _ in rows)
|
||||
return rows
|
||||
|
||||
async def capture_forms(*args, **kwargs):
|
||||
rows = await original_forms(*args, **kwargs)
|
||||
loaded_form_owners.extend(doc.legal_entity_id for doc, _ in rows)
|
||||
return rows
|
||||
|
||||
monkeypatch.setattr(version_repo, "load_osv_rows", capture_rows)
|
||||
monkeypatch.setattr(goodwill_repo, "forms", capture_forms)
|
||||
user = (await db_session.scalars(select(AppUser).where(AppUser.email == KA_RF))).one()
|
||||
scope = VisibilityScope(office_id=None)
|
||||
source = GoodwillSource(db_session, ConsolidatedOsvService(db_session, scope))
|
||||
source.load = AsyncMock(side_effect=AssertionError("Полная таблица для КФО не нужна"))
|
||||
service = GoodwillService(db_session, scope, SimpleNamespace(user=user), source)
|
||||
result = await service.contributions(world.version.id)
|
||||
assert len(result) == 1
|
||||
assert {(r.head_id, r.subsidiary_id) for r in result} == {(world.a.id, world.b.id)}
|
||||
assert loaded_pairs and set(loaded_pairs) == {(world.a.id, world.b.id)}
|
||||
assert loaded_form_owners == [world.b.id]
|
||||
source.load.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_kfo_without_selected_rows_does_not_load_sources(db_session, auth_client, fixtures_dir):
|
||||
world = await goodwill_world(db_session, fixtures_dir)
|
||||
world.version.goodwill_enabled = True
|
||||
await db_session.flush()
|
||||
user = (await db_session.scalars(select(AppUser).where(AppUser.email == KA_RF))).one()
|
||||
scope = VisibilityScope(office_id=None)
|
||||
source = GoodwillSource(db_session, ConsolidatedOsvService(db_session, scope))
|
||||
source.load_for_kfo = AsyncMock(side_effect=AssertionError("Нет выбранных пар — нет источников"))
|
||||
service = GoodwillService(db_session, scope, SimpleNamespace(user=user), source)
|
||||
assert await service.contributions(world.version.id) == []
|
||||
source.load_for_kfo.assert_not_awaited()
|
||||
db_session.add(
|
||||
Goodwill(
|
||||
consolidation_version_id=world.version.id,
|
||||
head_legal_entity_id=world.a.id,
|
||||
subsidiary_legal_entity_id=world.b.id,
|
||||
ownership_share=Decimal("100"),
|
||||
include_in_kfo=True,
|
||||
comment="Да",
|
||||
)
|
||||
)
|
||||
world.version.goodwill_enabled = False
|
||||
await db_session.flush()
|
||||
assert await service.contributions(world.version.id) == []
|
||||
source.load_for_kfo.assert_not_awaited()
|
||||
52
api/tests/test_goodwill_domain.py
Normal file
52
api/tests/test_goodwill_domain.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""Расчёт и жизненный цикл на реальной сумме вложений Приманыческого."""
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from src.domain.goodwill import Decision, Investment, build, totals
|
||||
from tests.test_goodwill_golden import investment_rows
|
||||
|
||||
pytestmark = pytest.mark.fixtures
|
||||
A, B, C = (UUID(int=i) for i in (1, 2, 3))
|
||||
|
||||
|
||||
def test_calculation_and_lifecycle(fixtures_dir: Path) -> None:
|
||||
fv = Decimal(investment_rows(fixtures_dir)[0]["closing_debit"])
|
||||
source = [Investment(A, B, fv)]
|
||||
rows = build(source, {}, {B: Decimal("100000.00")})
|
||||
assert rows[0].goodwill_amount is None
|
||||
saved = {(A, B): Decision(Decimal("12.3456"), True, "Обоснование")}
|
||||
row = build(source, saved, {B: Decimal("100000.00")})[0]
|
||||
assert row.participation_amount == Decimal("12345.60")
|
||||
assert row.goodwill_amount == fv - Decimal("12345.60")
|
||||
gone = build([], saved, {})[0]
|
||||
assert not gone.has_investments and gone.goodwill_amount is None
|
||||
assert gone.financial_investment_amount is None
|
||||
assert gone.decision == saved[A, B]
|
||||
assert totals([gone], True)["financial_investment_amount"] == 0
|
||||
assert build(source, saved, {B: Decimal("100000.00")})[0] == row
|
||||
|
||||
|
||||
def test_zero_missing_capital_and_warning(fixtures_dir: Path) -> None:
|
||||
fv = Decimal(investment_rows(fixtures_dir)[0]["closing_debit"])
|
||||
source = [Investment(A, B, fv), Investment(C, B, fv)]
|
||||
saved = {(A, B): Decision(Decimal("70"), True, "A"), (C, B): Decision(Decimal("40"), True, "C")}
|
||||
rows = build(source, saved, {B: Decimal("0")})
|
||||
assert all(r.participation_amount == 0 and r.goodwill_amount == fv for r in rows)
|
||||
assert all("OWNERSHIP_TOTAL_EXCEEDED" in r.warnings for r in rows)
|
||||
assert totals(rows, False)["goodwill_in_kfo_amount"] == 0
|
||||
assert all(r.goodwill_amount is None for r in build(source, saved, {}))
|
||||
|
||||
|
||||
def test_rounding_and_unselected(fixtures_dir: Path) -> None:
|
||||
fv = Decimal(investment_rows(fixtures_dir)[0]["closing_debit"])
|
||||
source = [Investment(A, B, fv)]
|
||||
row = build(source, {(A, B): Decision(Decimal("0.0001"), False, None)}, {B: Decimal("5000")})[0]
|
||||
assert row.participation_amount is None
|
||||
selected = build(source, {(A, B): Decision(Decimal("0.0001"), True, "Подтверждено")}, {B: Decimal("5000")})[0]
|
||||
assert selected.participation_amount == Decimal("0.01")
|
||||
assert totals([row], True)["goodwill_amount"] == 0
|
||||
assert totals([row], True)["financial_investment_amount"] == fv
|
||||
78
api/tests/test_goodwill_export.py
Normal file
78
api/tests/test_goodwill_export.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""XLSX: пустые расчёты, группировка, итоги и аудит через настоящий API."""
|
||||
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from openpyxl import load_workbook
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from src.models.audit_ex import AuditEvent
|
||||
from src.models.enums import AuditAction
|
||||
from src.models.osv import OsvEntry
|
||||
from tests.test_api_consolidated_osv import KA_RF
|
||||
from tests.test_api_goodwill import goodwill_world
|
||||
|
||||
INVESTMENT_AMOUNT = 269997300
|
||||
FRONTEND_COLUMN_COUNT = 8
|
||||
EMPTY_EXPORT_ROW_COUNT = 2
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
|
||||
|
||||
|
||||
async def test_export_matches_server_table(db_session, auth_client, fixtures_dir):
|
||||
world = await goodwill_world(db_session, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{world.version.id}"
|
||||
response = await client.get(base + "/goodwill/export")
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert "filename*=" in response.headers["content-disposition"]
|
||||
sheet = load_workbook(BytesIO(response.content)).active
|
||||
assert sheet.max_column == FRONTEND_COLUMN_COUNT
|
||||
assert sheet.cell(1, 1).value == "Учет в отчете"
|
||||
assert sheet.cell(1, 2).value == "Наименование дочерней организации"
|
||||
assert sheet.cell(2, 2).value == world.a.name
|
||||
assert sheet.cell(3, 2).value == world.b.name
|
||||
assert sheet.cell(3, 4).value == INVESTMENT_AMOUNT
|
||||
assert sheet.cell(3, 5).value is None
|
||||
assert sheet.cell(3, 7).value is None
|
||||
assert sheet.cell(4, 2).value == "Всего"
|
||||
assert sheet.cell(5, 2).value == "Итого по листу"
|
||||
assert sheet.cell(5, 4).value == sheet.cell(4, 4).value == sheet.cell(3, 4).value
|
||||
assert sheet.cell(5, 4).fill.patternType is None
|
||||
event = (await db_session.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
|
||||
assert event.after_value == {"report": "goodwill", "rows": 1}
|
||||
# Нерассчитанные строки не считаются нулевыми; итоги фильтр не меняет (PDF §4).
|
||||
filtered = await client.get(base + "/goodwill/export?sign=zero")
|
||||
filtered_sheet = load_workbook(BytesIO(filtered.content)).active
|
||||
assert filtered_sheet.max_row == EMPTY_EXPORT_ROW_COUNT
|
||||
assert filtered_sheet.cell(2, 2).value == "Итого по листу"
|
||||
assert filtered_sheet.cell(2, 4).value == INVESTMENT_AMOUNT
|
||||
assert (await client.get(base + "/goodwill/export?sign=unknown")).status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
|
||||
body = dict(ownership_share="100", include_in_kfo=True, comment="=1+1")
|
||||
path = base + f"/goodwill/{world.a.id}/{world.b.id}"
|
||||
assert (await client.put(path, json=body)).status_code == HTTPStatus.OK
|
||||
response = await client.get(base + "/goodwill/export")
|
||||
sheet = load_workbook(BytesIO(response.content)).active
|
||||
assert sheet.cell(3, 8).data_type == "s"
|
||||
assert sheet.cell(3, 8).value == "=1+1"
|
||||
assert sheet.cell(3, 7).value == sheet.cell(5, 7).value
|
||||
filtered = await client.get(base + "/goodwill/export?sign=negative")
|
||||
filtered_sheet = load_workbook(BytesIO(filtered.content)).active
|
||||
assert filtered_sheet.max_row == EMPTY_EXPORT_ROW_COUNT
|
||||
assert filtered_sheet.cell(2, 2).value == "Итого по листу"
|
||||
assert filtered_sheet.cell(2, 4).value == INVESTMENT_AMOUNT
|
||||
assert filtered_sheet.cell(2, 7).value == sheet.cell(5, 7).value
|
||||
|
||||
await db_session.execute(
|
||||
update(OsvEntry)
|
||||
.where(OsvEntry.owner_legal_entity_id == world.a.id, OsvEntry.account_code == "58.01")
|
||||
.values(closing_debit=0)
|
||||
)
|
||||
response = await client.get(base + "/goodwill/export")
|
||||
sheet = load_workbook(BytesIO(response.content)).active
|
||||
assert sheet.cell(3, 4).value is None
|
||||
assert sheet.cell(3, 7).value is None
|
||||
assert sheet.cell(5, 4).value == 0
|
||||
assert sheet.cell(5, 7).value == 0
|
||||
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"}
|
||||
59
api/tests/test_goodwill_source.py
Normal file
59
api/tests/test_goodwill_source.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Выбор дерева счетов и отчётной колонки без догадок по is_current."""
|
||||
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace as N
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.models.accounting import ChartOfAccount
|
||||
from src.models.enums import StatementLineStatus
|
||||
from src.repositories.goodwill import accounts
|
||||
from src.services.goodwill_source import capital_amounts
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_accounts_query_loads_only_investment_subtree(seeded) -> None:
|
||||
root = (await seeded.scalars(select(ChartOfAccount).where(ChartOfAccount.code == "58.01"))).one()
|
||||
child = ChartOfAccount(
|
||||
code="GOODWILL_CHILD",
|
||||
name="Потомок 58.01",
|
||||
parent_id=root.id,
|
||||
kind=root.kind,
|
||||
row_type=root.row_type,
|
||||
)
|
||||
sibling = ChartOfAccount(
|
||||
code="58.0199",
|
||||
name="Похожий код вне ветки",
|
||||
parent_id=root.parent_id,
|
||||
kind=root.kind,
|
||||
row_type=root.row_type,
|
||||
)
|
||||
seeded.add_all([child, sibling])
|
||||
await seeded.flush()
|
||||
selected = await accounts(seeded)
|
||||
ids = {account.id for account in selected}
|
||||
assert {root.id, child.id} <= ids
|
||||
assert sibling.id not in ids
|
||||
assert all(account.id == root.id or account.parent_id in ids for account in selected)
|
||||
|
||||
|
||||
def test_capital_aggregates_duplicates_at_period_end() -> None:
|
||||
mid, col = UUID(int=1), UUID(int=2)
|
||||
columns = [
|
||||
N(id=col, meta_id=mid, period_date=dt.date(2025, 9, 30), is_current=False),
|
||||
N(id=UUID(int=3), meta_id=mid, period_date=dt.date(2024, 12, 31), is_current=True),
|
||||
]
|
||||
values = [
|
||||
(
|
||||
N(id=UUID(int=i), meta_id=mid, line_code="1310", row_index=i, line_status=StatementLineStatus.ok),
|
||||
col,
|
||||
Decimal(amount),
|
||||
)
|
||||
for i, amount in [(4, "100"), (5, "20")]
|
||||
]
|
||||
assert capital_amounts(mid, "тыс. руб.", columns, values, dt.date(2025, 9, 30)) == (Decimal("120000"), None)
|
||||
assert capital_amounts(mid, "тыс. руб.", columns, values, dt.date(2025, 6, 30)) == (None, None)
|
||||
assert capital_amounts(mid, None, columns, values, dt.date(2025, 9, 30)) == (None, None)
|
||||
@ -82,6 +82,13 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
||||
# Корректировки, первый этап
|
||||
"/api/v1/consolidations/versions/{version_id}/corrections": {"get"},
|
||||
"/api/v1/consolidations/versions/{version_id}/corrections/export": {"get"},
|
||||
# Таблица гудвилла (goodwill-table)
|
||||
"/api/v1/consolidations/versions/{version_id}/goodwill": {"get"},
|
||||
"/api/v1/consolidations/versions/{version_id}/goodwill-settings": {"put"},
|
||||
"/api/v1/consolidations/versions/{version_id}/goodwill/{head_legal_entity_id}/{subsidiary_legal_entity_id}": {
|
||||
"put"
|
||||
},
|
||||
"/api/v1/consolidations/versions/{version_id}/goodwill/export": {"get"},
|
||||
# шахматка ВГО и правила элиминации
|
||||
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
|
||||
"/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"},
|
||||
|
||||
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
|
||||
72
openspec/changes/goodwill-table/design.md
Normal file
72
openspec/changes/goodwill-table/design.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
Проверены модели Goodwill и KfoResult, ConsolidatedOsvService, CorrectionService, consolidation_guard, текущая DBML v5.24, §8 domain-rules и PDF DOC-259025. Таблица goodwill уже содержит nullable суммы и numeric(7,4); audit_action содержит GOODWILL_EDIT. Расчётного сервиса КФО и признака устаревания нет. Контекст взят из конфигурации проекта и общей документации.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** отделить чистые вычисления от загрузки источников и ручного ввода; повторно использовать существующие правила выбора ОСВ и guard; обеспечить единый расчёт для чтения, сохранения, выгрузки и будущего КФО.
|
||||
|
||||
**Non-Goals:** новый движок КФО, изменение его таблиц и корректировок, UI, изменение workflow OpenSpec. Историческая фиксация VERIFIED не реализована этим этапом. Это ограничение реализации относительно PDF, а не новое правило: не объявлять текущие источники историческим снимком.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Приоритет требований
|
||||
|
||||
Явные решения пользователя имеют приоритет над PDF, затем PDF, затем MD/OpenAPI. Четыре знака доли, построчное PUT и XLSX намеренно отличаются от PDF. Сохраняем четыре пути MD. is_head_entity полностью исключается. ASSUMPTION(OQ-10): источник УК — только ББ 1310; 1350 можно показывать справочно, но не включать в P. ASSUMPTION(OQ-37): вклад использует GOODWILL. Подтверждение другого источника УК или формата экспорта требует обновления этого change перед соответствующей реализацией.
|
||||
|
||||
### 2. Слои и источники
|
||||
|
||||
Добавить доменный модуль расчёта гудвилла с типами без ORM: пара, источники, ручной ввод, рассчитанная строка, итоги и вклад КФО. Репозиторий гудвилла загружает сохранённые строки, дерево счетов и привязанные ББ пакетно. Адаптер источников гудвилла преобразует полный результат ConsolidatedOsvService, без пагинации: использовать его выбор периодов и нормализацию денежных строк, а не разнесение VGO. Альтернатива — брать только назначенные ББ-2 позиции — отвергнута: PDF определяет вложения независимо от ручного назначения ВГО.
|
||||
|
||||
Отбор по chart_of_account.parent_id выполняется рекурсивным SQL от 58.01. Финансовые вложения — сумма closing_debit подходящих строк готового свода по владельцу и контрагенту внутри периметра. Собственных приоритетов документов, исключения родительских счетов по наличию потомков и предпочтения BU перед SUM нет. Используются только существующие правила сводной ОСВ; фраза PDF об исключении повторного суммирования не определяет дополнительного алгоритма между перекрывающимися файлами.
|
||||
|
||||
ББ — только подтверждённый документ, привязанный к версии, владелец ДО. Дата колонки — конец квартала (reporting_date существующей консолидации может быть первым днём следующего квартала), is_current — недостаточно. Агрегировать повтор кода 1310 общим механизмом формы и перевести units в рубли. По прямому решению пользователя отсутствие пригодной 1310 или неоднозначность ББ не блокируют сохранение ручного ввода. Нет подходящего ББ/1310/даты или неоднозначные подходящие формы — charter_capital_amount=null и warning CAPITAL_SOURCE_MISSING/AMBIGUOUS; P/G=null. Не выбирать документ случайно. Числовой 0 — полноценный источник.
|
||||
|
||||
### 3. Ручной ввод и отсутствие вложений
|
||||
|
||||
Ключ — (version_id, head_id, subsidiary_id), существующий UNIQUE. GET объединяет найденные пары с goodwill и ничего не пишет. Впервые запись создаётся при PUT строки; не материализуем все строки при чтении или смене переключателя. Это обычное сохранение по PUT из MD §3.3; GET формирует ответ без записи.
|
||||
|
||||
has_investments — вычисляемый DTO-флаг, не колонка. По прямому решению пользователя для «Нет вложений» все денежные колонки ответа равны null; сохранённый ввод и последний денежный снимок в БД не удаляются. Строка исключается из итогов и применения в КФО. Собственной ошибки GOODWILL_PAIR_INACTIVE нет. Отсутствующие доля и комментарий не затирают сохранённый ввод неактивной пары (PDF §10).
|
||||
|
||||
### 4. Деньги и DTO
|
||||
|
||||
Decimal, P.quantize(0.01, ROUND_HALF_UP), затем G=ФВ−P. Ограничение numeric(18,2) проверять до записи, переполнение отклонять 422 MONEY_OUT_OF_RANGE без частичного сохранения; float не использовать. Значения в API — рубли с двумя знаками, ownership_share — проценты с четырьмя. Максимум четыре дробных знака во входе, без автоматического округления; запятая и точка допустимы по PDF §5.2; null допускается для снятой галочки. P/G рассчитываются только у действующей отмеченной строки при наличии доли и капитала (PDF §6); итоги P/G учитывают только выбранные действующие строки. Для первоначальной строки include_in_kfo=false, доля/comment/updated_* = null.
|
||||
|
||||
GoodwillTableOut и GoodwillRowOut повторяют MD с уточнениями spec: has_investments, warnings[] из {code, message}; charter_capital_amount и additional_capital_amount тоже nullable, чтобы отсутствие ББ не маскировать нулём. totals — поля MD; в этом этапе без отдельного API групповых итогов: они выводимы из всех строк. При goodwill_enabled=false потенциальные G видны, goodwill_in_kfo_amount=0.00.
|
||||
|
||||
### 5. Транзакции и безопасность
|
||||
|
||||
Маршруты гудвилла используют зависимости по образцу API корректировок. Service использует текущие policy и lock_editable_version. PUT строки под блокировкой версии повторно загружает актуальные источники, проверяет пару и ввод, upsert существующей записи, записывает GOODWILL_EDIT before/after в той же транзакции. Вычисляемые суммы из тела не принимаются. PUT настроек не трогает строки; GOODWILL_EDIT имеет object_type=consolidation_version и changed_field=goodwill_enabled, строки — object_type=goodwill. Новые enum не нужны. Отдельная оптимистическая ревизия не добавляется: существующая блокировка версии сериализует операции, последнее сохранение владельца побеждает.
|
||||
|
||||
### 6. Граница КФО
|
||||
|
||||
Внутренний DTO/чистая функция возвращает применимые пары с version_id, head_id, subsidiary_id, FV/P/G, дельтами и ключом исключения (balance, rule_id ББ-2 закреплённой elimination_rules_version, head_id, subsidiary_id). Переключатель — дополнительное обязательное условие. При выключенном переключателе или отсутствии применимых пар вклад пуст и правило не запрашивается. Для непустого вклада нет правила ББ-2 в закреплённом наборе — подготовка вклада даёт явный конфликт GOODWILL_RULE_MISSING, не подставляет старое правило №7. GET таблицы может работать без подготовки вклада. Не записывать KfoResult и не менять статусы/суммы Correction. Не создавать фиктивный механизм инвалидирования: требование AURORA-1543 о stale и фиксация VERIFIED перенесены в будущий change КФО с явной зависимостью на этот контракт.
|
||||
|
||||
### 7. XLSX
|
||||
|
||||
Сервис экспорта гудвилла использует DTO таблицы, openpyxl и безопасную запись текстов из osv_export. Экспорт текущего серверного состояния с сохранённым вводом. Параметр sign=all|positive|negative|zero выбирает строки по текущему фильтру (PDF §4); нерассчитанные строки не являются нулевыми. Итоги сохраняются по полному составу блока и листа независимо от фильтра. По запросу пользователя экспорт использует состав и порядок восьми колонок экранной таблицы; цветовое оформление стандартное. Изменения фронта и параметров API не требуются. Денежные значения выгружаются в рублях. Добавочный капитал и статус отдельными колонками не выгружаются. null — пустая ячейка, а не 0. Аудит EXPORT по образцу corrections_export, filename* для кириллицы.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Старые MD/OpenAPI обещают все пары ГО и обязательные денежные суммы → при реализации обновить оба локальных контракта согласованно и передать совместимый контракт фронту до подключения.
|
||||
- В РБ 2024 Q3 присутствует 58.03, который не является инвестицией 58.01 → отрицательный тест исключения, не переименовывать данные ради критерия старого XML. Положительные тесты брать из реальных файлов 58 других периодов/групп.
|
||||
- Отсутствие пригодной 1310 и неоднозначность ББ → решение пользователя: null, сохранение ручного ввода разрешено.
|
||||
- Фиксация исторических источников и stale отсутствуют → текущий этап нельзя выдавать за завершённый полный AURORA-1543; приёмка интеграции КФО отдельная.
|
||||
- Новые уточнения §8 отсутствуют в общем docs → отдельный docs-change должен записать решения и нерешённые пункты в реестр открытых вопросов без выдуманных OQ-номеров.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Миграция БД не требуется: существующие поля и enum достаточны. Выкладка добавляет маршруты и обновлённый контракт; перед подключением фронта проверить nullable поля. Откат кода оставляет goodwill и аудит в совместимой схеме. Пока фронт отдельно, существующие страницы не изменяются.
|
||||
|
||||
По решению пользователя ФВ пары — сумма только заполненных closing_debit; пустые значения остальных строк не блокируют расчёт P/G и сохранение ручного ввода.
|
||||
|
||||
## Отбор источников для КФО по запросу пользователя
|
||||
|
||||
У таблицы остаётся полный load. contributions проверяет видимость/переключатель, выбирает только сохранённые
|
||||
include_in_kfo=true и вызывает load_for_kfo с направленными парами. Репозиторий ОСВ получает company_pairs и
|
||||
account_ids ветки 58.01, репозиторий ББ — идентификаторы выбранных ДО. Общая нормализация периодов и дорезолв
|
||||
сохраняются: NULL-контрагенты выбранных владельцев/счетов рассматриваются как кандидаты, итог свода после
|
||||
дорезолва фильтруется строго по выбранным парам. Невыбранные сопоставленные пары не загружаются из osv_entry.
|
||||
Без выбранных записей и при выключении нет загрузки ОСВ/ББ.
|
||||
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.
|
||||
- Подготовка чистого результата для будущего КФО: применимые пары, изменения 1170/1310/GOODWILL и исключаемое направление ББ-2. Запись КФО не выполняется.
|
||||
- Уточнения PDF, отсутствующие в §8 (неактивные пары, выборка по дереву, итоги, исключение ББ-2), требуют отдельной синхронизации в docs; при реализации не подменять ими молча старую редакцию правил.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `goodwill-table`: источники, расчёт, сохранение, доступ, экспорт и подготовка вклада в КФО.
|
||||
|
||||
### Modified Capabilities
|
||||
Нет: существующие API и требования к сводной ОСВ остаются прежними.
|
||||
|
||||
## Impact
|
||||
|
||||
Репозиторий реализации — arm-core, только api. Документация общего домена и общий контракт — отдельный согласованный change в ../docs; текущий change не изменяет соседний репозиторий.
|
||||
|
||||
Читаются таблицы DBML v5.24: consolidation, consolidation_version, consolidation_member, consolidation_document, document, osv_entry, chart_of_account, financial_statement_meta/line/column/value, legal_entity, app_user, elimination_rule. Изменяются goodwill, consolidation_version.goodwill_enabled и audit_event. Для аудита использовать существующий GOODWILL_EDIT, для выгрузки EXPORT; новых enum и колонок нет. `has_investments`, предупреждения и вклад КФО — вычисляемые DTO.
|
||||
|
||||
Затрагиваются domain/, repositories/, schemas/, services/ и endpoints/consolidations; четыре маршрута из MD. Изменение чернового контракта: новые признаки состояния и nullable P/G, обновлённая семантика пар и итогов. Используются существующие guard, Decimal и openpyxl.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Фронт AURORA-1544, пакетное сохранение и CSV.
|
||||
- Построение и запись kfo_result, применение корректировок, экран отметки «Учтено в гудвилле».
|
||||
- Механизм устаревания и фиксации снимка КФО: отдельный change; не добавлять несуществующий флаг БД.
|
||||
- ОСВ 58.03 как замена 58.01; автоматический перенос разницы в 1370.
|
||||
- Новая модель данных и окончательное решение методологов по источнику УК; XLSX пока предварительно согласован.
|
||||
107
openspec/changes/goodwill-table/specs/goodwill-table/spec.md
Normal file
107
openspec/changes/goodwill-table/specs/goodwill-table/spec.md
Normal file
@ -0,0 +1,107 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Таблица гудвилла позволяет аналитику сохранить долю и обоснование для фактических вложений между компаниями версии и получить проверяемые суммы для последующего расчёта КФО.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Фактические направленные пары
|
||||
Система SHALL находить пары ГО→ДО по ненулевой сумме closing_debit счёта 58.01 и его потомков по дереву счетов. Обе компании входят в периметр и различаются; контрагент сопоставлен. is_head_entity MUST NOT влиять на результат. Источники подчиняются правилам актуальности, периодов и исключения двойного суммирования сводной ОСВ.
|
||||
|
||||
#### Scenario: Реальная ОСВ счёта 58
|
||||
- **WHEN** строится таблица по привязанной ОСВ `fixtures/osv-dkb/primanych/2025-Q3/Оборотно-сальдовая ведомость по счету 58 за 9 месяцев 2025 г. ООО Приманыческий.xls`
|
||||
- **THEN** учитываются только подходящие строки 58.01 и потомков с контрагентами периметра; 58.03 не включается, строки ACCOUNT не включаются; собственные приоритеты перекрывающихся файлов не применяются
|
||||
|
||||
#### Scenario: Направление не зависит от признака головы
|
||||
- **WHEN** подходящие вложения найдены у двух участников периметра с любыми значениями is_head_entity
|
||||
- **THEN** каждый владелец становится ГО своих пар, включая встречные направления; пустой контрагент, самовложение и внешний контрагент не создают пары
|
||||
|
||||
### Requirement: Сохранение исчезнувших пар
|
||||
Таблица SHALL объединять текущие пары с ранее сохранёнными этой версии. has_investments=false обозначает «Нет вложений»: пара отсутствует, её сальдо нулевое или участник вышел из периметра. По решению пользователя денежные поля неактивной строки SHALL быть null. Сохранённый ввод MUST оставаться; неактивная строка не входит в итоги и вклад КФО. Чтение MUST NOT создавать или изменять записи goodwill.
|
||||
|
||||
#### Scenario: Замена источника
|
||||
- **WHEN** после сохранения пары из реальной ОСВ документ заменён и подходящих вложений больше нет
|
||||
- **THEN** строка остаётся с has_investments=false, прежними долей, комментарием и галочкой, но без вклада в итоги; повторное появление пары восстанавливает расчёт по текущим источникам
|
||||
|
||||
### Requirement: Источник капитала и расчёт
|
||||
Система SHALL использовать 1310 ББ ДО на конец отчётного периода в рублях: P=round(УК×доля/100,2), G=ФВ−P. Дубликаты 1310 обрабатываются агрегированием формы. Ноль допустим; отсутствие доли или достоверного УК даёт null P/G. 1350 не участвует: ASSUMPTION(OQ-10).
|
||||
|
||||
#### Scenario: Ноль отличается от отсутствия расчёта
|
||||
- **WHEN** из привязанного реального ББ получен УК U, доля ещё не введена
|
||||
- **THEN** P и G равны null; при доле 0 P равен 0.00 и G равен ФВ; отсутствующая 1310 не подменяется нулём
|
||||
|
||||
#### Scenario: Проверка отчётной колонки
|
||||
- **WHEN** в привязанном ББ есть несколько сравнительных колонок и единицы «тыс. руб.»
|
||||
- **THEN** УК берётся по дате конца отчётного периода, а не только is_current; сумма переводится в рубли до расчёта
|
||||
|
||||
### Requirement: Получение таблицы
|
||||
GET /api/v1/consolidations/versions/{version_id}/goodwill SHALL возвращать version_id, goodwill_enabled, editable, read_only_reason, rows, totals. Строки отсортированы по именам ГО/ДО и ID; содержат поля MD-контракта, has_investments и warnings. Деньги — строки с двумя знаками в рублях или null, доли — строки с четырьмя.
|
||||
|
||||
#### Scenario: Пустая таблица
|
||||
- **WHEN** нет подходящих вложений и сохранённых пар
|
||||
- **THEN** rows пуст, денежные итоги 0.00; запрос не создаёт goodwill
|
||||
|
||||
### Requirement: Итоги и предупреждения
|
||||
Система SHALL суммировать ФВ действующих строк независимо от выбора; P/G — только действующих выбранных рассчитанных строк. goodwill_in_kfo_amount включает только положительные применимые G и равен нулю при выключенном goodwill_enabled. Сумма заполненных долей действующих ГО в одной ДО свыше 100% даёт предупреждение, не ошибку.
|
||||
|
||||
#### Scenario: Несколько ГО одной ДО
|
||||
- **WHEN** у действующих пар одной ДО заполнены доли 70% и 40%
|
||||
- **THEN** строки содержат предупреждение OWNERSHIP_TOTAL_EXCEEDED, сохранение не блокируется; проценты по разным ДО не складываются в общий процент
|
||||
|
||||
### Requirement: Построчное сохранение
|
||||
PUT …/goodwill/{head_id}/{subsidiary_id} SHALL атомарно сохранять ownership_share, include_in_kfo, comment и расчётные суммы только одной пары. Доля допускает null только при снятом выборе, иначе 0–100 с точностью до четырёх знаков; превышение точности не округляется. Для выбранной действующей строки обязателен непустой комментарий до 1000 символов. Ошибки — INVALID_SHARE и COMMENT_REQUIRED (422).
|
||||
|
||||
#### Scenario: Снятие выбора
|
||||
- **WHEN** сохраняется действующая строка с include_in_kfo=false и прежними долей и комментарием
|
||||
- **THEN** ввод сохраняется, строка исключается из итогов P/G и вклада КФО; ответ содержит пересчитанную строку
|
||||
|
||||
#### Scenario: Повторное сохранение и неактивная строка
|
||||
- **WHEN** повторно сохраняется существующая пара
|
||||
- **THEN** новая запись не создаётся; произвольная неизвестная пара даёт 404, сохранённая неактивная пара не получает дополнительного запрета GOODWILL_PAIR_INACTIVE; отсутствующий ввод не затирает сохранённые данные
|
||||
|
||||
### Requirement: Переключатель версии
|
||||
PUT …/goodwill-settings SHALL принимать goodwill_enabled и возвращать таблицу. Выключение MUST NOT очищать строки и ручной ввод; оно отключает вклад в КФО. Изменение значения и сохранение строки SHALL записывать GOODWILL_EDIT с before/after, автором и версией в одной транзакции; повтор прежнего значения переключателя не создаёт изменение.
|
||||
|
||||
#### Scenario: Выключение и включение
|
||||
- **WHEN** пользователь выключает гудвилл и затем включает обратно
|
||||
- **THEN** доли, комментарии и галочки сохранены; вклад вновь вычисляется из актуальных данных, записи КФО не изменяются
|
||||
|
||||
### Requirement: Общие права и блокировки
|
||||
Чтение и экспорт SHALL требовать CONSOLIDATION_VIEW и видимость версии. Изменения SHALL требовать CONSOLIDATION_EDIT, редактируемый статус и собственную активную блокировку. Невидимая версия возвращает 404 NOT_FOUND; изменения без доступа — 403, без редактируемости/блокировки — 409 VERSION_NOT_EDITABLE, LOCK_REQUIRED или LOCKED_BY_OTHER.
|
||||
|
||||
#### Scenario: Просмотр завершённой версии
|
||||
- **WHEN** видимая версия VERIFIED или CANCELLED открывается либо изменяется
|
||||
- **THEN** чтение доступно с editable=false, изменение отклоняется без записи goodwill или аудита изменения
|
||||
|
||||
### Requirement: Экспорт XLSX
|
||||
GET …/goodwill/export SHALL выгружать XLSX с составом и порядком восьми колонок экранной таблицы. Параметры API остаются прежними: sign выбирает фильтр знака без изменения общих итогов. Деньги выгружаются в рублях; экспорт использует сохранённый ввод, оставляет нерассчитанные значения пустыми и записывает EXPORT. Цветовое оформление стандартное.
|
||||
|
||||
#### Scenario: Выгрузка таблицы
|
||||
- **WHEN** выгружается таблица реальной версии с сохранёнными решениями и неактивной парой
|
||||
- **THEN** XLSX соответствует серверной таблице, содержит «Нет вложений» и не учитывает эту строку в итогах; отдельное событие EXPORT содержит report=goodwill и число строк
|
||||
|
||||
### Requirement: Подготовка вклада в КФО
|
||||
Система SHALL готовить данные только из сохранённого ввода и актуальных источников: при включённом переключателе, действующей выбранной паре, заполненных доле/комментарии и G>0 — 1170 ГО −ФВ, 1310 ДО −P, GOODWILL ГО +G и направление исключения ББ-2 закреплённой версии правил. Запись КФО и изменение корректировок MUST NOT выполняться.
|
||||
|
||||
#### Scenario: Неположительный гудвилл
|
||||
- **WHEN** пересчитанная сохранённая пара имеет G≤0, нет УК либо выключен переключатель
|
||||
- **THEN** её специальный вклад пуст, ББ-2 не исключается; отрицательное G не переносится в 1370 и не уменьшает положительный вклад других пар
|
||||
|
||||
### Requirement: Пустые значения конечного дебета
|
||||
По решению пользователя система SHALL суммировать только заполненные closing_debit пары; пустые значения остальных строк MUST NOT блокировать расчёт P/G и сохранение ручного ввода.
|
||||
|
||||
#### Scenario: Заполненная и пустая строки одной пары
|
||||
- **WHEN** у пары есть вложения 269997300.00 и строка с closing_debit=null, доля 100%, УК 100000000.00 и комментарий
|
||||
- **THEN** сохранение разрешено; ФВ=269997300.00, P=100000000.00, G=169997300.00
|
||||
|
||||
### Requirement: Источники КФО только выбранных сохранённых пар
|
||||
Подготовка КФО SHALL брать только сохранённые goodwill выбранной версии с include_in_kfo=true, ОСВ этих направленных пар и ББ выбранных ДО. При выключенном переключателе или пустом выборе источники ОСВ/ББ MUST NOT загружаться. Дорезолв SHALL сохранять соответствие расчёту таблицы.
|
||||
|
||||
#### Scenario: Выбранная и невыбранная пары
|
||||
- **WHEN** сохранены А→Б с include_in_kfo=true и А→В с false, а для Б→А goodwill отсутствует
|
||||
- **THEN** загружаются ОСВ А→Б и ББ Б; А→В и Б→А не дают источников и вклада, полный load таблицы не вызывается
|
||||
|
||||
#### Scenario: Пустой выбор
|
||||
- **WHEN** выбранных сохранённых строк нет либо goodwill_enabled=false
|
||||
- **THEN** вклад пуст, загрузка ОСВ и ББ не выполняется
|
||||
56
openspec/changes/goodwill-table/tasks.md
Normal file
56
openspec/changes/goodwill-table/tasks.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Tasks
|
||||
|
||||
Каждая задача рассчитана не более чем на половину дня. Реализация — arm-core/api; фронт и запись КФО отдельно.
|
||||
|
||||
## 1. Источники и контракт расчёта
|
||||
|
||||
- [x] 1.1 arm-core: добавить тесты на реальных исходных данных с загрузкой неизменённых реальных fixtures счёта 58.01 «Приманыческий» 2025 Q3 и 58.03 РБ 2025 Q1 / 2024 Q3; проверить реальные счета, контрагентов и суммы, зафиксировать положительный набор 58.01 и отрицательный 58.03. Не подменять отсутствующий реальный источник синтетической суммой.
|
||||
- [x] 1.2 arm-core: добавить доменные тесты гудвилла и кейсы состояний на реальных исходных строках: направление, неиспользование is_head_entity, исчезновение/возврат пары, ноль/null, четыре знака, Decimal-округление, итоги, сумма долей >100. Проверка — воспроизводимый набор ожидаемых расчётов до реализации.
|
||||
- [x] 1.3 arm-core: реализовать доменный модуль расчёта гудвилла: пары по дереву счетов, объединение сохранённого ввода, формулы и предупреждения; проверить тесты 1.1–1.2, отсутствие ORM/float и записи вычисляемых пар при чтении.
|
||||
|
||||
## 2. Загрузка и API чтения
|
||||
|
||||
- [x] 2.1 arm-core: добавить тесты загрузки источников гудвилла на реальных ОСВ/ББ: источник периода, parent_id, дубли уровней и перекрывающиеся документы, колонка конца периода, units, повторы 1310, отсутствующий/неоднозначный ББ; проверить, что отсутствующий УК не становится нулём.
|
||||
- [x] 2.2 arm-core: реализовать репозиторий гудвилла и адаптер источников гудвилла через полный ConsolidatedOsvService и пакетную загрузку форм; проверить тесты 2.1 и отсутствие запроса ББ на каждую строку.
|
||||
- [x] 2.3 arm-core: добавить схемы, сервис и API-маршруты гудвилла и регистрацию роутера; реализовать GET таблицы. Добавить тесты API гудвилла: сортировка, пустой ответ, null, has_investments, права, чужая версия, отсутствие записей при GET; тесты должны проходить.
|
||||
- [x] 2.4 arm-core: обновить описательный контракт API и OpenAPI-контракт для чтения, nullable капиталов/P/G, вычисляемых состояний и новых правил пар; проверить соответствие OpenAPI фактическому ответу и перечислить предварительные OQ-10/OQ-37.
|
||||
|
||||
## 3. Сохранение и переключатель
|
||||
|
||||
- [x] 3.1 arm-core: добавить тесты изменения данных гудвилла для создания/повторного сохранения, снятия выбора, доли null/0/100/лишней точности, COMMENT_REQUIRED, неактивной пары, переполнения денег и отката аудита вместе с записью; добавить проверки статусов/блокировок и двух сериализованных сохранений.
|
||||
- [x] 3.2 arm-core: реализовать PUT строки в сервисе и репозитории гудвилла через consolidation_guard; запись goodwill и GOODWILL_EDIT атомарна. Проверка — тесты 3.1, одна запись на естественный ключ и неизменность остальных строк.
|
||||
- [x] 3.3 arm-core: реализовать PUT goodwill-settings, добавить тесты изменения/повтора значения, сохранности ручного ввода и нулевого вклада при выключении; проверить единое событие GOODWILL_EDIT только при изменении переключателя и отсутствие изменений KfoResult.
|
||||
- [x] 3.4 arm-core: обновить описательный контракт API и OpenAPI-контракт для PUT, ошибок и null доли снятой строки; проверить примеры запросов/ответов контрактными тестами и одинаковую семантику MD/OpenAPI.
|
||||
|
||||
## 4. Экспорт и граница КФО
|
||||
|
||||
- [x] 4.1 arm-core: добавить тесты экспорта гудвилла и сервис экспорта гудвилла, маршрут экспорта; проверить содержимое XLSX, групповые/общие итоги, пустые ячейки null, текстовую безопасность, имя кириллицей и EXPORT с report=goodwill. Документировать экспорт в локальном MD/OpenAPI.
|
||||
- [x] 4.2 arm-core: добавить чистую подготовку вклада в доменном модуле расчёта гудвилла и адаптер сохранённого ввода в сервисе гудвилла; тестами подготовки вклада гудвилла проверить баланс −ФВ+G=−P, только положительные G, выключенный режим, отсутствие капитала, закреплённое ББ-2 и направление исключения. Проверить, что KfoResult и Correction не записываются.
|
||||
- [x] 4.3 arm-core: описать внутренний контракт вклада и ограничения этапа в описательном контракте API; проверка — документ явно отделяет подготовку от применения, устаревания и снимка КФО, не обещает завершение полного AURORA-1543.
|
||||
|
||||
## 5. Сквозная проверка и передача
|
||||
|
||||
- [x] 5.1 arm-core: выполнить pytest новых тестов и регрессии consolidated-osv/corrections, штатные линтеры и проверку типов API по конфигурации репозитория; проверить четыре маршрута через OpenAPI и неизменность схемы БД. Зафиксировать результаты и ограничения реальных фикстур в описании реализации.
|
||||
- [x] 5.2 arm-core: обновить обзор проекта и подготовить описание изменений для общей архитектуры, доменных правил, реестра открытых вопросов и общих контрактов. Проверить отражение источника 1310, предварительного XLSX, неактивных пар, границы КФО и различий с PDF. Изменения самого ../docs провести отдельным согласованным docs-change; этот пункт завершает только локальную документацию и передачу.
|
||||
|
||||
## Workflow follow-up
|
||||
|
||||
- Отдельный change ../docs синхронизирует архитектуру, доменные правила, открытые вопросы и общий API-контракт; не назначать новые OQ-номера без проверки реестра.
|
||||
- Следующий change КФО реализует запись результата, устаревание, применение/исключение ББ-2 и фиксацию VERIFIED; AURORA-1543 полностью закрывается после его приёмки.
|
||||
- Фронт AURORA-1544 подключается по согласованному обновлённому контракту, отдельной задачей.
|
||||
|
||||
## 6. Исправления по ревью пользователя
|
||||
|
||||
- [x] 6.1 arm-core: убрать собственные приоритеты документов и уровней счетов, лишние метаданные consolidated_osv; проверить сохранение всех допустимых строк и единственную загрузку ОСВ.
|
||||
- [x] 6.2 arm-core: привести расчёт отмеченных строк, ввод с запятой, null неактивных денежных колонок и экспорт с фильтром к PDF/решениям пользователя; проверить API, XLSX и OpenAPI-контракт.
|
||||
- [x] 6.3 arm-core: записать ответы пользователя о неопределённых источниках, удалить несогласованные ограничения и синхронизировать design/specs/контракты; проверить тесты и явно оставить за рамками запись/фиксацию КФО по согласованной границе этапа.
|
||||
|
||||
## 7. Выбор источников для КФО
|
||||
|
||||
- [x] 7.1 arm-core: отдельный GoodwillSource.load_for_kfo; contributions загружает только goodwill include_in_kfo=true, ОСВ выбранных направленных пар и ББ выбранных ДО. Проверить SQL-отбор и отсутствие полной загрузки при пустом выборе/выключении.
|
||||
- [x] 7.2 arm-core: обновить описание внутреннего контракта и спецификацию; проверить регрессию таблицы/ОСВ, линтеры и типы.
|
||||
|
||||
|
||||
## 8. Экспорт по экранной таблице
|
||||
|
||||
- [x] 8.1 arm-core: привести XLSX к составу и порядку восьми колонок фронта со стандартным оформлением. Сохранить прежний контракт ручки и вызов экспорта на фронте. Проверить интеграционные сценарии выгрузки.
|
||||
Loading…
x
Reference in New Issue
Block a user