fix(consolidations): верхние границы входов и видимость до FOR UPDATE (M-6)

MembersIn.legal_entity_ids ≤ 500, StatusIn.comment ≤ 1000, OsvFilters.q ≤ 500 (422 со списком ошибок);
lock_visible_version проверяет видимость версии до FOR UPDATE. Спека consolidations-api: требование
«Границы входных данных».

Refs #21

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 13:06:11 +04:00
parent 74be7d357a
commit 5868b14c4d
8 changed files with 118 additions and 9 deletions

View File

@ -715,8 +715,8 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
(`legal_entity_id`, `related_group_id`) и без значений ключей. Особенности: повторное добавление в ту же группу (`legal_entity_id`, `related_group_id`) и без значений ключей. Особенности: повторное добавление в ту же группу
отдаёт `MEMBER_OF_OTHER_GROUP` с нейтральным текстом «ЮЛ уже входит в группу этого вида» (его раньше срабатывает индекс отдаёт `MEMBER_OF_OTHER_GROUP` с нейтральным текстом «ЮЛ уже входит в группу этого вида» (его раньше срабатывает индекс
«ЮЛ + вид»); гонку за один алиас у разных ЮЛ (`ALIAS_TAKEN`) БД не ловит (уникален только `(ЮЛ, алиас)`) — её «ЮЛ + вид»); гонку за один алиас у разных ЮЛ (`ALIAS_TAKEN`) БД не ловит (уникален только `(ЮЛ, алиас)`) — её
сериализует advisory-блокировка по нормализованному алиасу (`lock_alias_key`: добавление алиаса и ручное сопоставление сериализует advisory-блокировка по нормализованному алиасу (`lock_alias_key`: добавление и удаление алиаса, ручное
контрагента); сопоставление контрагента; удаление перечитывает алиас под блокировкой и отвечает 404, если его уже убрал перенос);
email без учёта регистра проверяет только сервис, поэтому гонка с другим регистром остаётся открытым ограничением. email без учёта регистра проверяет только сервис, поэтому гонка с другим регистром остаётся открытым ограничением.
Любое иное нарушение целостности (FK, check, неизвестное unique) — прежняя строка `нарушение целостности данных в БД`. Любое иное нарушение целостности (FK, check, неизвестное unique) — прежняя строка `нарушение целостности данных в БД`.

View File

@ -28,6 +28,9 @@ YEAR_MAX = 2100
DEFAULT_OSV_LIMIT = 500 DEFAULT_OSV_LIMIT = 500
MAX_OSV_LIMIT = 2000 MAX_OSV_LIMIT = 2000
MAX_TAKEOVER_REASON = 1000 MAX_TAKEOVER_REASON = 1000
MAX_MEMBERS = 500 # M-6: периметр версии (ЮЛ одной ГК), с запасом на порядок выше реальных групп
MAX_STATUS_COMMENT = 1000 # как причина перехвата
MAX_OSV_QUERY = 500 # как NAME_MAX_LENGTH справочников
CENT = Decimal("0.01") CENT = Decimal("0.01")
@ -137,14 +140,14 @@ class VersionCard(VersionBrief):
class MembersIn(BaseModel): class MembersIn(BaseModel):
"""Тело `PUT …/members`: новый периметр версии (дубли схлопываются сервисом).""" """Тело `PUT …/members`: новый периметр версии (дубли схлопываются сервисом)."""
legal_entity_ids: list[uuid.UUID] legal_entity_ids: list[uuid.UUID] = Field(max_length=MAX_MEMBERS)
class StatusIn(BaseModel): class StatusIn(BaseModel):
"""Тело `POST …/status`: ручной переход (`CANCELLED` — комментарий обязателен; возврат в `INITIATED`).""" """Тело `POST …/status`: ручной переход (`CANCELLED` — комментарий обязателен; возврат в `INITIATED`)."""
to_status: ConsolidationStatus to_status: ConsolidationStatus
comment: str | None = None comment: str | None = Field(default=None, max_length=MAX_STATUS_COMMENT)
class ChecklistRuleOut(BaseModel): class ChecklistRuleOut(BaseModel):
@ -240,7 +243,7 @@ class OsvFilters(BaseModel):
counterparty_type: CounterpartyType | None = None counterparty_type: CounterpartyType | None = None
unmapped: bool | None = None unmapped: bool | None = None
match_kind: list[CounterpartyMatchKind] | None = None match_kind: list[CounterpartyMatchKind] | None = None
q: str | None = None q: str | None = Field(default=None, max_length=MAX_OSV_QUERY)
class OsvPageParams(BaseModel): class OsvPageParams(BaseModel):

View File

@ -23,10 +23,16 @@ VERSION_NOT_FOUND = "версия консолидации не найдена"
async def lock_visible_version( async def lock_visible_version(
session: AsyncSession, scope: VisibilityScope, version_id: uuid.UUID session: AsyncSession, scope: VisibilityScope, version_id: uuid.UUID
) -> tuple[ConsolidationVersion, Consolidation]: ) -> tuple[ConsolidationVersion, Consolidation]:
"""Версия под `FOR UPDATE` и её консолидация, если ГК видна пользователю; иначе 404 `VERSION_NOT_FOUND`.""" """Версия под `FOR UPDATE` и её консолидация, если ГК видна пользователю; иначе 404 `VERSION_NOT_FOUND`.
Видимость проверяется ДО блокировки (M-6): невидимая версия отвечает 404 сразу, не ожидая чужую транзакцию, которая
держит её строку. Затем `FOR UPDATE` с обновлением состояния; исчезнувшая за это время версия — тоже 404.
Повторной проверки видимости нет: ГК и филиал версия не меняет."""
found = await repo.get_version(session, scope, version_id)
if found is None:
raise NotFoundError(VERSION_NOT_FOUND)
version = await repo.lock_version(session, version_id) version = await repo.lock_version(session, version_id)
found = None if version is None else await repo.get_version(session, scope, version_id) if version is None:
if version is None or found is None:
raise NotFoundError(VERSION_NOT_FOUND) raise NotFoundError(VERSION_NOT_FOUND)
return version, found[1] return version, found[1]

View File

@ -2,7 +2,7 @@
audit-trail `OSV_COUNTERPARTY_MAP`, `LEGAL_ENTITY_CHANGE`; design D-10; §3 «Ручное сопоставление»). audit-trail `OSV_COUNTERPARTY_MAP`, `LEGAL_ENTITY_CHANGE`; design D-10; §3 «Ручное сопоставление»).
Одна транзакция, порядок проверок и блокировок: Одна транзакция, порядок проверок и блокировок:
1. `lock_editable_version` — версия `FOR UPDATE` → видимость (404) → статус «в работе» и своя блокировка (409). 1. `lock_editable_version` — видимость (404) → версия `FOR UPDATE` → статус «в работе» и своя блокировка (409).
2. Строка — строка свода версии (`entry_in_version`, иначе 404 «строка не найдена»); ЮЛ существует (без policy: 2. Строка — строка свода версии (`entry_in_version`, иначе 404 «строка не найдена»); ЮЛ существует (без policy:
контрагент может быть из другого филиала; иначе 404 `LEGAL_ENTITY_NOT_FOUND`). контрагент может быть из другого филиала; иначе 404 `LEGAL_ENTITY_NOT_FOUND`).
3. `normalized = normalize_alias(имя строки)`; непустой — advisory-блокировка алиаса (`lock_alias_key`, общая со 3. `normalized = normalize_alias(имя строки)`; непустой — advisory-блокировка алиаса (`lock_alias_key`, общая со

View File

@ -468,3 +468,20 @@ async def test_takeover_during_cancellation_sees_cancelled_version(
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
async with committed_sessionmaker() as session: async with committed_sessionmaker() as session:
assert all(lock.released_at is not None for lock in await _locks(session, version_id)) assert all(lock.released_at is not None for lock in await _locks(session, version_id))
@pytest.mark.integration
async def test_foreign_version_is_hidden_without_waiting_for_row_lock(
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory
) -> None:
"""M-6: видимость проверяется до `FOR UPDATE` — чужая версия отвечает 404 сразу, даже когда её строку держит
правка владельца (иначе невидимый пользователь ждал бы чужую транзакцию и мог влиять на её длительность)."""
async with committed_sessionmaker() as setup, setup.begin():
version_id = (await _version(setup, office_name=KRASNODAR)).id
client = await committed_client(KA_RF)
async with committed_sessionmaker() as holder, holder.begin():
assert await repo.lock_version(holder, version_id) is not None
response = await asyncio.wait_for(client.post(_lock_url(version_id)), LOCK_WAIT_SECONDS)
assert response.status_code == HTTP_NOT_FOUND

View File

@ -0,0 +1,47 @@
"""Верхние границы входов консолидаций (spec consolidations-api «Границы входных данных», M-6 из #21): превышение —
422 со списком ошибок валидации (формат приложения: `detail[].loc/msg`) до обращения к БД."""
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.schemas.consolidations import MAX_MEMBERS, MAX_OSV_QUERY, MAX_STATUS_COMMENT
from tests.conftest import AuthClientFactory
HTTP_UNPROCESSABLE = 422
KA_RF = "ka_rf@armka.local"
VERSIONS = "/consolidations/versions"
@pytest.mark.integration
async def test_members_over_limit_is_422(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: # noqa: ARG001
client = await auth_client(KA_RF)
ids = [str(uuid.uuid4()) for _ in range(MAX_MEMBERS + 1)]
response = await client.put(f"{VERSIONS}/{uuid.uuid4()}/members", json={"legal_entity_ids": ids})
assert response.status_code == HTTP_UNPROCESSABLE
assert response.json()["detail"][0]["loc"] == ["body", "legal_entity_ids"]
@pytest.mark.integration
async def test_status_comment_over_limit_is_422(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: # noqa: ARG001
client = await auth_client(KA_RF)
response = await client.post(
f"{VERSIONS}/{uuid.uuid4()}/status", json={"to_status": "CANCELLED", "comment": "а" * (MAX_STATUS_COMMENT + 1)}
)
assert response.status_code == HTTP_UNPROCESSABLE
assert response.json()["detail"][0]["loc"] == ["body", "comment"]
@pytest.mark.integration
async def test_osv_query_over_limit_is_422(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: # noqa: ARG001
client = await auth_client(KA_RF)
response = await client.get(f"{VERSIONS}/{uuid.uuid4()}/osv", params={"q": "а" * (MAX_OSV_QUERY + 1)})
assert response.status_code == HTTP_UNPROCESSABLE
assert response.json()["detail"][0]["loc"] == ["query", "q"]

View File

@ -13,10 +13,14 @@ from src.domain.consolidated_osv import CounterpartyType as DomainCounterpartyTy
from src.models.enums import CounterpartyMatchKind, FileStatus, PeriodQuarter from src.models.enums import CounterpartyMatchKind, FileStatus, PeriodQuarter
from src.schemas import consolidations as schemas from src.schemas import consolidations as schemas
from src.schemas.consolidations import ( from src.schemas.consolidations import (
MAX_MEMBERS,
MAX_OSV_QUERY,
MAX_STATUS_COMMENT,
ChecklistCellOut, ChecklistCellOut,
ConsolidatedRowOut, ConsolidatedRowOut,
ConsolidationCreate, ConsolidationCreate,
MapCounterpartyIn, MapCounterpartyIn,
MembersIn,
OsvFilters, OsvFilters,
OsvPageParams, OsvPageParams,
StatusIn, StatusIn,
@ -121,3 +125,20 @@ def test_counterparty_type_is_the_domain_enum() -> None:
assert OsvFilters(counterparty_type="GK").counterparty_type is DomainCounterpartyType.GK assert OsvFilters(counterparty_type="GK").counterparty_type is DomainCounterpartyType.GK
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
OsvFilters(counterparty_type="OTHER") # type: ignore[arg-type] OsvFilters(counterparty_type="OTHER") # type: ignore[arg-type]
def test_members_in_upper_bound() -> None:
"""M-6: периметр версии — не больше `MAX_MEMBERS` идентификаторов (до разбора дублей сервисом)."""
ids = [uuid.uuid4() for _ in range(MAX_MEMBERS)]
assert len(MembersIn(legal_entity_ids=ids).legal_entity_ids) == MAX_MEMBERS
with pytest.raises(ValidationError):
MembersIn(legal_entity_ids=[*ids, uuid.uuid4()])
def test_status_comment_and_osv_query_upper_bounds() -> None:
assert StatusIn(to_status="CANCELLED", comment="а" * MAX_STATUS_COMMENT).comment
with pytest.raises(ValidationError):
StatusIn(to_status="CANCELLED", comment="а" * (MAX_STATUS_COMMENT + 1))
assert OsvFilters(q="а" * MAX_OSV_QUERY).q
with pytest.raises(ValidationError):
OsvFilters(q="а" * (MAX_OSV_QUERY + 1))

View File

@ -149,3 +149,18 @@ SHALL закрываться только документом с `osv_account_c
#### Scenario: Файл в обработке #### Scenario: Файл в обработке
- **WHEN** у ЮЛ загружен файл, задание ещё не завершено, других документов нет - **WHEN** у ЮЛ загружен файл, задание ещё не завершено, других документов нет
- **THEN** все ячейки ЮЛ — `PROCESSING` - **THEN** все ячейки ЮЛ — `PROCESSING`
### Requirement: Границы входных данных
Входы консолидаций SHALL иметь верхние границы: `legal_entity_ids` тела `PUT …/members` — не больше 500 идентификаторов,
`comment` тела `POST …/status` — не больше 1000 символов, `q` фильтров `GET …/osv` и экспорта — не больше 500 символов.
Превышение SHALL давать 422 со списком ошибок валидации (`detail[].loc` указывает поле) до обращения к БД. Версия
консолидации SHALL проверяться на видимость до блокировки `FOR UPDATE`: невидимая версия отвечает 404 сразу, не ожидая
транзакцию, которая держит её строку.
#### Scenario: Периметр длиннее предела
- **WHEN** в `PUT …/members` передано 501 ЮЛ
- **THEN** 422, `detail[0].loc = ["body", "legal_entity_ids"]`, периметр не изменился
#### Scenario: Невидимая версия при занятой строке
- **WHEN** версию чужого филиала держит `FOR UPDATE` другая транзакция, пользователь без видимости вызывает `POST …/lock`
- **THEN** 404 `VERSION_NOT_FOUND` без ожидания