feat(mapping): автозамена прежних версий файла после маппинга (FILE_REPLACE)

`supersede_previous` вызывается в `map_file` после `PROCESSED`, в той же транзакции (D-10):
файлы того же ЮЛ (не SUPERSEDED, не сам новый) с документом тех же type/счёт/год/квартал →
SUPERSEDED, `superseded_by_file_id`, `updated_at`; новому — `version_no` = max + 1;
одно системное событие `FILE_REPLACE` на заменённый файл (user_id NULL, инициатор загрузки,
перечень документов и `confirmed=true|false` в description). Документы и строки заменённых
файлов сохраняются. Автозамены одного ЮЛ сериализуются advisory-блокировкой, заменяемые
файлы блокируются FOR UPDATE в порядке id, документы перечитываются после блокировки.

Тесты: сценарий спеки «Новая ОСВ 60 за 1 кв. 2025» (CONFIRMED и RECOGNIZED), другой
счёт/год/квартал, другое ЮЛ, идемпотентность после сбоя, цепочка v1→v2→v3, одно событие на файл.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 23:06:37 +04:00
parent 67e255d6c2
commit b62cec83ba
4 changed files with 395 additions and 10 deletions

View File

@ -11,13 +11,14 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn
3. транзакция маппинга: задание и файл перечитываются под блокировкой (порядок «задание → файл», как у inbox, 3. транзакция маппинга: задание и файл перечитываются под блокировкой (порядок «задание → файл», как у inbox,
вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат
старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла, старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла,
документы и строки, `group_schema`, предупреждения, `PROCESSED`. документы и строки, `group_schema`, предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`,
D-10, аудит `FILE_REPLACE`).
Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов ОСВ) Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов ОСВ)
— сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции — сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции
`metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`; следующая попытка — после паузы цикла (R-15). `metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`; следующая попытка — после паузы цикла (R-15).
Статус файла — через `services/file_status.py` (только текущее задание). `audit_event` не пишется: обмен и маппинг Статус файла — через `services/file_status.py` (только текущее задание). `audit_event` не пишется: обмен и маппинг
не аудируются (ASSUMPTION(OQ-55)); `FILE_REPLACE` автозамены — отдельно. не аудируются (ASSUMPTION(OQ-55)); `FILE_REPLACE` автозамены — системное событие (`user_id = NULL`).
""" """
import asyncio import asyncio
@ -31,7 +32,7 @@ from typing import Any
from loguru import logger from loguru import logger
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import Integer, delete, func, insert, select from sqlalchemy import Integer, Select, delete, func, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.contract.result import OsvDocument, RecognitionResult from src.contract.result import OsvDocument, RecognitionResult
@ -48,7 +49,10 @@ from src.domain.osv_mapping import (
from src.models.accounting import ChartOfAccount from src.models.accounting import ChartOfAccount
from src.models.directories import LegalEntity from src.models.directories import LegalEntity
from src.models.enums import ( from src.models.enums import (
AuditAction,
AuditSection,
DocumentCheckResult, DocumentCheckResult,
DocumentStatus,
DocumentType, DocumentType,
FileStatus, FileStatus,
OsvDataType, OsvDataType,
@ -59,6 +63,7 @@ from src.models.enums import (
from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry from src.models.osv import OsvEntry
from src.repositories.counterparties import CounterpartyIndex from src.repositories.counterparties import CounterpartyIndex
from src.services.audit import AuditService
from src.services.file_status import set_file_status_for_job from src.services.file_status import set_file_status_for_job
__all__ = [ __all__ = [
@ -72,6 +77,7 @@ __all__ = [
"pick_candidate", "pick_candidate",
"process_once", "process_once",
"run_mapping_loop", "run_mapping_loop",
"supersede_previous",
"validate_result_key", "validate_result_key",
] ]
@ -85,6 +91,8 @@ MAPPABLE_JOB_STATUSES = (RecognitionStatus.done, RecognitionStatus.needs_check)
CUMULATIVE_CHECK_QUARTERS = frozenset({PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4}) CUMULATIVE_CHECK_QUARTERS = frozenset({PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4})
RESOLVED_DATA_TYPES = frozenset({OsvDataType.BU, OsvDataType.SUM}) # резолв контрагента — §3 п. 4, R-3 RESOLVED_DATA_TYPES = frozenset({OsvDataType.BU, OsvDataType.SUM}) # резолв контрагента — §3 п. 4, R-3
ROOT_LEVEL = 0 ROOT_LEVEL = 0
STORED_FILE_OBJECT_TYPE = "stored_file" # `audit_event.object_type` события FILE_REPLACE
SUPERSEDE_LOCK_PREFIX = "supersede" # пространство ключей advisory-блокировки автозамены в пределах ЮЛ
_FORBIDDEN_KEY_SEGMENTS = frozenset({"", ".", ".."}) _FORBIDDEN_KEY_SEGMENTS = frozenset({"", ".", ".."})
Clock = Callable[[], datetime] Clock = Callable[[], datetime]
@ -315,11 +323,11 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай
index: CounterpartyIndex, index: CounterpartyIndex,
now: datetime, now: datetime,
) -> list[str]: ) -> list[str]:
"""Маппинг результата в транзакции вызывающего (D-3, D-6…D-9); возвращает предупреждения маппинга файла. """Маппинг результата в транзакции вызывающего (D-3, D-6…D-10); возвращает предупреждения маппинга файла.
Очистка прежних документов/строк файла → документ на каждый лист ОСВ (повтор счёта — предупреждение) → строки Очистка прежних документов/строк файла → документ на каждый лист ОСВ (повтор счёта — предупреждение) → строки
пакетами по `ENTRY_BATCH_SIZE` → `job.group_schema`, `metrics.mapping_warnings` → файл `PROCESSED` (только из пакетами по `ENTRY_BATCH_SIZE` → `job.group_schema`, `metrics.mapping_warnings` → файл `PROCESSED` (только из
`PROCESSING` и только для текущего задания). Без commit. `PROCESSING` и только для текущего задания) → автозамена прежних версий (`supersede_previous`). Без commit.
""" """
sheets = _osv_documents(result) sheets = _osv_documents(result)
if not sheets: if not sheets:
@ -408,9 +416,128 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай
): ):
raise RuntimeError(f"файл {file.id} не переведён в PROCESSED: не PROCESSING или задание {job.id} не текущее") raise RuntimeError(f"файл {file.id} не переведён в PROCESSED: не PROCESSING или задание {job.id} не текущее")
await session.refresh(file, ["status", "updated_at"]) await session.refresh(file, ["status", "updated_at"])
await supersede_previous(session, new_file=file, new_documents=documents, uploader_id=file.uploaded_by, now=now)
return warnings return warnings
# --- автозамена прежних версий --------------------------------------------------------------------------------------
DocumentKey = tuple[DocumentType, str | None, int, PeriodQuarter]
def _document_key(document: Document) -> DocumentKey | None:
"""Ключ совпадения документов для автозамены (D-10); документ без года или квартала в замене не участвует."""
if document.period_year is None or document.period_quarter is None:
return None
return (document.document_type, document.osv_account_code, document.period_year, document.period_quarter)
def _describe_document(document: Document) -> str:
period = f"{document.period_year} {document.period_quarter.value if document.period_quarter else ''}".strip()
return f"{document.document_type.value} {document.osv_account_code or ''} {period} ({document.status.value})"
async def supersede_previous(
session: AsyncSession,
*,
new_file: StoredFile,
new_documents: Sequence[Document],
uploader_id: uuid.UUID | None,
now: datetime,
) -> list[StoredFile]:
"""D-10, spec primary-files «Автозамена прежних версий»: файлы того же ЮЛ (не `SUPERSEDED`, не сам новый) с
документом тех же `document_type`, `osv_account_code`, `period_year`, `period_quarter` → `SUPERSEDED`,
`superseded_by_file_id = new_file.id`; у нового файла `version_no` = максимум заменённых + 1. На каждый
заменённый файл — одно системное событие `FILE_REPLACE` (`user_id = NULL`, инициатор загрузки — в `description`,
`confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение владельца 05.10.2026). Документы и
строки заменённых файлов не трогаются (история). Возвращает заменённые файлы; в транзакции вызывающего, без commit.
Блокировки: advisory-блокировка ЮЛ сериализует автозамены одного ЮЛ (два новых файла одного документа, смапленные
параллельно, иначе не увидели бы друг друга и оба остались бы действующими); заменяемые файлы — `FOR UPDATE` в
порядке `id` (сериализация с подтверждением документа, которое перечитывает файл под `FOR UPDATE`).
"""
keys = {key for key in map(_document_key, new_documents) if key is not None}
if not keys:
return []
lock_key = func.hashtextextended(f"{SUPERSEDE_LOCK_PREFIX}:{new_file.legal_entity_id}", 0)
await session.execute(select(func.pg_advisory_xact_lock(lock_key)))
def matching(stmt: Select[tuple[Document]]) -> Select[tuple[Document]]:
return stmt.join(StoredFile, StoredFile.id == Document.stored_file_id).where(
StoredFile.legal_entity_id == new_file.legal_entity_id,
StoredFile.id != new_file.id,
StoredFile.status != FileStatus.SUPERSEDED,
Document.document_type.in_(sorted({key[0] for key in keys})),
Document.period_year.in_(sorted({key[2] for key in keys})),
)
candidates = (await session.execute(matching(select(Document)))).scalars().all()
file_ids = sorted({document.stored_file_id for document in candidates if _document_key(document) in keys})
if not file_ids:
return []
locked = (
(
await session.execute(
select(StoredFile)
.where(StoredFile.id.in_(file_ids), StoredFile.status != FileStatus.SUPERSEDED)
.order_by(StoredFile.id)
.with_for_update()
.execution_options(populate_existing=True)
)
)
.scalars()
.all()
)
if not locked:
return []
ids = [old.id for old in locked]
# документы перечитываются ПОСЛЕ блокировки файлов: подтверждение, зафиксированное до неё, попадёт в `confirmed`
fresh = await session.execute(
matching(select(Document)).where(Document.stored_file_id.in_(ids)).execution_options(populate_existing=True)
)
replaced_documents: dict[uuid.UUID, list[Document]] = {}
for document in fresh.scalars():
if _document_key(document) in keys:
replaced_documents.setdefault(document.stored_file_id, []).append(document)
await session.execute(
update(StoredFile)
.where(StoredFile.id.in_(ids), StoredFile.status != FileStatus.SUPERSEDED)
.values(status=FileStatus.SUPERSEDED, superseded_by_file_id=new_file.id, updated_at=now)
.execution_options(synchronize_session=False)
)
new_file.version_no = max(old.version_no for old in locked) + 1
new_file.updated_at = now
await session.flush()
audit = AuditService(session)
uploader = f"user:{uploader_id}" if uploader_id is not None else "неизвестен"
for old in locked:
await session.refresh(old, ["status", "superseded_by_file_id", "updated_at"])
documents = sorted(replaced_documents.get(old.id, []), key=_describe_document)
confirmed = any(document.status is DocumentStatus.CONFIRMED for document in documents)
await audit.record(
action=AuditAction.FILE_REPLACE,
section=AuditSection.PRIMARY_DOCUMENTS,
object_type=STORED_FILE_OBJECT_TYPE,
object_id=old.id,
user_id=None,
after_value={"file_id": new_file.id, "version_no": new_file.version_no},
description=(
f"заменён файлом {new_file.original_name} (загрузил {uploader}); "
f"документы: {', '.join(map(_describe_document, documents))}; "
f"confirmed={'true' if confirmed else 'false'}"
),
)
logger.info(
"Автозамена: прежние версии файла заменены. file_id={file_id}, version_no={version_no}, replaced={replaced}",
file_id=new_file.id,
version_no=new_file.version_no,
replaced=[str(old_id) for old_id in ids],
)
return list(locked)
# --- проход и цикл -------------------------------------------------------------------------------------------------- # --- проход и цикл --------------------------------------------------------------------------------------------------

View File

@ -24,9 +24,13 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.core.config import Settings, get_settings from src.core.config import Settings, get_settings
from src.models.access import AppUser
from src.models.accounting import ChartOfAccount from src.models.accounting import ChartOfAccount
from src.models.audit_ex import AuditEvent
from src.models.directories import LegalEntity from src.models.directories import LegalEntity
from src.models.enums import ( from src.models.enums import (
AuditAction,
AuditSection,
DocumentCheckResult, DocumentCheckResult,
DocumentStatus, DocumentStatus,
DocumentType, DocumentType,
@ -47,6 +51,7 @@ from src.services.result_mapping import (
MappingFatal, MappingFatal,
process_once, process_once,
run_mapping_loop, run_mapping_loop,
supersede_previous,
validate_result_key, validate_result_key,
) )
from tests.conftest import FakeStorage from tests.conftest import FakeStorage
@ -104,13 +109,16 @@ async def _prepare( # noqa: PLR0913 - каждое поле подготовк
job_status: RecognitionStatus = RecognitionStatus.done, job_status: RecognitionStatus = RecognitionStatus.done,
file_status: FileStatus = FileStatus.PROCESSING, file_status: FileStatus = FileStatus.PROCESSING,
key: str | None = None, key: str | None = None,
uploaded_by: uuid.UUID | None = None,
) -> tuple[uuid.UUID, uuid.UUID, uuid.UUID]: ) -> tuple[uuid.UUID, uuid.UUID, uuid.UUID]:
"""ЮЛ-владелец, файл `PROCESSING` с текущим заданием `done`, результат в `storage`; `(file_id, job_id, entity_id)`. """ЮЛ-владелец, файл `PROCESSING` с текущим заданием `done`, результат в `storage`; `(file_id, job_id, entity_id)`.
`job_id`/`file_id` результата подставляются; `key` — подменить ключ `s3_object` результата (OQ-70).""" `job_id`/`file_id` результата подставляются; `key` — подменить ключ `s3_object` результата (OQ-70)."""
async with maker() as session, session.begin(): async with maker() as session, session.begin():
owner = entity or await make_legal_entity(session, name=owner_name, inn=owner_inn) 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) 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.finished_at = _now()
job.metrics = copy.deepcopy(result.get("metrics")) # как inbox: метрики сервиса из конверта job.metrics = copy.deepcopy(result.get("metrics")) # как inbox: метрики сервиса из конверта
payload = copy.deepcopy(result) | {"job_id": str(job.id), "file_id": str(file.id)} payload = copy.deepcopy(result) | {"job_id": str(job.id), "file_id": str(file.id)}
@ -722,3 +730,253 @@ async def test_run_mapping_loop_survives_errors(monkeypatch: pytest.MonkeyPatch)
assert sleeps == [settings.MAPPING_POLL_SECONDS, 0] assert sleeps == [settings.MAPPING_POLL_SECONDS, 0]
assert len(records) == 1 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

View File

@ -247,9 +247,9 @@ async def supersede_previous(session, *, new_file: StoredFile, new_documents: Se
``` ```
Вызывается в `map_file` после установки `PROCESSED`, в той же транзакции. Вызывается в `map_file` после установки `PROCESSED`, в той же транзакции.
- [ ] **Step 1: Тесты:** сценарий спеки «Новая ОСВ 60 за 1 кв. 2025» (старый `CONFIRMED` → `SUPERSEDED`, `version_no = 2`, аудит с `confirmed=true`); два разных счёта не заменяют друг друга; тот же счёт другого года не заменяет; заменённый файл не появляется в `GET files` без `include_superseded` (проверяется в Task 9, здесь — по БД). - [x] **Step 1: Тесты:** сценарий спеки «Новая ОСВ 60 за 1 кв. 2025» (старый `CONFIRMED` → `SUPERSEDED`, `version_no = 2`, аудит с `confirmed=true`); два разных счёта не заменяют друг друга; тот же счёт другого года не заменяет; заменённый файл не появляется в `GET files` без `include_superseded` (проверяется в Task 9, здесь — по БД).
- [ ] **Step 2–4:** падает → реализация → тесты, `make check`. - [x] **Step 2–4:** падает → реализация → тесты, `make check`.
- [ ] **Step 5: Commit** `feat(mapping): автозамена прежних версий файла после маппинга (FILE_REPLACE)`. - [x] **Step 5: Commit** `feat(mapping): автозамена прежних версий файла после маппинга (FILE_REPLACE)`.
--- ---

View File

@ -41,7 +41,7 @@
повторы `mapping_attempts` → `ERROR`, `run_mapping_loop` в lifespan (`ExchangeTasks`). Интеграционные тесты: сценарии спеки повторы `mapping_attempts` → `ERROR`, `run_mapping_loop` в lifespan (`ExchangeTasks`). Интеграционные тесты: сценарии спеки
`osv-result-mapping` на `FakeStorage` с golden JSON (`ОСВ_66`), ключ вне папки, сбой с повтором (monkeypatch), идемпотентность, `osv-result-mapping` на `FakeStorage` с golden JSON (`ОСВ_66`), ключ вне папки, сбой с повтором (monkeypatch), идемпотентность,
два листа одного счёта, MISMATCH шапки, нарастающая по Q1, RECOGNIZED. два листа одного счёта, MISMATCH шапки, нарастающая по Q1, RECOGNIZED.
- [ ] 3.3 **Автозамена.** В `map_file` после `PROCESSED`: поиск прежних файлов по документам, `SUPERSEDED`, `version_no`, аудит - [x] 3.3 **Автозамена.** В `map_file` после `PROCESSED`: поиск прежних файлов по документам, `SUPERSEDED`, `version_no`, аудит
`FILE_REPLACE` (системное событие). Тесты: сценарий спеки (CONFIRMED заменяется, `confirmed=true` в description), два `FILE_REPLACE` (системное событие). Тесты: сценарий спеки (CONFIRMED заменяется, `confirmed=true` в description), два
разных счёта не заменяют друг друга, `include_superseded`. разных счёта не заменяют друг друга, `include_superseded`.
- [ ] 3.4 **Golden-прогон.** `tests/fixtures/test_mapping_golden.py` (`fixtures`): все `../fixtures/golden/expected_osv_parse/**/*.json` - [ ] 3.4 **Golden-прогон.** `tests/fixtures/test_mapping_golden.py` (`fixtures`): все `../fixtures/golden/expected_osv_parse/**/*.json`