From 081d0e60fbb8a87d24406a0bfc77cbc9a7a1e9da Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 00:33:16 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B2=D1=82=D0=BE=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0:=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=85=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0,=20=D0=BA=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D1=8B=D1=85=20=D0=BD=D0=B5=D1=82=20=D0=B2=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=BC=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замена остаётся на уровне файла (§9.5 п. 3, OQ-115), но если у заменённого файла есть документы, ключа которых (document_type, osv_account_code, год, квартал) нет среди документов нового файла, в metrics.mapping_warnings задания нового файла добавляется предупреждение (OQ-154): имя и версия заменённого файла, выпавшие документы (тип, счёт, период, статус, в т. ч. CONFIRMED) и версии консолидации, с которых сняты привязки выпавших документов. Поведение замены, FILE_REPLACE и статусы документов не меняются. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 6 +- api/src/services/result_mapping.py | 115 +++++++++++++++++-- api/tests/test_result_mapping.py | 164 +++++++++++++++++++++++++++ openspec/specs/primary-files/spec.md | 12 ++ 4 files changed, 289 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7f83147..aae3b73 100644 --- a/README.md +++ b/README.md @@ -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()` diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 972c554..1edd5d2 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -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, diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 52fc7bc..ae199c5 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -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) ------------------------------ diff --git a/openspec/specs/primary-files/spec.md b/openspec/specs/primary-files/spec.md index 426b9ba..2a1bd25 100644 --- a/openspec/specs/primary-files/spec.md +++ b/openspec/specs/primary-files/spec.md @@ -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, -- 2.47.2