Автозамена: предупреждение о документах заменённого файла, которых нет в новом (#58)
Замена остаётся на уровне файла (§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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
53ac9f19ed
commit
081d0e60fb
@ -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()`
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) ------------------------------
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user