arm-core/api/tests/test_consolidation_documents.py
Andrey Epifancev 4abfa03539 fix-волна ревью: delta статусов, блокировка версий консолидации, тесты (#96)
I-2: delta MODIFIED consolidations-api «Статусы Среза 4» (загрузка в консолидацию).
T1.1: шапки моделей — DBML v5.18.
T3.1: lock_versions_of_consolidation — двухшаговая блокировка (FOR UPDATE без членства, затем выборка членов);
тесты гонки и выборки.
M-1: тест миграции 0006 — порядок кварталов внутри года.
M-2: тест 404 для ЮЛ чужого филиала на столе своей консолидации.
T4.1: docstring supersede_previous — перенос строк.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 02:23:09 +04:00

1284 lines
66 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Привязка документов к версиям консолидации (spec documents-api «Подтверждение документа», «Подхват документов при
создании версии и расширении периметра»; audit-trail `CONSOLIDATION_STATUS_CHANGE`; design D-5, D-6, D-9; рулинги
R-1 — ключ замены с признаком нарастающего, R-2 — гонки потерянной привязки, R-3 — `FOR NO KEY UPDATE` документа).
Сценарии подтверждения и подхвата — поверх `seeded`/`auth_client` (транзакция теста). Гонки — на `committed_*`
(настоящие параллельные транзакции, таймаут 5 с): подтверждение ∥ правка периметра в обе стороны, создание версии ∥
подтверждение в обе стороны и совместимость блокировки документа подтверждения со вставкой привязки.
"""
import asyncio
import datetime
import uuid
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import pytest
from loguru import logger
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.common.clock import utc_now
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.consolidation import (
Consolidation,
ConsolidationDocument,
ConsolidationMember,
ConsolidationStatusHistory,
ConsolidationVersion,
)
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup
from src.models.enums import (
AuditAction,
ConsolidationStatus,
DocumentStatus,
DocumentType,
FileStatus,
PeriodQuarter,
RecognitionStatus,
)
from src.models.files import Document, StoredFile
from src.repositories import consolidations as repo
from src.repositories import files as files_repo
from src.services import consolidation_documents
from src.services.audit import AuditService
from src.services.consolidation_documents import fits
from src.services.result_mapping import supersede_previous
from tests.conftest import AuthClientFactory
from tests.factories import (
make_company_group,
make_confirmed_osv,
make_consolidation,
make_document,
make_legal_entity,
make_lock,
make_membership,
make_related_group,
make_stored_file,
)
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
HTTP_OK = 200
HTTP_CREATED = 201
ROSTOV = "Ростовский региональный филиал"
KA_RF = "ka_rf@armka.local"
ACCOUNT = "60"
YEAR = 2024
FORM_YEAR = 2025 # период golden-формы `FORM_SNAPSHOT`
FORM_SNAPSHOT = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json"
LOCK_WAIT_SECONDS = 5
ROWS = [{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}]
Q1, Q2, Q3, Q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4
# --- fits: чистая функция (D-5) --------------------------------------------------------------------------------------
_MEMBER = uuid.uuid4()
_STRANGER = uuid.uuid4()
def _pure_document(
*,
entity: uuid.UUID = _MEMBER,
year: int = YEAR,
quarter: PeriodQuarter | None = Q3,
cumulative: bool | None,
kind: DocumentType = DocumentType.OSV,
) -> Document:
return Document(
legal_entity_id=entity,
document_type=kind,
osv_account_code=ACCOUNT if kind == DocumentType.OSV else None,
period_year=year,
period_quarter=quarter,
is_cumulative=cumulative,
status=DocumentStatus.CONFIRMED,
)
@pytest.mark.parametrize(
("document", "expected"),
[
(_pure_document(cumulative=False, quarter=Q1), True), # член, квартальный Q1 ≤ Q3
(_pure_document(entity=_STRANGER, cumulative=True), False), # не член периметра
(_pure_document(year=YEAR - 1, cumulative=True), False), # другой год
(_pure_document(cumulative=True, quarter=Q3), True), # нарастающий за квартал версии
(_pure_document(cumulative=True, quarter=Q2), False), # нарастающий за Q2 при версии Q3 (Review Focus 2)
(_pure_document(cumulative=False, quarter=Q4), False), # квартальный позже квартала версии
(_pure_document(cumulative=False, quarter=None), False), # квартал не определён
(_pure_document(cumulative=None, quarter=Q3), True), # ББ/ОФР: NULL — нарастающий (решение №10)
(_pure_document(cumulative=None, quarter=Q2), False), # NULL за Q2 при версии Q3 — как нарастающий
(_pure_document(cumulative=True, quarter=Q1), True), # R-14: нарастающий Q1 = квартальный Q1, подходит Q3
(_pure_document(cumulative=None, quarter=Q1), True), # R-14: флаг за Q1 не различает
(_pure_document(year=YEAR - 1, cumulative=True, quarter=Q1), False), # Q1 другого года
(_pure_document(entity=_STRANGER, cumulative=True, quarter=Q1), False), # Q1 не члена периметра
],
)
def test_fits(document: Document, expected: bool) -> None:
version = ConsolidationVersion(status=ConsolidationStatus.INITIATED)
consolidation = Consolidation(year=YEAR, quarter=Q3)
assert fits(document, version, consolidation, {_MEMBER}) is expected
# --- данные ---------------------------------------------------------------------------------------------------------
async def _user(session: AsyncSession, email: str = KA_RF) -> AppUser:
return (await session.execute(select(AppUser).where(AppUser.email == email))).scalar_one()
async def _group(session: AsyncSession, members: int = 2) -> tuple[CompanyGroup, list[LegalEntity]]:
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
entities = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(members)]
return group, entities
async def _version(
session: AsyncSession,
group: CompanyGroup,
members: Sequence[LegalEntity],
*,
quarter: PeriodQuarter = Q3,
status: ConsolidationStatus = ConsolidationStatus.INITIATED,
) -> ConsolidationVersion:
_, version = await make_consolidation(
session, group=group, year=YEAR, quarter=quarter, members=members, initiator=await _user(session), status=status
)
return version
async def _document( # noqa: PLR0913 - тестовая фабрика: ЮЛ, счёт, период и статусы документа и файла
session: AsyncSession,
entity: LegalEntity,
*,
quarter: PeriodQuarter = Q2,
cumulative: bool = False,
account: str = ACCOUNT,
status: DocumentStatus = DocumentStatus.NEEDS_CHECK,
) -> Document:
"""Документ ОСВ файла `PROCESSED` (по умолчанию — квартальный `NEEDS_CHECK` за 2024 Q2)."""
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
return await make_document(
session,
file=file,
job=job,
account=account,
year=YEAR,
quarter=quarter,
status=status,
is_cumulative=cumulative,
)
async def _confirmed( # noqa: PLR0913 - тестовая фабрика: ЮЛ, период, счёт и время подтверждения
session: AsyncSession,
entity: LegalEntity,
*,
quarter: PeriodQuarter,
cumulative: bool = False,
account: str = ACCOUNT,
confirmed_at: datetime.datetime | None = None,
) -> Document:
document = await make_confirmed_osv(
session, entity=entity, account=account, year=YEAR, quarter=quarter, is_cumulative=cumulative, rows=ROWS
)
if confirmed_at is not None:
document.confirmed_at = confirmed_at
await session.flush()
return document
async def _bound(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]:
stmt = select(ConsolidationDocument.document_id).where(ConsolidationDocument.consolidation_version_id == version_id)
return set((await session.execute(stmt)).scalars())
async def _status(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationStatus:
stmt = select(ConsolidationVersion.status).where(ConsolidationVersion.id == version_id)
return (await session.execute(stmt.execution_options(populate_existing=True))).scalar_one()
async def _history(session: AsyncSession, version_id: uuid.UUID) -> list[ConsolidationStatusHistory]:
return await repo.history(session, version_id)
async def _bind(session: AsyncSession, version: ConsolidationVersion, document: Document) -> None:
session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id))
await session.flush()
@pytest.mark.parametrize("version_quarter", [Q1, Q2, Q3, Q4])
@pytest.mark.parametrize("cumulative", [True, False, None])
def test_q1_document_fits_every_version_of_year(version_quarter: PeriodQuarter, cumulative: bool | None) -> None:
"""R-14: ОСВ за Q1 равнозначна нарастающей и квартальной — подходит любой версии года."""
version = ConsolidationVersion(status=ConsolidationStatus.INITIATED)
consolidation = Consolidation(year=YEAR, quarter=version_quarter)
assert fits(_pure_document(cumulative=cumulative, quarter=Q1), version, consolidation, {_MEMBER}) is True
@pytest.mark.parametrize("kind", [DocumentType.BALANCE, DocumentType.INCOME])
@pytest.mark.parametrize(("version_quarter", "expected"), [(Q1, True), (Q2, False), (Q3, False), (Q4, False)])
def test_balance_and_income_of_q1_fit_only_q1_version(
kind: DocumentType, version_quarter: PeriodQuarter, expected: bool
) -> None:
"""R-14a: ББ/ОФР за Q1 — отчёт на конец Q1, не источник версий Q2–Q4 (NULL — нарастающий, D-5 без изменений)."""
version = ConsolidationVersion(status=ConsolidationStatus.INITIATED)
consolidation = Consolidation(year=YEAR, quarter=version_quarter)
document = _pure_document(cumulative=None, quarter=Q1, kind=kind)
assert fits(document, version, consolidation, {_MEMBER}) is expected
@pytest.mark.parametrize("kind", [DocumentType.BALANCE, DocumentType.INCOME])
@pytest.mark.parametrize("quarter", [Q1, Q2, Q3, Q4])
def test_binding_key_of_balance_and_income_is_cumulative(kind: DocumentType, quarter: PeriodQuarter) -> None:
"""R-14a: равнозначность Q1 ключ замены ББ/ОФР не меняет: признак нарастающего у них всегда `True` (№10)."""
document = _pure_document(cumulative=None, quarter=quarter, kind=kind)
assert consolidation_documents._key(document)[-1] is True # noqa: SLF001 - ключ замены R-1 — внутренний
@pytest.mark.parametrize("cumulative", [True, False])
def test_binding_key_of_osv_q1_ignores_flag(cumulative: bool) -> None:
"""R-14: нарастающая и квартальная ОСВ за Q1 одной пары — один слот привязки."""
document = _pure_document(cumulative=cumulative, quarter=Q1)
assert consolidation_documents._key(document)[-1] is False # noqa: SLF001 - ключ замены R-1 — внутренний
# --- подтверждение ----------------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_confirm_binds_document_and_moves_version(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""«Привязка к версии и переход статуса»: квартальная ОСВ 60 за Q2 при версии Q3 в `INITIATED`."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
document = await _document(seeded, entities[0])
user = await _user(seeded)
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_OK, response.text
assert await _bound(seeded, version.id) == {document.id}
assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION
last = (await _history(seeded, version.id))[-1]
assert (last.from_status, last.to_status, last.changed_by) == (
ConsolidationStatus.INITIATED,
ConsolidationStatus.DATA_CORRECTION,
user.id,
)
[event] = (
await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_STATUS_CHANGE))
).scalars()
assert (event.user_id, event.consolidation_version_id, event.object_id) == (user.id, version.id, version.id)
assert (event.before_value, event.after_value) == ({"status": "INITIATED"}, {"status": "DATA_CORRECTION"})
@pytest.mark.integration
@pytest.mark.parametrize(
("status", "moved"),
[
(ConsolidationStatus.DOCUMENTS_PROCESSING, True),
(ConsolidationStatus.DATA_CORRECTION, False), # уже там: привязка без перехода
],
)
async def test_confirm_binds_in_other_in_work_statuses(
seeded: AsyncSession, auth_client: AuthClientFactory, status: ConsolidationStatus, moved: bool
) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, status=status)
before = len(await _history(seeded, version.id))
document = await _document(seeded, entities[0], quarter=Q3, cumulative=True)
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_OK, response.text
assert await _bound(seeded, version.id) == {document.id}
assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION
assert len(await _history(seeded, version.id)) == before + (1 if moved else 0)
@pytest.mark.integration
async def test_confirm_binds_cumulative_q1_to_version_q3(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""R-14 (фикстуры РБ): нарастающая ОСВ за Q1 привязывается к версии Q3 как квартальный Q1 и двигает версию."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
document = await _document(seeded, entities[0], quarter=Q1, cumulative=True)
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_OK, response.text
assert await _bound(seeded, version.id) == {document.id}
assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION
@pytest.mark.integration
@pytest.mark.parametrize("kind", [DocumentType.BALANCE, DocumentType.INCOME])
async def test_confirm_does_not_bind_balance_or_income_of_q1_to_version_q3(
seeded: AsyncSession, auth_client: AuthClientFactory, kind: DocumentType
) -> None:
"""R-14a: ББ/ОФР за Q1 к версии Q3 не привязываются и статус версии не двигают."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
document = await _document(seeded, entities[0], quarter=Q1)
document.document_type, document.osv_account_code, document.is_cumulative = kind, None, None
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_OK, response.text
assert await _bound(seeded, version.id) == set()
assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED
@pytest.mark.integration
@pytest.mark.parametrize("bound_cumulative", [False, True])
async def test_confirm_q1_replaces_bound_q1_of_other_flag(
seeded: AsyncSession, auth_client: AuthClientFactory, bound_cumulative: bool
) -> None:
"""R-14: нарастающий и квартальный Q1 одной пары (ЮЛ, счёт) — один слот привязки: подтверждённый позже вытесняет
привязанный."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, status=ConsolidationStatus.DATA_CORRECTION)
old = await _confirmed(seeded, entities[0], quarter=Q1, cumulative=bound_cumulative)
await _bind(seeded, version, old)
new = await _document(seeded, entities[0], quarter=Q1, cumulative=not bound_cumulative)
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{new.id}/confirm")
assert response.status_code == HTTP_OK, response.text
assert await _bound(seeded, version.id) == {new.id}
@pytest.mark.integration
async def test_confirm_does_not_bind_cumulative_of_earlier_quarter(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""«Не подходит по периоду»: нарастающая ОСВ 60 за Q2 при версии Q3 — привязки нет, статус прежний."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
document = await _document(seeded, entities[0], quarter=Q2, cumulative=True)
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_OK, response.text
assert await _bound(seeded, version.id) == set()
assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED
assert len(await _history(seeded, version.id)) == 1
@pytest.mark.integration
async def test_confirm_does_not_bind_cancelled_version(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""«Версия в CANCELLED»: подходящий документ не привязывается."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, status=ConsolidationStatus.CANCELLED)
document = await _document(seeded, entities[0])
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_OK, response.text
assert await _bound(seeded, version.id) == set()
assert await _status(seeded, version.id) is ConsolidationStatus.CANCELLED
@pytest.mark.integration
async def test_confirm_does_not_bind_entity_outside_perimeter(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""ЮЛ той же ГК, но вне периметра версии: привязки нет (блокируется надмножество, привязывается только член)."""
group, entities = await _group(seeded, members=3)
version = await _version(seeded, group, entities[:2])
document = await _document(seeded, entities[2])
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_OK, response.text
assert await _bound(seeded, version.id) == set()
assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED
@pytest.mark.integration
async def test_confirm_replaces_bound_document_with_same_key(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Привязанный документ того же ЮЛ с той же тройкой и тем же признаком нарастающего отвязывается (D-5); нарастающий
той же пары (ЮЛ, счёт) за тот же квартал остаётся (R-1); документ другого ЮЛ не трогается."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, status=ConsolidationStatus.DATA_CORRECTION)
old_quarterly = await _confirmed(seeded, entities[0], quarter=Q3)
cumulative = await _confirmed(seeded, entities[0], quarter=Q3, cumulative=True)
other_entity = await _confirmed(seeded, entities[1], quarter=Q3)
for document in (old_quarterly, cumulative, other_entity):
await _bind(seeded, version, document)
new_quarterly = await _document(seeded, entities[0], quarter=Q3)
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{new_quarterly.id}/confirm")
assert response.status_code == HTTP_OK, response.text
assert await _bound(seeded, version.id) == {new_quarterly.id, cumulative.id, other_entity.id}
@pytest.mark.integration
async def test_one_document_binds_to_two_versions(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Review Focus 1: консолидации ГК за Q2 и Q3 одного года; квартальная ОСВ Q1 подходит обеим — подтверждение
привязывает её к обеим и переводит обе в `DATA_CORRECTION`."""
group, entities = await _group(seeded)
q2 = await _version(seeded, group, entities, quarter=Q2)
q3 = await _version(seeded, group, entities, quarter=Q3)
document = await _document(seeded, entities[0], quarter=Q1)
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_OK, response.text
for version in (q2, q3):
assert await _bound(seeded, version.id) == {document.id}
assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION
events = (
await seeded.execute(
select(AuditEvent.consolidation_version_id).where(
AuditEvent.action == AuditAction.CONSOLIDATION_STATUS_CHANGE
)
)
).scalars()
assert sorted(events) == sorted([q2.id, q3.id])
@pytest.mark.integration
async def test_on_document_confirmed_returns_bound_versions(seeded: AsyncSession) -> None:
"""Хук возвращает id версий, получивших привязку (по возрастанию `id`); неподходящие и аннулированные — нет."""
group, entities = await _group(seeded)
q2 = await _version(seeded, group, entities, quarter=Q2)
q3 = await _version(seeded, group, entities, quarter=Q3)
q1 = await _version(seeded, group, entities, quarter=Q1) # квартальный Q2 не подходит версии Q1
q4 = await _version(seeded, group, entities, quarter=Q4, status=ConsolidationStatus.CANCELLED)
document = await _document(seeded, entities[0], quarter=Q2, status=DocumentStatus.CONFIRMED)
user = await _user(seeded)
bound = await consolidation_documents.on_document_confirmed(
seeded, AuditService(seeded), document=document, changed_by=user.id, now=utc_now()
)
assert bound == sorted([q2.id, q3.id])
assert await _bound(seeded, q1.id) == set()
assert await _bound(seeded, q4.id) == set()
@pytest.mark.integration
async def test_on_document_confirmed_without_changed_by_binds_but_keeps_status(seeded: AsyncSession) -> None:
"""Автоподтверждение файла без `uploaded_by` (OQ-157, D-2): привязка есть, перехода версии нет."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
document = await _confirmed(seeded, entities[0], quarter=Q2)
history = len(await _history(seeded, version.id))
bound = await consolidation_documents.on_document_confirmed(
seeded, AuditService(seeded), document=document, changed_by=None, now=utc_now()
)
assert bound == [version.id]
assert document.id in await _bound(seeded, version.id)
assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED
assert len(await _history(seeded, version.id)) == history
@pytest.mark.integration
async def test_on_document_confirmed_writes_comment_to_history(seeded: AsyncSession) -> None:
"""Комментарий перехода от вызывающего (автоподтверждение) попадает в историю статусов."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
user = await _user(seeded)
document = await _confirmed(seeded, entities[0], quarter=Q2)
await consolidation_documents.on_document_confirmed(
seeded,
AuditService(seeded),
document=document,
changed_by=user.id,
comment="автоподтверждение документа OSV 60",
now=utc_now(),
)
last = (await _history(seeded, version.id))[-1]
assert (last.to_status, last.changed_by, last.comment) == (
ConsolidationStatus.DATA_CORRECTION,
user.id,
"автоподтверждение документа OSV 60",
)
# --- подхват ----------------------------------------------------------------------------------------------------------
async def _gsk(session: AsyncSession, group: CompanyGroup, entities: Sequence[LegalEntity]) -> RelatedGroup:
gsk = await make_related_group(session, group=group, name=f"ГСК {uuid.uuid4().hex[:6]}")
for entity in entities:
await make_membership(session, related_group=gsk, entity=entity)
return gsk
def _create_body(group: CompanyGroup, gsk: RelatedGroup, quarter: str = "Q3") -> dict[str, Any]:
return {"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": YEAR, "quarter": quarter}
@pytest.mark.integration
async def test_create_picks_up_documents_confirmed_before(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""«Фикстуры, подтверждённые до консолидации»: ОСВ 60 за Q1, Q2, Q3 подтверждены, затем создана консолидация
за Q3 — три привязки, версия в `INITIATED` (ячейка чек-листа — Task 8)."""
group, entities = await _group(seeded)
gsk = await _gsk(seeded, group, entities)
documents = [await _confirmed(seeded, entities[0], quarter=quarter) for quarter in (Q1, Q2, Q3)]
client = await auth_client(KA_RF)
response = await client.post("/consolidations", json=_create_body(group, gsk))
assert response.status_code == HTTP_CREATED, response.text
version_id = uuid.UUID(response.json()["id"])
assert await _bound(seeded, version_id) == {document.id for document in documents}
assert await _status(seeded, version_id) is ConsolidationStatus.INITIATED
assert len(await _history(seeded, version_id)) == 1
async def _latest_with_smaller_id(
session: AsyncSession, entity: LegalEntity, *, account: str = ACCOUNT, equal: bool = False
) -> tuple[Document, Document]:
"""Два `CONFIRMED` документа одного ключа (квартальная ОСВ за Q3): `(победитель, проигравший)`.
Без `equal` более поздний `confirmed_at` получает документ с МЕНЬШИМ `id` — выбор «по наибольшему id» вместо
«по самому позднему `confirmed_at`» проиграет. С `equal` время одинаковое — побеждает больший `id` (решение №35).
"""
first = await _confirmed(session, entity, quarter=Q3, account=account)
second = await _confirmed(session, entity, quarter=Q3, account=account)
smaller, larger = sorted((first, second), key=lambda document: document.id)
now = utc_now()
smaller.confirmed_at = now
larger.confirmed_at = now if equal else now - datetime.timedelta(hours=1)
await session.flush()
return (larger, smaller) if equal else (smaller, larger)
@pytest.mark.integration
async def test_create_picks_latest_confirmed_of_same_key(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""«Две подтверждённые версии одного документа»: два незаменённых файла с `CONFIRMED` ОСВ 62 за Q3 — привязан
документ с более поздним `confirmed_at`."""
group, entities = await _group(seeded)
gsk = await _gsk(seeded, group, entities)
later, _ = await _latest_with_smaller_id(seeded, entities[0], account="62")
client = await auth_client(KA_RF)
response = await client.post("/consolidations", json=_create_body(group, gsk))
assert response.status_code == HTTP_CREATED, response.text
assert await _bound(seeded, uuid.UUID(response.json()["id"])) == {later.id}
@pytest.mark.integration
async def test_bind_confirmed_filters_documents(seeded: AsyncSession) -> None:
"""Подхват берёт только `CONFIRMED` документы незаменённых файлов, подходящие версии; квартальный и нарастающий
за один квартал — оба (R-1); статус версии не меняется; повторный подхват ничего не добавляет."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
consolidation = await seeded.get(Consolidation, version.consolidation_id)
assert consolidation is not None
quarterly = await _confirmed(seeded, entities[0], quarter=Q3)
cumulative = await _confirmed(seeded, entities[0], quarter=Q3, cumulative=True)
await _confirmed(seeded, entities[0], quarter=Q2, cumulative=True) # нарастающий за Q2 — не подходит
await _document(seeded, entities[0], quarter=Q1) # не подтверждён
superseded = await _confirmed(seeded, entities[0], quarter=Q1)
await seeded.execute(
text("UPDATE stored_file SET status = 'SUPERSEDED' WHERE id = :id"), {"id": superseded.stored_file_id}
)
count = await consolidation_documents.bind_confirmed(
seeded, version=version, consolidation=consolidation, entity_ids=[entities[0].id]
)
assert count == 2 # noqa: PLR2004 - квартальный и нарастающий Q3
assert await _bound(seeded, version.id) == {quarterly.id, cumulative.id}
assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED
again = await consolidation_documents.bind_confirmed(
seeded, version=version, consolidation=consolidation, entity_ids=[entities[0].id]
)
assert again == 0
assert await _bound(seeded, version.id) == {quarterly.id, cumulative.id}
@pytest.mark.integration
async def test_bind_confirmed_replaces_older_binding_of_same_key(seeded: AsyncSession) -> None:
"""Существующая привязка той же тройки заменяется документом с более поздним `confirmed_at`."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
consolidation = await seeded.get(Consolidation, version.consolidation_id)
assert consolidation is not None
newer, older = await _latest_with_smaller_id(seeded, entities[0])
await _bind(seeded, version, older)
count = await consolidation_documents.bind_confirmed(
seeded, version=version, consolidation=consolidation, entity_ids=[entities[0].id]
)
assert count == 1
assert await _bound(seeded, version.id) == {newer.id}
@pytest.mark.integration
@pytest.mark.parametrize("later_cumulative", [True, False])
async def test_bind_confirmed_q1_of_both_flags_is_one_slot(seeded: AsyncSession, later_cumulative: bool) -> None:
"""R-14: подхват из нарастающего и квартального Q1 одной пары берёт один — с более поздним `confirmed_at`."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
consolidation = await seeded.get(Consolidation, version.consolidation_id)
assert consolidation is not None
now = datetime.datetime.now(datetime.UTC)
later = await _confirmed(seeded, entities[0], quarter=Q1, cumulative=later_cumulative, confirmed_at=now)
await _confirmed(
seeded,
entities[0],
quarter=Q1,
cumulative=not later_cumulative,
confirmed_at=now - datetime.timedelta(hours=1),
)
count = await consolidation_documents.bind_confirmed(
seeded, version=version, consolidation=consolidation, entity_ids=[entities[0].id]
)
assert count == 1
assert await _bound(seeded, version.id) == {later.id}
@pytest.mark.integration
async def test_bind_confirmed_tie_on_confirmed_at_takes_larger_id(seeded: AsyncSession) -> None:
"""Решение №35: при равном `confirmed_at` привязывается документ с большим `id`."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
consolidation = await seeded.get(Consolidation, version.consolidation_id)
assert consolidation is not None
winner, _ = await _latest_with_smaller_id(seeded, entities[0], equal=True)
count = await consolidation_documents.bind_confirmed(
seeded, version=version, consolidation=consolidation, entity_ids=[entities[0].id]
)
assert count == 1
assert await _bound(seeded, version.id) == {winner.id}
# --- гонки (настоящие параллельные транзакции) ------------------------------------------------------------------------
async def _wait_for_lock_waiter(maker: async_sessionmaker[AsyncSession]) -> None:
"""Ждёт, пока какой-то сеанс БД встанет в ожидание блокировки (строки или advisory)."""
deadline = asyncio.get_running_loop().time() + LOCK_WAIT_SECONDS
while asyncio.get_running_loop().time() < deadline:
async with maker() as session:
waiting = (
await session.execute(
text(
"SELECT count(*) FROM pg_stat_activity "
"WHERE datname = current_database() AND wait_event_type = 'Lock'"
)
)
).scalar_one()
if waiting:
return
await asyncio.sleep(0.05)
raise AssertionError("запрос не встал в ожидание блокировки")
async def _committed_setup(
maker: async_sessionmaker[AsyncSession], *, members: int = 2, spare: int = 1, with_version: bool = True
) -> dict[str, Any]:
"""ГК филиала: `members` ЮЛ в периметре версии 2024 Q3 (`INITIATED`, блокировка у ka_rf), `spare` ЮЛ ГК вне
периметра, ГСК из всех ЮЛ; документ `NEEDS_CHECK` квартальной ОСВ 60 за Q2 у первого запасного ЮЛ (или у
первого члена, если запасных нет)."""
async with maker() as session, session.begin():
group, entities = await _group(session, members=members + spare)
gsk = await _gsk(session, group, entities)
version_id = None
if with_version:
version = await _version(session, group, entities[:members])
await make_lock(session, version=version, user=await _user(session))
version_id = version.id
owner = entities[members] if spare else entities[0]
document = await _document(session, owner)
return {
"group_id": group.id,
"gsk_id": gsk.id,
"entity_ids": [entity.id for entity in entities],
"version_id": version_id,
"document_id": document.id,
"file_id": document.stored_file_id,
"owner_id": owner.id,
"user_id": (await _user(session)).id,
}
async def _confirm_in(session: AsyncSession, data: dict[str, Any]) -> list[uuid.UUID]:
"""Подтверждение, как в `DocumentService.confirm`: файл → документ → статус → хук привязки (транзакция открыта)."""
file = await files_repo.lock_file(session, data["file_id"])
document = await files_repo.lock_document(session, data["document_id"])
assert file is not None
assert document is not None
now = utc_now()
document.status = DocumentStatus.CONFIRMED
document.confirmed_by = data["user_id"]
document.confirmed_at = now
await session.flush()
return await consolidation_documents.on_document_confirmed(
session, AuditService(session), document=document, changed_by=data["user_id"], now=now
)
async def _committed_bound(maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID) -> set[uuid.UUID]:
async with maker() as session:
return await _bound(session, version_id)
@pytest.mark.integration
async def test_confirm_waits_for_member_edit_and_binds_new_member(
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory
) -> None:
"""R-2: правка периметра (версия `FOR UPDATE`, добавлен новый член) ∥ подтверждение документа нового члена.
Подтверждение блокирует надмножество версий ГК, ждёт правку и после её commit видит нового члена — привязка не
теряется, версия в `DATA_CORRECTION`."""
maker = committed_sessionmaker
data = await _committed_setup(maker)
client = await committed_client(KA_RF)
async with maker() as editor, editor.begin():
assert await repo.lock_version(editor, data["version_id"]) is not None
editor.add(ConsolidationMember(consolidation_version_id=data["version_id"], legal_entity_id=data["owner_id"]))
await editor.flush()
confirm = asyncio.create_task(client.post(f"/documents/{data['document_id']}/confirm"))
await _wait_for_lock_waiter(maker)
assert not confirm.done()
response = await asyncio.wait_for(confirm, LOCK_WAIT_SECONDS)
assert response.status_code == HTTP_OK, response.text
assert await _committed_bound(maker, data["version_id"]) == {data["document_id"]}
async with maker() as session:
assert await _status(session, data["version_id"]) is ConsolidationStatus.DATA_CORRECTION
@pytest.mark.integration
async def test_member_edit_waits_for_confirm_and_picks_document_up(
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory
) -> None:
"""R-2/R-3, обратный порядок: подтверждение держит файл, документ и версии ГК; `PUT …/members`, добавляющий ЮЛ
документа, ждёт версию, после commit подтверждения подхватывает уже `CONFIRMED` документ. Взаимоблокировки нет."""
maker = committed_sessionmaker
data = await _committed_setup(maker)
client = await committed_client(KA_RF)
body = {"legal_entity_ids": [str(entity_id) for entity_id in data["entity_ids"]]}
async with maker() as confirmer, confirmer.begin():
assert await _confirm_in(confirmer, data) == [] # ЮЛ ещё не член — привязывать не к чему
edit = asyncio.create_task(client.put(f"/consolidations/versions/{data['version_id']}/members", json=body))
await _wait_for_lock_waiter(maker)
assert not edit.done()
response = await asyncio.wait_for(edit, LOCK_WAIT_SECONDS)
assert response.status_code == HTTP_OK, response.text
assert await _committed_bound(maker, data["version_id"]) == {data["document_id"]}
async with maker() as session: # подхват статус не меняет
assert await _status(session, data["version_id"]) is ConsolidationStatus.INITIATED
@pytest.mark.integration
async def test_document_lock_does_not_block_binding_insert(
committed_sessionmaker: async_sessionmaker[AsyncSession],
) -> None:
"""R-3: подтверждение держит документ (`lock_document`) и затем ждёт версию; подхват держит версию и вставляет
привязку на этот документ (FK берёт `FOR KEY SHARE` документа). С `FOR UPDATE` документа это взаимоблокировка;
с `FOR NO KEY UPDATE` вставка проходит, обе транзакции завершаются за таймаут."""
maker = committed_sessionmaker
data = await _committed_setup(maker, spare=0)
async with maker() as session, session.begin():
document = await session.get(Document, data["document_id"])
assert document is not None
document.status = DocumentStatus.CONFIRMED
document.confirmed_at = utc_now()
version_held = asyncio.Event()
async def binder() -> None:
async with maker() as session, session.begin():
assert await repo.lock_version(session, data["version_id"]) is not None
version_held.set()
session.add(
ConsolidationDocument(consolidation_version_id=data["version_id"], document_id=data["document_id"])
)
await session.flush() # FK: FOR KEY SHARE документа, который держит «подтверждение»
async with maker() as confirmer, confirmer.begin():
assert await files_repo.lock_file(confirmer, data["file_id"]) is not None
assert await files_repo.lock_document(confirmer, data["document_id"]) is not None
binding = asyncio.create_task(binder())
await asyncio.wait_for(version_held.wait(), LOCK_WAIT_SECONDS)
# «подтверждение» ждёт версию, которую держит подхват; с FOR UPDATE документа — взаимоблокировка
assert await asyncio.wait_for(repo.lock_version(confirmer, data["version_id"]), LOCK_WAIT_SECONDS)
await asyncio.wait_for(binding, LOCK_WAIT_SECONDS)
assert await _committed_bound(maker, data["version_id"]) == {data["document_id"]}
@pytest.mark.integration
async def test_create_waits_for_confirm_and_picks_document_up(
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory
) -> None:
"""R-2, создание версии ∥ подтверждение: подтверждение держит advisory-блокировку ГК; `POST /consolidations` ждёт
её и после commit подхватывает уже `CONFIRMED` документ."""
maker = committed_sessionmaker
data = await _committed_setup(maker, spare=0, with_version=False)
client = await committed_client(KA_RF)
body = {"company_group_id": str(data["group_id"]), "related_group_id": str(data["gsk_id"]), "year": YEAR}
async with maker() as confirmer, confirmer.begin():
assert await _confirm_in(confirmer, data) == []
create = asyncio.create_task(client.post("/consolidations", json={**body, "quarter": "Q3"}))
await _wait_for_lock_waiter(maker)
assert not create.done()
response = await asyncio.wait_for(create, LOCK_WAIT_SECONDS)
assert response.status_code == HTTP_CREATED, response.text
assert await _committed_bound(maker, uuid.UUID(response.json()["id"])) == {data["document_id"]}
@pytest.mark.integration
async def test_confirm_waits_for_create_and_binds_new_version(
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory
) -> None:
"""R-2, подтверждение ∥ создание версии: создание держит advisory-блокировку ГК (новая версия до commit невидима);
подтверждение ждёт её и после commit привязывает документ к новой версии и двигает её статус."""
maker = committed_sessionmaker
data = await _committed_setup(maker, spare=0, with_version=False)
client = await committed_client(KA_RF)
async with maker() as creator, creator.begin():
await repo.lock_group_bindings(creator, data["group_id"]) # как `ConsolidationService.create`
group = await creator.get(CompanyGroup, data["group_id"])
assert group is not None
entities = [await creator.get(LegalEntity, entity_id) for entity_id in data["entity_ids"]]
version = await _version(creator, group, [entity for entity in entities if entity is not None])
version_id = version.id
confirm = asyncio.create_task(client.post(f"/documents/{data['document_id']}/confirm"))
await _wait_for_lock_waiter(maker)
assert not confirm.done()
response = await asyncio.wait_for(confirm, LOCK_WAIT_SECONDS)
assert response.status_code == HTTP_OK, response.text
assert await _committed_bound(maker, version_id) == {data["document_id"]}
async with maker() as session:
assert await _status(session, version_id) is ConsolidationStatus.DATA_CORRECTION
async def _committed_replacement(maker: async_sessionmaker[AsyncSession], data: dict[str, Any]) -> uuid.UUID:
"""У ЮЛ документа `data` — прежний файл с `CONFIRMED` квартальной ОСВ 60 за Q2 (тот же ключ автозамены, что у
документа `data`, файл которого станет новым). Возвращает id прежнего документа."""
async with maker() as session, session.begin():
entity = await session.get(LegalEntity, data["owner_id"])
assert entity is not None
return (await _confirmed(session, entity, quarter=Q2)).id
async def _supersede(maker: async_sessionmaker[AsyncSession], data: dict[str, Any]) -> list[uuid.UUID]:
"""Автозамена в отдельной транзакции: новый файл — файл документа `data` (как в конце `map_file`)."""
async with maker() as session, session.begin():
new_file = await session.get(StoredFile, data["file_id"])
new_document = await session.get(Document, data["document_id"])
assert new_file is not None
assert new_document is not None
replaced = await supersede_previous(
session, new_file=new_file, new_documents=[new_document], uploader_id=None, now=utc_now()
)
return [file.id for file in replaced]
async def _replace_event(maker: async_sessionmaker[AsyncSession]) -> AuditEvent:
async with maker() as session:
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_REPLACE)
return (await session.execute(stmt)).scalar_one()
@pytest.mark.integration
async def test_supersede_waits_for_pick_up_and_unbinds_replaced_document(
committed_sessionmaker: async_sessionmaker[AsyncSession],
) -> None:
"""R-2, автозамена ∥ подхват: правка периметра держит версию, добавила ЮЛ и подхватила его `CONFIRMED` документ
(ещё не зафиксировано). Автозамена файла этого документа блокирует надмножество версий ГК, ждёт правку и после её
commit снимает уже видимую привязку; `unbound_version_ids` — эта версия. Блокируй автозамена только версии, где
документ уже привязан, — она не увидела бы незафиксированную привязку и оставила бы её висеть."""
maker = committed_sessionmaker
data = await _committed_setup(maker)
old_document_id = await _committed_replacement(maker, data)
async with maker() as editor, editor.begin():
version = await repo.lock_version(editor, data["version_id"])
assert version is not None
consolidation = await editor.get(Consolidation, version.consolidation_id)
assert consolidation is not None
editor.add(ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=data["owner_id"]))
await editor.flush()
assert (
await consolidation_documents.bind_confirmed(
editor, version=version, consolidation=consolidation, entity_ids=[data["owner_id"]]
)
== 1
)
replacement = asyncio.create_task(_supersede(maker, data))
await _wait_for_lock_waiter(maker)
assert not replacement.done()
replaced = await asyncio.wait_for(replacement, LOCK_WAIT_SECONDS)
assert len(replaced) == 1
assert old_document_id not in await _committed_bound(maker, data["version_id"])
event = await _replace_event(maker)
assert event.after_value is not None
assert event.after_value["unbound_version_ids"] == [str(data["version_id"])]
@pytest.mark.integration
async def test_supersede_waits_for_create_and_unbinds_replaced_document(
committed_sessionmaker: async_sessionmaker[AsyncSession],
) -> None:
"""R-2, автозамена ∥ создание версии: создание держит advisory ГК и подхватило `CONFIRMED` документ в новую
(невидимую до commit) версию; автозамена ждёт advisory ГК и после commit снимает привязку новой версии."""
maker = committed_sessionmaker
data = await _committed_setup(maker, spare=0, with_version=False)
old_document_id = await _committed_replacement(maker, data)
async with maker() as creator, creator.begin():
await repo.lock_group_bindings(creator, data["group_id"]) # как `ConsolidationService.create`
group = await creator.get(CompanyGroup, data["group_id"])
assert group is not None
entities = [await creator.get(LegalEntity, entity_id) for entity_id in data["entity_ids"]]
version = await _version(creator, group, [entity for entity in entities if entity is not None])
consolidation = await creator.get(Consolidation, version.consolidation_id)
assert consolidation is not None
bound = await consolidation_documents.bind_confirmed(
creator, version=version, consolidation=consolidation, entity_ids=data["entity_ids"]
)
assert bound == 1
version_id = version.id
replacement = asyncio.create_task(_supersede(maker, data))
await _wait_for_lock_waiter(maker)
assert not replacement.done()
await asyncio.wait_for(replacement, LOCK_WAIT_SECONDS)
assert old_document_id not in await _committed_bound(maker, version_id)
event = await _replace_event(maker)
assert event.after_value is not None
assert event.after_value["unbound_version_ids"] == [str(version_id)]
# --- on_documents_mapped: NEEDS_CHECK при маппинге (osv-result-mapping, D-6) -------------------------------
async def _mapped_file_documents(
session: AsyncSession, entity: LegalEntity, *, uploaded_by: uuid.UUID | None, status: DocumentStatus, quarter=Q2
) -> tuple[StoredFile, list[Document]]:
"""Файл `PROCESSED` ЮЛ с одним квартальным документом ОСВ 60 за 2024 `quarter` в статусе `status`."""
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, uploaded_by=uploaded_by
)
document = await make_document(
session, file=file, job=job, account=ACCOUNT, year=YEAR, quarter=quarter, status=status, is_cumulative=False
)
return file, [document]
@pytest.mark.integration
async def test_on_documents_mapped_moves_documents_processing_to_data_correction(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, status=ConsolidationStatus.DOCUMENTS_PROCESSING)
user = await _user(seeded)
file, documents = await _mapped_file_documents(
seeded, entities[0], uploaded_by=user.id, status=DocumentStatus.NEEDS_CHECK
)
moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now())
assert moved == [version.id]
assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION
last = (await _history(seeded, version.id))[-1]
assert (last.from_status, last.to_status, last.changed_by) == (
ConsolidationStatus.DOCUMENTS_PROCESSING,
ConsolidationStatus.DATA_CORRECTION,
user.id,
)
[event] = (
await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_STATUS_CHANGE))
).scalars()
assert event.user_id == user.id
assert await _bound(seeded, version.id) == set() # привязку маппинг не создаёт
@pytest.mark.integration
@pytest.mark.parametrize("status", [ConsolidationStatus.INITIATED, ConsolidationStatus.DATA_CORRECTION])
async def test_on_documents_mapped_ignores_other_statuses(seeded: AsyncSession, status: ConsolidationStatus) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, status=status)
user = await _user(seeded)
file, documents = await _mapped_file_documents(
seeded, entities[0], uploaded_by=user.id, status=DocumentStatus.NEEDS_CHECK
)
history = len(await _history(seeded, version.id))
moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now())
assert moved == []
assert await _status(seeded, version.id) is status
assert len(await _history(seeded, version.id)) == history
@pytest.mark.integration
async def test_on_documents_mapped_ignores_recognized_document(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, status=ConsolidationStatus.DOCUMENTS_PROCESSING)
file, documents = await _mapped_file_documents(
seeded, entities[0], uploaded_by=(await _user(seeded)).id, status=DocumentStatus.RECOGNIZED
)
moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now())
assert moved == []
assert await _status(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING
@pytest.mark.integration
async def test_on_documents_mapped_skips_document_that_does_not_fit_version(seeded: AsyncSession) -> None:
"""Квартальный документ Q4 не подходит версии Q3 (D-5): `NEEDS_CHECK`, но перехода нет."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, status=ConsolidationStatus.DOCUMENTS_PROCESSING)
file, documents = await _mapped_file_documents(
seeded, entities[0], uploaded_by=(await _user(seeded)).id, status=DocumentStatus.NEEDS_CHECK, quarter=Q4
)
moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now())
assert moved == []
assert await _status(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING
@pytest.mark.integration
async def test_on_documents_mapped_skips_entity_outside_perimeter(seeded: AsyncSession) -> None:
group, entities = await _group(seeded, members=3)
version = await _version(seeded, group, entities[:2], status=ConsolidationStatus.DOCUMENTS_PROCESSING)
file, documents = await _mapped_file_documents(
seeded, entities[2], uploaded_by=(await _user(seeded)).id, status=DocumentStatus.NEEDS_CHECK
)
moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now())
assert moved == []
assert await _status(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING
@pytest.mark.integration
async def test_on_documents_mapped_without_uploader_logs_and_skips(seeded: AsyncSession) -> None:
"""`uploaded_by` NULL: `changed_by NOT NULL` — переход пропускается, в лог пишется причина, ошибки нет."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, status=ConsolidationStatus.DOCUMENTS_PROCESSING)
file, documents = await _mapped_file_documents(
seeded, entities[0], uploaded_by=None, status=DocumentStatus.NEEDS_CHECK
)
messages: list[str] = []
sink = logger.add(lambda message: messages.append(str(message)), level="INFO")
try:
moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now())
finally:
logger.remove(sink)
assert moved == []
assert await _status(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING
assert any(str(file.id) in message and "uploaded_by" in message for message in messages)
async def _mapped_in_own_transaction(maker: async_sessionmaker[AsyncSession], data: dict[str, Any]) -> list[uuid.UUID]:
"""Хук маппинга файла `data["file_id"]` в своей транзакции (как цикл маппинга: файл и документы уже есть)."""
async with maker() as session, session.begin():
file = await session.get(StoredFile, data["file_id"])
document = await session.get(Document, data["document_id"])
assert file is not None
assert document is not None
return await consolidation_documents.on_documents_mapped(
session, file=file, documents=[document], now=utc_now()
)
@pytest.mark.integration
async def test_on_documents_mapped_waits_for_member_edit_and_sees_new_member(
committed_sessionmaker: async_sessionmaker[AsyncSession],
) -> None:
"""R-2: правка периметра держит версию `DOCUMENTS_PROCESSING` и добавила ЮЛ файла (не зафиксировано); хук маппинга
блокирует надмножество версий ГК, ждёт правку и после commit видит нового члена — переход не теряется."""
maker = committed_sessionmaker
data = await _committed_setup(maker)
async with maker() as session, session.begin():
version = await session.get(ConsolidationVersion, data["version_id"])
assert version is not None
version.status = ConsolidationStatus.DOCUMENTS_PROCESSING
file = await session.get(StoredFile, data["file_id"])
assert file is not None
file.uploaded_by = data["user_id"]
async with maker() as editor, editor.begin():
assert await repo.lock_version(editor, data["version_id"]) is not None
editor.add(ConsolidationMember(consolidation_version_id=data["version_id"], legal_entity_id=data["owner_id"]))
await editor.flush()
hook = asyncio.create_task(_mapped_in_own_transaction(maker, data))
await _wait_for_lock_waiter(maker)
assert not hook.done()
moved = await asyncio.wait_for(hook, LOCK_WAIT_SECONDS)
assert moved == [data["version_id"]]
async with maker() as session:
assert await _status(session, data["version_id"]) is ConsolidationStatus.DATA_CORRECTION
async def _lock_of_consolidation_in_own_transaction(
maker: async_sessionmaker[AsyncSession], consolidation_id: uuid.UUID, entity_id: uuid.UUID
) -> list[tuple[uuid.UUID, ConsolidationStatus]]:
async with maker() as session, session.begin():
versions = await repo.lock_versions_of_consolidation(
session, consolidation_id, entity_id, (ConsolidationStatus.INITIATED,)
)
return [(version.id, version.status) for version in versions]
@pytest.mark.integration
async def test_lock_versions_of_consolidation_waits_for_member_edit_and_sees_new_member(
committed_sessionmaker: async_sessionmaker[AsyncSession],
) -> None:
"""T3.1: правка периметра держит версию (`FOR UPDATE`) и добавила ЮЛ (не зафиксировано); блокировка версий
консолидации ждёт правку и после commit видит нового члена — `EXISTS` не пересчитывается в одном операторе с
`FOR UPDATE`, поэтому членство выбирается вторым запросом."""
maker = committed_sessionmaker
data = await _committed_setup(maker)
async with maker() as session:
version = await session.get(ConsolidationVersion, data["version_id"])
assert version is not None
consolidation_id = version.consolidation_id
async with maker() as editor, editor.begin():
assert await repo.lock_version(editor, data["version_id"]) is not None
editor.add(ConsolidationMember(consolidation_version_id=data["version_id"], legal_entity_id=data["owner_id"]))
await editor.flush()
waiter = asyncio.create_task(
_lock_of_consolidation_in_own_transaction(maker, consolidation_id, data["owner_id"])
)
await _wait_for_lock_waiter(maker)
assert not waiter.done()
locked = await asyncio.wait_for(waiter, LOCK_WAIT_SECONDS)
assert locked == [(data["version_id"], ConsolidationStatus.INITIATED)]
@pytest.mark.integration
async def test_lock_versions_of_consolidation_returns_only_member_versions(
committed_sessionmaker: async_sessionmaker[AsyncSession],
) -> None:
"""T3.1: ЮЛ вне периметра — пусто; член периметра — версия со свежим статусом; статус не из набора — пусто."""
maker = committed_sessionmaker
data = await _committed_setup(maker)
async with maker() as session:
version = await session.get(ConsolidationVersion, data["version_id"])
assert version is not None
consolidation_id = version.consolidation_id
member_id, outsider_id = data["entity_ids"][0], data["owner_id"]
assert await _lock_of_consolidation_in_own_transaction(maker, consolidation_id, outsider_id) == []
assert await _lock_of_consolidation_in_own_transaction(maker, consolidation_id, member_id) == [
(data["version_id"], ConsolidationStatus.INITIATED)
]
async with maker() as session, session.begin():
assert (
await repo.lock_versions_of_consolidation(
session, consolidation_id, member_id, (ConsolidationStatus.DATA_CORRECTION,)
)
== []
)
@pytest.mark.integration
async def test_on_documents_mapped_waits_for_transition_to_documents_processing(
committed_sessionmaker: async_sessionmaker[AsyncSession],
) -> None:
"""R-2/№40, маппинг ∥ загрузка (`after_upload_to_consolidation`): версия `INITIATED` держится транзакцией, которая
переводит её в `DOCUMENTS_PROCESSING` (не зафиксировано). Хук блокирует надмножество версий «в работе» (а не только
`DOCUMENTS_PROCESSING`), поэтому встаёт в ожидание строки версии и после commit видит `DOCUMENTS_PROCESSING` —
переход в `DATA_CORRECTION` не теряется. Хук, ищущий версии только в `DOCUMENTS_PROCESSING`, снимок `INITIATED`
пропустил бы без ожидания и вернул бы `[]`."""
maker = committed_sessionmaker
data = await _committed_setup(maker, members=2, spare=0) # владелец документа — член периметра, версия INITIATED
async with maker() as session, session.begin():
file = await session.get(StoredFile, data["file_id"])
assert file is not None
file.uploaded_by = data["user_id"]
async with maker() as uploader, uploader.begin():
version = await repo.lock_version(uploader, data["version_id"])
assert version is not None
assert version.status is ConsolidationStatus.INITIATED
version.status = ConsolidationStatus.DOCUMENTS_PROCESSING
await uploader.flush()
hook = asyncio.create_task(_mapped_in_own_transaction(maker, data))
await _wait_for_lock_waiter(maker)
assert not hook.done()
moved = await asyncio.wait_for(hook, LOCK_WAIT_SECONDS)
assert moved == [data["version_id"]]
async with maker() as session:
assert await _status(session, data["version_id"]) is ConsolidationStatus.DATA_CORRECTION
@pytest.mark.integration
@pytest.mark.fixtures
async def test_mapped_statement_moves_version_without_binding(seeded: AsyncSession, fixtures_dir: Path) -> None:
"""Форма двигает версию: `BALANCE` Q3 в `NEEDS_CHECK` (ИНН шапки ≠ ИНН владельца) → `DATA_CORRECTION`."""
group, entities = await _group(seeded)
user = await _user(seeded)
_, version = await make_consolidation(
seeded,
group=group,
year=FORM_YEAR,
quarter=Q3,
members=entities,
initiator=user,
status=ConsolidationStatus.DOCUMENTS_PROCESSING,
)
documents = await map_golden_snapshot(
seeded, entity=entities[0], path=fixtures_dir / FORM_SNAPSHOT, uploaded_by=user.id
)
balance = next(d for d in documents if d.document_type is DocumentType.BALANCE)
assert (balance.status, balance.period_year, balance.period_quarter) == (DocumentStatus.NEEDS_CHECK, FORM_YEAR, Q3)
file = await seeded.get(StoredFile, balance.stored_file_id)
assert file is not None
# `map_file` сам перехода не делает: он идёт после него, в `map_job_result`
assert await _status(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING
moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now())
assert moved == [version.id]
assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION
assert await _bound(seeded, version.id) == set()