Merge pull request 'Автозамена: предупреждение о выпавших документах (#58)' (#60) from feat/58-replace-warning into main

Reviewed-on: #60
This commit is contained in:
aep 2026-10-07 23:36:22 +03:00
commit d8bf5b22dc
4 changed files with 289 additions and 8 deletions

View File

@ -592,7 +592,11 @@ S3 (storage); без S3 — предупреждение в логе, цикл
заменённых файлов остаются (история), но список файлов их скрывает. Заменяется и файл с `CONFIRMED` документом (решение
владельца 05.10.2026); системное событие `FILE_REPLACE` (`user_id` пуст) фиксирует перечень документов и `confirmed=true`.
Документ без года или квартала в замене не участвует. Если новый файл содержит лишь часть документов прежнего, прежний
уходит в `SUPERSEDED` целиком (OQ-115). Автозамены одного ЮЛ идут под advisory-блокировкой.
уходит в `SUPERSEDED` целиком (OQ-115), но последствия видны: в `metrics.mapping_warnings` задания нового файла
(`mapping_warnings` карточки файла, `JobBrief`) добавляется предупреждение на каждый заменённый файл — его имя и версия,
выпавшие документы (тип, счёт, период, статус, в т. ч. `CONFIRMED`) и версии консолидации «в работе», с которых сняты
привязки этих документов (OQ-154; статусы документов нового файла от него не зависят). Автозамены одного ЮЛ идут под
advisory-блокировкой.
**Удаление.** `DELETE /files/{id}` удаляет формы ББ/ОФР (шапки, колонки, строки, значения), комментарии документов, строки ОСВ, документы, задания и `stored_file`, проставляет объектам S3
(`uploads`, `results`) из папок этого файла (`uploads/{file_id}/`, `results/{file_id}/`) `expires_at = now()`

View File

@ -51,7 +51,8 @@ from src.domain.osv_mapping import (
from src.domain.statement_checks import load_check_rules, run_checks
from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok
from src.models.accounting import ChartOfAccount
from src.models.directories import LegalEntity
from src.models.consolidation import Consolidation, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity
from src.models.enums import (
AuditAction,
AuditSection,
@ -443,7 +444,8 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай
Очистка прежних документов/строк файла → документ на каждый лист ОСВ (повтор счёта — предупреждение) и на каждую
форму ББ/ОФР (`_map_statement_docs`) → строки ОСВ пакетами по `ENTRY_BATCH_SIZE` → вставка форм и их статус →
`job.group_schema`, `metrics.mapping_warnings` → файл `PROCESSED` (только из
`PROCESSING` и только для текущего задания) → автозамена прежних версий (`supersede_previous`). Без commit.
`PROCESSING` и только для текущего задания) → автозамена прежних версий (`supersede_previous`; её предупреждение
о выпавших документах дописывается в `metrics.mapping_warnings`, OQ-154). Без commit.
"""
if not result.documents:
raise MappingFatal(NO_DOCUMENTS)
@ -547,7 +549,39 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай
):
raise RuntimeError(f"файл {file.id} не переведён в PROCESSED: не PROCESSING или задание {job.id} не текущее")
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 await _supersede_and_warn(session, job=job, file=file, documents=documents, warnings=warnings, now=now)
async def _supersede_and_warn( # noqa: PLR0913 - входы автозамены именованные
session: AsyncSession,
*,
job: RecognitionJob,
file: StoredFile,
documents: Sequence[Document],
warnings: list[str],
now: datetime,
) -> list[str]:
"""Автозамена нового файла; её предупреждение о выпавших документах (OQ-154) дописывается в
`metrics.mapping_warnings` задания. Возвращает полный список предупреждений маппинга.
Предупреждение — про заменённые файлы, а не про документы нового: их статусы уже посчитаны и от него не меняются.
"""
replace_warnings: list[str] = []
await supersede_previous(
session,
new_file=file,
new_documents=documents,
uploader_id=file.uploaded_by,
now=now,
warnings=replace_warnings,
)
if not replace_warnings:
return warnings
# новый список, а не `extend`: предыдущая версия `metrics` ссылается на тот же список, и SQLAlchemy не увидел бы
# изменения JSONB (старое и новое значение сравнялись бы)
warnings = [*warnings, *replace_warnings]
job.metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings}
await session.flush()
return warnings
@ -569,13 +603,59 @@ def _describe_document(document: Document) -> str:
return f"{document.document_type.value} {document.osv_account_code or ''} {period} ({document.status.value})"
async def supersede_previous(
def _describe_dropped(document: Document) -> str:
"""Выпавший из замены документ для предупреждения (OQ-154): тип, счёт, период и статус (в т. ч. `CONFIRMED`)."""
period = (
f"{document.period_year} {document.period_quarter.value}"
if document.period_year is not None and document.period_quarter is not None
else "период не определён"
)
parts = [document.document_type.value, document.osv_account_code, period]
return f"{' '.join(part for part in parts if part)} ({document.status.value})"
async def _version_labels(session: AsyncSession, version_ids: Sequence[uuid.UUID]) -> list[str]:
"""Человекочитаемые подписи версий консолидации («ГК» год квартал, версия N) в порядке `version_ids`."""
if not version_ids:
return []
rows = await session.execute(
select(
ConsolidationVersion.id,
ConsolidationVersion.version_no,
Consolidation.year,
Consolidation.quarter,
CompanyGroup.name,
)
.join(Consolidation, Consolidation.id == ConsolidationVersion.consolidation_id)
.join(CompanyGroup, CompanyGroup.id == Consolidation.company_group_id)
.where(ConsolidationVersion.id.in_(version_ids))
)
labels = {
version_id: f"«{group_name}» {year} {quarter.value}, версия {version_no}"
for version_id, version_no, year, quarter, group_name in rows
}
return [labels.get(version_id, str(version_id)) for version_id in version_ids]
def _replace_warning(old: StoredFile, dropped: Sequence[Document], version_labels: Sequence[str]) -> str:
"""Предупреждение нового файла о документах заменённого файла `old`, которых в новом файле нет (OQ-154)."""
text = (
f"автозамена: файл «{old.original_name}» (версия {old.version_no}) заменён этим файлом целиком, "
f"но в новом файле нет документов прежнего: {', '.join(sorted(map(_describe_dropped, dropped)))}"
)
if version_labels:
text += f"; привязки сняты с версий консолидации: {', '.join(version_labels)}"
return text
async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена одной транзакцией: входы и шаги именованные
session: AsyncSession,
*,
new_file: StoredFile,
new_documents: Sequence[Document],
uploader_id: uuid.UUID | None,
now: datetime,
warnings: list[str] | None = None,
) -> list[StoredFile]:
"""D-10, spec primary-files «Автозамена прежних версий»: файлы того же ЮЛ (не `SUPERSEDED`, не сам новый) с
документом тех же `document_type`, `osv_account_code`, `period_year`, `period_quarter` → `SUPERSEDED`,
@ -588,6 +668,11 @@ async def supersede_previous(
(`consolidation_documents.on_documents_superseded`); id версий — в `after_value.unbound_version_ids` события
заменённого файла (spec audit-trail), статус версий не меняется.
OQ-154: замена остаётся на уровне файла, но если у заменённого файла есть документы, ключа которых нет среди
`new_documents` (например, подтверждённый ББ из книги «ББ + ОФР», заменённой отдельным ОФР), в `warnings` (если
передан) добавляется по одному предупреждению на заменённый файл: имя файла, выпавшие документы (тип, счёт,
период, статус) и версии консолидации, с которых сняты привязки этих документов. Поведение замены не меняется.
Блокировки: advisory-блокировка ЮЛ сериализует автозамены одного ЮЛ (два новых файла одного документа, смапленные
параллельно, иначе не увидели бы друг друга и оба остались бы действующими); заменяемые файлы — `FOR UPDATE` в
порядке `id` (сериализация с подтверждением документа, которое перечитывает файл под `FOR UPDATE`); затем
@ -647,15 +732,28 @@ async def supersede_previous(
await session.flush()
# привязки к версиям «в работе» снимаются у ВСЕХ документов заменённого файла (spec primary-files), а не только у
# совпавших по ключу: подхват берёт документы лишь незаменённых файлов (design №33)
file_documents = await session.execute(select(Document).where(Document.stored_file_id.in_(ids)))
file_documents = await session.execute(
select(Document).where(Document.stored_file_id.in_(ids)).execution_options(populate_existing=True)
)
documents_by_file: dict[uuid.UUID, list[Document]] = {}
for document in file_documents.scalars():
documents_by_file.setdefault(document.stored_file_id, []).append(document)
unbound: dict[uuid.UUID, list[uuid.UUID]] = {}
dropped_by_file: dict[uuid.UUID, list[Document]] = {} # документы без ключа среди новых (OQ-154)
dropped_versions: dict[uuid.UUID, list[uuid.UUID]] = {} # версии, где сняты привязки именно выпавших документов
for old in locked: # по возрастанию id; advisory ГК и версии берутся первым вызовом (порядок D-9)
unbound[old.id] = await consolidation_documents.on_documents_superseded(
session, documents=documents_by_file.get(old.id, []), now=now
old_documents = documents_by_file.get(old.id, [])
dropped = [document for document in old_documents if _document_key(document) not in keys]
dropped_by_file[old.id] = dropped
# выпавшие снимаются отдельным первым вызовом: он возвращает версии именно их привязок (после блокировки
# версий, без окна между чтением и удалением); второй вызов добирает остальные документы файла
dropped_versions[old.id] = (
await consolidation_documents.on_documents_superseded(session, documents=dropped, now=now)
if dropped
else []
)
rest_versions = await consolidation_documents.on_documents_superseded(session, documents=old_documents, now=now)
unbound[old.id] = sorted({*dropped_versions[old.id], *rest_versions})
audit = AuditService(session)
uploader = f"user:{uploader_id}" if uploader_id is not None else "неизвестен"
@ -680,6 +778,9 @@ async def supersede_previous(
f"confirmed={'true' if confirmed else 'false'}"
),
)
if warnings is not None and dropped_by_file[old.id]:
labels = await _version_labels(session, dropped_versions[old.id])
warnings.append(_replace_warning(old, dropped_by_file[old.id], labels))
logger.info(
"Автозамена: прежние версии файла заменены. file_id={file_id}, version_no={version_no}, replaced={replaced}",
file_id=new_file.id,

View File

@ -1215,6 +1215,170 @@ async def test_replaced_document_is_unbound_from_versions_in_work(
assert event.after_value == {"file_id": str(new_file.id), "version_no": 2, "unbound_version_ids": [str(in_work_id)]}
# --- OQ-154: предупреждение о документах заменённого файла, которых нет в новом -----------------------------------
STATEMENT_MINIMAL = API_DIR / "contracts" / "examples" / "result-statement-minimal.json"
def _statement_result(*document_types: str) -> dict[str, Any]:
"""Результат с формами `document_types` за 30.09.2025 (Q3 2025), копии `result-statement-minimal.json`."""
result = json.loads(STATEMENT_MINIMAL.read_text("utf-8"))
template = result["documents"][0]
result["documents"] = []
for number, document_type in enumerate(document_types, start=1):
document = copy.deepcopy(template) | {"document_type": document_type, "locator": f"pages:{number}"}
result["documents"].append(document)
return result
async def _mapping_warnings(maker: async_sessionmaker[AsyncSession], job_id: uuid.UUID) -> list[str]:
job = await _job(maker, job_id)
assert isinstance(job.metrics, dict)
warnings: list[str] = job.metrics[MAPPING_WARNINGS_KEY]
return warnings
async def test_replace_warns_about_dropped_confirmed_balance(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
"""OQ-154: книга «ББ + ОФР» за Q3 2025, ББ подтверждён и привязан к версии «в работе»; отдельный файл только с ОФР
того же периода заменяет всю книгу, а в `mapping_warnings` нового задания — имя прежнего файла, выпавший
`BALANCE 2025 Q3 (CONFIRMED)` и версия, с которой снята привязка. Замена и статус документа ОФР не меняются."""
maker, storage = committed_sessionmaker, FakeStorage()
book_id, book_job_id, entity_id = await _prepare(
maker, storage, _statement_result("BALANCE", "INCOME"), owner_inn="7700000000"
)
assert await process_once(maker, storage, _settings()) is True
assert await _mapping_warnings(maker, book_job_id) == [] # книга ничего не заменяла: предупреждений нет
async with maker() as session, session.begin():
initiator = (await session.execute(select(AppUser).order_by(AppUser.email).limit(1))).scalar_one()
entity = await session.get(LegalEntity, entity_id)
assert entity is not None
group = await make_company_group(session, name="ГК Книга")
entity.company_group_id = group.id
_, version = await make_consolidation(
session,
group=group,
year=2025,
quarter=PeriodQuarter.Q3,
members=[entity],
initiator=initiator,
status=ConsolidationStatus.DATA_CORRECTION,
)
documents = {
doc.document_type: doc
for doc in (await session.execute(select(Document).where(Document.stored_file_id == book_id))).scalars()
}
assert set(documents) == {DocumentType.BALANCE, DocumentType.INCOME}
documents[DocumentType.BALANCE].status = DocumentStatus.CONFIRMED
session.add(
ConsolidationDocument(consolidation_version_id=version.id, document_id=documents[DocumentType.BALANCE].id)
)
version_id = version.id
async with maker() as session:
entity = await session.get(LegalEntity, entity_id)
new_id, new_job_id, _ = await _prepare(maker, storage, _statement_result("INCOME"), entity=entity)
assert await process_once(maker, storage, _settings()) is True
[warning] = await _mapping_warnings(maker, new_job_id)
book_name = (await _file(maker, book_id)).original_name
assert warning.startswith(f"автозамена: файл «{book_name}» (версия 1) заменён этим файлом")
assert "BALANCE 2025 Q3 (CONFIRMED)" in warning
assert "INCOME" not in warning # ОФР есть в новом файле: не выпал
assert "ГК Книга" in warning and "2025 Q3, версия 1" in warning
book = await _file(maker, book_id)
assert (book.status, book.superseded_by_file_id) == (FileStatus.SUPERSEDED, new_id) # поведение замены прежнее
async with maker() as session:
bound = (
await session.execute(
select(ConsolidationDocument).where(ConsolidationDocument.consolidation_version_id == version_id)
)
).all()
assert bound == []
[event] = await _replace_events(maker)
assert event.after_value is not None and event.after_value["unbound_version_ids"] == [str(version_id)]
async def test_replace_without_dropped_documents_has_no_warning(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
"""Новый файл содержит ключи всех документов прежнего — предупреждения нет (обычная автозамена)."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id = await _entity(maker)
await _old_file(maker, entity_id, [OSV_60_2025_Q1])
async with maker() as session:
entity = await session.get(LegalEntity, entity_id)
_, new_job_id, _ = await _prepare(maker, storage, _minimal(), entity=entity)
assert await process_once(maker, storage, _settings()) is True
assert await _mapping_warnings(maker, new_job_id) == []
async def test_supersede_previous_reports_dropped_documents_without_changing_replace(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
"""`supersede_previous(warnings=…)`: по предупреждению на заменённый файл; выпавшие — и подтверждённый, и нет, и
документ другого периода; версия «не в работе» в предупреждении не фигурирует (привязка не снималась)."""
maker = committed_sessionmaker
async with maker() as session, session.begin():
initiator = (await session.execute(select(AppUser).order_by(AppUser.email).limit(1))).scalar_one()
group = await make_company_group(session, name="ГК Выпавшие")
entity = await make_legal_entity(session, name=OWNER_NAME, inn="7700000000", group=group)
consolidation, in_work = await make_consolidation(
session,
group=group,
year=2025,
quarter=PeriodQuarter.Q1,
members=[entity],
initiator=initiator,
status=ConsolidationStatus.DATA_CORRECTION,
)
cancelled = await make_version(
session,
consolidation=consolidation,
version_no=2,
members=[entity],
initiator=initiator,
status=ConsolidationStatus.CANCELLED,
)
old_file, old_job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
await make_document(session, file=old_file, job=old_job, account="60", status=DocumentStatus.CONFIRMED)
old_62 = await make_document(session, file=old_file, job=old_job, account="62", status=DocumentStatus.CONFIRMED)
old_66 = await make_document(
session, file=old_file, job=old_job, account="66", status=DocumentStatus.NEEDS_CHECK
)
for version in (in_work, cancelled):
for document in (old_62, old_66):
session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id))
new_file, new_job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
new_60 = await make_document(session, file=new_file, job=new_job, account="60")
new_file_id, in_work_id = new_file.id, in_work.id
warnings: list[str] = []
async with maker() as session, session.begin():
new = await session.get(StoredFile, new_file_id)
assert new is not None
replaced = await supersede_previous(
session, new_file=new, new_documents=[new_60], uploader_id=None, now=_now(), warnings=warnings
)
assert len(replaced) == 1
[warning] = warnings
assert "OSV 62 2025 Q1 (CONFIRMED)" in warning and "OSV 66 2025 Q1 (NEEDS_CHECK)" in warning
assert "OSV 60" not in warning # совпал по ключу — не выпал
assert "«ГК Выпавшие» 2025 Q1, версия 1" in warning
assert "версия 2" not in warning.split("привязки сняты", 1)[1] # CANCELLED-версия не затронута
[event] = await _replace_events(maker)
assert event.after_value is not None and event.after_value["unbound_version_ids"] == [str(in_work_id)]
# --- osv-result-mapping «Документ NEEDS_CHECK переводит версию в корректировку» (D-6) ------------------------------

View File

@ -83,6 +83,12 @@ queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg →
статусах не трогаются; статус версии не меняется. Аудит `FILE_REPLACE` (`object_id` заменённого, `after_value` с
новым файлом и `unbound_version_ids`, в `description` — перечень заменённых документов и признак `confirmed`, если
заменён документ `CONFIRMED`). Решение владельца 05.10.2026: замена происходит и для `CONFIRMED`.
Замена остаётся на уровне файла (OQ-115), но если у заменённого файла есть документ, ключа которого
(`document_type`, `osv_account_code`, `period_year`, `period_quarter`) нет среди документов нового файла (в т. ч. документ
без года или квартала), ядро SHALL добавить в `metrics.mapping_warnings` задания нового файла (карточка файла,
`JobBrief.mapping_warnings`) по одному предупреждению на заменённый файл (OQ-154): имя и версия заменённого файла,
выпавшие документы (тип, счёт, период, статус — в т. ч. `CONFIRMED`) и версии консолидации «в работе», с которых сняты
привязки этих документов. Поведение замены и статусы документов нового файла от предупреждения не меняются.
#### Scenario: Новая ОСВ 60 за 1 кв. 2025
- **WHEN** у ЮЛ есть `PROCESSED` файл с документом OSV 60 / 2025 / Q1 (`CONFIRMED`), загружен и смапплен новый файл с таким же документом
@ -94,6 +100,12 @@ queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg →
- **THEN** привязка удалена; в чек-листе ячейка ОСВ 60 показывает `missing_quarters = ["Q2"]` (или `NEEDS_CHECK`
по новому документу), пока новый документ не подтверждён; статус версии прежний
#### Scenario: Отдельный ОФР заменил книгу «ББ + ОФР»
- **WHEN** у ЮЛ есть файл с `CONFIRMED` ББ и ОФР за Q3 2025, ББ привязан к версии 2025 Q3 в `DATA_CORRECTION`; смаплен
новый файл только с ОФР за Q3 2025
- **THEN** прежний файл — `SUPERSEDED`, привязка ББ снята; в `metrics.mapping_warnings` нового задания одно предупреждение:
имя прежнего файла, `BALANCE 2025 Q3 (CONFIRMED)` и версия «ГК» 2025 Q3, версия 1; ОФР в предупреждении нет
### Requirement: Удаление файла
`DELETE /api/v1/files/{file_id}` (`DOCUMENT_EDIT`, policy) SHALL удалить строки ОСВ, документы, задания и `stored_file`,
проставить `s3_object.expires_at = now()` (объект физически не удаляется, OQ-22) и записать `audit_event(FILE_DELETE,