Гудвилл: пакетное сохранение таблицы одной транзакцией

PUT …/goodwill {rows: [...]} вместо построчного PUT …/goodwill/{head}/{sub}: все строки
проверяются до записи, ошибка любой (NOT_FOUND, INVALID_SHARE, COMMENT_REQUIRED,
MONEY_OUT_OF_RANGE, DUPLICATE_ROW) несёт ключ пары и откатывает пакет; неизменённые
строки не перезаписываются и аудит не пишут (DOC-259025 §5.1 п. 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-06 18:24:49 +04:00
parent a086c39a78
commit 46fba8875b
7 changed files with 248 additions and 122 deletions

View File

@ -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)

View File

@ -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

View File

@ -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,
}

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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"},