fix(statements): валидация units при правке шапки и comment в аудите сброса акцепта (T6.2, T6.4)

- HeaderPatch.units: обрезка пробелов и min_length=1, пробельная строка — 422; null — «не менять» (docstring);
- запись аудита сброса акцепта содержит comment сброшенного акцепта.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 13:37:01 +04:00
parent e45502b2ea
commit fb457fc06b
3 changed files with 37 additions and 4 deletions

View File

@ -9,8 +9,9 @@ import datetime
import uuid import uuid
from collections.abc import Sequence from collections.abc import Sequence
from decimal import Decimal from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, StringConstraints
from src.domain.statement_checks import ChecksReport, FailedCheck from src.domain.statement_checks import ChecksReport, FailedCheck
from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter
@ -237,13 +238,16 @@ class CellPatch(BaseModel):
class HeaderPatch(BaseModel): class HeaderPatch(BaseModel):
"""Отсутствующее поле не меняется; `null` у `report_date`/`inn` — очистить значение (`model_fields_set`).""" """Отсутствующее поле не меняется; `null` у `report_date`/`inn` — очистить значение (`model_fields_set`).
`units: null` — «не менять» (единицы измерения очищать нельзя); непустая строка после обрезки пробелов, до 50
символов (пробельная строка — 422)."""
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
inn: str | None = None inn: str | None = None
report_date: datetime.date | None = None report_date: datetime.date | None = None
units: str | None = Field(None, min_length=1, max_length=50) units: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)] | None = None
class StatementPatch(BaseModel): class StatementPatch(BaseModel):

View File

@ -202,7 +202,11 @@ class StatementService:
if not after: if not after:
return await self.build_out(document, meta) return await self.build_out(document, meta)
if meta.checks_accepted_at is not None: if meta.checks_accepted_at is not None:
before["checks_accepted"] = {"by": meta.checks_accepted_by, "at": meta.checks_accepted_at} before["checks_accepted"] = {
"by": meta.checks_accepted_by,
"at": meta.checks_accepted_at,
"comment": meta.checks_accepted_comment,
}
after["checks_accepted"] = None after["checks_accepted"] = None
meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None
await self._session.flush() await self._session.flush()

View File

@ -410,6 +410,7 @@ async def test_patch_rereads_meta_under_lock(
assert held.checks_accepted_at is None and held.checks_accepted_comment is None assert held.checks_accepted_at is None and held.checks_accepted_comment is None
event = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().one() event = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().one()
assert event.before_value["checks_accepted"]["by"] == str(user_id) and event.after_value["checks_accepted"] is None assert event.before_value["checks_accepted"]["by"] == str(user_id) and event.after_value["checks_accepted"] is None
assert event.before_value["checks_accepted"]["comment"] == "x" # T6.4: сброшенный комментарий акцепта сохранён
async def test_patch_duplicate_cell_rejected( async def test_patch_duplicate_cell_rejected(
@ -713,3 +714,27 @@ async def test_confirmed_form_in_checklist(
) )
).scalar_one() ).scalar_one()
assert meta.reviewed_by == user.id assert meta.reviewed_by == user.id
@pytest.mark.parametrize("units", [" ", "", "я" * 51])
async def test_patch_units_invalid_is_422(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, units: str
) -> None:
"""T6.2: пробельная, пустая и длиннее 50 символов единицы измерения отклоняются; `null` — «не менять»."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
response = await client.patch(url, json={"header": {"units": units}})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text
unchanged = await client.patch(url, json={"header": {"units": None}})
assert unchanged.status_code == HTTPStatus.OK
async def test_patch_units_are_stripped(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": " тыс. руб. "}})
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["header"]["units"]["value"] == "тыс. руб."