From dd7f7cc42d792f93c6752b2638c601f627f86221 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 14:26:34 +0400 Subject: [PATCH] =?UTF-8?q?fix(files):=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D1=83?= =?UTF-8?q?=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D1=8B=20=D0=91=D0=91/=D0=9E=D0=A4=D0=A0=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?(C-1,=20T8.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 6 +- api/src/repositories/files.py | 11 +++- api/src/services/primary_files.py | 3 +- api/tests/test_api_files_delete.py | 61 ++++++++++++++++++- .../changes/slice-3b-statements-api/design.md | 2 +- 5 files changed, 75 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 153004a..524011f 100644 --- a/README.md +++ b/README.md @@ -574,8 +574,8 @@ S3 (storage); без S3 — предупреждение в логе, цикл `{column_index, relation, expected, actual}`). Мягкие проверки (в т. ч. межформенная `1370 − 1370(начало года) = 2400`) подтверждение не блокируют. Подтверждённая форма привязывается к версиям консолидации и закрывает ячейки ББ/ОФР чек-листа, как ОСВ. - **Порядок блокировок** правки и акцепта — «файл → документ» (как у подтверждения); шапка перечитывается под блокировкой. - Комментарии аудит не пишут; при удалении файла комментарии его документов остаются в таблице `comment` без владельца - (полиморфная связь без FK, решение №18; наружу не отдаются). + Комментарии аудит не пишут; при удалении файла комментарии его документов удаляются вместе с документами + (полиморфная связь без FK; решение №18 закрыто). Расчёт — `src/domain/statement_mapping.py` и `src/domain/statement_checks.py` (чистые функции), доступ к данным — `src/repositories/statements.py`, правка и акцепт — `src/services/statements.py`. @@ -590,7 +590,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл Документ без года или квартала в замене не участвует. Если новый файл содержит лишь часть документов прежнего, прежний уходит в `SUPERSEDED` целиком (OQ-115). Автозамены одного ЮЛ идут под advisory-блокировкой. -**Удаление.** `DELETE /files/{id}` удаляет строки ОСВ, документы, задания и `stored_file`, проставляет объектам S3 +**Удаление.** `DELETE /files/{id}` удаляет формы ББ/ОФР (шапки, колонки, строки, значения), комментарии документов, строки ОСВ, документы, задания и `stored_file`, проставляет объектам S3 (`uploads`, `results`) из папок этого файла (`uploads/{file_id}/`, `results/{file_id}/`) `expires_at = now()` (физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет `FILE_DELETE` с карточкой файла в `before_value`. Нельзя удалить файл с документом `CONFIRMED` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`) и файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index 3ece499..98d834e 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -22,6 +22,7 @@ from src.models.directories import LegalEntity from src.models.enums import FileStatus, S3BucketRole from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +from src.repositories import statements as statements_repo COMMENT_OBJECT_TYPE = "document" # `comment.object_type` комментариев документа @@ -199,12 +200,18 @@ async def supersedes_others(session: AsyncSession, file_id: uuid.UUID) -> bool: async def delete_file_rows(session: AsyncSession, file: StoredFile) -> None: - """Удаляет строки ОСВ, документы, все задания и сам файл. FK-цикл `stored_file.current_recognition_job_id` ↔ - `recognition_job.file_id` разрывается обнулением ссылки до удаления заданий. Блокировки — у вызывающего.""" + """Удаляет формы ББ/ОФР (шапки, колонки, строки, значения), комментарии документов, строки ОСВ, документы, все + задания и сам файл (формы и комментарии — до документов и заданий: на них FK без CASCADE). FK-цикл + `stored_file.current_recognition_job_id` ↔ `recognition_job.file_id` разрывается обнулением ссылки до удаления + заданий. Блокировки — у вызывающего.""" file_id = file.id file.current_recognition_job_id = None await session.flush() document_ids = select(Document.id).where(Document.stored_file_id == file_id) + await statements_repo.delete_file_statements(session, file_id) + await session.execute( + delete(Comment).where(Comment.object_type == COMMENT_OBJECT_TYPE, Comment.object_id.in_(document_ids)) + ) await session.execute(delete(OsvEntry).where(OsvEntry.document_id.in_(document_ids))) await session.execute(delete(Document).where(Document.stored_file_id == file_id)) await session.execute(delete(RecognitionJob).where(RecognitionJob.file_id == file_id)) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 7d29469..bd3e34a 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -287,7 +287,8 @@ class PrimaryFilesService: `FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка версий не рвётся). Файл, исчезнувший, пока ждали блокировку, — 404. - Удаляются `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий + Удаляются формы ББ/ОФР (`financial_statement_*`), комментарии документов, `osv_entry`, `document`, все задания + и `stored_file`; `s3_object` файла и результатов заданий остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и `results/{file_id}/` (M-3). ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает ретеншн бакета. `before_value` аудита — карточка файла.""" diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index b45e4bf..918bfe0 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -6,6 +6,7 @@ design D-10). """ import asyncio +import datetime import json import uuid from pathlib import Path @@ -16,11 +17,18 @@ from sqlalchemy import func, select, text, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.core.config import get_settings -from src.models.audit_ex import AuditEvent +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent, Comment from src.models.directories import LegalEntity, RegionalOffice from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, RecognitionStatus from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) from src.services.result_mapping import process_once from tests.conftest import AuthClientFactory, FakeStorage from tests.factories import ( @@ -31,6 +39,7 @@ from tests.factories import ( make_result_object, make_stored_file, ) +from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot HTTP_NO_CONTENT = 204 HTTP_FORBIDDEN = 403 @@ -102,6 +111,56 @@ async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFa assert event.after_value is None +MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" + + +async def _form_file_with_comment(session: AsyncSession, fixtures_dir: Path) -> uuid.UUID: + """Файл с формой ББ (golden) и комментарием к её документу; возвращает id файла.""" + entity = await _rostov_entity(session) + (document,) = await map_golden_snapshot(session, entity=entity, path=fixtures_dir / MEZH_BALANCE, uploaded_by=None) + author = (await session.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() + session.add( + Comment( + object_type="document", + object_id=document.id, + text="проверить", + author_id=author, + created_at=datetime.datetime.now(datetime.UTC), + ) + ) + await session.flush() + return document.stored_file_id + + +async def _assert_form_rows_gone(session: AsyncSession, file_id: uuid.UUID, document_ids: list[uuid.UUID]) -> None: + assert await _count(session, StoredFile, StoredFile.id == file_id) == 0 + assert await _count(session, Document, Document.stored_file_id == file_id) == 0 + for model in (FinancialStatementMeta, FinancialStatementColumn, FinancialStatementLine, FinancialStatementValue): + assert await _count(session, model) == 0, model.__tablename__ + assert await _count(session, Comment, Comment.object_id.in_(document_ids)) == 0 + + +@pytest.mark.fixtures +@pytest.mark.parametrize("superseded", [False, True]) +async def test_delete_file_with_statement_form( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, superseded: bool +) -> None: + """C-1, T8.2: удаление файла с формой ББ/ОФР убирает шапку, колонки, строки, значения и комментарии документов + (раньше — падение по FK `financial_statement_meta`); то же для файла `SUPERSEDED`.""" + file_id = await _form_file_with_comment(seeded, fixtures_dir) + document_ids = list((await seeded.execute(select(Document.id).where(Document.stored_file_id == file_id))).scalars()) + assert await _count(seeded, FinancialStatementValue) > 0 and await _count(seeded, Comment) == 1 + if superseded: + await seeded.execute(update(StoredFile).where(StoredFile.id == file_id).values(status=FileStatus.SUPERSEDED)) + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + await _assert_form_rows_gone(seeded, file_id, document_ids) + + async def test_delete_expires_only_own_objects(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """M-3: `expires_at` ставится только объектам папок файла (`uploads/{file_id}/`, `results/{file_id}/`); ссылка задания на объект другого файла (inbox переиспользует `s3_object` при конфликте `(bucket, key)`) его не трогает.""" diff --git a/openspec/changes/slice-3b-statements-api/design.md b/openspec/changes/slice-3b-statements-api/design.md index a1927d7..b97b3fd 100644 --- a/openspec/changes/slice-3b-statements-api/design.md +++ b/openspec/changes/slice-3b-statements-api/design.md @@ -177,5 +177,5 @@ seed-dev` с нуля. | 15 | 2026-10-07 | R-5: сверка golden ОСВ в `test_mapping_golden` ограничена документами `OSV` | смешанный снимок `expected_osv_parse/synthetic/s12_statement_and_osv.json` содержит BALANCE, которую теперь тоже маппит ядро; сравнение ОСВ-строк не ослаблено | форма из s12 в этом тесте не сверяется (покрывается тестами форм) | | 16 | 2026-10-07 | R-6: документ `BALANCE`/`INCOME` без `financial_statement_meta` (создан до Среза 3b) подтверждается без условий D-9; `PATCH`/`GET …/statement` по нему — 409 `DOCUMENT_NOT_STATEMENT` | проверять нечего: формы в БД нет; блокировать подтверждение старых документов 3b не вправе | старый документ без данных формы подтверждается без проверок периода/§7 | | 17 | 2026-10-07 | R-7: список нарушений `CHECKS_NOT_PASSED` отдаётся в `detail.failures` (строки сумм), не в отдельном ключе `details` | единый формат ошибок ядра `{"detail": {"code","message", **extra}}` | — | -| 18 | 2026-10-07 | R-8: при удалении файла (`DELETE /files/{id}`) комментарии `comment(object_type='document')` удалённых документов остаются в таблице | связь полиморфная, FK нет; чтение идёт через видимый документ, наружу строки не попадают; спека о судьбе комментариев молчит | осиротевшие строки `comment` (единицы на файл); чистка — отдельной задачей | +| 18 | 2026-10-07 | R-8: при удалении файла (`DELETE /files/{id}`) комментарии `comment(object_type='document')` удалённых документов остаются в таблице | связь полиморфная, FK нет; чтение идёт через видимый документ, наружу строки не попадают; спека о судьбе комментариев молчит | осиротевшие строки `comment` (единицы на файл); чистка — отдельной задачей. **Закрыто (fix wave, T8.2): комментарии удаляются вместе с документами** в `delete_file_rows` — удаление файла и так обязано чистить зависимые от документов строки (формы ББ/ОФР, C-1), лишний `DELETE` дёшев, а осиротевшие строки не нужны никому | | 19 | 2026-10-07 | R-9: значения ячеек CSV чек-листа (название ЮЛ, ИНН) не нейтрализуются от формул Excel (`=`, `+`, `-`, `@`) | спека не требует; названия из справочника ЕЦП внутреннего контура | теоретическая формула в названии ЮЛ откроется в Excel как формула |