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`) `{column_index, relation, expected, actual}`). Мягкие проверки (в т. ч. межформенная `1370 − 1370(начало года) = 2400`)
подтверждение не блокируют. Подтверждённая форма привязывается к версиям консолидации и закрывает ячейки ББ/ОФР чек-листа, как ОСВ. подтверждение не блокируют. Подтверждённая форма привязывается к версиям консолидации и закрывает ячейки ББ/ОФР чек-листа, как ОСВ.
- **Порядок блокировок** правки и акцепта — «файл → документ» (как у подтверждения); шапка перечитывается под блокировкой. - **Порядок блокировок** правки и акцепта — «файл → документ» (как у подтверждения); шапка перечитывается под блокировкой.
Комментарии аудит не пишут; при удалении файла комментарии его документов остаются в таблице `comment` без владельца Комментарии аудит не пишут; при удалении файла комментарии его документов удаляются вместе с документами
(полиморфная связь без FK, решение №18; наружу не отдаются). (полиморфная связь без FK; решение №18 закрыто).
Расчёт — `src/domain/statement_mapping.py` и `src/domain/statement_checks.py` (чистые функции), доступ к данным — Расчёт — `src/domain/statement_mapping.py` и `src/domain/statement_checks.py` (чистые функции), доступ к данным —
`src/repositories/statements.py`, правка и акцепт — `src/services/statements.py`. `src/repositories/statements.py`, правка и акцепт — `src/services/statements.py`.
@ -590,7 +590,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл
Документ без года или квартала в замене не участвует. Если новый файл содержит лишь часть документов прежнего, прежний Документ без года или квартала в замене не участвует. Если новый файл содержит лишь часть документов прежнего, прежний
уходит в `SUPERSEDED` целиком (OQ-115). Автозамены одного ЮЛ идут под advisory-блокировкой. уходит в `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()` (`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`) (физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет `FILE_DELETE` с карточкой файла в `before_value`. Нельзя удалить файл с документом `CONFIRMED` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`)
и файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке и файл, который заменил другие (`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.enums import FileStatus, S3BucketRole
from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry from src.models.osv import OsvEntry
from src.repositories import statements as statements_repo
COMMENT_OBJECT_TYPE = "document" # `comment.object_type` комментариев документа 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: 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_id = file.id
file.current_recognition_job_id = None file.current_recognition_job_id = None
await session.flush() await session.flush()
document_ids = select(Document.id).where(Document.stored_file_id == file_id) 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(OsvEntry).where(OsvEntry.document_id.in_(document_ids)))
await session.execute(delete(Document).where(Document.stored_file_id == file_id)) await session.execute(delete(Document).where(Document.stored_file_id == file_id))
await session.execute(delete(RecognitionJob).where(RecognitionJob.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` (цепочка версий не рвётся). Файл, `FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка версий не рвётся). Файл,
исчезнувший, пока ждали блокировку, — 404. исчезнувший, пока ждали блокировку, — 404.
Удаляются `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий Удаляются формы ББ/ОФР (`financial_statement_*`), комментарии документов, `osv_entry`, `document`, все задания
и `stored_file`; `s3_object` файла и результатов заданий
остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и
`results/{file_id}/` (M-3). ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает `results/{file_id}/` (M-3). ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает
ретеншн бакета. `before_value` аудита — карточка файла.""" ретеншн бакета. `before_value` аудита — карточка файла."""

View File

@ -6,6 +6,7 @@ design D-10).
""" """
import asyncio import asyncio
import datetime
import json import json
import uuid import uuid
from pathlib import Path from pathlib import Path
@ -16,11 +17,18 @@ from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.core.config import get_settings 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.directories import LegalEntity, RegionalOffice
from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, RecognitionStatus from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, RecognitionStatus
from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry from src.models.osv import OsvEntry
from src.models.statements import (
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
from src.services.result_mapping import process_once from src.services.result_mapping import process_once
from tests.conftest import AuthClientFactory, FakeStorage from tests.conftest import AuthClientFactory, FakeStorage
from tests.factories import ( from tests.factories import (
@ -31,6 +39,7 @@ from tests.factories import (
make_result_object, make_result_object,
make_stored_file, make_stored_file,
) )
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
HTTP_NO_CONTENT = 204 HTTP_NO_CONTENT = 204
HTTP_FORBIDDEN = 403 HTTP_FORBIDDEN = 403
@ -102,6 +111,56 @@ async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFa
assert event.after_value is None 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: async def test_delete_expires_only_own_objects(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""M-3: `expires_at` ставится только объектам папок файла (`uploads/{file_id}/`, `results/{file_id}/`); ссылка """M-3: `expires_at` ставится только объектам папок файла (`uploads/{file_id}/`, `results/{file_id}/`); ссылка
задания на объект другого файла (inbox переиспользует `s3_object` при конфликте `(bucket, key)`) его не трогает.""" задания на объект другого файла (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 в этом тесте не сверяется (покрывается тестами форм) | | 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 | | 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}}` | — | | 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 как формула | | 19 | 2026-10-07 | R-9: значения ячеек CSV чек-листа (название ЮЛ, ИНН) не нейтрализуются от формул Excel (`=`, `+`, `-`, `@`) | спека не требует; названия из справочника ЕЦП внутреннего контура | теоретическая формула в названии ЮЛ откроется в Excel как формула |