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:
parent
e45502b2ea
commit
fb457fc06b
@ -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):
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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"] == "тыс. руб."
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user