7 задач: хук подтверждения с автором перехода, form_confirmable, auto_confirm, вызов в маппинге, удаление файла, документация arm-core, правило и OQ-157 в docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
80 KiB
Автоподтверждение чистых документов — план реализации
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: документ, который маппинг сделал бы RECOGNIZED, сразу становится CONFIRMED («Загружен») от имени системы, привязывается к версиям и двигает их как при ручном подтверждении; файл с автоподтверждёнными документами можно удалить, пока версии не ушли дальше DATA_CORRECTION.
Architecture: новый модуль services/auto_confirm.py вызывается в транзакции маппинга (result_mapping._map_candidate) после map_file (а значит после автозамены) и до on_documents_mapped; он переиспользует хук ручного подтверждения consolidation_documents.on_document_confirmed, которому теперь передают changed_by/comment. Условия формы ББ/ОФР — общая функция services/statements.form_confirmable. Удаление файла (primary_files.delete) снимает привязки автоподтверждённых документов через новые функции consolidation_documents.bound_versions / unbind_documents. Правило и OQ — отдельный PR в arm-ka-docs.
Tech Stack: Python 3.11, FastAPI, SQLAlchemy 2 async, PostgreSQL 16, pytest-asyncio; OpenSpec; Gitea tea.
Spec: openspec/changes/auto-confirm-clean-documents/ (proposal.md, design.md, specs/osv-result-mapping, specs/primary-files, specs/audit-trail). Issue RSHB/arm-core#64.
Рабочее место: зеркало /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/: arm-core/ — worktree ветки feat/64-auto-confirm, docs/ — worktree ветки feat/64-auto-confirm-rules, fixtures, arm-web, CLAUDE.md, .claude — симлинки. Команды make — из .wt/auto-confirm/arm-core, pytest — из .wt/auto-confirm/arm-core/api (.venv/bin/pytest; venv создаётся make venv, если его нет). Интеграционные тесты требуют PostgreSQL/NATS/MinIO стенда (docker ps — контейнеры arm-ka-*); на стенде работает ядро владельца на :8000 — его НЕ останавливать, тесты используют свою БД armcore_pytest_<hex>. codegraph_explore — с projectPath=/Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core.
Global Constraints
- DBML v5.17 не меняется; новых полей и миграций нет (
confirmed_byуже nullable,audit_event.user_id— «NULL для системных событий»). - Автор автоподтверждения:
document.confirmed_by = NULL,financial_statement_meta.reviewed_by = NULL;audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS, object_type='document')сuser_id = NULL,description = «автоподтверждение: без замечаний»,before_value = {"status": "RECOGNIZED"},after_value = {"status": "CONFIRMED"}. - Переход версии при автоподтверждении:
changed_by = stored_file.uploaded_by,comment = «автоподтверждение документа <document_type> <osv_account_code>»(счёт — только если есть, через пробел; например «автоподтверждение документа OSV 60», «автоподтверждение документа INCOME»).uploaded_by IS NULL→ привязка есть, перехода нет, строка в лог. - Автоматически подтверждается только документ со статусом
RECOGNIZEDсразу послеmap_file;NEEDS_CHECK— никогда; форма — только еслиform_confirmable(период определён, жёстких нарушений §7 нет). - Порядок блокировок: задание → файл → документы → advisory ГК → версии по
id(CLAUDE.mdarm-core «Консолидации», правило 2). - Удаление:
confirmed_by IS NOT NULLу любогоCONFIRMEDдокумента файла → 409FILE_HAS_CONFIRMED_DOCUMENTS; привязка автоподтверждённого документа к версии внеIN_WORK_STATUSES(INITIATED,DOCUMENTS_PROCESSING,DATA_CORRECTION) → 409FILE_HAS_CONFIRMED_DOCUMENTS; иначе привязки снимаются,FILE_DELETE.after_value = {"unbound_version_ids": [...]}(UUID по возрастанию;[]если нет). - Язык комментариев, docstring, коммитов — русский; номер § правил — в docstring; сообщения коммитов заканчиваются строкой
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. - Перед PR —
make check-full(из.wt/auto-confirm/arm-core); вывод — в описание PR.
Review Focus
- Файл с документами
RECOGNIZEDиNEEDS_CHECKвперемешку (книга «ББ чистый + ОФР с нарушением») — чистый подтверждается и привязывается,NEEDS_CHECKостаётся и двигает версию изDOCUMENTS_PROCESSINGхукомon_documents_mapped; версия получает ровно один переход (второй хук видит ужеDATA_CORRECTION). Тест — Task 4,test_mixed_file_confirms_clean_and_moves_version_once. - Автозамена: новый чистый файл того же ключа — привязка документа старого файла снимается, новый документ
CONFIRMEDи привязан к той же версии, в версии нет двух привязок одного ключа. Тест — Task 4,test_supersede_rebinds_auto_confirmed_document. - Повтор маппинга того же задания после сбоя (транзакция откатилась,
mapping_attempts + 1) — автоподтверждение откатывается вместе с маппингом, при повторе нет двойных событийDOCUMENT_CONFIRM. Тест — Task 4,test_auto_confirm_rolls_back_with_mapping. - Ручное подтверждение уже автоподтверждённого документа (фронт со старым статусом в кэше жмёт «Подтвердить») — 409
ALREADY_CONFIRMED, ничего не меняется. Тест — Task 3,test_manual_confirm_of_auto_confirmed_is_409. - Удаление файла, чей автоподтверждённый документ привязан к двум версиям, одна из которых ушла на согласование — 409, ни одна привязка не снята, файл на месте. Тест — Task 5,
test_delete_blocked_if_any_bound_version_left_in_work.
Task 1: Хук подтверждения принимает автора перехода и комментарий
Files:
- Modify:
api/src/services/consolidation_documents.py(on_document_confirmed, ~строки 200–240) - Modify:
api/src/services/documents.py(DocumentService.confirm, вызов хука, ~строка 275) - Test:
api/tests/test_consolidation_documents.py
Interfaces:
-
Produces:
async def on_document_confirmed(session: AsyncSession, audit: AuditService, *, document: Document, changed_by: uuid.UUID | None, now: datetime.datetime, comment: str | None = None) -> list[uuid.UUID]— параметрuser_idпереименован вchanged_by; приchanged_by is Noneпривязка создаётся, переход версии пропускается сlogger.info. -
Step 1: Найти всех вызывающих
Run: cd api && grep -rn "on_document_confirmed" src tests
Expected: src/services/documents.py (confirm) и tests/test_consolidation_documents.py (test_on_document_confirmed_returns_bound_versions и, возможно, _confirm_in). Все они будут переименованы на changed_by=.
- Step 2: Написать падающие тесты
В конец раздела «on_document_confirmed» файла api/tests/test_consolidation_documents.py (после test_on_document_confirmed_returns_bound_versions) добавить:
@pytest.mark.integration
async def test_on_document_confirmed_without_changed_by_binds_but_keeps_status(seeded: AsyncSession) -> None:
"""Автоподтверждение файла без `uploaded_by` (OQ-157, D-2): привязка есть, перехода версии нет."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
document = await _confirmed(seeded, entities[0], quarter=Q2)
history = len(await _history(seeded, version.id))
bound = await consolidation_documents.on_document_confirmed(
seeded, AuditService(seeded), document=document, changed_by=None, now=utc_now()
)
assert bound == [version.id]
assert document.id in await _bound(seeded, version.id)
assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED
assert len(await _history(seeded, version.id)) == history
@pytest.mark.integration
async def test_on_document_confirmed_writes_comment_to_history(seeded: AsyncSession) -> None:
"""Комментарий перехода от вызывающего (автоподтверждение) попадает в историю статусов."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
user = await _user(seeded)
document = await _confirmed(seeded, entities[0], quarter=Q2)
await consolidation_documents.on_document_confirmed(
seeded,
AuditService(seeded),
document=document,
changed_by=user.id,
comment="автоподтверждение документа OSV 60",
now=utc_now(),
)
last = (await _history(seeded, version.id))[-1]
assert (last.to_status, last.changed_by, last.comment) == (
ConsolidationStatus.DATA_CORRECTION,
user.id,
"автоподтверждение документа OSV 60",
)
- Step 3: Запустить — должны упасть
Run: cd api && .venv/bin/pytest tests/test_consolidation_documents.py -k "without_changed_by or writes_comment" -v
Expected: FAIL — TypeError: on_document_confirmed() got an unexpected keyword argument 'changed_by'.
- Step 4: Изменить хук
В api/src/services/consolidation_documents.py заменить сигнатуру и тело on_document_confirmed:
async def on_document_confirmed( # noqa: PLR0913 - автор и комментарий перехода задаёт вызывающий (OQ-157)
session: AsyncSession,
audit: AuditService,
*,
document: Document,
changed_by: uuid.UUID | None,
now: datetime.datetime,
comment: str | None = None,
) -> list[uuid.UUID]:
"""Хук подтверждения (spec «Подтверждение документа», osv-result-mapping «Автоподтверждение чистого документа»):
привязывает уже `CONFIRMED` документ ко всем версиям «в работе», которым он подходит, заменяя привязанный документ
того же ключа (R-1); версия из `INITIATED` / `DOCUMENTS_PROCESSING` переходит в `DATA_CORRECTION` с `changed_by` и
`comment` вызывающего (ручное подтверждение — пользователь и `None`, автоподтверждение — загрузивший файл и
«автоподтверждение документа …», D-2). `changed_by is None` (файл без `uploaded_by`) — привязка создаётся, переход
пропускается с записью в лог (`consolidation_status_history.changed_by` NOT NULL). Возвращает id версий, получивших
привязку (по возрастанию).
Вызывать после блокировки файла и документа (D-9): версии блокирует `repo.lock_versions_for_entity` — advisory ГК
(создание версии ∥ подтверждение) и `FOR UPDATE` надмножества версий ГК (правка периметра ∥ подтверждение, R-2).
Блокировка версии (`consolidation_lock`) не требуется.
"""
# цикл импортов: consolidation_status → consolidations → consolidation_documents
from src.services.consolidation_status import transition # noqa: PLC0415
versions = await repo.lock_versions_for_entity(session, document.legal_entity_id, IN_WORK_STATUSES)
bound: list[uuid.UUID] = []
for version in versions:
consolidation = await session.get(Consolidation, version.consolidation_id)
if consolidation is None: # FK гарантирует наличие
continue
if not fits(document, version, consolidation, await _member_ids(session, version.id)):
continue
await _bind(session, version.id, document)
if version.status in MOVES_TO_CORRECTION:
if changed_by is None:
logger.info(
"Переход версии по подтверждению пропущен: нет автора (changed_by обязателен). "
"version_id={version_id}, document_id={document_id}",
version_id=version.id,
document_id=document.id,
)
else:
await transition(
session,
audit,
version=version,
to=ConsolidationStatus.DATA_CORRECTION,
changed_by=changed_by,
comment=comment,
now=now,
)
bound.append(version.id)
return bound
В api/src/services/documents.py, в DocumentService.confirm, заменить вызов:
await consolidation_documents.on_document_confirmed(
self._session, self._audit.service, document=document, changed_by=self._audit.user_id, now=now
)
В api/tests/test_consolidation_documents.py заменить у остальных вызовов user_id= на changed_by= (найдены в Step 1).
- Step 5: Запустить тесты модуля
Run: cd api && .venv/bin/pytest tests/test_consolidation_documents.py tests/test_api_documents.py -v
Expected: PASS (новые два и все прежние).
- Step 6: Линтеры
Run: cd /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core && make lint
Expected: ruff и mypy без ошибок.
- Step 7: Commit
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/consolidation_documents.py api/src/services/documents.py api/tests/test_consolidation_documents.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "refactor(consolidations): on_document_confirmed принимает автора и комментарий перехода (#64)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 2: Условия подтверждения формы — одна функция для ручного и автоматического пути
Files:
- Modify:
api/src/services/statements.py(модульные функции рядом сFORM_TYPES; методStatementService.hard_failures) - Modify:
api/src/services/documents.py(_check_statementиспользуетstatement_hard_failures) - Test:
api/tests/test_form_confirmable.py(новый)
Interfaces:
-
Produces:
async def statement_hard_failures(session: AsyncSession, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]— жёсткие нарушения §7; при акцепте(). -
Produces:
async def form_confirmable(session: AsyncSession, document: Document) -> bool—Trueдля не-формы (document_type ∉ FORM_TYPES) и для формы без шапки; для формы с шапкой —period_yearиperiod_quarterнеNoneиstatement_hard_failures(...) == (). -
Step 1: Написать падающий тест
Создать api/tests/test_form_confirmable.py:
"""Условия подтверждения формы ББ/ОФР — общие для ручного и автоматического пути (OQ-157, design D-3; D-9 Среза 3b)."""
import json
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.directories import LegalEntity
from src.models.enums import DocumentType
from src.models.files import Document
from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue
from src.services.statements import form_confirmable, statement_hard_failures
from tests.factories import make_legal_entity
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3"
SYNTH = GOLDEN_STATEMENTS / "synthetic"
async def _owner(session: AsyncSession, path: Path) -> LegalEntity:
"""Владелец с ИНН шапки формы (MATCH)."""
data = json.loads(path.read_text("utf-8"))
inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None)
return await make_legal_entity(session, name="ООО Владелец", inn=inn)
async def _income(session: AsyncSession, fixtures_dir: Path) -> Document:
path = fixtures_dir / MEZH / "1_Форма2_бух_09.json"
(income,) = await map_golden_snapshot(session, entity=await _owner(session, path), path=path, uploaded_by=None)
assert income.document_type is DocumentType.INCOME
return income
async def test_clean_form_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
assert await form_confirmable(seeded, await _income(seeded, fixtures_dir)) is True
async def test_form_without_period_is_not_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
income = await _income(seeded, fixtures_dir)
income.period_quarter = None
await seeded.flush()
assert await form_confirmable(seeded, income) is False
async def test_form_with_hard_failure_is_not_confirmable_until_accepted(
seeded: AsyncSession, fixtures_dir: Path
) -> None:
income = await _income(seeded, fixtures_dir)
meta = (
await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
).scalar_one()
# ломаем итог 2400 «Чистая прибыль» во всех колонках: соотношение 2400 = … перестаёт сходиться
values = (
await seeded.execute(
select(FinancialStatementValue)
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
.where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2400")
)
).scalars()
for value in values:
value.amount = (value.amount or 0) + 1000
await seeded.flush()
assert await statement_hard_failures(seeded, income, meta) != ()
assert await form_confirmable(seeded, income) is False
meta.checks_accepted_at = income.created_at # акцепт: любые непустые дата и автор
await seeded.flush()
assert await form_confirmable(seeded, income) is True
async def test_disclosure_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
owner = await make_legal_entity(seeded, name="ООО Синтетика")
documents = await map_golden_snapshot(
seeded, entity=owner, path=fixtures_dir / SYNTH / "st06_header_variants.json", uploaded_by=None
)
disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE)
assert await form_confirmable(seeded, disclosure) is True
- Step 2: Запустить — должен упасть
Run: cd api && .venv/bin/pytest tests/test_form_confirmable.py -v
Expected: FAIL — ImportError: cannot import name 'form_confirmable'.
- Step 3: Реализовать
В api/src/services/statements.py сразу после FORM_TYPES = frozenset({...}) добавить:
async def statement_hard_failures(
session: AsyncSession, document: Document, meta: FinancialStatementMeta
) -> tuple[FailedCheck, ...]:
"""Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b)."""
if meta.checks_accepted_at is not None:
return ()
column_indexes, amounts = await repo.statement_amounts(session, meta.id)
report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules())
return tuple(report.failed)
async def form_confirmable(session: AsyncSession, document: Document) -> bool:
"""Условия подтверждения формы ББ/ОФР без исключений (D-9 Среза 3b; автоподтверждение — design D-3, OQ-157):
период определён и жёстких нарушений §7 нет или они акцептованы. Не-форма и форма без шапки (созданная до Среза 3b)
— `True`: условий у них нет. Ручной путь (`DocumentService._check_statement`) проверяет то же, но отвечает 409."""
if document.document_type not in FORM_TYPES:
return True
meta = await repo.get_meta(session, document.id, refresh=True)
if meta is None:
return True
if document.period_year is None or document.period_quarter is None:
return False
return not await statement_hard_failures(session, document, meta)
Заменить тело метода StatementService.hard_failures:
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
return await statement_hard_failures(self._session, document, meta)
В api/src/services/documents.py, в _check_statement, заменить импорт и вызов:
from src.services.statements import FORM_TYPES, statement_hard_failures # noqa: PLC0415 - цикл импорта
failures = await statement_hard_failures(self._session, document, meta)
(Импорты FailedCheck, run_checks, load_check_rules в statements.py уже есть — их использует метод; проверить grep -n "FailedCheck\|run_checks\|load_check_rules" api/src/services/statements.py.)
- Step 4: Запустить тесты
Run: cd api && .venv/bin/pytest tests/test_form_confirmable.py tests/test_api_statements.py tests/test_api_documents.py -v
Expected: PASS.
- Step 5: Линтеры и commit
Run: make lint (из .wt/auto-confirm/arm-core), затем:
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/statements.py api/src/services/documents.py api/tests/test_form_confirmable.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "refactor(statements): form_confirmable — условия подтверждения формы для ручного и автоматического пути (#64)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 3: auto_confirm — подтверждение чистых документов файла
Files:
- Create:
api/src/services/auto_confirm.py - Test:
api/tests/test_auto_confirm.py(новый)
Interfaces:
-
Consumes:
on_document_confirmed(..., changed_by=..., comment=...)(Task 1);form_confirmable(session, document)иFORM_TYPESизsrc.services.statements(Task 2). -
Produces:
AUTO_CONFIRM_DESCRIPTION: str = "автоподтверждение: без замечаний";def transition_comment(document: Document) -> str;async def auto_confirm(session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime) -> list[Document]— возвращает подтверждённые документы (по возрастаниюid). -
Step 1: Написать падающие тесты
Создать api/tests/test_auto_confirm.py:
"""Автоподтверждение чистых документов (osv-result-mapping «Автоподтверждение чистого документа»; OQ-157; design D-1–D-3)."""
import uuid
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.clock import utc_now
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.consolidation import ConsolidationDocument, ConsolidationStatusHistory, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice
from src.models.enums import (
AuditAction,
ConsolidationStatus,
DocumentStatus,
DocumentType,
FileStatus,
PeriodQuarter,
RecognitionStatus,
)
from src.models.files import Document, StoredFile
from src.models.statements import FinancialStatementMeta
from src.services.auto_confirm import AUTO_CONFIRM_DESCRIPTION, auto_confirm, transition_comment
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
pytestmark = pytest.mark.integration
ROSTOV = "Ростовский региональный филиал"
KA_RF = "ka_rf@armka.local"
YEAR = 2024
Q2, Q3 = PeriodQuarter.Q2, PeriodQuarter.Q3
HTTP_CONFLICT = 409
async def _user(session: AsyncSession) -> AppUser:
return (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
async def _group(session: AsyncSession) -> tuple[CompanyGroup, list[LegalEntity]]:
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
return group, [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(2)]
async def _version(
session: AsyncSession, group: CompanyGroup, members: list[LegalEntity], status: ConsolidationStatus
) -> ConsolidationVersion:
_, version = await make_consolidation(
session, group=group, year=YEAR, quarter=Q3, members=members, initiator=await _user(session), status=status
)
return version
async def _file_with(
session: AsyncSession, entity: LegalEntity, statuses: list[DocumentStatus], uploaded_by: uuid.UUID | None
) -> tuple[StoredFile, list[Document]]:
"""Файл `PROCESSED` с квартальными ОСВ 60, 62, … за 2024 Q2 в статусах `statuses`."""
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, uploaded_by=uploaded_by
)
documents = [
await make_document(
session, file=file, job=job, account=account, year=YEAR, quarter=Q2, status=status, is_cumulative=False
)
for account, status in zip(["60", "62", "76"], statuses, strict=False)
]
return file, documents
async def _bound(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]:
stmt = select(ConsolidationDocument.document_id).where(ConsolidationDocument.consolidation_version_id == version_id)
return set((await session.execute(stmt)).scalars())
async def _confirm_events(session: AsyncSession) -> list[AuditEvent]:
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM)
return list((await session.execute(stmt)).scalars())
def test_transition_comment() -> None:
osv = Document(document_type=DocumentType.OSV, osv_account_code="60")
income = Document(document_type=DocumentType.INCOME, osv_account_code=None)
assert transition_comment(osv) == "автоподтверждение документа OSV 60"
assert transition_comment(income) == "автоподтверждение документа INCOME"
async def test_clean_document_is_confirmed_by_system(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
user = await _user(seeded)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id)
now = utc_now()
confirmed = await auto_confirm(seeded, file=file, documents=[document], now=now)
assert confirmed == [document]
assert (document.status, document.confirmed_by, document.confirmed_at) == (DocumentStatus.CONFIRMED, None, now)
[event] = await _confirm_events(seeded)
assert (event.object_id, event.user_id, event.description) == (document.id, None, AUTO_CONFIRM_DESCRIPTION)
assert (event.before_value, event.after_value) == ({"status": "RECOGNIZED"}, {"status": "CONFIRMED"})
async def test_needs_check_is_left_for_analyst(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.NEEDS_CHECK], uuid.uuid4())
assert await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) == []
assert document.status is DocumentStatus.NEEDS_CHECK and document.confirmed_at is None
assert await _confirm_events(seeded) == []
assert await _bound(seeded, version.id) == set()
async def test_binds_and_moves_version_with_uploader_and_comment(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
user = await _user(seeded)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id)
await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
assert await _bound(seeded, version.id) == {document.id}
refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True)
assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION
last = (
await seeded.execute(
select(ConsolidationStatusHistory)
.where(ConsolidationStatusHistory.version_id == version.id)
.order_by(ConsolidationStatusHistory.changed_at.desc())
.limit(1)
)
).scalar_one()
assert (last.changed_by, last.comment) == (user.id, "автоподтверждение документа OSV 60")
async def test_file_without_uploader_binds_without_transition(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], None)
await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
assert document.status is DocumentStatus.CONFIRMED
assert await _bound(seeded, version.id) == {document.id}
refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True)
assert refreshed is not None and refreshed.status is ConsolidationStatus.INITIATED
async def test_mixed_documents_confirm_only_clean(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
file, documents = await _file_with(
seeded, entities[0], [DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK], uuid.uuid4()
)
confirmed = await auto_confirm(seeded, file=file, documents=documents, now=utc_now())
assert confirmed == [documents[0]]
assert [d.status for d in documents] == [DocumentStatus.CONFIRMED, DocumentStatus.NEEDS_CHECK]
@pytest.mark.fixtures
async def test_clean_form_sets_reviewed_at(seeded: AsyncSession, fixtures_dir: Path) -> None:
path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
import json # noqa: PLC0415 - нужен только здесь
data = json.loads(path.read_text("utf-8"))
inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn"))
owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn)
(income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
assert income.status is DocumentStatus.RECOGNIZED
file = await seeded.get(StoredFile, income.stored_file_id)
assert file is not None
now = utc_now()
assert await auto_confirm(seeded, file=file, documents=[income], now=now) == [income]
meta = (
await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
).scalar_one()
assert (income.status, meta.reviewed_by, meta.reviewed_at) == (DocumentStatus.CONFIRMED, None, now)
@pytest.mark.fixtures
async def test_recognized_form_without_period_stays(seeded: AsyncSession, fixtures_dir: Path) -> None:
path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
import json # noqa: PLC0415 - нужен только здесь
data = json.loads(path.read_text("utf-8"))
inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn"))
owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn)
(income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
income.period_quarter = None # рассинхрон правил: RECOGNIZED без периода не должен подтверждаться сам
await seeded.flush()
file = await seeded.get(StoredFile, income.stored_file_id)
assert file is not None
assert await auto_confirm(seeded, file=file, documents=[income], now=utc_now()) == []
assert income.status is DocumentStatus.RECOGNIZED
async def test_manual_confirm_of_auto_confirmed_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Review Focus 4: фронт с устаревшим статусом жмёт «Подтвердить» — 409 `ALREADY_CONFIRMED`."""
office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
entity = await make_legal_entity(seeded, name="ООО Ростов", office=office)
file, [document] = await _file_with(seeded, entity, [DocumentStatus.RECOGNIZED], None)
await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "ALREADY_CONFIRMED"
assert len(await _confirm_events(seeded)) == 1
Перед запуском свериться с фабриками: grep -n "async def make_legal_entity" -A12 api/tests/factories.py — если у make_legal_entity нет параметра office, в test_manual_confirm_of_auto_confirmed_is_409 создать ЮЛ через _group(seeded) (ГК в Ростовском филиале — ЮЛ видно ka_rf) и взять entities[0].
- Step 2: Запустить — должны упасть
Run: cd api && .venv/bin/pytest tests/test_auto_confirm.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'src.services.auto_confirm'.
- Step 3: Реализовать модуль
Создать api/src/services/auto_confirm.py:
"""Автоподтверждение чистых документов при маппинге результата (osv-result-mapping «Автоподтверждение чистого
документа»; `05-domain-rules.md` §9.3, §9.5; OQ-157; design D-1–D-3 change `auto-confirm-clean-documents`).
Документ, получивший при маппинге `RECOGNIZED` (ТЗ 03_9 п. 4: «при отсутствии замечаний — „Загружен“»), становится
`CONFIRMED` от имени системы: `confirmed_by = NULL`, у формы `reviewed_by = NULL`, `audit_event(DOCUMENT_CONFIRM)` без
пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором
`stored_file.uploaded_by`. Вызывается в транзакции маппинга под заданием, файлом и документами (порядок блокировок D-5).
"""
import datetime
from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.enums import AuditAction, AuditSection, DocumentStatus
from src.models.files import Document, StoredFile
from src.repositories import statements as statements_repo
from src.services import consolidation_documents
from src.services.audit import AuditService
from src.services.statements import FORM_TYPES, form_confirmable
AUTO_CONFIRM_DESCRIPTION = "автоподтверждение: без замечаний"
OBJECT_TYPE = "document"
def transition_comment(document: Document) -> str:
"""Комментарий перехода версии: «автоподтверждение документа <тип> [<счёт>]» (design решение №6)."""
parts = ["автоподтверждение документа", document.document_type.value]
if document.osv_account_code:
parts.append(document.osv_account_code)
return " ".join(parts)
async def auto_confirm(
session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime
) -> list[Document]:
"""Подтверждает документы `RECOGNIZED` файла (форма — только при `form_confirmable`, D-3); `NEEDS_CHECK` и прочие
не трогает. Возвращает подтверждённые документы по возрастанию `id`."""
audit = AuditService(session)
confirmed: list[Document] = []
for document in sorted(documents, key=lambda item: item.id):
if document.status is not DocumentStatus.RECOGNIZED:
continue
if not await form_confirmable(session, document):
continue
document.status = DocumentStatus.CONFIRMED
document.confirmed_by = None
document.confirmed_at = now
document.updated_at = now
if document.document_type in FORM_TYPES:
meta = await statements_repo.get_meta(session, document.id)
if meta is not None:
meta.reviewed_by = None
meta.reviewed_at = now
await session.flush()
await consolidation_documents.on_document_confirmed(
session,
audit,
document=document,
changed_by=file.uploaded_by,
comment=transition_comment(document),
now=now,
)
await audit.record(
action=AuditAction.DOCUMENT_CONFIRM,
section=AuditSection.PRIMARY_DOCUMENTS,
object_type=OBJECT_TYPE,
object_id=document.id,
user_id=None,
description=AUTO_CONFIRM_DESCRIPTION,
before_value={"status": DocumentStatus.RECOGNIZED.value},
after_value={"status": DocumentStatus.CONFIRMED.value},
)
confirmed.append(document)
return confirmed
- Step 4: Запустить тесты
Run: cd api && .venv/bin/pytest tests/test_auto_confirm.py -v
Expected: PASS (11 тестов). Если падает импорт из-за цикла (statements ↔ documents) — проверить python -c "import src.services.auto_confirm" из api/ и перенести импорт form_confirmable внутрь функции с # noqa: PLC0415 - цикл импорта.
- Step 5: Линтеры и commit
Run: make lint, затем:
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/auto_confirm.py api/tests/test_auto_confirm.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "feat(documents): auto_confirm — чистый документ подтверждается системой (#64, OQ-157)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 4: Автоподтверждение в цикле маппинга
Files:
- Modify:
api/src/services/result_mapping.py(_map_candidate, ~строки 897–902; docstring модуля — шаг маппинга) - Modify:
api/tests/test_result_mapping.py(ожиданияRECOGNIZEDпослеprocess_once: строки ~239, ~620,test_clean_result,test_mapping_moves_documents_processing_version_only_for_needs_check) - Modify:
api/tests/e2e/test_osv_ingest_e2e.py(строки ~144, ~150) - Test:
api/tests/test_result_mapping.py(новые тесты в конце раздела «переход версий по маппингу»)
Interfaces:
-
Consumes:
auto_confirm(session, *, file, documents, now)(Task 3). -
Step 1: Написать падающие тесты
В api/tests/test_result_mapping.py после test_mapping_does_not_move_initiated_version добавить (импорты ConsolidationDocument, AuditEvent, AuditAction — добавить в шапку файла, если их нет; func, select уже импортированы):
async def _history_of(maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID) -> list[ConsolidationStatusHistory]:
async with maker() as session:
stmt = (
select(ConsolidationStatusHistory)
.where(ConsolidationStatusHistory.version_id == version_id)
.order_by(ConsolidationStatusHistory.changed_at)
)
return list((await session.execute(stmt)).scalars())
async def _bound_ids(maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID) -> set[uuid.UUID]:
async with maker() as session:
stmt = select(ConsolidationDocument.document_id).where(
ConsolidationDocument.consolidation_version_id == version_id
)
return set((await session.execute(stmt)).scalars())
async def test_clean_document_is_auto_confirmed_bound_and_moves_version(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
"""osv-result-mapping «Привязка и переход версии»: чистая ОСВ 60 за 2025 Q1 → `CONFIRMED` системой, привязана к
версии 2025 Q3 (`DOCUMENTS_PROCESSING`), переход в `DATA_CORRECTION` от загрузившего с комментарием."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, version_id, user_id = await _version_in(maker)
file_id, _, _ = await _prepare(
maker, storage, _minimal(), entity=await _load_entity(maker, entity_id), uploaded_by=user_id
)
assert await process_once(maker, storage, _settings()) is True
[document] = await _documents(maker, file_id)
assert (document.status, document.confirmed_by) == (DocumentStatus.CONFIRMED, None)
assert document.confirmed_at is not None
assert await _bound_ids(maker, version_id) == {document.id}
last = (await _history_of(maker, version_id))[-1]
assert (last.to_status, last.changed_by, last.comment) == (
ConsolidationStatus.DATA_CORRECTION,
user_id,
"автоподтверждение документа OSV 60",
)
async with maker() as session:
events = (
await session.execute(
select(AuditEvent).where(
AuditEvent.action == AuditAction.DOCUMENT_CONFIRM, AuditEvent.object_id == document.id
)
)
).scalars().all()
assert [(event.user_id, event.description) for event in events] == [(None, "автоподтверждение: без замечаний")]
async def test_mixed_file_confirms_clean_and_moves_version_once(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
"""Review Focus 1: в файле чистый и `NEEDS_CHECK` документы — версия получает ровно один переход."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, version_id, user_id = await _version_in(maker)
result = _minimal()
second = copy.deepcopy(result["documents"][0])
second["header"]["account_code_raw"] = "62"
second["locator"] = "sheet:Лист_2"
second["checks"] = {"total_row_matches_root": False} # нарушение проверок → NEEDS_CHECK
result["documents"].append(second)
file_id, _, _ = await _prepare(
maker, storage, result, entity=await _load_entity(maker, entity_id), uploaded_by=user_id
)
assert await process_once(maker, storage, _settings()) is True
statuses = sorted((d.osv_account_code, d.status) for d in await _documents(maker, file_id))
assert statuses == [("60", DocumentStatus.CONFIRMED), ("62", DocumentStatus.NEEDS_CHECK)]
history = await _history_of(maker, version_id)
assert [h.to_status for h in history].count(ConsolidationStatus.DATA_CORRECTION) == 1
async def test_supersede_rebinds_auto_confirmed_document(committed_sessionmaker: async_sessionmaker[AsyncSession]):
"""Review Focus 2: новая чистая версия файла того же ключа заменяет привязку старой."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, version_id, user_id = await _version_in(maker)
entity = await _load_entity(maker, entity_id)
first_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=user_id)
assert await process_once(maker, storage, _settings()) is True
[first] = await _documents(maker, first_id)
assert await _bound_ids(maker, version_id) == {first.id}
second_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=user_id)
assert await process_once(maker, storage, _settings()) is True
[second] = await _documents(maker, second_id)
assert second.status is DocumentStatus.CONFIRMED
assert await _bound_ids(maker, version_id) == {second.id}
assert (await _file(maker, first_id)).status is FileStatus.SUPERSEDED
async def test_auto_confirm_rolls_back_with_mapping(
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
):
"""Review Focus 3: сбой после автоподтверждения откатывает и его; повтор — одно событие `DOCUMENT_CONFIRM`."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, _, user_id = await _version_in(maker)
file_id, job_id, _ = await _prepare(
maker, storage, _minimal(), entity=await _load_entity(maker, entity_id), uploaded_by=user_id
)
original = consolidation_documents.on_documents_mapped
calls = {"n": 0}
async def flaky(*args: Any, **kwargs: Any) -> list[uuid.UUID]:
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("сбой после автоподтверждения")
return await original(*args, **kwargs)
monkeypatch.setattr(consolidation_documents, "on_documents_mapped", flaky)
assert await process_once(maker, storage, _settings()) is False # повторяемая неудача (R-15)
assert await _documents(maker, file_id) == [] # маппинг вместе с автоподтверждением откатился целиком
assert await process_once(maker, storage, _settings()) is True
[document] = await _documents(maker, file_id)
assert document.status is DocumentStatus.CONFIRMED
async with maker() as session:
count = (
await session.execute(
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM)
)
).scalar_one()
assert count == 1
Проверить имена в шапке test_result_mapping.py: grep -n "^from\|^import" api/tests/test_result_mapping.py — нужны copy, Any, uuid, consolidation_documents (from src.services import consolidation_documents), ConsolidationDocument, ConsolidationStatusHistory, AuditEvent, AuditAction. Ключи снимка _minimal() (header.account_code_raw, locator, checks) сверить: python -c "import json;print(json.load(open('tests/data/<MINIMAL_RESULT>'))['documents'][0].keys())" (путь — константа MINIMAL_RESULT в начале файла). Между двумя process_once пауза не нужна: process_once вызывается напрямую, цикл со сном не участвует; если кандидат после неудачи не берётся повторно (например, отсечка по finished_at), посмотреть pick_candidate и при необходимости вызвать _map_candidate вторично через тот же кандидат.
- Step 2: Запустить — должны упасть
Run: cd api && .venv/bin/pytest tests/test_result_mapping.py -k "auto_confirmed or mixed_file or supersede_rebinds or rolls_back" -v
Expected: FAIL — документ RECOGNIZED, а не CONFIRMED.
- Step 3: Вызвать auto_confirm в маппинге
В api/src/services/result_mapping.py добавить импорт from src.services.auto_confirm import auto_confirm (рядом с from src.services import consolidation_documents) и заменить хвост _map_candidate:
warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=now)
# После `map_file`, а значит после автозамены (advisory ЮЛ → старые файлы → advisory ГК → версии): версии
# блокируются последними, порядок «задание → файл → документы → версии» (D-9) не нарушается. Сначала
# автоподтверждение чистых документов (привязка и переход версии, OQ-157, design D-1), затем переход по
# `NEEDS_CHECK` (D-6) — он уже видит версию после автоподтверждения и второго перехода не делает.
documents = list((await session.execute(select(Document).where(Document.stored_file_id == file.id))).scalars())
await auto_confirm(session, file=file, documents=documents, now=now)
await consolidation_documents.on_documents_mapped(session, file=file, documents=documents, now=now)
- Step 4: Обновить прежние ожидания
В api/tests/test_result_mapping.py:
- строка ~239:
assert document.status is DocumentStatus.CONFIRMED # done, проверки чистые, шапка = владелец по имени → автоподтверждение; - строка ~620 (ветка
else):assert document.status is DocumentStatus.CONFIRMED; test_clean_result: вparametrize(RecognitionStatus.done, DocumentStatus.CONFIRMED), docstring «… →CONFIRMED(автоподтверждение, OQ-157),MATCH,UNKNOWN»;test_mapping_moves_documents_processing_version_only_for_needs_check: ветку чистого документа переписать — документCONFIRMED,bindings == 1, версияDATA_CORRECTION, последняя запись истории сchanged_by = user_idи комментарием «автоподтверждение документа OSV 60»; дляNEEDS_CHECK— как было (bindings == 0). Утверждениеassert bindings == 0перенести внутрь веткиif moved:.
В api/tests/e2e/test_osv_ingest_e2e.py строки ~144 и ~150: "RECOGNIZED" → "CONFIRMED", комментарий «чистая ОСВ подтверждена автоматически (OQ-157)». Если ниже в e2e есть POST …/confirm для этого документа — ожидать 409 ALREADY_CONFIRMED (проверить grep -n "confirm" api/tests/e2e/test_osv_ingest_e2e.py).
Найти остальные ожидания RECOGNIZED после process_once: grep -n "process_once" -A12 api/tests/test_result_mapping.py api/tests/test_api_files_delete.py | grep RECOGNIZED — каждое заменить на CONFIRMED с тем же комментарием. Тесты, вызывающие map_file напрямую (test_mapping_statements.py, test_mapping_golden.py, fixtures_helpers.map_golden_snapshot), НЕ меняются: автоподтверждение — в _map_candidate, не в map_file.
- Step 5: Запустить
Run: cd api && .venv/bin/pytest tests/test_result_mapping.py tests/test_consolidation_documents.py tests/test_mapping_statements.py tests/test_api_consolidation_checklist.py -v
Expected: PASS.
- Step 6: Обновить docstring модуля маппинга
В docstring api/src/services/result_mapping.py (модельные шаги прохода) дописать к шагу 3: «… → автоподтверждение чистых документов (services/auto_confirm.py, OQ-157) → переход версий по NEEDS_CHECK».
- Step 7: Линтеры и commit
Run: make lint, затем:
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/result_mapping.py api/tests/test_result_mapping.py api/tests/e2e/test_osv_ingest_e2e.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "feat(mapping): автоподтверждение чистых документов в цикле маппинга (#64, OQ-157)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 5: Удаление файла с автоподтверждёнными документами
Files:
- Modify:
api/src/services/consolidation_documents.py(новыеbound_versions,unbind_documents) - Modify:
api/src/services/primary_files.py(PrimaryFilesService.delete, ~строки 281–330) - Modify:
api/src/api/endpoints/files/read.py(описание 409 уDELETE, ~строка 34) - Test:
api/tests/test_api_files_delete.py
Interfaces:
-
Produces:
async def bound_versions(session: AsyncSession, *, documents: Sequence[Document]) -> list[tuple[uuid.UUID, ConsolidationStatus]]— блокирует версии «в работе» ЮЛ документов (lock_versions_for_entityпо каждому ЮЛ, по возрастанию id ЮЛ) и возвращает пары (версия, статус) всех привязокdocuments(любой статус версии), отсортированные по id версии. -
Produces:
async def unbind_documents(session: AsyncSession, *, document_ids: Collection[uuid.UUID]) -> int— удаляет всеconsolidation_documentэтих документов. -
Step 1: Написать падающие тесты
В api/tests/test_api_files_delete.py:
- В
test_confirmed_document_blocks_deleteдокументCONFIRMEDсделать подтверждённым пользователем — послеmake_document(... account="62", status=DocumentStatus.CONFIRMED)сохранить его в переменнуюconfirmedи добавить:
confirmed.confirmed_by = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one().id
await seeded.flush()
- В конец файла добавить (импорты
AppUser,ConsolidationDocument,ConsolidationVersion,ConsolidationStatus,make_company_group,make_consolidation,PeriodQuarter,utc_now— добавить в шапку при отсутствии):
async def _auto_confirmed_in_versions(
session: AsyncSession, statuses: list[ConsolidationStatus]
) -> tuple[StoredFile, Document, list[ConsolidationVersion]]:
"""ЮЛ Ростова в ГК; по версии на каждый статус (2024 Q3, Q4 — разные консолидации); файл с автоподтверждённой ОСВ 60
за 2024 Q2 (`confirmed_by` NULL), привязанной ко всем версиям."""
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == "Ростовский региональный филиал"))).scalar_one()
group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
entity = await make_legal_entity(session, name="ООО Автоподтверждение", group=group)
other = await make_legal_entity(session, name="ООО Второе", group=group)
initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
versions = []
for quarter, status in zip([PeriodQuarter.Q3, PeriodQuarter.Q4], statuses, strict=False):
_, version = await make_consolidation(
session, group=group, year=2024, quarter=quarter, members=[entity, other], initiator=initiator, status=status
)
versions.append(version)
file, job = await make_stored_file(session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done)
document = await make_document(
session, file=file, job=job, account="60", year=2024, quarter=PeriodQuarter.Q2,
status=DocumentStatus.CONFIRMED, is_cumulative=False,
)
document.confirmed_at = utc_now()
for version in versions:
session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id))
await session.flush()
return file, document, versions
async def test_delete_auto_confirmed_in_correction_unbinds(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
file, document, [version] = await _auto_confirmed_in_versions(seeded, [ConsolidationStatus.DATA_CORRECTION])
file_id, version_id = file.id, version.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0
assert await _count(seeded, ConsolidationDocument, ConsolidationDocument.consolidation_version_id == version_id) == 0
refreshed = await seeded.get(ConsolidationVersion, version_id, populate_existing=True)
assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION
[event] = await _delete_events(seeded)
assert event.after_value == {"unbound_version_ids": [str(version_id)]}
async def test_delete_blocked_if_any_bound_version_left_in_work(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Review Focus 5: одна из двух версий на согласовании — 409, ни одна привязка не снята."""
file, document, versions = await _auto_confirmed_in_versions(
seeded, [ConsolidationStatus.DATA_CORRECTION, ConsolidationStatus.CONSOLIDATION_RF]
)
file_id, document_id = file.id, document.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "FILE_HAS_CONFIRMED_DOCUMENTS"
assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1
assert await _count(seeded, ConsolidationDocument, ConsolidationDocument.document_id == document_id) == len(versions)
assert await _delete_events(seeded) == []
async def test_delete_auto_confirmed_without_bindings(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done)
await make_document(seeded, file=file, job=job, account="60", status=DocumentStatus.CONFIRMED) # confirmed_by NULL
file_id = file.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
[event] = await _delete_events(seeded)
assert event.after_value == {"unbound_version_ids": []}
Проверить: HTTP_NO_CONTENT, HTTP_CONFLICT, _count, _delete_events, _rostov_entity, KA_RF, RegionalOffice уже определены/импортированы в файле (grep -n "HTTP_NO_CONTENT\|HTTP_CONFLICT\|^KA_RF\|RegionalOffice" api/tests/test_api_files_delete.py); make_consolidation переводит версию в нужный статус, включая CONSOLIDATION_RF (grep -n "async def make_consolidation" -A30 api/tests/factories.py) — если не умеет, после создания выставить version.status = ConsolidationStatus.CONSOLIDATION_RF и flush. Формат after_value (str UUID или UUID) сверить с тем, как FILE_REPLACE.unbound_version_ids проверяется в test_result_mapping.py (grep -n "unbound_version_ids" api/tests/test_result_mapping.py) и привести ожидания к нему. Также проверить test_delete_error_file: если он сравнивает after_value с None, заменить ожидание на {"unbound_version_ids": []}.
- Step 2: Запустить — должны упасть
Run: cd api && .venv/bin/pytest tests/test_api_files_delete.py -v
Expected: FAIL — 409 вместо 204 у test_delete_auto_confirmed_in_correction_unbinds и test_delete_auto_confirmed_without_bindings.
- Step 3: Функции привязок
В api/src/services/consolidation_documents.py добавить (после unbind_entity):
async def bound_versions(
session: AsyncSession, *, documents: Sequence[Document]
) -> list[tuple[uuid.UUID, ConsolidationStatus]]:
"""Привязки `documents` к версиям любого статуса — `(version_id, status)` по возрастанию `version_id` (удаление
файла с автоподтверждёнными документами, OQ-157, design D-4). Сначала блокируются версии «в работе» каждого ЮЛ
(`lock_versions_for_entity`, advisory ГК → версии по `id`), чтобы статус не сменился между проверкой и снятием
привязки; версии вне «в работе» читаются без блокировки — из них удаление и так запрещено."""
if not documents:
return []
for entity_id in sorted({document.legal_entity_id for document in documents}):
await repo.lock_versions_for_entity(session, entity_id, IN_WORK_STATUSES)
stmt = (
select(ConsolidationDocument.consolidation_version_id, ConsolidationVersion.status)
.join(ConsolidationVersion, ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id)
.where(ConsolidationDocument.document_id.in_([document.id for document in documents]))
.order_by(ConsolidationDocument.consolidation_version_id)
.execution_options(populate_existing=True)
)
return [(version_id, status) for version_id, status in (await session.execute(stmt)).all()]
async def unbind_documents(session: AsyncSession, *, document_ids: Collection[uuid.UUID]) -> int:
"""Снимает все привязки `document_ids` (удаление файла, D-4); статус версий не меняется. Версии — под блокировкой
вызывающего (`bound_versions`)."""
if not document_ids:
return 0
result = await session.execute(
delete(ConsolidationDocument).where(ConsolidationDocument.document_id.in_(list(document_ids)))
)
return _rowcount(result)
- Step 4: Правило удаления
В api/src/services/primary_files.py добавить импорт from src.services import consolidation_documents и DocumentStatus (если нет) и заменить в delete блок после documents = await files_repo.lock_file_documents(...) до current = next(...):
confirmed = [document for document in documents if document.status is DocumentStatus.CONFIRMED]
if any(document.confirmed_by is not None for document in confirmed):
raise ConflictError(
FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть документы, подтверждённые пользователем"
)
if await files_repo.supersedes_others(self._session, file_id):
raise ConflictError(
FILE_SUPERSEDES_OTHERS,
"файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется",
)
# OQ-157, design D-4: автоподтверждённые документы удаляются, пока их версии «в работе»; привязки снимаются
bound = await consolidation_documents.bound_versions(self._session, documents=confirmed)
if any(status not in IN_WORK_STATUSES for _, status in bound):
raise ConflictError(
FILE_HAS_CONFIRMED_DOCUMENTS,
"файл нельзя удалить: документ уже участвует в версии консолидации на согласовании",
)
unbound_version_ids = sorted({version_id for version_id, _ in bound})
await consolidation_documents.unbind_documents(
self._session, document_ids=[document.id for document in confirmed]
)
В вызове self._audit.record(action=AuditAction.FILE_DELETE, ...) добавить after_value={"unbound_version_ids": unbound_version_ids},. Обновить docstring delete: «409 FILE_HAS_CONFIRMED_DOCUMENTS — есть документ, подтверждённый пользователем, или автоподтверждённый документ привязан к версии вне «в работе» (OQ-157); привязки автоподтверждённых документов снимаются, after_value.unbound_version_ids». Порядок 409 в docstring: пользовательское подтверждение → FILE_SUPERSEDES_OTHERS → версия на согласовании.
В api/src/api/endpoints/files/read.py (описание 409 у DELETE): «FILE_HAS_CONFIRMED_DOCUMENTS (документ подтверждён пользователем или автоподтверждённый документ в версии на согласовании) или FILE_SUPERSEDES_OTHERS …».
- Step 5: Запустить
Run: cd api && .venv/bin/pytest tests/test_api_files_delete.py tests/test_consolidation_documents.py -v
Expected: PASS.
- Step 6: Линтеры и commit
Run: make lint, затем:
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/consolidation_documents.py api/src/services/primary_files.py api/src/api/endpoints/files/read.py api/tests/test_api_files_delete.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "feat(files): удаление файла с автоподтверждёнными документами до согласования (#64, OQ-157)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 6: Документация arm-core и полная проверка
Files:
-
Modify:
CLAUDE.md(arm-core: раздел «Первичные документы (Срез 2c)» и «Консолидации (Срез 4)») -
Modify:
README.md(arm-core: каталог ошибок —FILE_HAS_CONFIRMED_DOCUMENTS; поведение подтверждения) -
Modify:
openspec/changes/auto-confirm-clean-documents/design.md(раздел «Decisions made without discussion» — дописать решения, принятые при реализации, если были) -
Step 1: CLAUDE.md
В разделе «Первичные документы (Срез 2c)» после фразы о services/documents.py (карточки, confirm) добавить: «services/auto_confirm.py — автоподтверждение чистых документов в транзакции маппинга (_map_candidate: map_file → auto_confirm → on_documents_mapped; confirmed_by NULL, DOCUMENT_CONFIRM без пользователя, переход версии — от uploaded_by; OQ-157)». В перечне аудита: «DOCUMENT_CONFIRM (пользователь или система при автоподтверждении)». В разделе «Консолидации» у хуков: «on_document_confirmed(changed_by, comment); bound_versions / unbind_documents — удаление файла с автоподтверждёнными документами (OQ-157)».
- Step 2: README
Найти каталог ошибок: grep -n "FILE_HAS_CONFIRMED_DOCUMENTS" README.md. Обновить строку: «409 — в файле есть документ, подтверждённый пользователем, или автоподтверждённый документ привязан к версии на согласовании (вне INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION)». Если в README описан жизненный цикл документа (grep -n "RECOGNIZED" README.md) — дописать: «чистый документ при маппинге сразу CONFIRMED (система, confirmed_by = null, OQ-157); RECOGNIZED остаётся у документов до этой версии и у форм после правки».
- Step 3: Полная проверка
Run: cd /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core && make check-full 2>&1 | tail -40
Expected: lint, unit, dbml-check, check-infra, integration, e2e (skip без профиля services — допустимо, если так и было), docker-build, web-check — зелёные. Падения от окружения (стенд владельца в той же queue group NATS) — записать в ledger как O-находку с выводом и НЕ гасить стенд владельца без вопроса.
- Step 4: Commit
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add CLAUDE.md README.md openspec/changes/auto-confirm-clean-documents/design.md
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "docs: автоподтверждение чистых документов в CLAUDE.md и README (#64)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 7: Правило, OQ и гайды в arm-ka-docs
Files (репозиторий docs, worktree /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/docs, ветка feat/64-auto-confirm-rules):
-
Modify:
context/05-domain-rules.md(§9.3, §9.5 п. 4 и п. 5) -
Modify:
context/07-open-questions.md(новая строка OQ-157 после OQ-156; пометки в строках OQ-05 и OQ-110) -
Modify:
dev/qa-guide.md(§8 W13, W16–W18, §7.1; раздел 6 про подтверждение и удаление) -
Modify:
dev/frontend-api-guide.md(статусы документа,confirmed_by = null, удаление) -
Step 1: §9.3 и §9.5
В context/05-domain-rules.md, §9.3 (абзац «RECOGNIZED — задание done …»), после «(OQ-05 закрыт: RECOGNIZED нужен)» вставить: «Автоподтверждение (OQ-157, решение владельца 08.10.2026; ТЗ 03_9 п. 4, 10_10): документ, получивший при маппинге RECOGNIZED, в той же транзакции становится CONFIRMED от имени системы (confirmed_by NULL, audit_event(DOCUMENT_CONFIRM) с user_id NULL и описанием «автоподтверждение: без замечаний»), привязывается к версиям и двигает их, как при ручном подтверждении (§3 п. 1); переход версии — от stored_file.uploaded_by с комментарием «автоподтверждение документа …». Форма ББ/ОФР — только при условиях ручного подтверждения (п. 4); RECOGNIZED формы после правки или акцепта — только вручную. NEEDS_CHECK — только вручную.»
В §9.5 п. 4 (Подтверждение) дописать: «Автоподтверждённый документ (confirmed_by NULL) повторно подтвердить нельзя — 409 ALREADY_CONFIRMED.» В §9.5 п. 5 (Удаление) заменить условие запрета: «запрещено, если у файла есть документ, подтверждённый пользователем (confirmed_by не NULL), или автоподтверждённый документ привязан к версии вне INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION (FILE_HAS_CONFIRMED_DOCUMENTS; OQ-157, уточняет OQ-110); привязки автоподтверждённых документов снимаются, статус версий не меняется, FILE_DELETE.after_value.unbound_version_ids».
- Step 2: OQ
В context/07-open-questions.md после строки | OQ-156 | вставить:
| OQ-157 | Чистый документ (без замечаний) требовал ручного «Подтвердить» (`RECOGNIZED` → `CONFIRMED`), хотя строки ОСВ не правятся (OQ-114) и ТЗ 03_9 п. 4 / 10_10 даёт «Загружен» сразу при отсутствии замечаний; на комплекте 1 кв. 2025 — 16 пустых кликов | прогон владельца 08.10.2026 | arm-core `services/auto_confirm.py`, `result_mapping`, `primary_files.delete`; §9.3, §9.5 п. 4–5 | **решено** (владелец 08.10.2026): чистый документ при маппинге сразу `CONFIRMED` системой (`confirmed_by` NULL), переход версии — от загрузившего; удаление автоподтверждённого файла — пока версии «в работе»; RSHB/arm-core#64, change `auto-confirm-clean-documents` |
В строке OQ-05 в последний столбец дописать: «Пересмотрено OQ-157 (08.10.2026): после маппинга чистый документ сразу CONFIRMED; RECOGNIZED остаётся у старых документов и форм после правки.» В строке OQ-110 — «Уточнено OQ-157: файл с автоподтверждёнными документами удаляется, пока их версии «в работе»; привязки снимаются.»
- Step 3: Гайды
dev/qa-guide.md:
- §8 W13: ожидание «у каждого документ … статус «Требуется проверка»» → «чистые ОСВ сразу «Загружен» (автоподтверждение, OQ-157); «Требуется проверка» — только с замечаниями (например, шапка другой организации)»;
- W16–W17: «Просмотр ОСВ» открывается для документа «Загружен», кнопка «✓ Подтвердить» неактивна; проверка подтверждения — на документе «Требуется проверка» (загрузить ОСВ Счастливочной под ЮЛ РБ:
Контрагент ✗); - W18:
demo-prepare.shподтверждает только неподтверждённое — вывод скрипта без изменений; статус версии «Коррекция данных» появляется сразу после распознавания; - §6 (раздел про подтверждение и удаление файлов): сценарии «удаление автоподтверждённого файла в «Коррекции данных» → 204, привязка снята» и «версия на согласовании → 409».
dev/frontend-api-guide.md: в разделе статусов документа — «после маппинга чистый документ приходит сразу CONFIRMED с confirmed_by: null (автоподтверждение); кнопку «Подтвердить» показывать неактивной; удаление файла с таким документом возможно (409 FILE_HAS_CONFIRMED_DOCUMENTS — если подтверждал пользователь или версия на согласовании)».
Точные места найти: grep -n "W13\|W16\|W17\|W18\|FILE_HAS_CONFIRMED\|RECOGNIZED" dev/qa-guide.md dev/frontend-api-guide.md.
- Step 4: Commit
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/docs add context/05-domain-rules.md context/07-open-questions.md dev/qa-guide.md dev/frontend-api-guide.md
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/docs commit -m "docs: автоподтверждение чистых документов — §9.3, §9.5, OQ-157, гайды (RSHB/arm-core#64)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
После задач (контроллер)
- Финальное ревью всей ветки arm-core (модель opus) по спеке change и Review Focus.
- PR arm-core
feat/64-auto-confirm→main(closes #64; выводmake check-full; таблица находок с маркерами; «Rulings I made»); PR arm-ka-docsfeat/64-auto-confirm-rules→main(ссылка на arm-core PR, «сливать вместе»). - Ничего не мержить;
openspec archive— только по команде «закрывай».