fix(files): удаление файла убирает формы ББ/ОФР и комментарии документов (C-1, T8.2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 14:26:34 +04:00
parent 234e830476
commit dd7f7cc42d
5 changed files with 75 additions and 8 deletions

View File

@ -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` файл удалить можно. Блокировки — в порядке

View File

@ -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))

View File

@ -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` аудита — карточка файла."""

View File

@ -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)`) его не трогает."""

View File

@ -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 как формула |