arm-core/api/tests/test_api_goodwill_mutations.py
Andrey Epifancev 8485b2c53e Гудвилл: правки по финальному ревью ветки
I-1: сохранённая доля неактивной строки нормализуется (numeric(7,4) читается как 80.1200)
и не даёт INVALID_SHARE при null в пакете; экспорт помечает строку «Нет вложений» (сценарий
спеки); design/tasks/docstring без устаревшего «построчного» PUT, R-7/R-8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:52:59 +04:00

266 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Проверка ввода строки до обращения к хранилищу."""
from decimal import Decimal
from http import HTTPStatus
from uuid import uuid4
import pytest
from sqlalchemy import func, select
from src.api.dependencies.audit import RequestAudit
from src.common.exceptions import ConflictError, DomainValidationError
from src.models.audit_ex import AuditEvent
from src.models.enums import AuditAction
from src.models.kfo import Goodwill, KfoResult
from src.models.osv import OsvEntry
from src.models.statements import FinancialStatementValue
from src.schemas.consolidations import RefOut
from src.schemas.goodwill import GoodwillRowIn, GoodwillRowKeyIn, GoodwillRowOut
from src.services.goodwill import _decision, validate_decision
from tests.test_api_consolidated_osv import KA_RF
from tests.test_api_goodwill import batch, goodwill_world
@pytest.mark.parametrize(
"value", ["-1", "100.01", "100.0001", "80.123", "80,123", "12.3456", "1e2", "NaN", "Infinity", "abc", ""]
)
def test_invalid_share(value):
with pytest.raises(DomainValidationError) as error:
validate_decision(GoodwillRowIn(ownership_share=value, include_in_kfo=False))
assert error.value.code == "INVALID_SHARE"
@pytest.mark.parametrize("value", ["0", "100", "7.5", "80.12", "80,12"])
def test_valid_share(value):
result = validate_decision(GoodwillRowIn(ownership_share=value, include_in_kfo=True, comment=" Обоснование "))
assert str(result.ownership_share) == value.replace(",", ".")
assert result.comment == "Обоснование"
def test_required_fields():
assert validate_decision(GoodwillRowIn(ownership_share=None, include_in_kfo=False)).ownership_share is None
with pytest.raises(DomainValidationError) as error:
validate_decision(GoodwillRowIn(ownership_share=None, include_in_kfo=True, comment="Да"))
assert error.value.code == "INVALID_SHARE"
with pytest.raises(DomainValidationError) as error:
validate_decision(GoodwillRowIn(ownership_share="100", include_in_kfo=True, comment=" "))
assert error.value.code == "COMMENT_REQUIRED"
@pytest.mark.integration
@pytest.mark.fixtures
async def test_save_settings_and_disappearing_pair(db_session, auth_client, fixtures_dir):
w = await goodwill_world(db_session, fixtures_dir)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{w.version.id}"
path = base + "/goodwill"
body = dict(ownership_share="80.12", include_in_kfo=True, comment="Подтверждено")
response = await client.put(path, json=batch(w.a.id, w.b.id, **body))
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "LOCK_REQUIRED"
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
other = await auth_client("rka@armka.local")
rejected = await other.put(path, json=batch(w.a.id, w.b.id, **body))
assert rejected.status_code == HTTPStatus.CONFLICT
assert rejected.json()["detail"]["code"] == "LOCKED_BY_OTHER"
response = await client.put(path, json=batch(w.a.id, w.b.id, **body))
assert response.status_code == HTTPStatus.OK, response.text
row = response.json()["rows"][0]
assert row["ownership_share"] == "80.12"
assert row["participation_amount"] == "80120000.00"
assert row["goodwill_amount"] == "189877300.00"
again = await client.put(path, json=batch(w.a.id, w.b.id, **body))
assert again.status_code == HTTPStatus.OK
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 1
on = await client.put(base + "/goodwill-settings", json=dict(goodwill_enabled=True))
assert on.status_code == HTTPStatus.OK
assert on.json()["totals"]["goodwill_in_kfo_amount"] == "189877300.00"
audit_count = await db_session.scalar(
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.GOODWILL_EDIT)
)
assert (
await client.put(base + "/goodwill-settings", json=dict(goodwill_enabled=True))
).status_code == HTTPStatus.OK
assert audit_count == await db_session.scalar(
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.GOODWILL_EDIT)
)
off = await client.put(base + "/goodwill-settings", json=dict(goodwill_enabled=False))
assert off.json()["totals"]["goodwill_in_kfo_amount"] == "0.00"
assert off.json()["rows"][0]["ownership_share"] == "80.12"
body["include_in_kfo"] = False
assert (await client.put(path, json=batch(w.a.id, w.b.id, **body))).status_code == HTTPStatus.OK
assert (await client.get(base + "/goodwill")).json()["totals"]["goodwill_amount"] == "0.00"
entries = list(
(
await db_session.scalars(
select(OsvEntry).where(OsvEntry.account_code == "58.01", OsvEntry.owner_legal_entity_id == w.a.id)
)
).all()
)
for entry in entries:
entry.closing_debit = 0
await db_session.flush()
row = (await client.get(base + "/goodwill")).json()["rows"][0]
assert not row["has_investments"] and row["goodwill_amount"] is None
assert row["comment"] == "Подтверждено"
assert (await client.put(path, json=batch(w.a.id, w.b.id, **body))).status_code == HTTPStatus.OK
body["include_in_kfo"] = True
response = await client.put(path, json=batch(w.a.id, w.b.id, **body))
assert response.status_code == HTTPStatus.OK
assert response.json()["rows"][0]["financial_investment_amount"] is None
assert response.json()["rows"][0]["goodwill_amount"] is None
assert await db_session.scalar(select(func.count()).select_from(KfoResult)) == 0
@pytest.mark.integration
@pytest.mark.fixtures
async def test_mutation_validation_and_rollback(db_session, auth_client, fixtures_dir, monkeypatch):
w = await goodwill_world(db_session, fixtures_dir)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{w.version.id}"
path = base + "/goodwill"
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
body = dict(ownership_share="100", include_in_kfo=True, comment="")
response = await client.put(path, json=batch(w.a.id, w.b.id, **body))
assert (
response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
and response.json()["detail"]["code"] == "COMMENT_REQUIRED"
)
body.update(ownership_share="100.00001", comment="Да")
assert (await client.put(path, json=batch(w.a.id, w.b.id, **body))).json()["detail"]["code"] == "INVALID_SHARE"
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
async def reject_audit(*args, **kwargs):
raise ConflictError("TEST_AUDIT_FAILURE", "Ошибка тестового аудита")
monkeypatch.setattr(RequestAudit, "record", reject_audit)
body["ownership_share"] = "100"
response = await client.put(path, json=batch(w.a.id, w.b.id, **body))
assert response.status_code == HTTPStatus.CONFLICT
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
@pytest.mark.integration
@pytest.mark.fixtures
async def test_money_overflow_keeps_database_unchanged(db_session, auth_client, fixtures_dir):
w = await goodwill_world(db_session, fixtures_dir)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{w.version.id}"
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
value = (await db_session.scalars(select(FinancialStatementValue))).one()
value.amount = Decimal("9999999999999999.99")
await db_session.flush()
response = await client.put(
base + "/goodwill", json=batch(w.a.id, w.b.id, ownership_share="100", include_in_kfo=True, comment="Проверка")
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "MONEY_OUT_OF_RANGE"
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
async def _locked_world(db_session, auth_client, fixtures_dir):
w = await goodwill_world(db_session, fixtures_dir)
client = await auth_client(KA_RF)
base = f"/consolidations/versions/{w.version.id}"
assert (await client.post(base + "/lock")).status_code == HTTPStatus.CREATED
return w, client, base
async def _goodwill_audit_count(db_session) -> int:
return await db_session.scalar(
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.GOODWILL_EDIT)
)
@pytest.mark.integration
@pytest.mark.fixtures
async def test_batch_rolls_back_when_one_row_invalid(db_session, auth_client, fixtures_dir):
w, client, base = await _locked_world(db_session, auth_client, fixtures_dir)
valid = batch(w.a.id, w.b.id, ownership_share="100", include_in_kfo=True, comment="Да")["rows"][0]
invalid = batch(w.b.id, w.a.id, ownership_share="50", include_in_kfo=True, comment=" ")["rows"][0]
# обратная пара Б → А в таблице не существует: сперва проверяем порядок ошибок на существующей паре
response = await client.put(base + "/goodwill", json={"rows": [valid, {**valid, "comment": " "}]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "DUPLICATE_ROW"
response = await client.put(base + "/goodwill", json={"rows": [valid, invalid]})
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json()["detail"]["subsidiary_legal_entity_id"] == str(w.a.id)
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
assert await _goodwill_audit_count(db_session) == 0
@pytest.mark.integration
@pytest.mark.fixtures
async def test_batch_reports_invalid_row_key(db_session, auth_client, fixtures_dir):
w, client, base = await _locked_world(db_session, auth_client, fixtures_dir)
body = batch(w.a.id, w.b.id, ownership_share="100", include_in_kfo=True, comment=" ")
response = await client.put(base + "/goodwill", json=body)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
detail = response.json()["detail"]
assert detail["code"] == "COMMENT_REQUIRED"
assert (detail["head_legal_entity_id"], detail["subsidiary_legal_entity_id"]) == (str(w.a.id), str(w.b.id))
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
assert await _goodwill_audit_count(db_session) == 0
@pytest.mark.integration
@pytest.mark.fixtures
async def test_unchanged_rows_are_not_rewritten(db_session, auth_client, fixtures_dir):
w, client, base = await _locked_world(db_session, auth_client, fixtures_dir)
body = batch(w.a.id, w.b.id, ownership_share="80,5", include_in_kfo=True, comment="Устав")
first = await client.put(base + "/goodwill", json=body)
assert first.status_code == HTTPStatus.OK, first.text
saved_at = first.json()["rows"][0]["updated_at"]
again = await client.put(
base + "/goodwill", json=batch(w.a.id, w.b.id, ownership_share="80.50", include_in_kfo=True, comment="Устав")
)
assert again.status_code == HTTPStatus.OK
assert again.json()["rows"][0]["updated_at"] == saved_at
assert await _goodwill_audit_count(db_session) == 1
@pytest.mark.integration
@pytest.mark.fixtures
async def test_new_row_with_defaults_is_not_created(db_session, auth_client, fixtures_dir):
w, client, base = await _locked_world(db_session, auth_client, fixtures_dir)
body = batch(w.a.id, w.b.id, ownership_share=None, include_in_kfo=False, comment=None)
response = await client.put(base + "/goodwill", json=body)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["rows"][0]["include_in_kfo"] is False
assert await db_session.scalar(select(func.count()).select_from(Goodwill)) == 0
assert await _goodwill_audit_count(db_session) == 0
def test_inactive_row_keeps_stored_share_with_db_scale():
"""Колонка numeric(7,4) читается как Decimal("80.1200"): null в пакете не должен давать INVALID_SHARE."""
head, sub = uuid4(), uuid4()
row = GoodwillRowOut(
head_legal_entity=RefOut(id=head, name="ГО"),
subsidiary_legal_entity=RefOut(id=sub, name="ДО"),
ownership_share=Decimal("80.1200"),
financial_investment_amount=None,
charter_capital_amount=None,
additional_capital_amount=None,
participation_amount=None,
goodwill_amount=None,
include_in_kfo=True,
comment="Было",
has_investments=False,
warnings=[],
updated_by=None,
updated_at=None,
)
item = GoodwillRowKeyIn(
head_legal_entity_id=head,
subsidiary_legal_entity_id=sub,
ownership_share=None,
include_in_kfo=True,
comment=None,
)
decision = _decision(row, item, (head, sub))
assert decision.ownership_share == Decimal("80.12")
assert decision.comment == "Было"
row100 = row.model_copy(update={"ownership_share": Decimal("100.0000")})
assert _decision(row100, item, (head, sub)).ownership_share == Decimal(100)