Срез 3b: формы ББ/ОФР в ядре и API до сводной ОСВ #39

Merged
aep merged 36 commits from feat/36-slice-3b-statements-api into main 2026-10-07 14:20:49 +03:00
3 changed files with 37 additions and 4 deletions
Showing only changes of commit fb457fc06b - Show all commits

View File

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

View File

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

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
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"] == "тыс. руб."