feat(documents): auto_confirm — чистый документ подтверждается системой (#64, OQ-157)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
588c098838
commit
b48d9e6f4d
75
api/src/services/auto_confirm.py
Normal file
75
api/src/services/auto_confirm.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""Автоподтверждение чистых документов при маппинге результата (osv-result-mapping «Автоподтверждение чистого
|
||||||
|
документа»; `05-domain-rules.md` §9.3, §9.5; OQ-157; design D-1–D-3 change `auto-confirm-clean-documents`).
|
||||||
|
|
||||||
|
Документ, получивший при маппинге `RECOGNIZED` (ТЗ 03_9 п. 4: «при отсутствии замечаний — „Загружен“»), становится
|
||||||
|
`CONFIRMED` от имени системы: `confirmed_by = NULL`, у формы `reviewed_by = NULL`, `audit_event(DOCUMENT_CONFIRM)` без
|
||||||
|
пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором
|
||||||
|
`stored_file.uploaded_by`. Вызывается в транзакции маппинга под заданием, файлом и документами (порядок блокировок D-5).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.enums import AuditAction, AuditSection, DocumentStatus
|
||||||
|
from src.models.files import Document, StoredFile
|
||||||
|
from src.repositories import statements as statements_repo
|
||||||
|
from src.services import consolidation_documents
|
||||||
|
from src.services.audit import AuditService
|
||||||
|
from src.services.statements import FORM_TYPES, form_confirmable
|
||||||
|
|
||||||
|
AUTO_CONFIRM_DESCRIPTION = "автоподтверждение: без замечаний"
|
||||||
|
OBJECT_TYPE = "document"
|
||||||
|
|
||||||
|
|
||||||
|
def transition_comment(document: Document) -> str:
|
||||||
|
"""Комментарий перехода версии: «автоподтверждение документа <тип> [<счёт>]» (design решение №6)."""
|
||||||
|
parts = ["автоподтверждение документа", document.document_type.value]
|
||||||
|
if document.osv_account_code:
|
||||||
|
parts.append(document.osv_account_code)
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def auto_confirm(
|
||||||
|
session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime
|
||||||
|
) -> list[Document]:
|
||||||
|
"""Подтверждает документы `RECOGNIZED` файла (форма — только при `form_confirmable`, D-3); `NEEDS_CHECK` и прочие
|
||||||
|
не трогает. Возвращает подтверждённые документы по возрастанию `id`."""
|
||||||
|
audit = AuditService(session)
|
||||||
|
confirmed: list[Document] = []
|
||||||
|
for document in sorted(documents, key=lambda item: item.id):
|
||||||
|
if document.status is not DocumentStatus.RECOGNIZED:
|
||||||
|
continue
|
||||||
|
if not await form_confirmable(session, document):
|
||||||
|
continue
|
||||||
|
document.status = DocumentStatus.CONFIRMED
|
||||||
|
document.confirmed_by = None
|
||||||
|
document.confirmed_at = now
|
||||||
|
document.updated_at = now
|
||||||
|
if document.document_type in FORM_TYPES:
|
||||||
|
meta = await statements_repo.get_meta(session, document.id)
|
||||||
|
if meta is not None:
|
||||||
|
meta.reviewed_by = None
|
||||||
|
meta.reviewed_at = now
|
||||||
|
await session.flush()
|
||||||
|
await consolidation_documents.on_document_confirmed(
|
||||||
|
session,
|
||||||
|
audit,
|
||||||
|
document=document,
|
||||||
|
changed_by=file.uploaded_by,
|
||||||
|
comment=transition_comment(document),
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
await audit.record(
|
||||||
|
action=AuditAction.DOCUMENT_CONFIRM,
|
||||||
|
section=AuditSection.PRIMARY_DOCUMENTS,
|
||||||
|
object_type=OBJECT_TYPE,
|
||||||
|
object_id=document.id,
|
||||||
|
user_id=None,
|
||||||
|
description=AUTO_CONFIRM_DESCRIPTION,
|
||||||
|
before_value={"status": DocumentStatus.RECOGNIZED.value},
|
||||||
|
after_value={"status": DocumentStatus.CONFIRMED.value},
|
||||||
|
)
|
||||||
|
confirmed.append(document)
|
||||||
|
return confirmed
|
||||||
218
api/tests/test_auto_confirm.py
Normal file
218
api/tests/test_auto_confirm.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
"""Автоподтверждение чистых документов (osv-result-mapping «Автоподтверждение чистого документа»;
|
||||||
|
OQ-157; design D-1–D-3)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
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 ConsolidationDocument, ConsolidationStatusHistory, ConsolidationVersion
|
||||||
|
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice
|
||||||
|
from src.models.enums import (
|
||||||
|
AuditAction,
|
||||||
|
ConsolidationStatus,
|
||||||
|
DocumentStatus,
|
||||||
|
DocumentType,
|
||||||
|
FileStatus,
|
||||||
|
PeriodQuarter,
|
||||||
|
RecognitionStatus,
|
||||||
|
)
|
||||||
|
from src.models.files import Document, StoredFile
|
||||||
|
from src.models.statements import FinancialStatementMeta
|
||||||
|
from src.services.auto_confirm import AUTO_CONFIRM_DESCRIPTION, auto_confirm, transition_comment
|
||||||
|
from tests.conftest import AuthClientFactory
|
||||||
|
from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file
|
||||||
|
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
ROSTOV = "Ростовский региональный филиал"
|
||||||
|
KA_RF = "ka_rf@armka.local"
|
||||||
|
YEAR = 2024
|
||||||
|
Q2, Q3 = PeriodQuarter.Q2, PeriodQuarter.Q3
|
||||||
|
HTTP_CONFLICT = 409
|
||||||
|
|
||||||
|
|
||||||
|
async def _user(session: AsyncSession) -> AppUser:
|
||||||
|
return (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def _group(session: AsyncSession) -> 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)
|
||||||
|
return group, [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(2)]
|
||||||
|
|
||||||
|
|
||||||
|
async def _version(
|
||||||
|
session: AsyncSession, group: CompanyGroup, members: list[LegalEntity], status: ConsolidationStatus
|
||||||
|
) -> ConsolidationVersion:
|
||||||
|
_, version = await make_consolidation(
|
||||||
|
session, group=group, year=YEAR, quarter=Q3, members=members, initiator=await _user(session), status=status
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
async def _file_with(
|
||||||
|
session: AsyncSession, entity: LegalEntity, statuses: list[DocumentStatus], uploaded_by: uuid.UUID | None
|
||||||
|
) -> tuple[StoredFile, list[Document]]:
|
||||||
|
"""Файл `PROCESSED` с квартальными ОСВ 60, 62, … за 2024 Q2 в статусах `statuses`."""
|
||||||
|
file, job = await make_stored_file(
|
||||||
|
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, uploaded_by=uploaded_by
|
||||||
|
)
|
||||||
|
documents = [
|
||||||
|
await make_document(
|
||||||
|
session, file=file, job=job, account=account, year=YEAR, quarter=Q2, status=status, is_cumulative=False
|
||||||
|
)
|
||||||
|
for account, status in zip(["60", "62", "76"], statuses, strict=False)
|
||||||
|
]
|
||||||
|
return file, documents
|
||||||
|
|
||||||
|
|
||||||
|
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 _confirm_events(session: AsyncSession) -> list[AuditEvent]:
|
||||||
|
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM)
|
||||||
|
return list((await session.execute(stmt)).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_comment() -> None:
|
||||||
|
osv = Document(document_type=DocumentType.OSV, osv_account_code="60")
|
||||||
|
income = Document(document_type=DocumentType.INCOME, osv_account_code=None)
|
||||||
|
assert transition_comment(osv) == "автоподтверждение документа OSV 60"
|
||||||
|
assert transition_comment(income) == "автоподтверждение документа INCOME"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clean_document_is_confirmed_by_system(seeded: AsyncSession) -> None:
|
||||||
|
group, entities = await _group(seeded)
|
||||||
|
user = await _user(seeded)
|
||||||
|
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id)
|
||||||
|
now = utc_now()
|
||||||
|
|
||||||
|
confirmed = await auto_confirm(seeded, file=file, documents=[document], now=now)
|
||||||
|
|
||||||
|
assert confirmed == [document]
|
||||||
|
assert (document.status, document.confirmed_by, document.confirmed_at) == (DocumentStatus.CONFIRMED, None, now)
|
||||||
|
[event] = await _confirm_events(seeded)
|
||||||
|
assert (event.object_id, event.user_id, event.description) == (document.id, None, AUTO_CONFIRM_DESCRIPTION)
|
||||||
|
assert (event.before_value, event.after_value) == ({"status": "RECOGNIZED"}, {"status": "CONFIRMED"})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_needs_check_is_left_for_analyst(seeded: AsyncSession) -> None:
|
||||||
|
group, entities = await _group(seeded)
|
||||||
|
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
|
||||||
|
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.NEEDS_CHECK], (await _user(seeded)).id)
|
||||||
|
|
||||||
|
assert await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) == []
|
||||||
|
assert document.status is DocumentStatus.NEEDS_CHECK and document.confirmed_at is None
|
||||||
|
assert await _confirm_events(seeded) == []
|
||||||
|
assert await _bound(seeded, version.id) == set()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_binds_and_moves_version_with_uploader_and_comment(seeded: AsyncSession) -> None:
|
||||||
|
group, entities = await _group(seeded)
|
||||||
|
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
|
||||||
|
user = await _user(seeded)
|
||||||
|
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id)
|
||||||
|
|
||||||
|
await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
|
||||||
|
|
||||||
|
assert await _bound(seeded, version.id) == {document.id}
|
||||||
|
refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True)
|
||||||
|
assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION
|
||||||
|
last = (
|
||||||
|
await seeded.execute(
|
||||||
|
select(ConsolidationStatusHistory)
|
||||||
|
.where(ConsolidationStatusHistory.version_id == version.id)
|
||||||
|
.order_by(ConsolidationStatusHistory.changed_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert (last.changed_by, last.comment) == (user.id, "автоподтверждение документа OSV 60")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_file_without_uploader_binds_without_transition(seeded: AsyncSession) -> None:
|
||||||
|
group, entities = await _group(seeded)
|
||||||
|
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
|
||||||
|
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], None)
|
||||||
|
|
||||||
|
await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
|
||||||
|
|
||||||
|
assert document.status is DocumentStatus.CONFIRMED
|
||||||
|
assert await _bound(seeded, version.id) == {document.id}
|
||||||
|
refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True)
|
||||||
|
assert refreshed is not None and refreshed.status is ConsolidationStatus.INITIATED
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mixed_documents_confirm_only_clean(seeded: AsyncSession) -> None:
|
||||||
|
group, entities = await _group(seeded)
|
||||||
|
file, documents = await _file_with(
|
||||||
|
seeded, entities[0], [DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK], (await _user(seeded)).id
|
||||||
|
)
|
||||||
|
|
||||||
|
confirmed = await auto_confirm(seeded, file=file, documents=documents, now=utc_now())
|
||||||
|
|
||||||
|
assert confirmed == [documents[0]]
|
||||||
|
assert [d.status for d in documents] == [DocumentStatus.CONFIRMED, DocumentStatus.NEEDS_CHECK]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fixtures
|
||||||
|
async def test_clean_form_sets_reviewed_at(seeded: AsyncSession, fixtures_dir: Path) -> None:
|
||||||
|
path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
|
||||||
|
import json # noqa: PLC0415 - нужен только здесь
|
||||||
|
|
||||||
|
data = json.loads(path.read_text("utf-8"))
|
||||||
|
inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn"))
|
||||||
|
owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn)
|
||||||
|
(income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
|
||||||
|
assert income.status is DocumentStatus.RECOGNIZED
|
||||||
|
file = await seeded.get(StoredFile, income.stored_file_id)
|
||||||
|
assert file is not None
|
||||||
|
now = utc_now()
|
||||||
|
|
||||||
|
assert await auto_confirm(seeded, file=file, documents=[income], now=now) == [income]
|
||||||
|
|
||||||
|
meta = (
|
||||||
|
await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
|
||||||
|
).scalar_one()
|
||||||
|
assert (income.status, meta.reviewed_by, meta.reviewed_at) == (DocumentStatus.CONFIRMED, None, now)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fixtures
|
||||||
|
async def test_recognized_form_without_period_stays(seeded: AsyncSession, fixtures_dir: Path) -> None:
|
||||||
|
path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
|
||||||
|
import json # noqa: PLC0415 - нужен только здесь
|
||||||
|
|
||||||
|
data = json.loads(path.read_text("utf-8"))
|
||||||
|
inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn"))
|
||||||
|
owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn)
|
||||||
|
(income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
|
||||||
|
income.period_quarter = None # рассинхрон правил: RECOGNIZED без периода не должен подтверждаться сам
|
||||||
|
await seeded.flush()
|
||||||
|
file = await seeded.get(StoredFile, income.stored_file_id)
|
||||||
|
assert file is not None
|
||||||
|
|
||||||
|
assert await auto_confirm(seeded, file=file, documents=[income], now=utc_now()) == []
|
||||||
|
assert income.status is DocumentStatus.RECOGNIZED
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_confirm_of_auto_confirmed_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||||
|
"""Review Focus 4: фронт с устаревшим статусом жмёт «Подтвердить» — 409 `ALREADY_CONFIRMED`."""
|
||||||
|
office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
|
||||||
|
entity = await make_legal_entity(seeded, name="ООО Ростов", office=office)
|
||||||
|
file, [document] = await _file_with(seeded, entity, [DocumentStatus.RECOGNIZED], None)
|
||||||
|
await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
|
||||||
|
client = await auth_client(KA_RF)
|
||||||
|
|
||||||
|
response = await client.post(f"/documents/{document.id}/confirm")
|
||||||
|
|
||||||
|
assert response.status_code == HTTP_CONFLICT, response.text
|
||||||
|
assert response.json()["detail"]["code"] == "ALREADY_CONFIRMED"
|
||||||
|
assert len(await _confirm_events(seeded)) == 1
|
||||||
Loading…
x
Reference in New Issue
Block a user