Ревью T8.1: новый файл с ОСВ 60/62/66 заменяет три прежних файла с version_no 1, 3, 2 по возрастанию id (максимум в середине порядка блокировки) → три FILE_REPLACE, version_no = 4. Мутации `locked[0]` / `locked[-1]` вместо max тест ловит. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
1023 lines
49 KiB
Python
1023 lines
49 KiB
Python
"""Цикл маппинга результата распознавания в `document`/`osv_entry` (spec osv-result-mapping; design D-1, D-2, D-3, D-7,
|
||
D-9; рулинги R-3, R-4, R-5; Review Focus 1, 2).
|
||
|
||
Интеграционные тесты — на отдельной временной БД (`committed_sessionmaker`: цикл работает в собственных транзакциях),
|
||
результат лежит в `FakeStorage` под `results/{file_id}/{job_id}/result.json`. Синтетические результаты строятся из
|
||
примера контракта `contracts/examples/result-osv-minimal.json`; сценарий «Результат смапплен» и замер скорости — на
|
||
golden-снимках парсера (маркер `fixtures`).
|
||
"""
|
||
|
||
import asyncio
|
||
import copy
|
||
import json
|
||
import time
|
||
import uuid
|
||
from collections.abc import Callable
|
||
from datetime import UTC, datetime
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from loguru import logger
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||
|
||
from src.core.config import Settings, get_settings
|
||
from src.models.access import AppUser
|
||
from src.models.accounting import ChartOfAccount
|
||
from src.models.audit_ex import AuditEvent
|
||
from src.models.directories import LegalEntity
|
||
from src.models.enums import (
|
||
AuditAction,
|
||
AuditSection,
|
||
DocumentCheckResult,
|
||
DocumentStatus,
|
||
DocumentType,
|
||
FileStatus,
|
||
OsvDataType,
|
||
OsvGroupType,
|
||
PeriodQuarter,
|
||
RecognitionStatus,
|
||
)
|
||
from src.models.files import Document, RecognitionJob, StoredFile
|
||
from src.models.osv import OsvEntry
|
||
from src.services import result_mapping as rm
|
||
from src.services.result_mapping import (
|
||
MAPPING_ATTEMPTS_KEY,
|
||
MAPPING_ERROR_KEY,
|
||
MAPPING_WARNINGS_KEY,
|
||
Candidate,
|
||
MappingFatal,
|
||
process_once,
|
||
run_mapping_loop,
|
||
supersede_previous,
|
||
validate_result_key,
|
||
)
|
||
from tests.conftest import FakeStorage
|
||
from tests.factories import make_document, make_legal_entity, make_result_object, make_stored_file
|
||
|
||
API_DIR = Path(__file__).resolve().parents[1]
|
||
MINIMAL_RESULT = API_DIR / "contracts" / "examples" / "result-osv-minimal.json"
|
||
GOLDEN_OSV_66 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОСВ_66 за 2024.json"
|
||
GOLDEN_OSV_60 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОСВ_60 за 2024.json"
|
||
BUCKET = "arm-ka"
|
||
OWNER_NAME = "АО «Русский бисквит»"
|
||
PERF_BUDGET_SECONDS = 2.0 # цель плана: маппинг ~1000 строк ОСВ_60 за 2024 быстрее 2 с
|
||
|
||
pytestmark = pytest.mark.integration
|
||
|
||
|
||
# --- данные ---------------------------------------------------------------------------------------------------------
|
||
|
||
|
||
def _minimal() -> dict[str, Any]:
|
||
return json.loads(MINIMAL_RESULT.read_text("utf-8"))
|
||
|
||
|
||
def _golden(fixtures_dir: Path, path: Path) -> dict[str, Any]:
|
||
data = json.loads((fixtures_dir / path).read_text("utf-8"))
|
||
# у снимка нет job_id/file_id (подставляются) и версии сервиса (в контракте она обязательна)
|
||
data["service"] = {"version": "golden", **data["service"]}
|
||
return data
|
||
|
||
|
||
def _osv(result: dict[str, Any]) -> dict[str, Any]:
|
||
return result["documents"][0]
|
||
|
||
|
||
def _root(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||
return next(row for row in rows if row["level"] == 0 and row["parent_row_number"] is None)
|
||
|
||
|
||
def _settings() -> Settings:
|
||
return get_settings()
|
||
|
||
|
||
def _now() -> datetime:
|
||
return datetime.now(UTC)
|
||
|
||
|
||
async def _prepare( # noqa: PLR0913 - каждое поле подготовки задаётся тестом явно
|
||
maker: async_sessionmaker[AsyncSession],
|
||
storage: FakeStorage,
|
||
result: dict[str, Any],
|
||
*,
|
||
entity: LegalEntity | None = None,
|
||
owner_name: str = OWNER_NAME,
|
||
owner_inn: str | None = None,
|
||
job_status: RecognitionStatus = RecognitionStatus.done,
|
||
file_status: FileStatus = FileStatus.PROCESSING,
|
||
key: str | None = None,
|
||
uploaded_by: uuid.UUID | None = None,
|
||
) -> tuple[uuid.UUID, uuid.UUID, uuid.UUID]:
|
||
"""ЮЛ-владелец, файл `PROCESSING` с текущим заданием `done`, результат в `storage`; `(file_id, job_id, entity_id)`.
|
||
|
||
`job_id`/`file_id` результата подставляются; `key` — подменить ключ `s3_object` результата (OQ-70)."""
|
||
async with maker() as session, session.begin():
|
||
owner = entity or await make_legal_entity(session, name=owner_name, inn=owner_inn)
|
||
file, job = await make_stored_file(
|
||
session, entity=owner, status=file_status, job_status=job_status, uploaded_by=uploaded_by
|
||
)
|
||
job.finished_at = _now()
|
||
job.metrics = copy.deepcopy(result.get("metrics")) # как inbox: метрики сервиса из конверта
|
||
payload = copy.deepcopy(result) | {"job_id": str(job.id), "file_id": str(file.id)}
|
||
s3 = await make_result_object(session, job=job, storage=storage, result=payload)
|
||
if key is not None:
|
||
storage.objects[key] = storage.objects[s3.key]
|
||
s3.key = key
|
||
return file.id, job.id, owner.id
|
||
|
||
|
||
async def _file(maker: async_sessionmaker[AsyncSession], file_id: uuid.UUID) -> StoredFile:
|
||
async with maker() as session:
|
||
file = await session.get(StoredFile, file_id)
|
||
assert file is not None
|
||
return file
|
||
|
||
|
||
async def _job(maker: async_sessionmaker[AsyncSession], job_id: uuid.UUID) -> RecognitionJob:
|
||
async with maker() as session:
|
||
job = await session.get(RecognitionJob, job_id)
|
||
assert job is not None
|
||
return job
|
||
|
||
|
||
async def _documents(maker: async_sessionmaker[AsyncSession], file_id: uuid.UUID) -> list[Document]:
|
||
async with maker() as session:
|
||
rows = await session.execute(select(Document).where(Document.stored_file_id == file_id))
|
||
return list(rows.scalars())
|
||
|
||
|
||
async def _entries(maker: async_sessionmaker[AsyncSession], document_id: uuid.UUID) -> list[OsvEntry]:
|
||
async with maker() as session:
|
||
rows = await session.execute(
|
||
select(OsvEntry).where(OsvEntry.document_id == document_id).order_by(OsvEntry.row_number)
|
||
)
|
||
return list(rows.scalars())
|
||
|
||
|
||
# --- проверка ключа результата (без БД) ----------------------------------------------------------------------------
|
||
|
||
|
||
def _candidate(key: str, bucket: str = BUCKET) -> Candidate:
|
||
return Candidate(job_id=JOB_ID, file_id=FILE_ID, bucket=bucket, key=key)
|
||
|
||
|
||
JOB_ID = uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01")
|
||
FILE_ID = uuid.UUID("a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"key",
|
||
[f"results/{FILE_ID}/{JOB_ID}/result.json", f"results/{FILE_ID}/{JOB_ID}/sub/result.json"],
|
||
)
|
||
def test_validate_result_key_accepts_job_folder(key: str) -> None:
|
||
validate_result_key(_candidate(key), BUCKET)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("key", "bucket"),
|
||
[
|
||
(f"results/{uuid.uuid4()}/{JOB_ID}/result.json", BUCKET), # чужой файл
|
||
(f"results/{FILE_ID}/{uuid.uuid4()}/result.json", BUCKET), # чужое задание
|
||
(f"uploads/{FILE_ID}/{JOB_ID}/result.json", BUCKET), # не папка результатов
|
||
(f"results/{FILE_ID}/{JOB_ID}/", BUCKET), # нет имени объекта
|
||
(f"results/{FILE_ID}/{JOB_ID}/../{uuid.uuid4()}/result.json", BUCKET), # выход из папки
|
||
(f"results/{FILE_ID}/{JOB_ID}/result.json", "other-bucket"), # чужой бакет
|
||
],
|
||
)
|
||
def test_validate_result_key_rejects_outside_job_folder(key: str, bucket: str) -> None:
|
||
with pytest.raises(MappingFatal):
|
||
validate_result_key(_candidate(key, bucket), BUCKET)
|
||
|
||
|
||
# --- сценарии спеки ------------------------------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.fixtures
|
||
async def test_golden_osv_66_mapped( # noqa: PLR0915 - один сценарий спеки целиком
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession], fixtures_dir: Path
|
||
):
|
||
"""«Результат смапплен», «Дерево и счета»: документ 66/2024/Q4/нарастающая, строки без TOTAL, `group_schema`."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
golden = _golden(fixtures_dir, GOLDEN_OSV_66)
|
||
file_id, job_id, entity_id = await _prepare(maker, storage, golden)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
file = await _file(maker, file_id)
|
||
assert file.status is FileStatus.PROCESSED
|
||
assert file.updated_at is not None
|
||
[document] = await _documents(maker, file_id)
|
||
assert document.document_type is DocumentType.OSV
|
||
assert document.osv_account_code == "66"
|
||
assert document.period_year == 2024 # noqa: PLR2004 - год из шапки снимка
|
||
assert document.period_quarter is PeriodQuarter.Q4
|
||
assert document.is_cumulative is True
|
||
assert document.locator == "sheet:Лист_1"
|
||
assert document.legal_entity_id == entity_id
|
||
assert document.status is DocumentStatus.RECOGNIZED # done, проверки чистые, шапка = владелец по имени
|
||
assert document.counterparty_check is DocumentCheckResult.MATCH
|
||
assert document.period_check is DocumentCheckResult.UNKNOWN
|
||
|
||
rows = _osv(golden)["rows"]
|
||
entries = await _entries(maker, document.id)
|
||
assert len(entries) == len(rows) - 1 # всё, кроме TOTAL
|
||
total_rows = {row["row_number"] for row in rows if row["group_type"] == "TOTAL"}
|
||
assert total_rows and not total_rows & {entry.row_number for entry in entries} # в enum DBML TOTAL нет
|
||
by_row = {entry.row_number: entry for entry in entries}
|
||
for row in rows:
|
||
if row["group_type"] == "TOTAL":
|
||
continue
|
||
entry = by_row[row["row_number"]]
|
||
assert entry.recognition_job_id == job_id
|
||
assert entry.owner_legal_entity_id == entity_id
|
||
assert entry.is_modified is False
|
||
assert entry.level == row["level"]
|
||
assert entry.row_value == row["row_value"]
|
||
for name in ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit"):
|
||
expected = row.get(name)
|
||
assert getattr(entry, name) == (Decimal(expected) if expected is not None else None)
|
||
if row["parent_row_number"] is None:
|
||
assert entry.parent_entry_id is None
|
||
else:
|
||
assert entry.parent_entry_id == by_row[row["parent_row_number"]].id
|
||
|
||
async with maker() as session:
|
||
chart_6603 = (await session.execute(select(ChartOfAccount.id).where(ChartOfAccount.code == "66.03"))).scalar()
|
||
[yug] = [e for e in entries if e.account_code == "66.03" and e.group_type is OsvGroupType.COUNTERPARTY]
|
||
assert yug.counterparty_name == "Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ"
|
||
parent = next(e for e in entries if e.id == yug.parent_entry_id)
|
||
assert parent.account_code == "66.03"
|
||
assert parent.group_type is OsvGroupType.ACCOUNT
|
||
assert yug.account_root == "66"
|
||
assert yug.account_id == chart_6603 is not None
|
||
|
||
job = await _job(maker, job_id)
|
||
assert job.group_schema == [
|
||
{
|
||
"locator": "sheet:Лист_1",
|
||
"groups": [
|
||
{"type": "ACCOUNT", "name": "Счет", "has_inn": None},
|
||
{"type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": None},
|
||
],
|
||
}
|
||
]
|
||
assert job.metrics[MAPPING_WARNINGS_KEY] == []
|
||
assert job.metrics["rows"] == 9 # noqa: PLR2004 - метрики сервиса сохранены рядом с ключами маппинга
|
||
|
||
|
||
@pytest.mark.fixtures
|
||
async def test_mapping_performance_osv_60(committed_sessionmaker: async_sessionmaker[AsyncSession], fixtures_dir: Path):
|
||
"""Цель плана: маппинг самой длинной ОСВ (`ОСВ_60 за 2024`, ~1000 строк) — быстрее 2 с на один проход цикла."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
golden = _golden(fixtures_dir, GOLDEN_OSV_60)
|
||
file_id, _, _ = await _prepare(maker, storage, golden)
|
||
|
||
started = time.perf_counter()
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
elapsed = time.perf_counter() - started
|
||
|
||
[document] = await _documents(maker, file_id)
|
||
rows = _osv(golden)["rows"]
|
||
entries = await _entries(maker, document.id)
|
||
total_rows = sum(1 for row in rows if row["group_type"] == "TOTAL")
|
||
assert len(entries) == len(rows) - total_rows
|
||
logger.info("Маппинг ОСВ_60 за 2024: {rows} строк за {elapsed:.3f} с", rows=len(entries), elapsed=elapsed)
|
||
print(f"\nPERF: ОСВ_60 за 2024 — {len(entries)} строк за {elapsed:.3f} с") # noqa: T201 - замер в отчёт
|
||
assert elapsed < PERF_BUDGET_SECONDS
|
||
|
||
|
||
async def test_result_key_outside_job_folder_is_error(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||
"""«Ключ результата вне папки задания»: файл `ERROR` с ошибкой маппинга, документов нет, повтора нет."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
file_id, job_id, _ = await _prepare(
|
||
maker, storage, _minimal(), key=f"results/{uuid.uuid4()}/{uuid.uuid4()}/result.json"
|
||
)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
file = await _file(maker, file_id)
|
||
assert file.status is FileStatus.ERROR
|
||
job = await _job(maker, job_id)
|
||
assert "results/" in job.metrics[MAPPING_ERROR_KEY]
|
||
assert MAPPING_ATTEMPTS_KEY not in job.metrics # неповторяемая ошибка — без счётчика попыток
|
||
assert await _documents(maker, file_id) == []
|
||
assert await process_once(maker, storage, _settings()) is False # повтора нет
|
||
|
||
|
||
async def test_missing_result_object_is_error(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||
"""Объекта результата нет в S3 — неповторяемая ошибка (D-2): файл `ERROR`."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
file_id, job_id, _ = await _prepare(maker, storage, _minimal())
|
||
storage.objects.clear()
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
assert (await _file(maker, file_id)).status is FileStatus.ERROR
|
||
assert "не найден" in (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("documents", "status", "message"),
|
||
[
|
||
([], "needs_check", "схем"), # контракт требует documents ≥ 1 — результат не проходит схему
|
||
(
|
||
[{"document_type": "BALANCE", "locator": "pages:1", "header": {}, "columns": [], "lines": []}],
|
||
"needs_check",
|
||
"нет документов ОСВ",
|
||
),
|
||
],
|
||
)
|
||
async def test_result_without_osv_documents_is_error(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession], documents: list[Any], status: str, message: str
|
||
):
|
||
"""Review Focus 2: результат без документов ОСВ → файл `ERROR` «нет документов ОСВ», не исключение цикла."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
result = _minimal() | {"status": status, "documents": documents}
|
||
file_id, job_id, _ = await _prepare(maker, storage, result, job_status=RecognitionStatus.needs_check)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
assert (await _file(maker, file_id)).status is FileStatus.ERROR
|
||
assert message in (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY]
|
||
assert await _documents(maker, file_id) == []
|
||
|
||
|
||
async def test_mapping_failure_retries_then_error(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
|
||
):
|
||
"""«Сбой маппинга с повтором»: откат (документов нет), `mapping_attempts` 1, 2 — файл `PROCESSING`; третья
|
||
неудача — `ERROR` с `metrics.mapping_error`."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
file_id, job_id, _ = await _prepare(maker, storage, _minimal())
|
||
real_map_file = rm.map_file
|
||
calls: list[int] = []
|
||
|
||
async def failing_map_file(session: AsyncSession, **kwargs: Any) -> list[str]:
|
||
await real_map_file(session, **kwargs) # вставка выполнена — и откатывается вместе с транзакцией
|
||
calls.append(1)
|
||
raise RuntimeError("подставная ошибка БД при вставке строк")
|
||
|
||
monkeypatch.setattr(rm, "map_file", failing_map_file)
|
||
|
||
for attempt in (1, 2):
|
||
# R-15: повторяемая неудача — `False`, цикл выдерживает паузу MAPPING_POLL_SECONDS до следующей попытки
|
||
assert await process_once(maker, storage, _settings()) is False
|
||
job = await _job(maker, job_id)
|
||
assert job.metrics[MAPPING_ATTEMPTS_KEY] == attempt
|
||
assert MAPPING_ERROR_KEY not in job.metrics
|
||
assert (await _file(maker, file_id)).status is FileStatus.PROCESSING
|
||
assert await _documents(maker, file_id) == []
|
||
|
||
# третья неудача переводит файл в ERROR: кандидат покинул очередь, следующий можно брать без паузы — `True`
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
job = await _job(maker, job_id)
|
||
assert job.metrics[MAPPING_ATTEMPTS_KEY] == 3 # noqa: PLR2004 - MAPPING_MAX_ATTEMPTS по умолчанию
|
||
assert "подставная ошибка БД" in job.metrics[MAPPING_ERROR_KEY]
|
||
assert (await _file(maker, file_id)).status is FileStatus.ERROR
|
||
assert len(calls) == 3 # noqa: PLR2004 - три попытки
|
||
assert await process_once(maker, storage, _settings()) is False
|
||
|
||
|
||
async def test_repeated_mapping_is_idempotent(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||
"""«Повторный маппинг идемпотентен»: остатки прерванной вставки (документ и половина строк) удаляются, у файла
|
||
ровно один набор документов и строк."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
result = _minimal()
|
||
rows = _osv(result)["rows"]
|
||
rows.extend(
|
||
rows[-1] | {"row_number": number, "counterparty_name": f"ООО Лютик-{number}", "counterparty_inn": None}
|
||
for number in range(3, 7)
|
||
)
|
||
file_id, job_id, entity_id = await _prepare(maker, storage, result)
|
||
async with maker() as session, session.begin():
|
||
file = await session.get(StoredFile, file_id)
|
||
job = await session.get(RecognitionJob, job_id)
|
||
assert file is not None and job is not None
|
||
stale = await make_document(session, file=file, job=job, account="60", status=DocumentStatus.NEEDS_CHECK)
|
||
for row in rows[: len(rows) // 2]:
|
||
session.add(
|
||
OsvEntry(
|
||
recognition_job_id=job_id,
|
||
document_id=stale.id,
|
||
owner_legal_entity_id=entity_id,
|
||
row_number=row["row_number"],
|
||
level=row["level"],
|
||
row_value=row["row_value"],
|
||
)
|
||
)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
[document] = await _documents(maker, file_id)
|
||
assert document.id != stale.id
|
||
entries = await _entries(maker, document.id)
|
||
assert [entry.row_number for entry in entries] == [row["row_number"] for row in rows]
|
||
async with maker() as session:
|
||
total = (
|
||
await session.execute(
|
||
select(func.count())
|
||
.select_from(OsvEntry)
|
||
.join(Document, Document.id == OsvEntry.document_id)
|
||
.where(Document.stored_file_id == file_id)
|
||
)
|
||
).scalar_one()
|
||
assert total == len(rows)
|
||
|
||
|
||
async def test_two_sheets_of_same_account(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||
"""«Два листа одного счёта»: один документ (первый лист), `NEEDS_CHECK`, предупреждение называет оба `locator`."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
result = _minimal()
|
||
first = _osv(result)
|
||
second = copy.deepcopy(first) | {"locator": "sheet:Лист_2"}
|
||
second["header"]["account_code_raw"] = "60"
|
||
result["documents"] = [first, second]
|
||
file_id, job_id, _ = await _prepare(maker, storage, result, owner_inn="7700000000")
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
[document] = await _documents(maker, file_id)
|
||
assert document.locator == "sheet:Лист_1"
|
||
assert document.osv_account_code == "60"
|
||
assert document.status is DocumentStatus.NEEDS_CHECK
|
||
assert len(await _entries(maker, document.id)) == len(first["rows"])
|
||
job = await _job(maker, job_id)
|
||
assert job.metrics[MAPPING_WARNINGS_KEY] == ["sheet=sheet:Лист_2: счёт 60 уже есть в листе sheet:Лист_1"]
|
||
assert (await _file(maker, file_id)).status is FileStatus.PROCESSED
|
||
|
||
|
||
async def test_header_of_another_organization(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||
"""«Шапка другой организации»: файл ЮЛ «Пекарня Счастливочная», в шапке `АО "Русский бисквит"` → `MISMATCH`,
|
||
`NEEDS_CHECK`."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
result = _minimal()
|
||
_osv(result)["header"] |= {"organization_name": 'АО "Русский бисквит"', "organization_inn": None}
|
||
file_id, _, _ = await _prepare(maker, storage, result, owner_name="ООО «Пекарня Счастливочная»")
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
[document] = await _documents(maker, file_id)
|
||
assert document.counterparty_check is DocumentCheckResult.MISMATCH
|
||
assert document.status is DocumentStatus.NEEDS_CHECK
|
||
|
||
|
||
async def _q1_document(
|
||
maker: async_sessionmaker[AsyncSession],
|
||
entity_id: uuid.UUID,
|
||
*,
|
||
opening_credit: Decimal,
|
||
file_status: FileStatus,
|
||
) -> None:
|
||
"""Документ ОСВ 60 за 2024 Q1 другого файла того же ЮЛ с корнем (level 0) и строкой-потомком."""
|
||
async with maker() as session, session.begin():
|
||
entity = await session.get(LegalEntity, entity_id)
|
||
assert entity is not None
|
||
file, job = await make_stored_file(
|
||
session, entity=entity, status=file_status, job_status=RecognitionStatus.done
|
||
)
|
||
document = await make_document(session, file=file, job=job, account="60", year=2024, quarter=PeriodQuarter.Q1)
|
||
common = {"recognition_job_id": job.id, "document_id": document.id, "owner_legal_entity_id": entity_id}
|
||
root = OsvEntry(
|
||
**common, row_number=5, level=0, row_value="60", opening_debit=None, opening_credit=opening_credit
|
||
)
|
||
session.add(root)
|
||
await session.flush()
|
||
# строка уровня 1 с меньшим номером не должна считаться корнем
|
||
session.add(OsvEntry(**common, row_number=1, level=1, parent_entry_id=root.id, opening_credit=Decimal(1)))
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("q1_opening", "q1_file_status", "expected"),
|
||
[
|
||
(Decimal("1000.00"), FileStatus.PROCESSED, True), # сальдо на начало совпало с Q1
|
||
(Decimal("1000.01"), FileStatus.PROCESSED, True), # в пределах ±0.01
|
||
(Decimal("999.00"), FileStatus.PROCESSED, False), # не совпало — по тексту
|
||
(Decimal("1000.00"), FileStatus.SUPERSEDED, False), # Q1 из заменённого файла не учитывается
|
||
],
|
||
)
|
||
async def test_cumulative_by_q1_data(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||
q1_opening: Decimal,
|
||
q1_file_status: FileStatus,
|
||
expected: bool,
|
||
):
|
||
"""«Нарастающая по данным»: ОСВ 60 «за 3 квартал 2024» с `opening_*` корня = Q1 → `is_cumulative = true` и
|
||
предупреждение «нарастающая по данным Q1» (ASSUMPTION(OQ-02))."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
async with maker() as session, session.begin():
|
||
entity = await make_legal_entity(session, name=OWNER_NAME, inn="7700000000")
|
||
await _q1_document(maker, entity.id, opening_credit=q1_opening, file_status=q1_file_status)
|
||
result = _minimal()
|
||
_osv(result)["header"] |= {
|
||
"period_text": "3 квартал 2024 г.",
|
||
"period_year": 2024,
|
||
"period_quarter": "Q3",
|
||
"is_cumulative": False,
|
||
}
|
||
assert _root(_osv(result)["rows"])["opening_credit"] == "1000.00"
|
||
file_id, job_id, _ = await _prepare(maker, storage, result, entity=entity)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
[document] = await _documents(maker, file_id)
|
||
assert document.period_quarter is PeriodQuarter.Q3
|
||
assert document.is_cumulative is expected
|
||
warnings = (await _job(maker, job_id)).metrics[MAPPING_WARNINGS_KEY]
|
||
if expected:
|
||
assert len(warnings) == 1
|
||
assert "нарастающая по данным Q1" in warnings[0]
|
||
assert "sheet:Лист_1" in warnings[0]
|
||
assert document.status is DocumentStatus.NEEDS_CHECK
|
||
else:
|
||
assert warnings == []
|
||
assert document.status is DocumentStatus.RECOGNIZED
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("job_status", "expected"),
|
||
[(RecognitionStatus.done, DocumentStatus.RECOGNIZED), (RecognitionStatus.needs_check, DocumentStatus.NEEDS_CHECK)],
|
||
)
|
||
async def test_clean_result(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||
job_status: RecognitionStatus,
|
||
expected: DocumentStatus,
|
||
):
|
||
"""«Чистый результат»: задание `done`, шапка = владелец (ИНН), нарушений нет → `RECOGNIZED`, `MATCH`, `UNKNOWN`;
|
||
задание `needs_check` → `NEEDS_CHECK`."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
file_id, _, _ = await _prepare(maker, storage, _minimal(), owner_inn="7700000000", job_status=job_status)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
[document] = await _documents(maker, file_id)
|
||
assert document.status is expected
|
||
assert document.counterparty_check is DocumentCheckResult.MATCH
|
||
assert document.period_check is DocumentCheckResult.UNKNOWN
|
||
assert document.osv_account_code == "60"
|
||
assert document.period_quarter is PeriodQuarter.Q1
|
||
assert document.is_cumulative is False
|
||
assert (await _file(maker, file_id)).status is FileStatus.PROCESSED
|
||
|
||
|
||
async def test_counterparty_resolved_in_entries(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||
"""R-3: `counterparty_id` — только у `COUNTERPARTY` с `data_type ∈ {BU, SUM}`; `NU` хранится как пришёл, без
|
||
резолва; строка-счёт без контрагента."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
async with maker() as session, session.begin():
|
||
lyutik = await make_legal_entity(session, name="ООО «Лютик»", inn="7711111111")
|
||
result = _minimal()
|
||
rows = _osv(result)["rows"]
|
||
rows.append(rows[1] | {"row_number": 3, "data_type": "NU"})
|
||
file_id, _, _ = await _prepare(maker, storage, result)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
[document] = await _documents(maker, file_id)
|
||
account, bu, nu = await _entries(maker, document.id)
|
||
assert account.group_type is OsvGroupType.ACCOUNT
|
||
assert account.counterparty_id is None
|
||
assert bu.counterparty_id == lyutik.id
|
||
assert bu.counterparty_inn == "7711111111"
|
||
assert nu.data_type is OsvDataType.NU
|
||
assert nu.counterparty_id is None
|
||
|
||
|
||
async def test_result_of_non_current_job_is_ignored(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||
"""Review Focus 1: результат задания, которое уже не текущее для файла, не выбирается и файл не трогает."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
file_id, old_job_id, _ = await _prepare(maker, storage, _minimal())
|
||
async with maker() as session, session.begin():
|
||
file = await session.get(StoredFile, file_id)
|
||
assert file is not None
|
||
new_job = RecognitionJob(
|
||
file_id=file_id,
|
||
pipeline=(await session.get(RecognitionJob, old_job_id)).pipeline, # type: ignore[union-attr]
|
||
request_subject="recognition.tabular.request",
|
||
schema_version="1.0",
|
||
status=RecognitionStatus.processing,
|
||
attempt=2,
|
||
)
|
||
session.add(new_job)
|
||
await session.flush()
|
||
file.current_recognition_job_id = new_job.id
|
||
before = await _file(maker, file_id)
|
||
|
||
assert await process_once(maker, storage, _settings()) is False
|
||
|
||
after = await _file(maker, file_id)
|
||
assert (after.status, after.updated_at) == (FileStatus.PROCESSING, before.updated_at)
|
||
assert await _documents(maker, file_id) == []
|
||
assert (await _job(maker, old_job_id)).group_schema is None
|
||
|
||
|
||
async def test_job_replaced_while_reading_result(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
|
||
):
|
||
"""Review Focus 1, гонка: пока цикл читал S3, у файла сменилось текущее задание → транзакция маппинга ничего не
|
||
меняет (задание и файл перечитываются под блокировкой)."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
file_id, job_id, _ = await _prepare(maker, storage, _minimal())
|
||
real_get_bytes = storage.get_bytes
|
||
|
||
async def get_bytes_and_replace_job(key: str) -> bytes:
|
||
async with maker() as session, session.begin():
|
||
file = await session.get(StoredFile, file_id)
|
||
job = await session.get(RecognitionJob, job_id)
|
||
assert file is not None and job is not None
|
||
new_job = RecognitionJob(
|
||
file_id=file_id,
|
||
pipeline=job.pipeline,
|
||
request_subject=job.request_subject,
|
||
schema_version="1.0",
|
||
status=RecognitionStatus.queued,
|
||
attempt=2,
|
||
)
|
||
session.add(new_job)
|
||
await session.flush()
|
||
file.current_recognition_job_id = new_job.id
|
||
return await real_get_bytes(key)
|
||
|
||
monkeypatch.setattr(storage, "get_bytes", get_bytes_and_replace_job)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
file = await _file(maker, file_id)
|
||
assert file.status is FileStatus.PROCESSING
|
||
assert await _documents(maker, file_id) == []
|
||
job = await _job(maker, job_id)
|
||
assert job.group_schema is None
|
||
assert MAPPING_ATTEMPTS_KEY not in (job.metrics or {})
|
||
|
||
|
||
async def test_run_mapping_loop_processes_two_candidates(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||
"""`run_mapping_loop`: два кандидата подряд без паузы (`sleep(0)`), затем пауза `MAPPING_POLL_SECONDS`."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
first, _, _ = await _prepare(maker, storage, _minimal())
|
||
second, _, _ = await _prepare(maker, storage, _minimal())
|
||
settings = _settings()
|
||
sleeps: list[float] = []
|
||
|
||
async def fake_sleep(seconds: float) -> None:
|
||
sleeps.append(seconds)
|
||
if len(sleeps) == 3: # noqa: PLR2004 - два кандидата и один пустой проход
|
||
raise asyncio.CancelledError
|
||
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await run_mapping_loop(maker, storage, settings, sleep=fake_sleep)
|
||
|
||
assert sleeps == [0, 0, settings.MAPPING_POLL_SECONDS]
|
||
assert (await _file(maker, first)).status is FileStatus.PROCESSED
|
||
assert (await _file(maker, second)).status is FileStatus.PROCESSED
|
||
|
||
|
||
async def test_run_mapping_loop_pauses_after_retryable_failure(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
|
||
):
|
||
"""R-15: после повторяемой неудачи цикл спит `MAPPING_POLL_SECONDS` (попытки не сгорают за миллисекунды), после
|
||
успешного маппинга — `sleep(0)`."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
file_id, job_id, _ = await _prepare(maker, storage, _minimal())
|
||
real_map_file = rm.map_file
|
||
failures = [RuntimeError("кратковременный сбой БД")]
|
||
|
||
async def flaky_map_file(session: AsyncSession, **kwargs: Any) -> list[str]:
|
||
if failures:
|
||
raise failures.pop()
|
||
return await real_map_file(session, **kwargs)
|
||
|
||
monkeypatch.setattr(rm, "map_file", flaky_map_file)
|
||
settings = _settings()
|
||
sleeps: list[float] = []
|
||
|
||
async def fake_sleep(seconds: float) -> None:
|
||
sleeps.append(seconds)
|
||
if len(sleeps) == 3: # noqa: PLR2004 - неудача, успех, пустой проход
|
||
raise asyncio.CancelledError
|
||
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await run_mapping_loop(maker, storage, settings, sleep=fake_sleep)
|
||
|
||
assert sleeps == [settings.MAPPING_POLL_SECONDS, 0, settings.MAPPING_POLL_SECONDS]
|
||
assert (await _file(maker, file_id)).status is FileStatus.PROCESSED
|
||
assert (await _job(maker, job_id)).metrics[MAPPING_ATTEMPTS_KEY] == 1
|
||
|
||
|
||
async def test_run_mapping_loop_survives_errors(monkeypatch: pytest.MonkeyPatch):
|
||
"""Ошибка прохода логируется, цикл не умирает и ждёт `MAPPING_POLL_SECONDS`."""
|
||
outcomes: list[Callable[[], bool]] = [lambda: (_ for _ in ()).throw(RuntimeError("сбой")), lambda: True]
|
||
sleeps: list[float] = []
|
||
|
||
async def fake_process_once(*args: Any, **kwargs: Any) -> bool:
|
||
return outcomes.pop(0)()
|
||
|
||
async def fake_sleep(seconds: float) -> None:
|
||
sleeps.append(seconds)
|
||
if not outcomes:
|
||
raise asyncio.CancelledError
|
||
|
||
monkeypatch.setattr(rm, "process_once", fake_process_once)
|
||
settings = _settings()
|
||
records: list[Any] = []
|
||
handler_id = logger.add(lambda message: records.append(message.record), level="ERROR")
|
||
try:
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await run_mapping_loop(None, FakeStorage(), settings, sleep=fake_sleep) # type: ignore[arg-type]
|
||
finally:
|
||
logger.remove(handler_id)
|
||
|
||
assert sleeps == [settings.MAPPING_POLL_SECONDS, 0]
|
||
assert len(records) == 1
|
||
|
||
|
||
# --- автозамена прежних версий (spec primary-files «Автозамена прежних версий», audit-trail; design D-10) ----------
|
||
|
||
OldDocument = tuple[str, int, PeriodQuarter, DocumentStatus] # счёт, год, квартал, статус документа прежнего файла
|
||
OSV_60_2025_Q1: OldDocument = ("60", 2025, PeriodQuarter.Q1, DocumentStatus.CONFIRMED)
|
||
|
||
|
||
async def _old_file( # noqa: PLR0913 - каждое поле прежнего файла задаётся тестом явно
|
||
maker: async_sessionmaker[AsyncSession],
|
||
entity_id: uuid.UUID,
|
||
documents: list[OldDocument],
|
||
*,
|
||
version_no: int = 1,
|
||
status: FileStatus = FileStatus.PROCESSED,
|
||
superseded_by: uuid.UUID | None = None,
|
||
) -> uuid.UUID:
|
||
"""Прежний файл ЮЛ с документами ОСВ и строкой ОСВ на каждый документ (история должна сохраниться)."""
|
||
async with maker() as session, session.begin():
|
||
entity = await session.get(LegalEntity, entity_id)
|
||
assert entity is not None
|
||
file, job = await make_stored_file(
|
||
session, entity=entity, status=status, job_status=RecognitionStatus.done, original_name="old.xls"
|
||
)
|
||
file.version_no = version_no
|
||
file.superseded_by_file_id = superseded_by
|
||
for account, year, quarter, document_status in documents:
|
||
document = await make_document(
|
||
session, file=file, job=job, account=account, year=year, quarter=quarter, status=document_status
|
||
)
|
||
session.add(
|
||
OsvEntry(
|
||
recognition_job_id=job.id,
|
||
document_id=document.id,
|
||
owner_legal_entity_id=entity_id,
|
||
row_number=1,
|
||
level=0,
|
||
row_value=account,
|
||
)
|
||
)
|
||
return file.id
|
||
|
||
|
||
async def _entity(
|
||
maker: async_sessionmaker[AsyncSession], name: str = OWNER_NAME, inn: str = "7700000000"
|
||
) -> uuid.UUID:
|
||
async with maker() as session, session.begin():
|
||
return (await make_legal_entity(session, name=name, inn=inn)).id
|
||
|
||
|
||
async def _any_user(maker: async_sessionmaker[AsyncSession]) -> uuid.UUID:
|
||
async with maker() as session:
|
||
return (await session.execute(select(AppUser.id).order_by(AppUser.email).limit(1))).scalar_one()
|
||
|
||
|
||
async def _replace_events(maker: async_sessionmaker[AsyncSession]) -> list[AuditEvent]:
|
||
async with maker() as session:
|
||
rows = await session.execute(
|
||
select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_REPLACE).order_by(AuditEvent.occurred_at)
|
||
)
|
||
return list(rows.scalars())
|
||
|
||
|
||
async def _active_files(maker: async_sessionmaker[AsyncSession], entity_id: uuid.UUID) -> set[uuid.UUID]:
|
||
"""Файлы ЮЛ, которые список файлов показывает по умолчанию (без `include_superseded`)."""
|
||
async with maker() as session:
|
||
rows = await session.execute(
|
||
select(StoredFile.id).where(
|
||
StoredFile.legal_entity_id == entity_id, StoredFile.status != FileStatus.SUPERSEDED
|
||
)
|
||
)
|
||
return set(rows.scalars())
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("old_status", "confirmed"),
|
||
[(DocumentStatus.CONFIRMED, "confirmed=true"), (DocumentStatus.RECOGNIZED, "confirmed=false")],
|
||
)
|
||
async def test_new_osv_60_supersedes_previous_file(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession], old_status: DocumentStatus, confirmed: str
|
||
):
|
||
"""«Новая ОСВ 60 за 1 кв. 2025»: прежний файл (документ `CONFIRMED` тоже, решение владельца 05.10.2026) —
|
||
`SUPERSEDED`, `superseded_by_file_id` = новый, у нового `version_no = 2`; `FILE_REPLACE` — системное событие
|
||
(`user_id` NULL, инициатор загрузки — в `description`); документы и строки прежнего файла сохранены."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
entity_id = await _entity(maker)
|
||
uploader = await _any_user(maker)
|
||
old_id = await _old_file(maker, entity_id, [("60", 2025, PeriodQuarter.Q1, old_status)])
|
||
async with maker() as session:
|
||
entity = await session.get(LegalEntity, entity_id)
|
||
new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=uploader)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
old, new = await _file(maker, old_id), await _file(maker, new_id)
|
||
assert old.status is FileStatus.SUPERSEDED
|
||
assert old.superseded_by_file_id == new_id
|
||
assert old.updated_at is not None
|
||
assert (new.status, new.version_no, new.superseded_by_file_id) == (FileStatus.PROCESSED, 2, None)
|
||
assert await _active_files(maker, entity_id) == {new_id}
|
||
[old_document] = await _documents(maker, old_id) # история: документ и строки прежнего файла на месте
|
||
assert old_document.status is old_status
|
||
assert len(await _entries(maker, old_document.id)) == 1
|
||
|
||
[event] = await _replace_events(maker)
|
||
assert event.user_id is None
|
||
assert event.section is AuditSection.PRIMARY_DOCUMENTS
|
||
assert (event.object_type, event.object_id) == ("stored_file", old_id)
|
||
assert event.after_value == {"file_id": str(new_id), "version_no": 2}
|
||
assert event.description is not None
|
||
assert f"user:{uploader}" in event.description
|
||
assert f"OSV 60 2025 Q1 ({old_status.value})" in event.description
|
||
assert confirmed in event.description
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"document",
|
||
[
|
||
("62", 2025, PeriodQuarter.Q1, DocumentStatus.CONFIRMED), # другой счёт
|
||
("60", 2024, PeriodQuarter.Q1, DocumentStatus.CONFIRMED), # тот же счёт, другой год
|
||
("60", 2025, PeriodQuarter.Q2, DocumentStatus.CONFIRMED), # тот же счёт, другой квартал
|
||
],
|
||
)
|
||
async def test_other_document_is_not_superseded(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession], document: OldDocument
|
||
):
|
||
"""Два разных счёта (или периода) не заменяют друг друга: прежний файл `PROCESSED`, у нового `version_no = 1`."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
entity_id = await _entity(maker)
|
||
old_id = await _old_file(maker, entity_id, [document])
|
||
async with maker() as session:
|
||
entity = await session.get(LegalEntity, entity_id)
|
||
new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
old, new = await _file(maker, old_id), await _file(maker, new_id)
|
||
assert (old.status, old.superseded_by_file_id) == (FileStatus.PROCESSED, None)
|
||
assert (new.status, new.version_no) == (FileStatus.PROCESSED, 1)
|
||
assert await _replace_events(maker) == []
|
||
|
||
|
||
async def test_other_legal_entity_is_not_superseded(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||
"""Тот же документ у другого ЮЛ не затрагивается: замена — только в пределах ЮЛ-владельца."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
other_id = await _entity(maker, name="ООО «Другое ЮЛ»", inn="7799999999")
|
||
old_id = await _old_file(maker, other_id, [OSV_60_2025_Q1])
|
||
new_id, _, _ = await _prepare(maker, storage, _minimal(), owner_inn="7700000000")
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
assert (await _file(maker, old_id)).status is FileStatus.PROCESSED
|
||
assert (await _file(maker, new_id)).version_no == 1
|
||
assert await _replace_events(maker) == []
|
||
|
||
|
||
async def test_supersede_is_idempotent(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
|
||
):
|
||
"""Сбой после автозамены откатывает и замену, и аудит; повтор маппинга даёт ровно одно `FILE_REPLACE`. Повторный
|
||
вызов `supersede_previous` для того же файла ничего не находит (заменённый уже `SUPERSEDED`)."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
entity_id = await _entity(maker)
|
||
old_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1])
|
||
async with maker() as session:
|
||
entity = await session.get(LegalEntity, entity_id)
|
||
new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity)
|
||
real_map_file = rm.map_file
|
||
failures = [RuntimeError("сбой БД после автозамены")]
|
||
|
||
async def flaky_map_file(session: AsyncSession, **kwargs: Any) -> list[str]:
|
||
warnings = await real_map_file(session, **kwargs)
|
||
if failures:
|
||
raise failures.pop()
|
||
return warnings
|
||
|
||
monkeypatch.setattr(rm, "map_file", flaky_map_file)
|
||
|
||
assert await process_once(maker, storage, _settings()) is False # повторяемая неудача: всё откачено
|
||
assert (await _file(maker, old_id)).status is FileStatus.PROCESSED
|
||
assert await _replace_events(maker) == []
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
assert (await _file(maker, old_id)).status is FileStatus.SUPERSEDED
|
||
assert len(await _replace_events(maker)) == 1
|
||
|
||
async with maker() as session, session.begin():
|
||
new = await session.get(StoredFile, new_id)
|
||
assert new is not None
|
||
documents = list((await session.execute(select(Document).where(Document.stored_file_id == new_id))).scalars())
|
||
replaced = await supersede_previous(
|
||
session, new_file=new, new_documents=documents, uploader_id=None, now=_now()
|
||
)
|
||
assert replaced == []
|
||
assert len(await _replace_events(maker)) == 1
|
||
assert (await _file(maker, new_id)).version_no == 2 # noqa: PLR2004 - версия не растёт от повторного вызова
|
||
|
||
|
||
async def test_version_chain(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||
"""Цепочка v1 → v2 → v3: третий файл заменяет только действующий v2 и получает `version_no = 3`; v1 остаётся
|
||
заменённым файлом v2."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
entity_id = await _entity(maker)
|
||
v2_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1], version_no=2)
|
||
v1_id = await _old_file(
|
||
maker, entity_id, [OSV_60_2025_Q1], version_no=1, status=FileStatus.SUPERSEDED, superseded_by=v2_id
|
||
)
|
||
async with maker() as session:
|
||
entity = await session.get(LegalEntity, entity_id)
|
||
v3_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
v1, v2, v3 = await _file(maker, v1_id), await _file(maker, v2_id), await _file(maker, v3_id)
|
||
assert (v1.status, v1.superseded_by_file_id) == (FileStatus.SUPERSEDED, v2_id)
|
||
assert (v2.status, v2.superseded_by_file_id) == (FileStatus.SUPERSEDED, v3_id)
|
||
assert v3.version_no == 3 # noqa: PLR2004 - третья версия
|
||
[event] = await _replace_events(maker)
|
||
assert event.object_id == v2_id
|
||
assert event.after_value == {"file_id": str(v3_id), "version_no": 3}
|
||
|
||
|
||
async def test_one_event_per_replaced_file(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||
"""Новый файл с ОСВ 60 и 62 заменяет прежний файл с теми же двумя документами одним событием `FILE_REPLACE`,
|
||
в `description` — оба документа."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
entity_id = await _entity(maker)
|
||
old_id = await _old_file(
|
||
maker,
|
||
entity_id,
|
||
[OSV_60_2025_Q1, ("62", 2025, PeriodQuarter.Q1, DocumentStatus.NEEDS_CHECK)],
|
||
)
|
||
result = _minimal()
|
||
second = copy.deepcopy(_osv(result)) | {"locator": "sheet:Лист_2"}
|
||
second["header"]["account_code_raw"] = "62"
|
||
result["documents"].append(second)
|
||
async with maker() as session:
|
||
entity = await session.get(LegalEntity, entity_id)
|
||
new_id, _, _ = await _prepare(maker, storage, result, entity=entity)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
assert (await _file(maker, old_id)).superseded_by_file_id == new_id
|
||
assert len(await _documents(maker, new_id)) == 2 # noqa: PLR2004 - два листа, два счёта
|
||
[event] = await _replace_events(maker)
|
||
assert event.object_id == old_id
|
||
assert event.description is not None
|
||
assert "OSV 60 2025 Q1 (CONFIRMED)" in event.description
|
||
assert "OSV 62 2025 Q1 (NEEDS_CHECK)" in event.description
|
||
assert "confirmed=true" in event.description
|
||
|
||
|
||
async def test_new_file_supersedes_several_files(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||
"""Один новый файл (ОСВ 60, 62, 66) заменяет три разных прежних файла: событие `FILE_REPLACE` на каждый заменённый
|
||
файл, у нового `version_no` = МАКСИМУМ заменённых + 1. Версии раздаются по возрастанию `id` как 1, 3, 2: максимум —
|
||
в середине порядка блокировки, поэтому ни первый, ни последний заблокированный файл его не дают."""
|
||
maker, storage = committed_sessionmaker, FakeStorage()
|
||
entity_id = await _entity(maker)
|
||
old_ids = [
|
||
await _old_file(maker, entity_id, [(account, 2025, PeriodQuarter.Q1, DocumentStatus.RECOGNIZED)])
|
||
for account in ("60", "62", "66")
|
||
]
|
||
ordered = sorted(old_ids)
|
||
versions = dict(zip(ordered, (1, 3, 2), strict=True))
|
||
async with maker() as session, session.begin():
|
||
for file_id, version_no in versions.items():
|
||
file = await session.get(StoredFile, file_id)
|
||
assert file is not None
|
||
file.version_no = version_no
|
||
result = _minimal()
|
||
for number, account in enumerate(("62", "66"), start=2):
|
||
sheet = copy.deepcopy(_osv(result)) | {"locator": f"sheet:Лист_{number}"}
|
||
sheet["header"]["account_code_raw"] = account
|
||
result["documents"].append(sheet)
|
||
async with maker() as session:
|
||
entity = await session.get(LegalEntity, entity_id)
|
||
new_id, _, _ = await _prepare(maker, storage, result, entity=entity)
|
||
|
||
assert await process_once(maker, storage, _settings()) is True
|
||
|
||
expected_version = 4 # max(1, 3, 2) + 1
|
||
for old_id in old_ids:
|
||
old = await _file(maker, old_id)
|
||
assert (old.status, old.superseded_by_file_id) == (FileStatus.SUPERSEDED, new_id)
|
||
assert (await _file(maker, new_id)).version_no == expected_version
|
||
events = await _replace_events(maker)
|
||
assert sorted(event.object_id for event in events) == ordered # type: ignore[type-var]
|
||
for event in events:
|
||
assert event.user_id is None
|
||
assert event.after_value == {"file_id": str(new_id), "version_no": expected_version}
|