feat/goodwillback: бэкенд гудвилл #222
@ -10,8 +10,7 @@ from src.api.dependencies.auth import require
|
||||
from src.api.dependencies.consolidations import ConsolidationServiceDep, GoodwillServiceDep
|
||||
from src.common.filenames import content_disposition
|
||||
from src.schemas.goodwill import (
|
||||
GoodwillRowIn,
|
||||
GoodwillRowOut,
|
||||
GoodwillBatchIn,
|
||||
GoodwillSettingsIn,
|
||||
GoodwillSign,
|
||||
GoodwillTableOut,
|
||||
@ -28,6 +27,14 @@ async def table(version_id: UUID, service: GoodwillServiceDep) -> GoodwillTableO
|
||||
return await service.table(version_id)
|
||||
|
||||
|
||||
@router.put("/{version_id}/goodwill", response_model=GoodwillTableOut, dependencies=EDIT)
|
||||
async def save(
|
||||
version_id: UUID, data: GoodwillBatchIn, service: GoodwillServiceDep, audit: AuditDep
|
||||
) -> GoodwillTableOut:
|
||||
"""Пакет строк одной транзакцией; ошибка любой строки — 404/422 с её ключом, ничего не сохраняется."""
|
||||
return await service.save_rows(version_id, data.rows, audit)
|
||||
|
||||
|
||||
@router.put("/{version_id}/goodwill-settings", response_model=GoodwillTableOut, dependencies=EDIT)
|
||||
async def settings(
|
||||
version_id: UUID, data: GoodwillSettingsIn, service: GoodwillServiceDep, audit: AuditDep
|
||||
@ -64,19 +71,3 @@ async def export(
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": content_disposition(filename, ascii_fallback="goodwill.xlsx")},
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{version_id}/goodwill/{head_legal_entity_id}/{subsidiary_legal_entity_id}",
|
||||
response_model=GoodwillRowOut,
|
||||
dependencies=EDIT,
|
||||
)
|
||||
async def save( # noqa: PLR0913, PLR0917 - ключ пары, тело и зависимости FastAPI
|
||||
version_id: UUID,
|
||||
head_legal_entity_id: UUID,
|
||||
subsidiary_legal_entity_id: UUID,
|
||||
data: GoodwillRowIn,
|
||||
service: GoodwillServiceDep,
|
||||
audit: AuditDep,
|
||||
) -> GoodwillRowOut:
|
||||
return await service.save(version_id, head_legal_entity_id, subsidiary_legal_entity_id, data, audit)
|
||||
|
||||
@ -70,6 +70,20 @@ class GoodwillRowIn(BaseModel):
|
||||
comment: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class GoodwillRowKeyIn(GoodwillRowIn):
|
||||
"""Строка пакетного сохранения: пара ГО → ДО и ручной ввод."""
|
||||
|
||||
head_legal_entity_id: UUID
|
||||
subsidiary_legal_entity_id: UUID
|
||||
|
||||
|
||||
class GoodwillBatchIn(BaseModel):
|
||||
"""Пакет строк таблицы гудвилла; сохраняется целиком или не сохраняется."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
rows: list[GoodwillRowKeyIn] = Field(min_length=1)
|
||||
|
||||
|
||||
class GoodwillSettingsIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
goodwill_enabled: bool
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"""Таблица и построчные решения гудвилла в транзакции запроса."""
|
||||
"""Таблица гудвилла, пакетное сохранение решений и переключатель — в транзакции запроса (§8, DOC-259025)."""
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from http import HTTPStatus
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
@ -11,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.api.dependencies.audit import RequestAudit
|
||||
from src.api.dependencies.auth import Principal
|
||||
from src.common.clock import utc_now
|
||||
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
|
||||
from src.common.exceptions import ConflictError, DomainError, DomainValidationError
|
||||
from src.domain import goodwill as domain
|
||||
from src.domain.policy import VisibilityScope, ensure_version_editable
|
||||
from src.models.access import AppUser
|
||||
@ -24,6 +25,7 @@ from src.schemas.consolidations import RefOut
|
||||
from src.schemas.goodwill import (
|
||||
GoodwillGroupOut,
|
||||
GoodwillRowIn,
|
||||
GoodwillRowKeyIn,
|
||||
GoodwillRowOut,
|
||||
GoodwillSign,
|
||||
GoodwillTableOut,
|
||||
@ -150,87 +152,80 @@ class GoodwillService:
|
||||
totals=GoodwillTotalsOut(**domain.totals(rows, ctx.version.goodwill_enabled)),
|
||||
)
|
||||
|
||||
async def save(
|
||||
self, version_id: UUID, head: UUID, subsidiary: UUID, data: GoodwillRowIn, audit: RequestAudit
|
||||
) -> GoodwillRowOut:
|
||||
async def save_rows(self, version_id: UUID, rows: list[GoodwillRowKeyIn], audit: RequestAudit) -> GoodwillTableOut:
|
||||
"""Пакет строк одной транзакцией (DOC-259025 §5.1 п. 4): любая ошибка — откат всего пакета.
|
||||
|
||||
Сначала проверяются все строки, потом пишутся только изменённые; аудит `GOODWILL_EDIT` — по каждой изменённой.
|
||||
"""
|
||||
await lock_editable_version(self.session, self.scope, version_id, self.principal.user.id)
|
||||
table = await self.table(version_id)
|
||||
row = next(
|
||||
(r for r in table.rows if (r.head_legal_entity.id, r.subsidiary_legal_entity.id) == (head, subsidiary)),
|
||||
None,
|
||||
)
|
||||
if row is None:
|
||||
raise NotFoundError("Пара не найдена")
|
||||
if not row.has_investments:
|
||||
# PDF §10: отсутствующий ввод не затирает сохранённые данные неактивной пары.
|
||||
data = data.model_copy(
|
||||
update={
|
||||
"ownership_share": data.ownership_share
|
||||
if data.ownership_share is not None
|
||||
else str(row.ownership_share)
|
||||
if row.ownership_share is not None
|
||||
else None,
|
||||
"comment": data.comment if data.comment is not None else row.comment,
|
||||
}
|
||||
current = {(r.head_legal_entity.id, r.subsidiary_legal_entity.id): r for r in table.rows}
|
||||
saved = {
|
||||
(r.head_legal_entity_id, r.subsidiary_legal_entity_id): r
|
||||
for r in await repo.saved(self.session, version_id)
|
||||
}
|
||||
_check_duplicates(rows)
|
||||
planned: list[tuple[GoodwillRowOut, domain.Decision]] = []
|
||||
for item in rows:
|
||||
key = item.head_legal_entity_id, item.subsidiary_legal_entity_id
|
||||
row = current.get(key)
|
||||
if row is None:
|
||||
raise DomainError(HTTPStatus.NOT_FOUND, "NOT_FOUND", "Пара не найдена", extra=_key_extra(key))
|
||||
decision = _decision(row, item, key)
|
||||
entity = saved.get(key)
|
||||
previous = (
|
||||
domain.Decision(entity.ownership_share, entity.include_in_kfo, entity.comment)
|
||||
if entity
|
||||
else domain.Decision()
|
||||
)
|
||||
decision = validate_decision(data)
|
||||
saved = await repo.saved(self.session, version_id)
|
||||
entity = next(
|
||||
(r for r in saved if (r.head_legal_entity_id, r.subsidiary_legal_entity_id) == (head, subsidiary)), None
|
||||
)
|
||||
before = row.model_dump(mode="json") if entity else None
|
||||
if decision != previous:
|
||||
planned.append((row, decision))
|
||||
now = utc_now()
|
||||
if entity is None:
|
||||
entity = Goodwill(
|
||||
id=uuid4(),
|
||||
consolidation_version_id=version_id,
|
||||
head_legal_entity_id=head,
|
||||
subsidiary_legal_entity_id=subsidiary,
|
||||
created_by=self.principal.user.id,
|
||||
created_at=now,
|
||||
written: list[tuple[Goodwill, dict | None]] = []
|
||||
for row, decision in planned:
|
||||
key = row.head_legal_entity.id, row.subsidiary_legal_entity.id
|
||||
entity = saved.get(key)
|
||||
before = row.model_dump(mode="json") if entity else None
|
||||
if entity is None:
|
||||
entity = Goodwill(
|
||||
id=uuid4(),
|
||||
consolidation_version_id=version_id,
|
||||
head_legal_entity_id=key[0],
|
||||
subsidiary_legal_entity_id=key[1],
|
||||
created_by=self.principal.user.id,
|
||||
created_at=now,
|
||||
)
|
||||
self.session.add(entity)
|
||||
entity.ownership_share, entity.include_in_kfo, entity.comment = (
|
||||
decision.ownership_share,
|
||||
decision.include_in_kfo,
|
||||
decision.comment,
|
||||
)
|
||||
self.session.add(entity)
|
||||
entity.ownership_share, entity.include_in_kfo, entity.comment = (
|
||||
decision.ownership_share,
|
||||
decision.include_in_kfo,
|
||||
decision.comment,
|
||||
)
|
||||
entity.updated_by, entity.updated_at = self.principal.user.id, now
|
||||
if row.has_investments:
|
||||
assert row.financial_investment_amount is not None
|
||||
p = (
|
||||
domain.participation(row.charter_capital_amount, decision.ownership_share)
|
||||
if decision.include_in_kfo
|
||||
and row.charter_capital_amount is not None
|
||||
and decision.ownership_share is not None
|
||||
else None
|
||||
)
|
||||
amounts = dict(
|
||||
financial_investment_amount=row.financial_investment_amount,
|
||||
charter_capital_amount=row.charter_capital_amount,
|
||||
additional_capital_amount=row.additional_capital_amount,
|
||||
participation_amount=p,
|
||||
goodwill_amount=row.financial_investment_amount - p if p is not None else None,
|
||||
)
|
||||
for name, amount in amounts.items():
|
||||
if amount is not None and abs(amount) >= Decimal("10000000000000000"):
|
||||
raise DomainValidationError("MONEY_OUT_OF_RANGE", "Сумма не помещается в numeric(18,2)")
|
||||
setattr(entity, name, amount)
|
||||
entity.updated_by, entity.updated_at = self.principal.user.id, now
|
||||
if row.has_investments:
|
||||
for name, amount in _amounts(row, decision).items():
|
||||
if amount is not None and abs(amount) >= MONEY_LIMIT:
|
||||
raise DomainValidationError(
|
||||
"MONEY_OUT_OF_RANGE", "Сумма не помещается в numeric(18,2)", **_key_extra(key)
|
||||
)
|
||||
setattr(entity, name, amount)
|
||||
written.append((entity, before))
|
||||
if not written:
|
||||
return table
|
||||
await self.session.flush()
|
||||
result = next(
|
||||
r
|
||||
for r in (await self.table(version_id)).rows
|
||||
if (r.head_legal_entity.id, r.subsidiary_legal_entity.id) == (head, subsidiary)
|
||||
)
|
||||
await audit.record(
|
||||
action=AuditAction.GOODWILL_EDIT,
|
||||
section=AuditSection.CONSOLIDATION,
|
||||
object_type="goodwill",
|
||||
object_id=entity.id,
|
||||
consolidation_version_id=version_id,
|
||||
before_value=before,
|
||||
after_value=result.model_dump(mode="json"),
|
||||
)
|
||||
result = await self.table(version_id)
|
||||
after = {(r.head_legal_entity.id, r.subsidiary_legal_entity.id): r for r in result.rows}
|
||||
for entity, before in written:
|
||||
key = entity.head_legal_entity_id, entity.subsidiary_legal_entity_id
|
||||
await audit.record(
|
||||
action=AuditAction.GOODWILL_EDIT,
|
||||
section=AuditSection.CONSOLIDATION,
|
||||
object_type="goodwill",
|
||||
object_id=entity.id,
|
||||
consolidation_version_id=version_id,
|
||||
before_value=before,
|
||||
after_value=after[key].model_dump(mode="json"),
|
||||
)
|
||||
return result
|
||||
|
||||
async def settings(self, version_id: UUID, enabled: bool, audit: RequestAudit) -> GoodwillTableOut:
|
||||
@ -264,3 +259,53 @@ class GoodwillService:
|
||||
after_value={"report": "goodwill", "rows": sum(matches_sign(row, sign) for row in table.rows)},
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
MONEY_LIMIT = Decimal("10000000000000000")
|
||||
|
||||
|
||||
def _key_extra(key: tuple[UUID, UUID]) -> dict[str, str]:
|
||||
return {"head_legal_entity_id": str(key[0]), "subsidiary_legal_entity_id": str(key[1])}
|
||||
|
||||
|
||||
def _check_duplicates(rows: list[GoodwillRowKeyIn]) -> None:
|
||||
seen: set[tuple[UUID, UUID]] = set()
|
||||
for item in rows:
|
||||
key = item.head_legal_entity_id, item.subsidiary_legal_entity_id
|
||||
if key in seen:
|
||||
raise DomainValidationError("DUPLICATE_ROW", "Пара встречается в пакете дважды", **_key_extra(key))
|
||||
seen.add(key)
|
||||
|
||||
|
||||
def _decision(row: GoodwillRowOut, item: GoodwillRowKeyIn, key: tuple[UUID, UUID]) -> domain.Decision:
|
||||
"""Проверенный ввод строки; ошибка несёт ключ пары."""
|
||||
share, comment = item.ownership_share, item.comment
|
||||
if not row.has_investments:
|
||||
# DOC-259025 §10 п. 9: отсутствующий ввод не затирает сохранённые данные неактивной пары.
|
||||
if share is None and row.ownership_share is not None:
|
||||
share = str(row.ownership_share)
|
||||
if comment is None:
|
||||
comment = row.comment
|
||||
try:
|
||||
return validate_decision(
|
||||
GoodwillRowIn(ownership_share=share, include_in_kfo=item.include_in_kfo, comment=comment)
|
||||
)
|
||||
except DomainValidationError as error:
|
||||
raise DomainValidationError(error.code, error.message, **_key_extra(key)) from error
|
||||
|
||||
|
||||
def _amounts(row: GoodwillRowOut, decision: domain.Decision) -> dict[str, Decimal | None]:
|
||||
"""Снимок сумм действующей строки при сохранении (поля `goodwill` DBML)."""
|
||||
assert row.financial_investment_amount is not None
|
||||
p = (
|
||||
domain.participation(row.charter_capital_amount, decision.ownership_share)
|
||||
if decision.include_in_kfo and row.charter_capital_amount is not None and decision.ownership_share is not None
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"financial_investment_amount": row.financial_investment_amount,
|
||||
"charter_capital_amount": row.charter_capital_amount,
|
||||
"additional_capital_amount": row.additional_capital_amount,
|
||||
"participation_amount": p,
|
||||
"goodwill_amount": row.financial_investment_amount - p if p is not None else None,
|
||||
}
|
||||
|
||||
@ -39,6 +39,11 @@ HTTP_CREATED = 201
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
|
||||
|
||||
|
||||
def batch(head, subsidiary, **body) -> dict:
|
||||
"""Тело пакетного сохранения из одной строки."""
|
||||
return {"rows": [{"head_legal_entity_id": str(head), "subsidiary_legal_entity_id": str(subsidiary), **body}]}
|
||||
|
||||
|
||||
async def goodwill_world(session, fixtures_dir: Path):
|
||||
w = await _world(session)
|
||||
raw = investment_rows(fixtures_dir)[0]
|
||||
@ -344,11 +349,11 @@ async def test_null_closing_debit_does_not_block_pair_calculation(db_session, au
|
||||
base = f"/consolidations/versions/{world.version.id}"
|
||||
assert (await client.post(base + "/lock")).status_code == HTTP_CREATED
|
||||
response = await client.put(
|
||||
base + f"/goodwill/{world.a.id}/{world.b.id}",
|
||||
json=dict(ownership_share="100", include_in_kfo=True, comment="Подтверждено"),
|
||||
base + "/goodwill",
|
||||
json=batch(world.a.id, world.b.id, ownership_share="100", include_in_kfo=True, comment="Подтверждено"),
|
||||
)
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
row = response.json()
|
||||
row = response.json()["rows"][0]
|
||||
assert row["financial_investment_amount"] == "269997300.00"
|
||||
assert row["participation_amount"] == "100000000.00"
|
||||
assert row["goodwill_amount"] == "169997300.00"
|
||||
|
||||
@ -16,7 +16,7 @@ from src.models.statements import FinancialStatementValue
|
||||
from src.schemas.goodwill import GoodwillRowIn
|
||||
from src.services.goodwill import validate_decision
|
||||
from tests.test_api_consolidated_osv import KA_RF
|
||||
from tests.test_api_goodwill import goodwill_world
|
||||
from tests.test_api_goodwill import batch, goodwill_world
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -52,22 +52,22 @@ async def test_save_settings_and_disappearing_pair(db_session, auth_client, fixt
|
||||
w = await goodwill_world(db_session, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{w.version.id}"
|
||||
path = base + f"/goodwill/{w.a.id}/{w.b.id}"
|
||||
path = base + "/goodwill"
|
||||
body = dict(ownership_share="80.12", include_in_kfo=True, comment="Подтверждено")
|
||||
response = await client.put(path, json=body)
|
||||
response = await client.put(path, json=batch(w.a.id, w.b.id, **body))
|
||||
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "LOCK_REQUIRED"
|
||||
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
|
||||
other = await auth_client("rka@armka.local")
|
||||
rejected = await other.put(path, json=body)
|
||||
rejected = await other.put(path, json=batch(w.a.id, w.b.id, **body))
|
||||
assert rejected.status_code == HTTPStatus.CONFLICT
|
||||
assert rejected.json()["detail"]["code"] == "LOCKED_BY_OTHER"
|
||||
response = await client.put(path, json=body)
|
||||
response = await client.put(path, json=batch(w.a.id, w.b.id, **body))
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
row = response.json()
|
||||
row = response.json()["rows"][0]
|
||||
assert row["ownership_share"] == "80.12"
|
||||
assert row["participation_amount"] == "80120000.00"
|
||||
assert row["goodwill_amount"] == "189877300.00"
|
||||
again = await client.put(path, json=body)
|
||||
again = await client.put(path, json=batch(w.a.id, w.b.id, **body))
|
||||
assert again.status_code == HTTPStatus.OK
|
||||
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 1
|
||||
on = await client.put(base + "/goodwill-settings", json=dict(goodwill_enabled=True))
|
||||
@ -86,7 +86,7 @@ async def test_save_settings_and_disappearing_pair(db_session, auth_client, fixt
|
||||
assert off.json()["totals"]["goodwill_in_kfo_amount"] == "0.00"
|
||||
assert off.json()["rows"][0]["ownership_share"] == "80.12"
|
||||
body["include_in_kfo"] = False
|
||||
assert (await client.put(path, json=body)).status_code == HTTPStatus.OK
|
||||
assert (await client.put(path, json=batch(w.a.id, w.b.id, **body))).status_code == HTTPStatus.OK
|
||||
assert (await client.get(base + "/goodwill")).json()["totals"]["goodwill_amount"] == "0.00"
|
||||
entries = list(
|
||||
(
|
||||
@ -101,12 +101,12 @@ async def test_save_settings_and_disappearing_pair(db_session, auth_client, fixt
|
||||
row = (await client.get(base + "/goodwill")).json()["rows"][0]
|
||||
assert not row["has_investments"] and row["goodwill_amount"] is None
|
||||
assert row["comment"] == "Подтверждено"
|
||||
assert (await client.put(path, json=body)).status_code == HTTPStatus.OK
|
||||
assert (await client.put(path, json=batch(w.a.id, w.b.id, **body))).status_code == HTTPStatus.OK
|
||||
body["include_in_kfo"] = True
|
||||
response = await client.put(path, json=body)
|
||||
response = await client.put(path, json=batch(w.a.id, w.b.id, **body))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["financial_investment_amount"] is None
|
||||
assert response.json()["goodwill_amount"] is None
|
||||
assert response.json()["rows"][0]["financial_investment_amount"] is None
|
||||
assert response.json()["rows"][0]["goodwill_amount"] is None
|
||||
assert await db_session.scalar(select(func.count()).select_from(KfoResult)) == 0
|
||||
|
||||
|
||||
@ -117,16 +117,16 @@ async def test_mutation_validation_and_rollback(db_session, auth_client, fixture
|
||||
w = await goodwill_world(db_session, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{w.version.id}"
|
||||
path = base + f"/goodwill/{w.a.id}/{w.b.id}"
|
||||
path = base + "/goodwill"
|
||||
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
|
||||
body = dict(ownership_share="100", include_in_kfo=True, comment="")
|
||||
response = await client.put(path, json=body)
|
||||
response = await client.put(path, json=batch(w.a.id, w.b.id, **body))
|
||||
assert (
|
||||
response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
and response.json()["detail"]["code"] == "COMMENT_REQUIRED"
|
||||
)
|
||||
body.update(ownership_share="100.00001", comment="Да")
|
||||
assert (await client.put(path, json=body)).json()["detail"]["code"] == "INVALID_SHARE"
|
||||
assert (await client.put(path, json=batch(w.a.id, w.b.id, **body))).json()["detail"]["code"] == "INVALID_SHARE"
|
||||
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
|
||||
|
||||
async def reject_audit(*args, **kwargs):
|
||||
@ -134,7 +134,7 @@ async def test_mutation_validation_and_rollback(db_session, auth_client, fixture
|
||||
|
||||
monkeypatch.setattr(RequestAudit, "record", reject_audit)
|
||||
body["ownership_share"] = "100"
|
||||
response = await client.put(path, json=body)
|
||||
response = await client.put(path, json=batch(w.a.id, w.b.id, **body))
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
|
||||
|
||||
@ -150,8 +150,81 @@ async def test_money_overflow_keeps_database_unchanged(db_session, auth_client,
|
||||
value.amount = Decimal("9999999999999999.99")
|
||||
await db_session.flush()
|
||||
response = await client.put(
|
||||
base + f"/goodwill/{w.a.id}/{w.b.id}", json=dict(ownership_share="100", include_in_kfo=True, comment="Проверка")
|
||||
base + "/goodwill", json=batch(w.a.id, w.b.id, ownership_share="100", include_in_kfo=True, comment="Проверка")
|
||||
)
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert response.json()["detail"]["code"] == "MONEY_OUT_OF_RANGE"
|
||||
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
|
||||
|
||||
|
||||
async def _locked_world(db_session, auth_client, fixtures_dir):
|
||||
w = await goodwill_world(db_session, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{w.version.id}"
|
||||
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
|
||||
return w, client, base
|
||||
|
||||
|
||||
async def _goodwill_audit_count(db_session) -> int:
|
||||
return await db_session.scalar(
|
||||
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.GOODWILL_EDIT)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.fixtures
|
||||
async def test_batch_rolls_back_when_one_row_invalid(db_session, auth_client, fixtures_dir):
|
||||
w, client, base = await _locked_world(db_session, auth_client, fixtures_dir)
|
||||
valid = batch(w.a.id, w.b.id, ownership_share="100", include_in_kfo=True, comment="Да")["rows"][0]
|
||||
invalid = batch(w.b.id, w.a.id, ownership_share="50", include_in_kfo=True, comment=" ")["rows"][0]
|
||||
# обратная пара Б → А в таблице не существует: сперва проверяем порядок ошибок на существующей паре
|
||||
response = await client.put(base + "/goodwill", json={"rows": [valid, {**valid, "comment": " "}]})
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert response.json()["detail"]["code"] == "DUPLICATE_ROW"
|
||||
response = await client.put(base + "/goodwill", json={"rows": [valid, invalid]})
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.json()["detail"]["subsidiary_legal_entity_id"] == str(w.a.id)
|
||||
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
|
||||
assert await _goodwill_audit_count(db_session) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.fixtures
|
||||
async def test_batch_reports_invalid_row_key(db_session, auth_client, fixtures_dir):
|
||||
w, client, base = await _locked_world(db_session, auth_client, fixtures_dir)
|
||||
body = batch(w.a.id, w.b.id, ownership_share="100", include_in_kfo=True, comment=" ")
|
||||
response = await client.put(base + "/goodwill", json=body)
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "COMMENT_REQUIRED"
|
||||
assert (detail["head_legal_entity_id"], detail["subsidiary_legal_entity_id"]) == (str(w.a.id), str(w.b.id))
|
||||
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
|
||||
assert await _goodwill_audit_count(db_session) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.fixtures
|
||||
async def test_unchanged_rows_are_not_rewritten(db_session, auth_client, fixtures_dir):
|
||||
w, client, base = await _locked_world(db_session, auth_client, fixtures_dir)
|
||||
body = batch(w.a.id, w.b.id, ownership_share="80,5", include_in_kfo=True, comment="Устав")
|
||||
first = await client.put(base + "/goodwill", json=body)
|
||||
assert first.status_code == HTTPStatus.OK, first.text
|
||||
saved_at = first.json()["rows"][0]["updated_at"]
|
||||
again = await client.put(
|
||||
base + "/goodwill", json=batch(w.a.id, w.b.id, ownership_share="80.50", include_in_kfo=True, comment="Устав")
|
||||
)
|
||||
assert again.status_code == HTTPStatus.OK
|
||||
assert again.json()["rows"][0]["updated_at"] == saved_at
|
||||
assert await _goodwill_audit_count(db_session) == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.fixtures
|
||||
async def test_new_row_with_defaults_is_not_created(db_session, auth_client, fixtures_dir):
|
||||
w, client, base = await _locked_world(db_session, auth_client, fixtures_dir)
|
||||
body = batch(w.a.id, w.b.id, ownership_share=None, include_in_kfo=False, comment=None)
|
||||
response = await client.put(base + "/goodwill", json=body)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["rows"][0]["include_in_kfo"] is False
|
||||
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
|
||||
assert await _goodwill_audit_count(db_session) == 0
|
||||
|
||||
@ -11,7 +11,7 @@ from src.models.audit_ex import AuditEvent
|
||||
from src.models.enums import AuditAction
|
||||
from src.models.osv import OsvEntry
|
||||
from tests.test_api_consolidated_osv import KA_RF
|
||||
from tests.test_api_goodwill import goodwill_world
|
||||
from tests.test_api_goodwill import batch, goodwill_world
|
||||
|
||||
INVESTMENT_AMOUNT = 269997300
|
||||
FRONTEND_COLUMN_COUNT = 8
|
||||
@ -51,8 +51,9 @@ async def test_export_matches_server_table(db_session, auth_client, fixtures_dir
|
||||
assert (await client.get(base + "/goodwill/export?sign=unknown")).status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
|
||||
body = dict(ownership_share="100", include_in_kfo=True, comment="=1+1")
|
||||
path = base + f"/goodwill/{world.a.id}/{world.b.id}"
|
||||
assert (await client.put(path, json=body)).status_code == HTTPStatus.OK
|
||||
assert (
|
||||
await client.put(base + "/goodwill", json=batch(world.a.id, world.b.id, **body))
|
||||
).status_code == HTTPStatus.OK
|
||||
response = await client.get(base + "/goodwill/export")
|
||||
sheet = load_workbook(BytesIO(response.content)).active
|
||||
assert sheet.cell(3, 8).data_type == "s"
|
||||
|
||||
@ -83,11 +83,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
||||
"/api/v1/consolidations/versions/{version_id}/corrections": {"get"},
|
||||
"/api/v1/consolidations/versions/{version_id}/corrections/export": {"get"},
|
||||
# Таблица гудвилла (goodwill-table)
|
||||
"/api/v1/consolidations/versions/{version_id}/goodwill": {"get"},
|
||||
"/api/v1/consolidations/versions/{version_id}/goodwill": {"get", "put"},
|
||||
"/api/v1/consolidations/versions/{version_id}/goodwill-settings": {"put"},
|
||||
"/api/v1/consolidations/versions/{version_id}/goodwill/{head_legal_entity_id}/{subsidiary_legal_entity_id}": {
|
||||
"put"
|
||||
},
|
||||
"/api/v1/consolidations/versions/{version_id}/goodwill/export": {"get"},
|
||||
# шахматка ВГО и правила элиминации
|
||||
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user