AURORA-1644 автозамена не вытесняет документ другой организации (#181)

Пара «новый документ — прежний» с counterparty_check = MISMATCH у любого из двух
в автозамене не участвует: сканы разных организаций в одной карточке больше не
вытесняют друг друга. UNKNOWN заменяется как прежде. Если файл оставлен только
из-за MISMATCH, в mapping_warnings нового файла — «автозамена не выполнена»
с именем и версией оставленного файла. Спека primary-files дополнена.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 00:54:27 +04:00
parent ed0052bad0
commit 353b562dfc
3 changed files with 143 additions and 7 deletions

View File

@ -771,6 +771,12 @@ def _document_key(document: Document) -> DocumentKey | None:
return (document.document_type, document.osv_account_code, document.period_year, document.period_quarter)
def _replaceable(document: Document, keys: set[DocumentKey]) -> bool:
"""Документ участвует в автозамене: ключ среди ключей нового файла и шапка не другой организации (AURORA-1644,
§9.5 п. 3: пара с `MISMATCH` у любой стороны не заменяется; `UNKNOWN` — как прежде)."""
return _document_key(document) in keys and document.counterparty_check is not DocumentCheckResult.MISMATCH
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})"
@ -821,6 +827,34 @@ def _replace_warning(old: StoredFile, dropped: Sequence[Document], version_label
return text
def _skip_warning(old: StoredFile, documents: Sequence[Document]) -> str:
"""Предупреждение нового файла: файл `old` с документами того же ключа оставлен, потому что у нового или прежнего
документа организация в шапке не совпадает с карточкой (AURORA-1644)."""
return (
f"автозамена не выполнена: файл «{old.original_name}» (версия {old.version_no}) оставлен рядом с этим — "
f"у нового или прежнего документа организация в шапке не совпадает с организацией карточки: "
f"{', '.join(sorted(map(_describe_document, documents)))}"
)
async def _skip_warnings(
session: AsyncSession,
candidates: Sequence[Document],
all_keys: set[DocumentKey],
replaced_file_ids: set[uuid.UUID],
) -> list[str]:
"""Предупреждения о файлах с документами того же ключа, которые автозамена оставила из-за `MISMATCH`
(AURORA-1644): по одному на файл, в порядке `id`."""
skipped: dict[uuid.UUID, list[Document]] = {}
for document in candidates:
if document.stored_file_id not in replaced_file_ids and _document_key(document) in all_keys:
skipped.setdefault(document.stored_file_id, []).append(document)
if not skipped:
return []
kept = await session.execute(select(StoredFile).where(StoredFile.id.in_(sorted(skipped))).order_by(StoredFile.id))
return [_skip_warning(old, skipped[old.id]) for old in kept.scalars()]
async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена одной транзакцией: входы и шаги именованные
session: AsyncSession,
*,
@ -848,14 +882,24 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена
передан) добавляется по одному предупреждению на заменённый файл: имя файла, выпавшие документы (тип, счёт,
период, статус) и версии консолидации, с которых сняты привязки этих документов. Поведение замены не меняется.
AURORA-1644: пара «новый — прежний» с `counterparty_check = MISMATCH` у любого из двух в замене не участвует
(`_replaceable`) — документы другой организации, загруженные в карточку, не вытесняют друг друга. Файл, оставленный
только из-за этого, — предупреждение в `warnings` (`_skip_warning`); `UNKNOWN` заменяется как прежде.
Блокировки: advisory-блокировка ЮЛ сериализует автозамены одного ЮЛ (два новых файла одного документа, смапленные
параллельно, иначе не увидели бы друг друга и оба остались бы действующими); заменяемые файлы — `FOR UPDATE` в
порядке `id` (сериализация с подтверждением документа, которое перечитывает файл под `FOR UPDATE`); затем
advisory ГК и версии консолидации по `id` (D-9, R-2).
"""
keys = {key for key in map(_document_key, new_documents) if key is not None}
if not keys:
all_keys = {key for key in map(_document_key, new_documents) if key is not None}
if not all_keys:
return []
keys = { # ключи, которыми новый файл может заменять: без документов другой организации (AURORA-1644)
key
for document in new_documents
if (key := _document_key(document)) is not None
and document.counterparty_check is not DocumentCheckResult.MISMATCH
}
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)))
@ -864,12 +908,14 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена
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})),
Document.document_type.in_(sorted({key[0] for key in all_keys})),
Document.period_year.in_(sorted({key[2] for key in all_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})
file_ids = sorted({document.stored_file_id for document in candidates if _replaceable(document, keys)})
if warnings is not None:
warnings.extend(await _skip_warnings(session, candidates, all_keys, set(file_ids)))
if not file_ids:
return []
locked = (
@ -894,7 +940,7 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена
)
replaced_documents: dict[uuid.UUID, list[Document]] = {}
for document in fresh.scalars():
if _document_key(document) in keys:
if _replaceable(document, keys):
replaced_documents.setdefault(document.stored_file_id, []).append(document)
await session.execute(
update(StoredFile)

View File

@ -850,8 +850,10 @@ async def _old_file( # noqa: PLR0913 - каждое поле прежнего
version_no: int = 1,
status: FileStatus = FileStatus.PROCESSED,
superseded_by: uuid.UUID | None = None,
counterparty_check: DocumentCheckResult | None = None,
) -> uuid.UUID:
"""Прежний файл ЮЛ с документами ОСВ и строкой ОСВ на каждый документ (история должна сохраниться)."""
"""Прежний файл ЮЛ с документами ОСВ и строкой ОСВ на каждый документ (история должна сохраниться);
`counterparty_check` — у всех документов файла."""
async with maker() as session, session.begin():
entity = await session.get(LegalEntity, entity_id)
assert entity is not None
@ -864,6 +866,7 @@ async def _old_file( # noqa: PLR0913 - каждое поле прежнего
document = await make_document(
session, file=file, job=job, account=account, year=year, quarter=quarter, status=document_status
)
document.counterparty_check = counterparty_check
session.add(
OsvEntry(
recognition_job_id=job.id,
@ -990,6 +993,73 @@ async def test_other_legal_entity_is_not_superseded(committed_sessionmaker: asyn
assert await _replace_events(maker) == []
FOREIGN_INN = "5643007516" # ИНН другой организации в шапке (AURORA-1644: скан чужого баланса в карточке ЮЛ)
@pytest.mark.parametrize(
("old_check", "new_inn"),
[
(DocumentCheckResult.MATCH, FOREIGN_INN), # новый документ — другой организации
(DocumentCheckResult.MISMATCH, "7700000000"), # прежний документ — другой организации
(DocumentCheckResult.MISMATCH, FOREIGN_INN), # оба — не организации карточки
],
)
async def test_mismatch_pair_is_not_superseded(
committed_sessionmaker: async_sessionmaker[AsyncSession], old_check: DocumentCheckResult, new_inn: str
):
"""AURORA-1644: пара «новый — прежний» с `counterparty_check = MISMATCH` у любого из двух в автозамене не
участвует: прежний файл остаётся `PROCESSED` рядом с новым, у нового `version_no = 1`, `FILE_REPLACE` нет;
в `mapping_warnings` нового файла — предупреждение с именем и версией оставленного файла."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id = await _entity(maker)
old_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1], counterparty_check=old_check)
async with maker() as session:
entity = await session.get(LegalEntity, entity_id)
result = _minimal()
_osv(result)["header"]["organization_inn"] = new_inn
new_id, new_job_id, _ = await _prepare(maker, storage, result, 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 _active_files(maker, entity_id) == {old_id, new_id}
assert await _replace_events(maker) == []
[warning] = [w for w in await _mapping_warnings(maker, new_job_id) if w.startswith("автозамена")]
assert warning.startswith("автозамена не выполнена: файл «old.xls» (версия 1) оставлен")
assert "OSV 60 2025 Q1 (CONFIRMED)" in warning
@pytest.mark.parametrize(
("old_check", "new_header"),
[
(DocumentCheckResult.UNKNOWN, {"organization_inn": "7700000000"}), # у прежнего шапка пустая
(DocumentCheckResult.MATCH, {"organization_inn": None, "organization_name": "ООО"}), # у нового пустая
],
)
async def test_unknown_header_still_supersedes(
committed_sessionmaker: async_sessionmaker[AsyncSession],
old_check: DocumentCheckResult,
new_header: dict[str, Any],
):
"""Пустая шапка (`UNKNOWN`) — не несовпадение: пара заменяется как раньше, предупреждения о пропуске нет."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id = await _entity(maker)
old_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1], counterparty_check=old_check)
async with maker() as session:
entity = await session.get(LegalEntity, entity_id)
result = _minimal()
_osv(result)["header"].update(new_header)
new_id, new_job_id, _ = await _prepare(maker, storage, result, 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, new.version_no) == (FileStatus.SUPERSEDED, new_id, 2)
assert not [w for w in await _mapping_warnings(maker, new_job_id) if w.startswith("автозамена не выполнена")]
async def test_supersede_is_idempotent(
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
):

View File

@ -91,6 +91,12 @@ Query-параметр `consolidation_version_id` MUST NOT приниматьс
`JobBrief.mapping_warnings`) по одному предупреждению на заменённый файл (OQ-154): имя и версия заменённого файла,
выпавшие документы (тип, счёт, период, статус — в т. ч. `CONFIRMED`) и версии консолидации «в работе», с которых сняты
привязки этих документов. Поведение замены и статусы документов нового файла от предупреждения не меняются.
Пара «новый документ — прежний» с `counterparty_check = MISMATCH` у любого из двух SHALL NOT участвовать в замене
(AURORA-1644, решение владельца 21.10.2026): документы другой организации, загруженные в карточку ЮЛ, не вытесняют друг
друга и документы организации карточки. `UNKNOWN` (пустая шапка) несовпадением не считается. Если у ЮЛ есть файл с
документом того же ключа, оставленный только из-за `MISMATCH`, ядро SHALL добавить в `metrics.mapping_warnings` нового
файла по одному предупреждению на такой файл: «автозамена не выполнена», имя и версия оставленного файла, его документы
этого ключа (тип, счёт, период, статус).
#### Scenario: Новая ОСВ 60 за 1 кв. 2025
- **WHEN** у ЮЛ есть `PROCESSED` файл с документом OSV 60 / 2025 / Q1 (`CONFIRMED`), загружен и смапплен новый файл с таким же документом
@ -117,6 +123,20 @@ Query-параметр `consolidation_version_id` MUST NOT приниматьс
- **WHEN** прежний файл в консолидации 2025 Q1 (`C1`), новый с тем же документом загружен в консолидацию 2025 Q2 (`C2`)
- **THEN** у нового файла `consolidation_id = C2`
#### Scenario: Документы разных организаций в одной карточке
- **WHEN** в карточку ЮЛ (ИНН 6609493015) последовательно загружены и смаплены три скана ББ за 2026 Q2 разных
организаций (в шапках другие ИНН, `counterparty_check = MISMATCH`)
- **THEN** ни один файл не `SUPERSEDED`, список файлов ЮЛ показывает все три, у каждого `version_no = 1`, `FILE_REPLACE`
нет; у второго и третьего в `metrics.mapping_warnings` — «автозамена не выполнена» с именами и версиями оставленных файлов
#### Scenario: Документ организации карточки не вытесняет чужой
- **WHEN** у ЮЛ есть файл с ББ 2026 Q2 `MISMATCH`, смаплен новый файл с ББ 2026 Q2 `MATCH`
- **THEN** прежний файл остаётся `PROCESSED`, у нового `version_no = 1` и предупреждение «автозамена не выполнена»
#### Scenario: Пустая шапка заменяется как прежде
- **WHEN** у прежнего или нового документа того же ключа `counterparty_check = UNKNOWN`, у другого — не `MISMATCH`
- **THEN** прежний файл — `SUPERSEDED`, у нового `version_no = 2`, предупреждения «автозамена не выполнена» нет
### Requirement: Удаление файла
`DELETE /api/v1/files/{file_id}` (`DOCUMENT_EDIT`, policy) SHALL удалить строки ОСВ, документы, задания и `stored_file`,
проставить `s3_object.expires_at = now()` (объект физически не удаляется, OQ-22) и записать `audit_event(FILE_DELETE,