Merge pull request 'AURORA-1644: автозамена не вытесняет документ другой организации (#181)' (#185) from feat/181-supersede-inn-guard into main
This commit is contained in:
commit
ee43d5200a
@ -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)
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user