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 import consolidations as repo
from src.repositories.users import UserWithAccess from src.repositories.users import UserWithAccess
from src.schemas.consolidations import LockIn, LockOut 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" 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 "" 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: class ConsolidationLockService:
"""Взятие версии в работу от имени текущего пользователя в его области видимости.""" """Взятие версии в работу от имени текущего пользователя в его области видимости."""
@ -86,7 +76,7 @@ class ConsolidationLockService:
user = self._principal.user user = self._principal.user
current = await repo.active_lock(self._session, version.id) current = await repo.active_lock(self._session, version.id)
if current is not None and current.locked_by == user.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 reason = (data.takeover_reason or "").strip() or None
if current is not None and reason is None: if current is not None and reason is None:
raise await self._locked_by_other(current) raise await self._locked_by_other(current)
@ -102,7 +92,7 @@ class ConsolidationLockService:
consolidation_version_id=version.id, consolidation_version_id=version.id,
after_value={"locked_by": user.id, "released": False}, 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 previous_owner = current.locked_by
await release_active_lock(self._session, version.id, now) await release_active_lock(self._session, version.id, now)
@ -117,7 +107,7 @@ class ConsolidationLockService:
after_value={"locked_by": user.id}, after_value={"locked_by": user.id},
comment=reason, 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: async def release(self, version_id: uuid.UUID) -> None:
"""Снять свою активную блокировку (аудит `CONSOLIDATION_LOCK`, `released = true`); чужая — 409 """Снять свою активную блокировку (аудит `CONSOLIDATION_LOCK`, `released = true`); чужая — 409

View File

@ -58,7 +58,8 @@ MIN_PERIMETER = 2 # в периметре консолидации не мен
FIRST_VERSION_NO = 1 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( return LockOut(
locked_by=lock.locked_by, locked_by=lock.locked_by,
locked_by_name=locked_by_name, locked_by_name=locked_by_name,
@ -211,7 +212,7 @@ class ConsolidationService:
status=version.status, status=version.status,
scenario=version.scenario, scenario=version.scenario,
approach=version.approach, 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, created_at=version.created_at,
members_count=members_count, members_count=members_count,
) )

View File

@ -76,7 +76,6 @@ TABULAR_FILE_TYPES = frozenset({FileType.XLS, FileType.XLSX, FileType.XLSB})
FACTORY_BUCKET = "arm-ka" FACTORY_BUCKET = "arm-ka"
# статусы вне «в работе» (как фильтр `in_work_only` спеки): `is_in_work = false` ставится только им и аннулированию
def _now() -> datetime.datetime: def _now() -> datetime.datetime:
return datetime.datetime.now(datetime.UTC) 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`), Б в это время перехватывает. """Review Focus 4: владелец А правит периметр (`consolidation_version FOR UPDATE`), Б в это время перехватывает.
Перехват ждёт транзакцию А; после её commit правка А зафиксирована, Б — владелец, следующая правка А — Перехват ждёт транзакцию А; после её 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(): async with committed_sessionmaker() as setup, setup.begin():
version = await _version(setup) 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) user = await _user(seeded, KA_RF)
client = await auth_client(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 assert response.status_code == HTTP_OK, response.text
card = response.json() card = response.json()
@ -155,7 +159,8 @@ async def test_cancel_by_lock_owner(auth_client: AuthClientFactory, seeded: Asyn
{"status": "CANCELLED"}, {"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 @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`, домен не импортирует репозитории | нулевая (перенос определения) | | 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` | нулевая (разнесение по задачам) | | 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 *`/диаграмме; на поведение не влияет | | 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 | в журнале нет отдельной строки «блокировка снята» при аннулировании; реестр — нулевая |