diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index a6ccb9a..d1630fb 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -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) diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index d05c3a0..45d4148 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -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 ): diff --git a/openspec/specs/primary-files/spec.md b/openspec/specs/primary-files/spec.md index 69fb719..d7a06dd 100644 --- a/openspec/specs/primary-files/spec.md +++ b/openspec/specs/primary-files/spec.md @@ -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,