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:
parent
b2fa59061d
commit
e317fe59dd
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 | в журнале нет отдельной строки «блокировка снята» при аннулировании; реестр — нулевая |
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user