7 задач: хук подтверждения с автором перехода, form_confirmable, auto_confirm, вызов в маппинге, удаление файла, документация arm-core, правило и OQ-157 в docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1190 lines
80 KiB
Markdown
1190 lines
80 KiB
Markdown
# Автоподтверждение чистых документов — план реализации
|
||
|
||
> **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.md` arm-core «Консолидации», правило 2).
|
||
- Удаление: `confirmed_by IS NOT NULL` у любого `CONFIRMED` документа файла → 409 `FILE_HAS_CONFIRMED_DOCUMENTS`; привязка автоподтверждённого документа к версии вне `IN_WORK_STATUSES` (`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`) → 409 `FILE_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
|
||
|
||
1. **Файл с документами `RECOGNIZED` и `NEEDS_CHECK` вперемешку** (книга «ББ чистый + ОФР с нарушением») — чистый подтверждается и привязывается, `NEEDS_CHECK` остаётся и двигает версию из `DOCUMENTS_PROCESSING` хуком `on_documents_mapped`; версия получает ровно один переход (второй хук видит уже `DATA_CORRECTION`). Тест — Task 4, `test_mixed_file_confirms_clean_and_moves_version_once`.
|
||
2. **Автозамена: новый чистый файл того же ключа** — привязка документа старого файла снимается, новый документ `CONFIRMED` и привязан к той же версии, в версии нет двух привязок одного ключа. Тест — Task 4, `test_supersede_rebinds_auto_confirmed_document`.
|
||
3. **Повтор маппинга того же задания после сбоя** (транзакция откатилась, `mapping_attempts + 1`) — автоподтверждение откатывается вместе с маппингом, при повторе нет двойных событий `DOCUMENT_CONFIRM`. Тест — Task 4, `test_auto_confirm_rolls_back_with_mapping`.
|
||
4. **Ручное подтверждение уже автоподтверждённого документа** (фронт со старым статусом в кэше жмёт «Подтвердить») — 409 `ALREADY_CONFIRMED`, ничего не меняется. Тест — Task 3, `test_manual_confirm_of_auto_confirmed_is_409`.
|
||
5. **Удаление файла, чей автоподтверждённый документ привязан к двум версиям, одна из которых ушла на согласование** — 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`) добавить:
|
||
|
||
```python
|
||
@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`:
|
||
|
||
```python
|
||
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`, заменить вызов:
|
||
|
||
```python
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
"""Условия подтверждения формы ББ/ОФР — общие для ручного и автоматического пути (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({...})` добавить:
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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`, заменить импорт и вызов:
|
||
|
||
```python
|
||
from src.services.statements import FORM_TYPES, statement_hard_failures # noqa: PLC0415 - цикл импорта
|
||
```
|
||
```python
|
||
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`), затем:
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
"""Автоподтверждение чистых документов (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`:
|
||
|
||
```python
|
||
"""Автоподтверждение чистых документов при маппинге результата (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`, затем:
|
||
|
||
```bash
|
||
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` уже импортированы):
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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`, затем:
|
||
|
||
```bash
|
||
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`:
|
||
|
||
1) В `test_confirmed_document_blocks_delete` документ `CONFIRMED` сделать подтверждённым пользователем — после `make_document(... account="62", status=DocumentStatus.CONFIRMED)` сохранить его в переменную `confirmed` и добавить:
|
||
|
||
```python
|
||
confirmed.confirmed_by = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one().id
|
||
await seeded.flush()
|
||
```
|
||
|
||
2) В конец файла добавить (импорты `AppUser`, `ConsolidationDocument`, `ConsolidationVersion`, `ConsolidationStatus`, `make_company_group`, `make_consolidation`, `PeriodQuarter`, `utc_now` — добавить в шапку при отсутствии):
|
||
|
||
```python
|
||
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`):
|
||
|
||
```python
|
||
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(...)`:
|
||
|
||
```python
|
||
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`, затем:
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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-docs `feat/64-auto-confirm-rules` → `main` (ссылка на arm-core PR, «сливать вместе»).
|
||
- Ничего не мержить; `openspec archive` — только по команде «закрывай».
|