Merge pull request 'AURORA-1646 чек-лист версии — только файлы стола консолидации (#188)' (#189) from feat/188-checklist-scope into main

This commit is contained in:
aep 2026-10-27 09:58:40 +03:00
commit 66fac5811e
6 changed files with 200 additions and 54 deletions

View File

@ -42,6 +42,7 @@ from src.models.enums import (
from src.models.files import Document, StoredFile from src.models.files import Document, StoredFile
from src.models.osv import OsvEntry from src.models.osv import OsvEntry
from src.repositories.directories import LIKE_ESCAPE, escape_like from src.repositories.directories import LIKE_ESCAPE, escape_like
from src.repositories.files import on_desk
FILES_IN_PROGRESS = (FileStatus.UPLOADED, FileStatus.PROCESSING) FILES_IN_PROGRESS = (FileStatus.UPLOADED, FileStatus.PROCESSING)
GROUP_BINDINGS_LOCK_PREFIX = "consolidation-group" # пространство ключей advisory-блокировки привязок ГК (R-2) GROUP_BINDINGS_LOCK_PREFIX = "consolidation-group" # пространство ключей advisory-блокировки привязок ГК (R-2)
@ -339,24 +340,26 @@ async def history(session: AsyncSession, version_id: uuid.UUID) -> list[Consolid
async def entity_files_state( async def entity_files_state(
session: AsyncSession, entity_ids: Sequence[uuid.UUID] session: AsyncSession, entity_ids: Sequence[uuid.UUID], consolidation_id: uuid.UUID
) -> dict[uuid.UUID, EntityFilesState]: ) -> dict[uuid.UUID, EntityFilesState]:
"""Состояние файлов каждого ЮЛ из `entity_ids` (чек-лист, D-13): файл в работе и статус последнего файла. """Состояние файлов каждого ЮЛ из `entity_ids` на столе консолидации `consolidation_id` (чек-лист, D-13;
`files.on_desk`, AURORA-1646): файл в работе и статус последнего файла.
«В работе» — любой файл `UPLOADED`/`PROCESSING`; «последний» — по `uploaded_at` (при равенстве или NULL — по `id`), «В работе» — любой файл `UPLOADED`/`PROCESSING`; «последний» — по `uploaded_at` (при равенстве или NULL — по `id`),
включая замещённые файлы. ЮЛ без файлов — `EntityFilesState(False, None)`. Неподтверждённые документы-кандидаты включая замещённые файлы. ЮЛ без файлов на столе — `EntityFilesState(False, None)`. Неподтверждённые
сюда не входят (R-5). документы-кандидаты сюда не входят (R-5).
""" """
states = {entity_id: EntityFilesState(has_in_progress=False, last_file_status=None) for entity_id in entity_ids} states = {entity_id: EntityFilesState(has_in_progress=False, last_file_status=None) for entity_id in entity_ids}
if not states: if not states:
return states return states
desk = on_desk(consolidation_id)
in_progress = select(StoredFile.legal_entity_id).where( in_progress = select(StoredFile.legal_entity_id).where(
StoredFile.legal_entity_id.in_(states), StoredFile.status.in_(FILES_IN_PROGRESS) StoredFile.legal_entity_id.in_(states), StoredFile.status.in_(FILES_IN_PROGRESS), desk
) )
busy = set((await session.execute(in_progress.distinct())).scalars()) busy = set((await session.execute(in_progress.distinct())).scalars())
last = ( last = (
select(StoredFile.legal_entity_id, StoredFile.status) select(StoredFile.legal_entity_id, StoredFile.status)
.where(StoredFile.legal_entity_id.in_(states)) .where(StoredFile.legal_entity_id.in_(states), desk)
.distinct(StoredFile.legal_entity_id) .distinct(StoredFile.legal_entity_id)
.order_by(StoredFile.legal_entity_id, StoredFile.uploaded_at.desc().nulls_last(), StoredFile.id.desc()) .order_by(StoredFile.legal_entity_id, StoredFile.uploaded_at.desc().nulls_last(), StoredFile.id.desc())
) )
@ -399,24 +402,29 @@ async def file_consolidation_ids(
async def unconfirmed_documents( async def unconfirmed_documents(
session: AsyncSession, session: AsyncSession,
entity_ids: Collection[uuid.UUID], entity_ids: Collection[uuid.UUID],
consolidation_id: uuid.UUID,
year: int, year: int,
consolidation_id: uuid.UUID | None = None, *,
osv_any_year: bool = False,
) -> list[Document]: ) -> list[Document]:
"""Неподтверждённые документы ЮЛ за год (`RECOGNIZED`/`NEEDS_CHECK`) из файлов не `SUPERSEDED`; любых типов (R-5). """Неподтверждённые документы ЮЛ за год (`RECOGNIZED`/`NEEDS_CHECK`) из файлов не `SUPERSEDED` на столе консолидации
`consolidation_id` (режим «период из версии», arm-core#103) — плюс ОСВ, загруженные в эту консолидацию, любого года. `consolidation_id` (`files.on_desk`, AURORA-1646: документы других консолидаций и карточки ЮЛ не учитываются); любых
типов (R-5). `osv_any_year` (режим «период из версии», arm-core#103) — плюс ОСВ, загруженные в эту консолидацию,
любого года.
Кандидаты чек-листа: «подходящесть» версии (`fits`) проверяет сервис. Порядок — по ЮЛ, типу, счёту, кварталу, `id`. Кандидаты чек-листа: «подходящесть» версии (`fits`) проверяет сервис. Порядок — по ЮЛ, типу, счёту, кварталу, `id`.
""" """
if not entity_ids: if not entity_ids:
return [] return []
in_period: ColumnElement[bool] = Document.period_year == year in_period: ColumnElement[bool] = Document.period_year == year
if consolidation_id is not None: if osv_any_year:
in_period = or_(in_period, _osv_of_consolidation(consolidation_id)) in_period = or_(in_period, _osv_of_consolidation(consolidation_id))
stmt = ( stmt = (
select(Document) select(Document)
.join(StoredFile, StoredFile.id == Document.stored_file_id) .join(StoredFile, StoredFile.id == Document.stored_file_id)
.where( .where(
Document.legal_entity_id.in_(list(entity_ids)), Document.legal_entity_id.in_(list(entity_ids)),
on_desk(consolidation_id),
in_period, in_period,
Document.status.in_(UNCONFIRMED_STATUSES), Document.status.in_(UNCONFIRMED_STATUSES),
StoredFile.status != FileStatus.SUPERSEDED, StoredFile.status != FileStatus.SUPERSEDED,

View File

@ -12,8 +12,9 @@ import uuid
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any from typing import Any
from sqlalchemy import Select, and_, delete, exists, func, or_, select, update from sqlalchemy import ColumnElement, Select, and_, delete, exists, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from src.domain.policy import VisibilityScope, legal_entities_visible from src.domain.policy import VisibilityScope, legal_entities_visible
from src.models.access import AppUser from src.models.access import AppUser
@ -260,6 +261,20 @@ async def expire_s3_objects(
await session.execute(update(S3Object).where(S3Object.id.in_(object_ids), owned).values(expires_at=now)) await session.execute(update(S3Object).where(S3Object.id.in_(object_ids), owned).values(expires_at=now))
def on_desk(consolidation_id: uuid.UUID) -> ColumnElement[bool]:
"""Файл на столе консолидации (§9.5 п. 11): загружен в неё (`consolidation_id`) ИЛИ содержит документ, привязанный
к любой её версии. Один фильтр для панели «Первичные документы» и чек-листа версии (AURORA-1646). Документ
подзапроса — псевдоним: в запросе по `Document` иначе коррелирует с внешним и проверяет привязку его самого."""
sibling = aliased(Document)
bound = exists().where(
sibling.stored_file_id == StoredFile.id,
ConsolidationDocument.document_id == sibling.id,
ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id,
ConsolidationVersion.consolidation_id == consolidation_id,
)
return or_(StoredFile.consolidation_id == consolidation_id, bound)
async def list_desk_files( # noqa: PLR0913 - фильтр и пагинация списка передаются явными именованными аргументами async def list_desk_files( # noqa: PLR0913 - фильтр и пагинация списка передаются явными именованными аргументами
session: AsyncSession, session: AsyncSession,
scope: VisibilityScope, scope: VisibilityScope,
@ -271,19 +286,13 @@ async def list_desk_files( # noqa: PLR0913 - фильтр и пагинация
offset: int, offset: int,
) -> tuple[list[StoredFile], int]: ) -> tuple[list[StoredFile], int]:
"""Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6): """Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6):
файлы ЮЛ с `consolidation_id` этой консолидации ИЛИ с документом, привязанным к любой её версии. Порядок и файлы ЮЛ на столе консолидации (`on_desk`). Порядок и `include_superseded` — как в `list_files`; страница и общее
`include_superseded` — как в `list_files`; страница и общее число тем же фильтром.""" число тем же фильтром."""
bound = exists().where(
Document.stored_file_id == StoredFile.id,
ConsolidationDocument.document_id == Document.id,
ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id,
ConsolidationVersion.consolidation_id == consolidation_id,
)
def filtered(stmt: Select[Any]) -> Select[Any]: def filtered(stmt: Select[Any]) -> Select[Any]:
stmt = _visible_files(stmt, scope).where( stmt = _visible_files(stmt, scope).where(
StoredFile.legal_entity_id == entity_id, StoredFile.legal_entity_id == entity_id,
or_(StoredFile.consolidation_id == consolidation_id, bound), on_desk(consolidation_id),
) )
if not include_superseded: if not include_superseded:
stmt = stmt.where(StoredFile.status != FileStatus.SUPERSEDED) stmt = stmt.where(StoredFile.status != FileStatus.SUPERSEDED)

View File

@ -217,8 +217,9 @@ class ConsolidationService:
self, version: ConsolidationVersion, consolidation: Consolidation, entities: list[LegalEntity] self, version: ConsolidationVersion, consolidation: Consolidation, entities: list[LegalEntity]
) -> tuple[list[checklist_domain.Rule], list[tuple[LegalEntity, list[checklist_domain.Cell]]]]: ) -> tuple[list[checklist_domain.Rule], list[tuple[LegalEntity, list[checklist_domain.Cell]]]]:
"""Правила подхода версии и ячейки по каждому ЮЛ `entities` (D-13): привязанные документы версии, """Правила подхода версии и ячейки по каждому ЮЛ `entities` (D-13): привязанные документы версии,
неподтверждённые подходящие (`fits`) и состояние файлов ЮЛ. Кандидаты — `repo.unconfirmed_documents`, неподтверждённые подходящие (`fits`) и состояние файлов ЮЛ. Кандидаты и файлы — только со стола консолидации
фильтр `fits` — здесь.""" (§9.5 п. 11, AURORA-1646): документы других консолидаций на ячейки не влияют. Кандидаты —
`repo.unconfirmed_documents`, фильтр `fits` — здесь."""
rules = checklist_domain.order_rules( rules = checklist_domain.order_rules(
checklist_domain.Rule(rule.document_type, account, rule.is_mandatory) checklist_domain.Rule(rule.document_type, account, rule.is_mandatory)
for rule, account in await repo.rules_for(self._session, version.approach) for rule, account in await repo.rules_for(self._session, version.approach)
@ -227,7 +228,11 @@ class ConsolidationService:
candidates = await consolidation_documents.for_consolidation( candidates = await consolidation_documents.for_consolidation(
self._session, self._session,
await repo.unconfirmed_documents( await repo.unconfirmed_documents(
self._session, entity_ids, consolidation.year, self._period_from_version(consolidation) self._session,
entity_ids,
consolidation.id,
consolidation.year,
osv_any_year=consolidation_documents.period_from_version(),
), ),
consolidation, consolidation,
) )
@ -240,7 +245,7 @@ class ConsolidationService:
self._session, await repo.bound_documents(self._session, version.id), consolidation self._session, await repo.bound_documents(self._session, version.id), consolidation
), ),
unbound=unbound, unbound=unbound,
files_state=await repo.entity_files_state(self._session, entity_ids), files_state=await repo.entity_files_state(self._session, entity_ids, consolidation.id),
) )
return rules, rows return rules, rows

View File

@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.models.access import AppUser from src.models.access import AppUser
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice
from src.models.enums import DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter, RecognitionStatus
from tests.conftest import AuthClientFactory from tests.conftest import AuthClientFactory
from tests.factories import ( from tests.factories import (
make_company_group, make_company_group,
@ -109,10 +109,12 @@ async def test_checklist_gap_needs_check_and_processing(auth_client: AuthClientF
file, job = await make_stored_file( file, job = await make_stored_file(
seeded, entity=first, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done seeded, entity=first, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
) )
file.consolidation_id = version.consolidation_id
await make_document( await make_document(
seeded, file=file, job=job, account="62", year=YEAR, quarter=Q3, status=DocumentStatus.NEEDS_CHECK seeded, file=file, job=job, account="62", year=YEAR, quarter=Q3, status=DocumentStatus.NEEDS_CHECK
) )
await make_stored_file(seeded, entity=second, status=FileStatus.PROCESSING) busy, _ = await make_stored_file(seeded, entity=second, status=FileStatus.PROCESSING)
busy.consolidation_id = version.consolidation_id
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json()
@ -129,6 +131,42 @@ async def test_checklist_gap_needs_check_and_processing(auth_client: AuthClientF
assert {cell["status"] for cell in _cells(body, "ООО Б")} == {"PROCESSING"} assert {cell["status"] for cell in _cells(body, "ООО Б")} == {"PROCESSING"}
@pytest.mark.integration
async def test_checklist_ignores_files_outside_consolidation_desk(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""AURORA-1646: неподтверждённые документы и файлы ЮЛ из другой консолидации и из карточки ЮЛ (без контекста) — не
на столе консолидации (§9.5 п. 11), чек-лист их не учитывает: без документов — `MISSING`."""
version, (first, second) = await _world(seeded)
group = (await seeded.execute(select(CompanyGroup).where(CompanyGroup.name == "ГК Русский бисквит"))).scalar_one()
user = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
earlier, _ = await make_consolidation(
seeded, group=group, year=YEAR, quarter=Q2, members=[first, second], initiator=user
)
file, job = await make_stored_file(
seeded, entity=first, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
file.consolidation_id = earlier.id
balance = await make_document(
seeded, file=file, job=job, year=YEAR, quarter=Q3, status=DocumentStatus.NEEDS_CHECK, is_cumulative=True
)
balance.document_type, balance.osv_account_code = DocumentType.BALANCE, None
await make_document(
seeded, file=file, job=job, account="60", year=YEAR, quarter=Q3, status=DocumentStatus.RECOGNIZED
)
failed, _ = await make_stored_file(seeded, entity=second, status=FileStatus.ERROR)
failed.consolidation_id = earlier.id
await make_stored_file(seeded, entity=second, status=FileStatus.PROCESSING) # карточка ЮЛ, без контекста
await seeded.flush()
client = await auth_client(KA_RF)
body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json()
for name in ("ООО А", "ООО Б"):
assert {cell["status"] for cell in _cells(body, name)} == {"MISSING"}
assert all(cell["document_ids"] == [] for cell in _cells(body, name))
@pytest.mark.integration @pytest.mark.integration
async def test_card_members_have_same_cells_as_checklist(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: async def test_card_members_have_same_cells_as_checklist(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, (first, _) = await _bound_world(seeded) version, (first, _) = await _bound_world(seeded)
@ -188,8 +226,9 @@ async def test_checklist_visible_to_go_curator_hidden_from_foreign_office(
assert (await admin.get(url)).status_code == HTTP_FORBIDDEN assert (await admin.get(url)).status_code == HTTP_FORBIDDEN
async def _unconfirmed( # noqa: PLR0913 - тестовая фабрика: ЮЛ, счёт, период, признак и статус документа async def _unconfirmed( # noqa: PLR0913 - тестовая фабрика: версия, ЮЛ, счёт, период, признак и статус документа
session: AsyncSession, session: AsyncSession,
version: ConsolidationVersion,
entity: LegalEntity, entity: LegalEntity,
*, *,
account: str, account: str,
@ -201,6 +240,7 @@ async def _unconfirmed( # noqa: PLR0913 - тестовая фабрика: ЮЛ
file, job = await make_stored_file( file, job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
) )
file.consolidation_id = version.consolidation_id # загружен в консолидацию версии — на её столе
document = await make_document( document = await make_document(
session, session,
file=file, file=file,
@ -248,7 +288,9 @@ async def test_checklist_unconfirmed_candidate_must_fit_version( # noqa: PLR091
) -> None: ) -> None:
"""Неподтверждённый документ даёт `NEEDS_CHECK`/`RECOGNIZED` только если подходит версии (`fits`; I-1, T8.2).""" """Неподтверждённый документ даёт `NEEDS_CHECK`/`RECOGNIZED` только если подходит версии (`fits`; I-1, T8.2)."""
version, (first, _) = await _world(seeded) version, (first, _) = await _world(seeded)
await _unconfirmed(seeded, first, account=account, year=year, quarter=quarter, cumulative=cumulative, status=status) await _unconfirmed(
seeded, version, first, account=account, year=year, quarter=quarter, cumulative=cumulative, status=status
)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json()
@ -268,8 +310,8 @@ async def test_checklist_unconfirmed_of_entity_outside_perimeter_has_no_effect(
version, _ = await _world(seeded) version, _ = await _world(seeded)
group = (await seeded.execute(select(CompanyGroup).where(CompanyGroup.name == "ГК Русский бисквит"))).scalar_one() group = (await seeded.execute(select(CompanyGroup).where(CompanyGroup.name == "ГК Русский бисквит"))).scalar_one()
outsider = await make_legal_entity(seeded, name="ООО В вне периметра", group=group) outsider = await make_legal_entity(seeded, name="ООО В вне периметра", group=group)
await _unconfirmed(seeded, outsider, account="62") await _unconfirmed(seeded, version, outsider, account="62")
await _unconfirmed(seeded, outsider, account="60", status=DocumentStatus.RECOGNIZED) await _unconfirmed(seeded, version, outsider, account="60", status=DocumentStatus.RECOGNIZED)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json()

View File

@ -26,6 +26,7 @@ from src.models.enums import (
PeriodQuarter, PeriodQuarter,
RecognitionStatus, RecognitionStatus,
) )
from src.models.files import RecognitionJob, StoredFile
from src.repositories import consolidations as repo from src.repositories import consolidations as repo
from tests.factories import ( from tests.factories import (
make_company_group, make_company_group,
@ -377,30 +378,58 @@ async def test_bound_documents_and_document_versions(db_session: AsyncSession) -
@pytest.mark.integration @pytest.mark.integration
async def test_entity_files_state_in_progress_and_last_status(db_session: AsyncSession) -> None: async def test_entity_files_state_in_progress_and_last_status(db_session: AsyncSession) -> None:
group = await make_company_group(db_session) _, group, (busy, failed, idle, recovered), user = await _world(db_session, members=4)
busy = await make_legal_entity(db_session, group=group) consolidation, _ = await make_consolidation(
failed = await make_legal_entity(db_session, group=group) db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=[busy, failed], initiator=user
idle = await make_legal_entity(db_session, group=group) )
recovered = await make_legal_entity(db_session, group=group)
await make_stored_file(db_session, entity=busy, status=FileStatus.UPLOADED) async def file(owner: LegalEntity, status: FileStatus, uploaded: int | None = None) -> None:
await make_stored_file(db_session, entity=busy, status=FileStatus.PROCESSING) stored, _ = await make_stored_file(db_session, entity=owner, status=status)
old, _ = await make_stored_file(db_session, entity=failed, status=FileStatus.PROCESSED) stored.consolidation_id = consolidation.id
newer, _ = await make_stored_file(db_session, entity=failed, status=FileStatus.ERROR) if uploaded is not None:
old.uploaded_at = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) stored.uploaded_at = datetime.datetime(2024, uploaded, 1, tzinfo=datetime.UTC)
newer.uploaded_at = datetime.datetime(2024, 2, 1, tzinfo=datetime.UTC)
bad, _ = await make_stored_file(db_session, entity=recovered, status=FileStatus.ERROR) await file(busy, FileStatus.UPLOADED)
good, _ = await make_stored_file(db_session, entity=recovered, status=FileStatus.PROCESSED) await file(busy, FileStatus.PROCESSING)
bad.uploaded_at = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) await file(failed, FileStatus.PROCESSED, 1)
good.uploaded_at = datetime.datetime(2024, 2, 1, tzinfo=datetime.UTC) await file(failed, FileStatus.ERROR, 2)
await file(recovered, FileStatus.ERROR, 1)
await file(recovered, FileStatus.PROCESSED, 2)
await db_session.flush() await db_session.flush()
state = await repo.entity_files_state(db_session, [busy.id, failed.id, idle.id, recovered.id]) state = await repo.entity_files_state(db_session, [busy.id, failed.id, idle.id, recovered.id], consolidation.id)
assert state[busy.id].has_in_progress is True assert state[busy.id].has_in_progress is True
assert state[failed.id] == EntityFilesState(has_in_progress=False, last_file_status=FileStatus.ERROR) assert state[failed.id] == EntityFilesState(has_in_progress=False, last_file_status=FileStatus.ERROR)
assert state[idle.id] == EntityFilesState(has_in_progress=False, last_file_status=None) assert state[idle.id] == EntityFilesState(has_in_progress=False, last_file_status=None)
assert state[recovered.id] == EntityFilesState(has_in_progress=False, last_file_status=FileStatus.PROCESSED) assert state[recovered.id] == EntityFilesState(has_in_progress=False, last_file_status=FileStatus.PROCESSED)
assert await repo.entity_files_state(db_session, []) == {} assert await repo.entity_files_state(db_session, [], consolidation.id) == {}
@pytest.mark.integration
async def test_entity_files_state_only_desk_files(db_session: AsyncSession) -> None:
"""AURORA-1646: файлы другой консолидации и загруженные без контекста состояние ЮЛ не задают; файл с документом,
привязанным к версии консолидации, — задаёт (стол консолидации, §9.5 п. 11)."""
_, group, (entity, bound_owner), user = await _world(db_session)
earlier, _ = await make_consolidation(
db_session, group=group, year=2026, quarter=PeriodQuarter.Q1, members=[entity, bound_owner], initiator=user
)
consolidation, version = await make_consolidation(
db_session, group=group, year=2026, quarter=PeriodQuarter.Q2, members=[entity, bound_owner], initiator=user
)
foreign, _ = await make_stored_file(db_session, entity=entity, status=FileStatus.ERROR)
foreign.consolidation_id = earlier.id
await make_stored_file(db_session, entity=entity, status=FileStatus.PROCESSING) # карточка ЮЛ, без контекста
picked = await make_confirmed_osv(
db_session, entity=bound_owner, account="60", year=2026, quarter=PeriodQuarter.Q1, is_cumulative=False, rows=[]
)
db_session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=picked.id))
await db_session.flush()
state = await repo.entity_files_state(db_session, [entity.id, bound_owner.id], consolidation.id)
assert state[entity.id] == EntityFilesState(has_in_progress=False, last_file_status=None)
assert state[bound_owner.id] == EntityFilesState(has_in_progress=False, last_file_status=FileStatus.PROCESSED)
@pytest.mark.integration @pytest.mark.integration
@ -420,9 +449,10 @@ async def test_rules_for_basic_and_extended_from_seed(seeded: AsyncSession) -> N
@pytest.mark.integration @pytest.mark.integration
async def test_unconfirmed_documents_filters_status_year_entity_and_superseded(db_session: AsyncSession) -> None: async def test_unconfirmed_documents_filters_status_year_entity_and_superseded(db_session: AsyncSession) -> None:
group = await make_company_group(db_session) _, group, (entity, other), user = await _world(db_session)
entity = await make_legal_entity(db_session, group=group) consolidation, _ = await make_consolidation(
other = await make_legal_entity(db_session, group=group) db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=[entity, other], initiator=user
)
async def doc( async def doc(
owner: LegalEntity, owner: LegalEntity,
@ -435,6 +465,7 @@ async def test_unconfirmed_documents_filters_status_year_entity_and_superseded(d
file, job = await make_stored_file( file, job = await make_stored_file(
db_session, entity=owner, status=file_status, job_status=RecognitionStatus.done db_session, entity=owner, status=file_status, job_status=RecognitionStatus.done
) )
file.consolidation_id = consolidation.id
document = await make_document( document = await make_document(
db_session, file=file, job=job, account=account, year=year, quarter=PeriodQuarter.Q3, status=status db_session, file=file, job=job, account=account, year=year, quarter=PeriodQuarter.Q3, status=status
) )
@ -448,10 +479,47 @@ async def test_unconfirmed_documents_filters_status_year_entity_and_superseded(d
await doc(entity, status=DocumentStatus.NEEDS_CHECK, file_status=FileStatus.SUPERSEDED) await doc(entity, status=DocumentStatus.NEEDS_CHECK, file_status=FileStatus.SUPERSEDED)
await doc(other, status=DocumentStatus.NEEDS_CHECK) await doc(other, status=DocumentStatus.NEEDS_CHECK)
found = await repo.unconfirmed_documents(db_session, [entity.id], 2024) found = await repo.unconfirmed_documents(db_session, [entity.id], consolidation.id, 2024)
assert sorted(d.id for d in found) == sorted([needs_check, recognized]) assert sorted(d.id for d in found) == sorted([needs_check, recognized])
assert await repo.unconfirmed_documents(db_session, [], 2024) == [] assert await repo.unconfirmed_documents(db_session, [], consolidation.id, 2024) == []
@pytest.mark.integration
async def test_unconfirmed_documents_only_desk_files(db_session: AsyncSession) -> None:
"""AURORA-1646: кандидаты чек-листа — только из файлов стола консолидации (§9.5 п. 11): загруженных в неё или с
документом, привязанным к её версии; файлы другой консолидации и карточки ЮЛ (без контекста) — не кандидаты."""
_, group, (entity,), user = await _world(db_session, members=1)
earlier, _ = await make_consolidation(
db_session, group=group, year=2026, quarter=PeriodQuarter.Q1, members=[entity], initiator=user
)
consolidation, version = await make_consolidation(
db_session, group=group, year=2026, quarter=PeriodQuarter.Q2, members=[entity], initiator=user
)
async def doc(consolidation_id: uuid.UUID | None, account: str) -> tuple[StoredFile, RecognitionJob, uuid.UUID]:
file, job = await make_stored_file(
db_session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
file.consolidation_id = consolidation_id
document = await make_document(
db_session, file=file, job=job, account=account, year=2026, status=DocumentStatus.NEEDS_CHECK
)
return file, job, document.id
_, _, own = await doc(consolidation.id, "60")
await doc(earlier.id, "62")
picked_file, picked_job, picked = await doc(None, "58")
confirmed = await make_document(
db_session, file=picked_file, job=picked_job, account="01", year=2026, status=DocumentStatus.CONFIRMED
)
db_session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=confirmed.id))
await doc(None, "76")
await db_session.flush()
found = await repo.unconfirmed_documents(db_session, [entity.id], consolidation.id, 2026)
assert sorted(d.id for d in found) == sorted([own, picked])
@pytest.mark.integration @pytest.mark.integration

View File

@ -187,7 +187,10 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account`
документами (ОСВ: по правилу выбора источников `consolidated-osv`; ББ/ОФР: документ за квартал версии); документами (ОСВ: по правилу выбора источников `consolidated-osv`; ББ/ОФР: документ за квартал версии);
`NEEDS_CHECK` / `RECOGNIZED` — есть неподтверждённый документ ЮЛ того же типа, счёта и года, подходящий версии `NEEDS_CHECK` / `RECOGNIZED` — есть неподтверждённый документ ЮЛ того же типа, счёта и года, подходящий версии
(приоритет `NEEDS_CHECK`); `PROCESSING` — у ЮЛ есть файл `UPLOADED` или `PROCESSING`; `ERROR` — последний по (приоритет `NEEDS_CHECK`); `PROCESSING` — у ЮЛ есть файл `UPLOADED` или `PROCESSING`; `ERROR` — последний по
`uploaded_at` файл ЮЛ в `ERROR`; иначе `MISSING`. `missing_quarters` SHALL перечислять кварталы без квартальной ОСВ при `uploaded_at` файл ЮЛ в `ERROR`; иначе `MISSING`. Неподтверждённые документы и файлы SHALL браться только со стола
консолидации версии (§9.5 п. 11: файлы, загруженные в эту консолидацию, и файлы с документом, привязанным к любой её
версии) — тем же фильтром, что панель «Первичные документы»; документы и файлы ЮЛ из других консолидаций и из карточки
ЮЛ на ячейки MUST NOT влиять (AURORA-1646). `missing_quarters` SHALL перечислять кварталы без квартальной ОСВ при
частичном покрытии. Правило ОСВ SHALL закрываться только документом с `osv_account_code`, равным `account` правила частичном покрытии. Правило ОСВ SHALL закрываться только документом с `osv_account_code`, равным `account` правила
(OQ-04). Те же ячейки (с `document_type` и `account`, в том же порядке) SHALL отдаваться в карточке версии у каждого (OQ-04). Те же ячейки (с `document_type` и `account`, в том же порядке) SHALL отдаваться в карточке версии у каждого
члена. С `?format=csv` SHALL отдаваться тот же чек-лист файлом `text/csv; charset=utf-8` с BOM и разделителем `;`: члена. С `?format=csv` SHALL отдаваться тот же чек-лист файлом `text/csv; charset=utf-8` с BOM и разделителем `;`:
@ -206,9 +209,20 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account`
- **THEN** ячейка ОСВ 58 — `MISSING` - **THEN** ячейка ОСВ 58 — `MISSING`
#### Scenario: Файл в обработке #### Scenario: Файл в обработке
- **WHEN** у ЮЛ загружен файл, задание ещё не завершено, других документов нет - **WHEN** у ЮЛ в консолидацию загружен файл, задание ещё не завершено, других документов нет
- **THEN** все ячейки ЮЛ — `PROCESSING` - **THEN** все ячейки ЮЛ — `PROCESSING`
#### Scenario: Документы другой консолидации
- **WHEN** создана консолидация 2024 Q3; у ЮЛ «А» в файле, загруженном в консолидацию 2024 Q2, — ББ нарастающий за Q3
(`NEEDS_CHECK`) и ОСВ 60 за Q3 (`RECOGNIZED`); у ЮЛ «Б» — файл консолидации 2024 Q2 в `ERROR` и файл в `PROCESSING`,
загруженный из карточки ЮЛ; в консолидацию 2024 Q3 ничего не загружено
- **THEN** все ячейки обоих ЮЛ — `MISSING` с пустыми `document_ids`
#### Scenario: Файл, подхваченный привязкой
- **WHEN** файл ЮЛ загружен из карточки ЮЛ, один его документ подтверждён и привязан к версии консолидации, другой —
`NEEDS_CHECK` и подходит версии
- **THEN** файл на столе консолидации, ячейка второго документа — `NEEDS_CHECK`
#### Scenario: Ячейки карточки подписаны #### Scenario: Ячейки карточки подписаны
- **WHEN** запрошена карточка версии подхода `BASIC` - **WHEN** запрошена карточка версии подхода `BASIC`
- **THEN** у каждого члена `checklist[i].document_type` и `checklist[i].account` равны `rules[i]` чек-листа той же - **THEN** у каждого члена `checklist[i].document_type` и `checklist[i].account` равны `rules[i]` чек-листа той же