fix(consolidation): ревью Task 4 — общий lock_out, точнее тесты, решения #17–#24 в design.md

- I-1 (T4.1): lock_out — публичный в services/consolidations.py, копия в consolidation_locks удалена
- M-b (T4.3): удалён осиротевший комментарий в tests/factories.py
- M-c (T4.4): точная проверка ip_address/user_agent в аудите смены статуса
- M-d (T4.5): docstring теста Review Focus 4 ссылается на различающий тест порядка D-9
- design.md: решения без обсуждения #17 (R-8), #18 (R-9), #19–#24 (решения Task 4)

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-06 04:26:31 +04:00
parent b2fa59061d
commit e317fe59dd
6 changed files with 24 additions and 20 deletions

View File

@ -25,7 +25,7 @@ from src.models.enums import AuditAction, AuditSection, ConsolidationStatus
from src.repositories import consolidations as repo
from src.repositories.users import UserWithAccess
from src.schemas.consolidations import LockIn, LockOut
from src.services.consolidations import OBJECT_TYPE, VERSION_NOT_FOUND
from src.services.consolidations import OBJECT_TYPE, VERSION_NOT_FOUND, lock_out
ACTIVE_LOCK_INDEX = "uq_consolidation_lock_version_active"
@ -51,16 +51,6 @@ async def _user_name(session: AsyncSession, user_id: uuid.UUID) -> str:
return user.full_name if user is not None else ""
def _lock_out(lock: ConsolidationLock, locked_by_name: str) -> LockOut:
return LockOut(
locked_by=lock.locked_by,
locked_by_name=locked_by_name,
locked_at=lock.locked_at,
taken_over_from=lock.taken_over_from,
takeover_reason=lock.takeover_reason,
)
class ConsolidationLockService:
"""Взятие версии в работу от имени текущего пользователя в его области видимости."""
@ -86,7 +76,7 @@ class ConsolidationLockService:
user = self._principal.user
current = await repo.active_lock(self._session, version.id)
if current is not None and current.locked_by == user.id:
return _lock_out(current, user.full_name), False
return lock_out(current, user.full_name), False
reason = (data.takeover_reason or "").strip() or None
if current is not None and reason is None:
raise await self._locked_by_other(current)
@ -102,7 +92,7 @@ class ConsolidationLockService:
consolidation_version_id=version.id,
after_value={"locked_by": user.id, "released": False},
)
return _lock_out(lock, user.full_name), True
return lock_out(lock, user.full_name), True
previous_owner = current.locked_by
await release_active_lock(self._session, version.id, now)
@ -117,7 +107,7 @@ class ConsolidationLockService:
after_value={"locked_by": user.id},
comment=reason,
)
return _lock_out(lock, user.full_name), True
return lock_out(lock, user.full_name), True
async def release(self, version_id: uuid.UUID) -> None:
"""Снять свою активную блокировку (аудит `CONSOLIDATION_LOCK`, `released = true`); чужая — 409

View File

@ -58,7 +58,8 @@ MIN_PERIMETER = 2 # в периметре консолидации не мен
FIRST_VERSION_NO = 1
def _lock_out(lock: ConsolidationLock, locked_by_name: str) -> LockOut:
def lock_out(lock: ConsolidationLock, locked_by_name: str) -> LockOut:
"""Активная блокировка в ответе API (список, карточка, `POST …/lock`)."""
return LockOut(
locked_by=lock.locked_by,
locked_by_name=locked_by_name,
@ -211,7 +212,7 @@ class ConsolidationService:
status=version.status,
scenario=version.scenario,
approach=version.approach,
lock=_lock_out(*lock) if lock is not None else None,
lock=lock_out(*lock) if lock is not None else None,
created_at=version.created_at,
members_count=members_count,
)

View File

@ -76,7 +76,6 @@ TABULAR_FILE_TYPES = frozenset({FileType.XLS, FileType.XLSX, FileType.XLSB})
FACTORY_BUCKET = "arm-ka"
# статусы вне «в работе» (как фильтр `in_work_only` спеки): `is_in_work = false` ставится только им и аннулированию
def _now() -> datetime.datetime:
return datetime.datetime.now(datetime.UTC)

View File

@ -384,7 +384,8 @@ async def test_takeover_waits_for_owner_edit_then_owner_loses_lock(
"""Review Focus 4: владелец А правит периметр (`consolidation_version FOR UPDATE`), Б в это время перехватывает.
Перехват ждёт транзакцию А; после её commit правка А зафиксирована, Б — владелец, следующая правка А —
409 `LOCKED_BY_OTHER`.
409 `LOCKED_BY_OTHER`. Этот сценарий ждал бы и без `FOR UPDATE` версии в `take` (вставка блокировки берёт FK
`KEY SHARE` на версию); порядок D-9 различает `test_takeover_during_cancellation_sees_cancelled_version`.
"""
async with committed_sessionmaker() as setup, setup.begin():
version = await _version(setup)

View File

@ -118,7 +118,11 @@ async def test_cancel_by_lock_owner(auth_client: AuthClientFactory, seeded: Asyn
user = await _user(seeded, KA_RF)
client = await auth_client(KA_RF)
response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "Дубль"})
response = await client.post(
_status_url(version.id),
json={"to_status": "CANCELLED", "comment": "Дубль"},
headers={"User-Agent": "pytest-status"},
)
assert response.status_code == HTTP_OK, response.text
card = response.json()
@ -155,7 +159,8 @@ async def test_cancel_by_lock_owner(auth_client: AuthClientFactory, seeded: Asyn
{"status": "CANCELLED"},
"Дубль",
)
assert event.ip_address is not None or event.user_agent is not None # контекст запроса передан в аудит
# контекст запроса передан в аудит (ASGITransport отдаёт клиента 127.0.0.1)
assert (event.ip_address, event.user_agent) == ("127.0.0.1", "pytest-status")
@pytest.mark.integration

View File

@ -201,3 +201,11 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/
| 14 | 2026-10-06 | R-4: `Resolution(entity_id, kind)` определяется в `src/domain/osv_mapping.py`; `repositories/counterparties.py` импортирует и реэкспортирует | `domain/consolidated_osv.py` (mypy strict, без ORM/репозиториев) нуждается в нём в `Resolver`, домен не импортирует репозитории | нулевая (перенос определения) |
| 15 | 2026-10-06 | R-5: Task 2 создаёт `domain/checklist.py` с `EntityFilesState(has_in_progress, last_file_status)` и `entity_files_state(session, entity_ids)`; `unconfirmed_documents(...)` и `build` добавляет Task 8 | план описывал `entity_files_state` и в Task 2, и в Task 8; `build` получает и `files_state`, и `unbound` | нулевая (разнесение по задачам) |
| 16 | 2026-10-06 | `osv_entry.counterparty_match_kind` — последняя колонка таблицы (в ORM и DBML), а не сразу после `counterparty_id` | `ADD COLUMN` в миграции 0004 добавляет колонку в конец, а `test_migrated_schema_equals_orm_create_all` сравнивает `ordinal_position`; пересоздание таблицы с 200 тыс. строк ради порядка колонок не оправдано | косметика: порядок колонок в `SELECT *`/диаграмме; на поведение не влияет |
| 17 | 2026-10-06 | R-8: коды 404 `CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND` реализованы как текст сообщения при фиксированном коде `NOT_FOUND` | конвенция Срезов 1–2c: `NotFoundError` с фиксированным кодом, как `COMPANY_GROUP_NOT_FOUND` | если нужны отдельные коды — меняется конструктор ошибки и тесты |
| 18 | 2026-10-06 | R-9: `in_work_only` убирает период без оставшихся версий; невидимая/несуществующая ГК в списке → 404; неизвестная/невидимая ГСК → 404 (сообщение `RELATED_GROUP_NOT_FOUND`), видимая ГСК чужой ГК или `kind = gsz` → 422 `RELATED_GROUP_MISMATCH` | естественное продолжение policy «невидимое = несуществующее» (404); несоответствие видимой ГСК — ошибка запроса | расхождение с ожиданием фронта по пустому списку |
| 19 | 2026-10-06 | `takeover_reason` пустой или из одних пробелов считается отсутствующим: при чужой блокировке — 409 `LOCKED_BY_OTHER`, а не 422; при свободной версии причина не сохраняется (`taken_over_from` NULL, обычное взятие 201) | спека требует «непустую» причину только для перехвата; при свободной версии перехватывать не у кого | фронт не получит 422 на пустую причину; в БД нет «причины» у обычного взятия |
| 20 | 2026-10-06 | Комментарий `POST …/status` обрезается по краям; из одних пробелов — пустой (`COMMENT_REQUIRED` для `CANCELLED`); в `cancelled_reason` и историю пишется обрезанный текст | «пустой комментарий» по спеке включает пробельный; обрезка не меняет смысла | косметика: ведущие/хвостовые пробелы комментария теряются |
| 21 | 2026-10-06 | `NOT_LOCK_OWNER` отдаёт в `detail` `locked_by` и `locked_at` владельца (по аналогии с `LOCKED_BY_OTHER`) | спека полей не перечисляет; фронту нужно показать, у кого блокировка | нулевая (лишние поля ответа) |
| 22 | 2026-10-06 | Перехват пишет одно событие `CONSOLIDATION_LOCK_TAKEOVER` без отдельного `CONSOLIDATION_LOCK` на снятие прежней блокировки | сценарий audit-trail «Перехват» — «одно событие» | в журнале нет отдельной строки «блокировка А снята» при перехвате (она следует из `before_value`) |
| 23 | 2026-10-06 | `LOCKED_BY_OTHER` из `ensure_version_editable` (`PUT …/members`, `POST …/status`, `POST …/osv/entries/{id}/counterparty`) отдаёт `locked_by`, `locked_at` без `locked_by_name`; `POST …/lock` отдаёт и имя | доменная функция без БД имени не знает; directory-policy требует только `detail.locked_by`, имя спека требует лишь у `POST …/lock` | фронт для правок версии берёт имя владельца из карточки (`lock.locked_by_name`) или вызывающий сервис дополнит ответ |
| 24 | 2026-10-06 | `CANCELLED` снимает активную блокировку без отдельного события `CONSOLIDATION_LOCK` — снятие покрывает `CONSOLIDATION_STATUS_CHANGE`; `uq_consolidation_lock_version_active` зарегистрирован в `UNIQUE_CONSTRAINT_CONFLICTS` как `LOCKED_BY_OTHER` (страховка для вставки в обход сервиса; сервис сам ловит нарушение под SAVEPOINT и добавляет владельца) | таблица audit-trail относит `CONSOLIDATION_LOCK` к действиям «взята в работу, снята» пользователем, а не к побочному эффекту аннулирования; реестр — конвенция гонок 2c | в журнале нет отдельной строки «блокировка снята» при аннулировании; реестр — нулевая |