diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index e2d0a56..c546d2a 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -9,8 +9,9 @@ import datetime import uuid from collections.abc import Sequence 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.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter @@ -237,13 +238,16 @@ class CellPatch(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") inn: str | 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): diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 37c59c3..9812950 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -202,7 +202,11 @@ class StatementService: if not after: return await self.build_out(document, meta) 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 meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None await self._session.flush() diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 5a6d69e..1d9ba08 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -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 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"]["comment"] == "x" # T6.4: сброшенный комментарий акцепта сохранён async def test_patch_duplicate_cell_rejected( @@ -713,3 +714,27 @@ async def test_confirmed_form_in_checklist( ) ).scalar_one() 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"] == "тыс. руб."