test(consolidation): подходящесть неподтверждённых кандидатов чек-листа, правки spec и design по финальному ревью

I-1 (T8.2): тесты, что неподтверждённый документ даёт NEEDS_CHECK/RECOGNIZED только если подходит версии (fits):
нарастающий Q2 и квартальный Q4 при версии Q3, другой год и ЮЛ вне периметра не влияют; положительный контроль.
M-2: 404 `NOT_FOUND` с сообщением `…_NOT_FOUND`, ключ привязки с признаком нарастающего (R-1, R-14), порядок и
режимы блокировок подтверждения (R-3) в spec. M-3: в design #39 и #43 — окно коммита перехода после загрузки.
M-5: docstring lock_versions_for_entity про ЮЛ без ГК в периметре.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 08:28:24 +04:00
parent 98922cc75c
commit 42c253146c
8 changed files with 109 additions and 13 deletions

View File

@ -128,8 +128,10 @@ async def lock_versions_for_entity(
) -> list[ConsolidationVersion]:
"""Версии со статусом из `statuses`, где ЮЛ — член периметра; блокируются `FOR UPDATE ORDER BY id` (R-2).
Первой берётся advisory-блокировка привязок ГК ЮЛ (`lock_group_bindings`; ЮЛ без ГК — без неё: членом версии оно
быть не может): иначе версия, создаваемая параллельно, не видна ни здесь, ни подтверждённый нами документ — там.
Первой берётся advisory-блокировка привязок ГК ЮЛ (`lock_group_bindings`; у ЮЛ без ГК её нет): иначе версия,
создаваемая параллельно, не видна ни здесь, ни подтверждённый нами документ — там. ЮЛ без ГК (после выхода из
группы или переноса) может оставаться членом версий «в работе» — его ловит ветка `is_member`; такое ЮЛ в периметре
заставляет любой `PUT …/members` отвечать `ENTITY_NOT_IN_GROUP`, пока его не уберут из периметра.
Блокируется НАДМНОЖЕСТВО: версии этих статусов, где ЮЛ — член ИЛИ `consolidation.company_group_id` равен ГК ЮЛ
(`legal_entity.company_group_id`). Иначе подтверждение документа ∥ `PUT …/members` теряет привязку: подтверждение

View File

@ -151,3 +151,93 @@ async def test_checklist_visible_to_go_curator_hidden_from_foreign_office(
assert hidden.status_code == unknown.status_code == HTTP_NOT_FOUND
assert hidden.json() == unknown.json()
assert (await admin.get(url)).status_code == HTTP_FORBIDDEN
async def _unconfirmed( # noqa: PLR0913 - тестовая фабрика: ЮЛ, счёт, период, признак и статус документа
session: AsyncSession,
entity: LegalEntity,
*,
account: str,
year: int = YEAR,
quarter: PeriodQuarter = Q3,
cumulative: bool = False,
status: DocumentStatus = DocumentStatus.NEEDS_CHECK,
) -> uuid.UUID:
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
document = await make_document(
session,
file=file,
job=job,
account=account,
year=year,
quarter=quarter,
status=status,
is_cumulative=cumulative,
)
return document.id
def _column(account: str) -> int:
return [account for _, account in EXPECTED_RULES].index(account)
@pytest.mark.integration
@pytest.mark.parametrize(
("account", "year", "quarter", "cumulative", "status", "expected"),
[
# положительный контроль: квартальный Q3 ОСВ версии Q3 подходит -> кандидат виден
("76", YEAR, Q3, False, DocumentStatus.NEEDS_CHECK, "NEEDS_CHECK"),
# квартальный Q1 не позже квартала версии (Q3) подходит; RECOGNIZED
("90", YEAR, Q1, False, DocumentStatus.RECOGNIZED, "RECOGNIZED"),
# нарастающий за Q2 при версии Q3 не подходит (нужен нарастающий за квартал версии)
("62", YEAR, Q2, True, DocumentStatus.NEEDS_CHECK, "MISSING"),
("62", YEAR, Q2, True, DocumentStatus.RECOGNIZED, "MISSING"),
# квартальный позже квартала версии не подходит
("66", YEAR, PeriodQuarter.Q4, False, DocumentStatus.NEEDS_CHECK, "MISSING"),
# другой год не подходит
("67", YEAR - 1, Q3, False, DocumentStatus.NEEDS_CHECK, "MISSING"),
],
ids=["fits-quarterly-q3", "fits-quarterly-q1", "cumulative-q2", "cumulative-q2-recognized", "quarterly-q4", "year"],
)
async def test_checklist_unconfirmed_candidate_must_fit_version( # noqa: PLR0913, PLR0917 - параметры сценария
auth_client: AuthClientFactory,
seeded: AsyncSession,
account: str,
year: int,
quarter: PeriodQuarter,
cumulative: bool,
status: DocumentStatus,
expected: str,
) -> None:
"""Неподтверждённый документ даёт `NEEDS_CHECK`/`RECOGNIZED` только если подходит версии (`fits`; I-1, T8.2)."""
version, (first, _) = await _world(seeded)
await _unconfirmed(seeded, first, account=account, year=year, quarter=quarter, cumulative=cumulative, status=status)
client = await auth_client(KA_RF)
body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json()
cells = _cells(body, "ООО А")
assert cells[_column(account)]["status"] == expected
others = [cell["status"] for position, cell in enumerate(cells) if position != _column(account)]
assert set(others) == {"MISSING"} # прочие столбцы не затронуты
assert {cell["status"] for cell in _cells(body, "ООО Б")} == {"MISSING"}
@pytest.mark.integration
async def test_checklist_unconfirmed_of_entity_outside_perimeter_has_no_effect(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Неподтверждённый документ ЮЛ вне периметра (той же ГК) не влияет на ячейки и не добавляет строк (I-1, T8.2)."""
version, _ = await _world(seeded)
group = (await seeded.execute(select(CompanyGroup).where(CompanyGroup.name == "ГК Русский бисквит"))).scalar_one()
outsider = await make_legal_entity(seeded, name="ООО В вне периметра", group=group)
await _unconfirmed(seeded, outsider, account="62")
await _unconfirmed(seeded, outsider, account="60", status=DocumentStatus.RECOGNIZED)
client = await auth_client(KA_RF)
body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json()
assert [row["legal_entity_name"] for row in body["rows"]] == ["ООО А", "ООО Б"]
assert all({cell["status"] for cell in row["cells"]} == {"MISSING"} for row in body["rows"])

View File

@ -223,11 +223,11 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/
| 36 | 2026-10-06 | Переход версии при подтверждении пишется `transition(...)` с `user_id` подтвердившего, но без IP/user-agent (сигнатура хука `on_document_confirmed(session, audit: AuditService, *, document, user_id, now)` фиксирована планом); `transition` импортируется внутри хука (цикл `consolidation_status → consolidations → consolidation_documents`) | audit-trail требует для автоматического перехода только `user_id` инициатора | в `CONSOLIDATION_STATUS_CHANGE` автоматического перехода нет IP/UA — они есть в соседнем `DOCUMENT_CONFIRM` той же транзакции |
| 37 | 2026-10-06 | Отдельная транзакция перехода после загрузки (`after_upload_to_version`) берётся из `app.state.session_audit.transaction` (та же фабрика `session_transaction`, что у аудита входа; без неё — `src.db.db.session_transaction`); `get_primary_files_service` передаёт её в `PrimaryFilesService(…, transaction)` | отдельной зависимости «фабрика транзакций» в приложении нет, а фикстуры тестов уже подставляют фабрику в `SessionAudit`; не меняем `app.py` и `conftest` | если `SessionAudit` когда-либо уйдёт из `app.state`, сервис молча возьмёт боевой `session_transaction` (в тестах это была бы чужая БД) |
| 38 | 2026-10-06 | Переход после загрузки пишет `CONSOLIDATION_STATUS_CHANGE` с IP и user-agent запроса (в отличие от хука подтверждения, №36); `transition` вызывается из сервиса загрузки с `RequestAudit`-контекстом, но в своей транзакции и своим `AuditService(session)` | сервис знает IP/UA запроса, а `transition` их принимает; потери информации нет | нет |
| 39 | 2026-10-06 | Версия в статусе `DOCUMENTS_PROCESSING`/`DATA_CORRECTION` при загрузке не меняется, `after_upload_to_version` — no-op; если между проверкой и переходом версию аннулировали/перевели, переход не выполняется (статус перечитывается под `FOR UPDATE`), загрузка при этом остаётся принятой (201) | проверка до приёма и переход после — две разные транзакции, окно между ними неустранимо без блокировки версии, которую спека запрещает | версия, аннулированная в окне, остаётся аннулированной; файл принят и виден в ЮЛ, но к версии не привязан (привязка — только подтверждением). Окно покрывает и удаление ЮЛ из периметра между проверкой и переходом: версия всё равно перейдёт в `DOCUMENTS_PROCESSING` — допустимо. Отдельная транзакция перехода открывает второе подключение к БД, пока запрос держит своё: при множестве параллельных загрузок в контексте версии это давит на пул (по умолчанию 5+10) — пока теоретически |
| 39 | 2026-10-06 | Версия в статусе `DOCUMENTS_PROCESSING`/`DATA_CORRECTION` при загрузке не меняется, `after_upload_to_version` — no-op; если между проверкой и переходом версию аннулировали/перевели, переход не выполняется (статус перечитывается под `FOR UPDATE`), загрузка при этом остаётся принятой (201) | проверка до приёма и переход после — две разные транзакции, окно между ними неустранимо без блокировки версии, которую спека запрещает | версия, аннулированная в окне, остаётся аннулированной; файл принят и виден в ЮЛ, но к версии не привязан (привязка — только подтверждением). Окно покрывает и удаление ЮЛ из периметра между проверкой и переходом: версия всё равно перейдёт в `DOCUMENTS_PROCESSING` — допустимо. Отдельная транзакция перехода открывает второе подключение к БД, пока запрос держит своё: при множестве параллельных загрузок в контексте версии это давит на пул (по умолчанию 5+10) — пока теоретически Переход после загрузки фиксируется отдельной транзакцией ДО коммита транзакции запроса с файлами: если коммит запроса затем упадёт, версия может остаться в `DOCUMENTS_PROCESSING` без файлов (исправимо: вручную вернуть в `INITIATED`); возможное последующее исправление — переход после коммита запроса. |
| 40 | 2026-10-06 | `on_documents_mapped` блокирует через `lock_versions_for_entity` надмножество по `IN_WORK_STATUSES` и фильтрует `DOCUMENTS_PROCESSING` ПОСЛЕ блокировки, а не передаёт `statuses={DOCUMENTS_PROCESSING}`; вызывается в `_map_candidate` ПОСЛЕ `map_file`, то есть после автозамены | при фильтре статуса в блокирующем запросе версия, которую параллельно переводят `INITIATED → DOCUMENTS_PROCESSING`, не ждалась бы; порядок «файл → документы → advisory ГК → версии» сохраняется, автозамена внутри `map_file` берёт advisory ЮЛ → старые файлы → advisory ГК → версии раньше хука; вызов до автозамены держал бы версии и затем ждал старые файлы (встречная блокировка с подтверждением) | блокируется чуть больше версий ГК (все «в работе»), на время короткой транзакции маппинга |
| 41 | 2026-10-06 | Хук получает документы файла отдельным `SELECT` по `stored_file_id` после `map_file` (возвращаемое значение `map_file` — предупреждения — не менялось) | сигнатура `map_file` и её тесты не трогаются; документы только что записаны в этой же транзакции | один лишний запрос на файл |
| 42 | 2026-10-06 | Переход по маппингу — только если среди документов файла есть `NEEDS_CHECK`, подходящий версии по `fits` (`fits` — D-5: период, год, член); статус файла/документа в других статусах (`RECOGNIZED`) переход не вызывает, привязки не создаются | спека osv-result-mapping | версия с неподходящим по периоду `NEEDS_CHECK` документом остаётся в `DOCUMENTS_PROCESSING` до подтверждения подходящего |
| 43 | 2026-10-06 | R-10: ошибка (`Exception`) отдельной транзакции перехода после загрузки (`after_upload_to_version`) логируется (`logger.exception`, `error_category=version_transition_error`) и не меняет ответ: 201 с принятыми файлами; `BaseException` (`asyncio.CancelledError`) не перехватывается | приём файлов важнее побочного статуса; иначе 500 откатил бы транзакцию запроса, а принятые объекты S3 остались бы сиротами (как в `SessionAudit`, ошибка записи побочного события ответ не меняет) | версия может остаться `INITIATED` после загрузки до следующей загрузки или подтверждения документа; сбой виден только в логе |
| 43 | 2026-10-06 | R-10: ошибка (`Exception`) отдельной транзакции перехода после загрузки (`after_upload_to_version`) логируется (`logger.exception`, `error_category=version_transition_error`) и не меняет ответ: 201 с принятыми файлами; `BaseException` (`asyncio.CancelledError`) не перехватывается | приём файлов важнее побочного статуса; иначе 500 откатил бы транзакцию запроса, а принятые объекты S3 остались бы сиротами (как в `SessionAudit`, ошибка записи побочного события ответ не меняет) | версия может остаться `INITIATED` после загрузки до следующей загрузки или подтверждения документа; сбой виден только в логе Переход после загрузки фиксируется отдельной транзакцией ДО коммита транзакции запроса с файлами: если коммит запроса затем упадёт, версия может остаться в `DOCUMENTS_PROCESSING` без файлов (исправимо: вручную вернуть в `INITIATED`); возможное последующее исправление — переход после коммита запроса. |
| 44 | 2026-10-06 | Статус ячейки — одно определение: `domain.checklist.CellStatus` (StrEnum); `ChecklistCellOut.status` в схеме API использует его, `Literal` `ChecklistCellStatus` удалён (T2.8) | два расходящихся определения — источник тихого рассинхрона домена и контракта | нулевая: OpenAPI-enum тот же набор значений |
| 45 | 2026-10-06 | Проверка «подходящести» неподтверждённых документов (`fits`: член периметра, год, квартал) выполняется в сервисе (`_checklist_cells`), в домен `build` приходят уже подходящие; домен сам сверяет только ЮЛ, тип, счёт и статус (`RECOGNIZED`/`NEEDS_CHECK`) | `fits` живёт в `services.consolidation_documents` (домен не импортирует сервисы и ORM); дублировать предикат в домене — риск расхождения с привязкой | если `fits` изменится — меняется и чек-лист, что желательно; вне `fits` домен кандидатов не отсеивает |
| 46 | 2026-10-06 | Выбор источников ОСВ в чек-листе — локальная функция `_osv_coverage` (нарастающий за квартал версии, иначе квартальные Q1..Qn все; нарастающий приоритетнее; пробел → `missing_quarters` + документы имеющихся кварталов), помечена `# Task 9 заменит на consolidation_periods.select_sources`; при полном отсутствии документов `missing_quarters = []` (перечисляются только при частичном покрытии, как в спеке) | `select_sources` появится в Task 9; план прямо разрешает локальную проверку до него | до Task 9 два места с правилом выбора; Task 9 удаляет локальное |

View File

@ -100,7 +100,7 @@ MUST NOT агрегироваться между контрагентами. `co
### Requirement: Ручное сопоставление контрагента
`POST …/osv/entries/{entry_id}/counterparty` (`CONSOLIDATION_EDIT`, policy, статус «в работе», своя блокировка) с телом
`{legal_entity_id, move_alias = false}` SHALL в одной транзакции: проверить, что строка принадлежит ОСВ-документу,
привязанному к версии (иначе 404 `ENTRY_NOT_FOUND`), и что ЮЛ существует (без policy; иначе 404 `LEGAL_ENTITY_NOT_FOUND`);
привязанному к версии (иначе 404 `NOT_FOUND` с сообщением `ENTRY_NOT_FOUND`; код фиксированный, идентификатор — текст сообщения), и что ЮЛ существует (без policy; иначе 404 `NOT_FOUND` с сообщением `LEGAL_ENTITY_NOT_FOUND`);
вычислить `normalized = normalize_alias(counterparty_name)`: пустой → алиас не создаётся; алиас с таким `normalized`
у других ЮЛ и `move_alias = false` → 409 `ALIAS_TAKEN` с `owners[] {legal_entity_id, name}`; с `move_alias = true` →
их алиасы удаляются (`audit_event(LEGAL_ENTITY_CHANGE)` на каждый); у выбранного ЮЛ алиаса нет → создаётся

View File

@ -43,7 +43,8 @@ Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_version` №1 (`INITIATE
версии и консолидации, `company_group {id, name}`, инициатора и его офис, `lock`, `members[]` (ЮЛ: `id`, `name`, `inn`,
`is_head_entity`, `checklist[]` — ячейки по правилам подхода, см. «Чек-лист комплекта»), `history[]` (`from_status`,
`to_status`, `changed_by`, `changed_at`, `comment`). Консолидация ГК вне видимости и несуществующий идентификатор —
404 `CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND` с одинаковым телом.
404 `NOT_FOUND` с сообщением `CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND` и одинаковым телом (так же во всех
спецификациях среза: идентификаторы `…_NOT_FOUND` — текст сообщения при фиксированном коде `NOT_FOUND`).
#### Scenario: Дерево версий
- **WHEN** у ГК консолидации 2024 Q1 (версии 1 и 2) и 2024 Q3 (версия 1), запрошен список
@ -51,7 +52,7 @@ Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_version` №1 (`INITIATE
#### Scenario: Чужой филиал
- **WHEN** `ka_rf` другого филиала запрашивает карточку версии
- **THEN** 404 `VERSION_NOT_FOUND`, тело совпадает с ответом на случайный `uuid`
- **THEN** 404 `NOT_FOUND` (сообщение `VERSION_NOT_FOUND`), тело совпадает с ответом на случайный `uuid`
### Requirement: Периметр версии
`PUT /consolidations/versions/{id}/members` (`CONSOLIDATION_EDIT`, policy, статус «в работе», своя активная блокировка)

View File

@ -4,7 +4,8 @@
### Requirement: Видимость консолидаций и редактируемость версии
Консолидация и её версии SHALL быть видимы тем же пользователям, что и её ГК (`company_group`); чужие и
несуществующие — 404 с одинаковым телом (`CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND`). Правка версии (периметр,
несуществующие — 404 с одинаковым телом (код `NOT_FOUND`, сообщение `CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND`;
так устроены все 404 этого среза: `NotFoundError` несёт только текст сообщения). Правка версии (периметр,
ручная смена статуса, сопоставление контрагента) SHALL проходить проверки в порядке: право → видимость → статус
версии ∈ {`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`} (иначе 409 `VERSION_NOT_EDITABLE`) → активная
`consolidation_lock` есть (иначе 409 `LOCK_REQUIRED`) и принадлежит пользователю (иначе 409 `LOCKED_BY_OTHER` с

View File

@ -7,8 +7,9 @@
или `NEEDS_CHECK` в `CONFIRMED` (`confirmed_by`, `confirmed_at`) с `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS)`;
из `CONFIRMED` повторно — 409 `ALREADY_CONFIRMED`; документ файла `SUPERSEDED` — 409 `FILE_SUPERSEDED`; документ в любом
другом статусе (например, `ERROR`) — 409 `DOCUMENT_NOT_CONFIRMABLE`. Если совпали несколько условий, приоритет у
`FILE_SUPERSEDED`. Подтверждение берёт блокировки в порядке «файл → документ → версии консолидации (по возрастанию
`id`)» и перечитывает их под `FOR UPDATE`; документ или файл, исчезнувшие за время ожидания блокировки, — 404 `NOT_FOUND`.
`FILE_SUPERSEDED`. Подтверждение берёт блокировки в порядке «файл → документ → (advisory-блокировка привязок ГК) → версии консолидации
(по возрастанию `id`)»: файл — `FOR UPDATE`, документ — `FOR NO KEY UPDATE` (R-3; совместима со вставкой привязки
`FOR KEY SHARE`), версии — `FOR UPDATE`; состояние перечитывается под этими блокировками; документ или файл, исчезнувшие за время ожидания блокировки, — 404 `NOT_FOUND`.
В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`,
`DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии,
`period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал
@ -49,8 +50,9 @@
### Requirement: Подхват документов при создании версии и расширении периметра
При создании версии и при добавлении ЮЛ в периметр ядро SHALL привязать уже подтверждённые документы этого ЮЛ из
незаменённых файлов, подходящие версии (условия — как при подтверждении); на одну тройку (`document_type`,
`osv_account_code`, `period_quarter`) — документ с самым поздним `confirmed_at`. Удаление ЮЛ из периметра SHALL
незаменённых файлов, подходящие версии (условия — как при подтверждении); на один ключ (`document_type`,
`osv_account_code`, `period_quarter`, признак «нарастающий»; NULL признака — нарастающий; для ОСВ за Q1 признак не
различается, R-1, R-14) — документ с самым поздним `confirmed_at`. Удаление ЮЛ из периметра SHALL
снимать привязку всех его документов в этой версии. Подхват и отвязка статус версии MUST NOT менять.
#### Scenario: Фикстуры, подтверждённые до консолидации

View File

@ -16,7 +16,7 @@ queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg →
`message`); код ответа 201, если принят хотя бы один, иначе 422. Тип документа и период при загрузке MUST NOT
запрашиваться (макет `40`). Без доступного S3 — 503 `STORAGE_UNAVAILABLE`.
Необязательный query-параметр `consolidation_version_id` SHALL проверяться до приёма файлов: версия видима (иначе 404
`VERSION_NOT_FOUND`), статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`), ЮЛ ∈ периметру (иначе 422
`NOT_FOUND` с сообщением `VERSION_NOT_FOUND`), статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`), ЮЛ ∈ периметру (иначе 422
`ENTITY_NOT_IN_PERIMETER`); если принят хотя бы один файл и версия в `INITIATED`, она SHALL перейти в
`DOCUMENTS_PROCESSING` (`changed_by` = загрузивший) в отдельной транзакции после приёма файлов. Блокировка версии
для загрузки MUST NOT требоваться.