arm-core/docs/superpowers/plans/2026-10-07-slice-3b-statements-api.md

2535 lines
150 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Срез 3b — формы ББ/ОФР в ядре и API до сводной ОСВ: план реализации
> **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:** формы ББ/ОФР из tabular-parser и scan-gateway попадают в `financial_statement_*`, у фронта есть API экрана
«Проверка распознавания» (форма, правка, проверки §7, акцепт, подтверждение) и закрыты мелкие дыры API пути до сводной
ОСВ (исходный файл, комментарии, имена пользователей, `documents_count`, CSV чек-листа).
**Architecture:** маппинг форм — ветка существующего `services/result_mapping.map_file` с чистыми функциями
`domain/statement_mapping.py`; проверки §7 — чистый `domain/statement_checks.py` (перенос tabular-parser) со словарём
`api/rules/statement_checks.yaml`; API формы — `services/statements.py` + маршруты в `api/endpoints/files/statements.py`;
мелкие дыры — точечные правки существующих сервисов и маршрутов. Контракт, DBML и миграции не меняются.
**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, PostgreSQL 16, pydantic v2, miniopy-async, PyYAML, pytest
(`asyncio_mode=auto`, маркеры `integration`, `fixtures`), ruff, mypy.
**Spec:** `openspec/changes/slice-3b-statements-api/` (`proposal.md`, `design.md` D-1…D-14 и решения №1–10,
`specs/osv-result-mapping`, `specs/statement-checks`, `specs/documents-api`, `specs/consolidations-api`,
`specs/directories-api`, `specs/users-admin`). Правила: `../docs/context/05-domain-rules.md` §7, §9.3, §9.5, §11, §12.
## Global Constraints
- Репозиторий arm-core, ветка `feat/36-slice-3b-statements-api`, worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/slice-3b/arm-core`;
рядом симлинки `../docs`, `../fixtures`, `../tabular-parser`, `../scan-gateway`. `make` — из корня worktree, `pytest` —
из `api/` через `.venv/bin/pytest`. Интеграционные тесты требуют `make up` (compose-проект `arm-ka`: PostgreSQL, NATS, MinIO).
- Никаких `cd` в основные checkout'ы `/Users/andrey.epifancev/projects/awp-ca/{docs,arm-core,tabular-parser,scan-gateway,fixtures}`
(они на `main`; хук guard-main блокирует записи и ломает сабагентов); читать — абсолютными путями.
- DBML v5.16 не меняется, миграций нет; новых значений enum нет. Поля — только из DBML (`financial_statement_*`, `comment`).
- Контракт `api/contracts/*` не меняется (копия v1.2 уже в ветке).
- Деньги — `Decimal`, `numeric(18,2)`; никаких `float` в суммах. Ответы API отдают суммы строкой (как `EntryOut`).
- Язык комментариев, docstring'ов, сообщений и коммитов — русский; идентификаторы — английские; строка ≤ 120 символов;
`make check` (ruff check, ruff format --check, mypy src scripts, unit-тесты) зелёный после каждой задачи.
- Права — через `require("<CODE>")` на маршруте, видимость — в сервисе (чужой = 404, как у документов): чтение формы,
комментариев, файла — `DOCUMENT_VIEW`; правка, акцепт, комментарий, подтверждение — `DOCUMENT_EDIT`; CSV чек-листа —
`CONSOLIDATION_VIEW`; `/users/brief` — `current_user` без права.
- Аудит — только через `RequestAudit.record` (append-only); правка формы и акцепт — `AuditAction.RECOGNIZED_DATA_EDIT`,
`AuditSection.RECOGNITION`, `object_type = "document"`.
- Порядок блокировок правки и акцепта — как у подтверждения: `files_repo.lock_file` → `files_repo.lock_document`.
- Коммит после каждой задачи; сообщения `feat(...)`, `test(...)`, `docs(...)` на русском; в конце строк — атрибуция из
системного напоминания сессии (`Co-Authored-By: …`, `Claude-Session: …`).
## Review Focus
1. **Одна ячейка дважды в теле PATCH** (`values` с повтором `(line_id, column_id)`): ожидается 422 `DUPLICATE_CELL`, а не
«побеждает последняя». Тест — Task 6 (`test_patch_duplicate_cell_rejected`).
2. **Сумма с тремя знаками после запятой или больше 16 цифр до запятой** в PATCH: 422 (валидация схемы), данные не
меняются. Тест — Task 6 (`test_patch_amount_precision_rejected`).
3. **Значение результата с `column_index` вне колонок формы** (контракт не запрещает): пропускается с предупреждением
маппинга, остальное маппится. Тест — Task 2 (`test_value_outside_columns_skipped`).
4. **Прерванное скачивание большого файла**: ответ S3 закрывается и отпускается (`close` + `release`) и при обрыве
клиента. Тест — Task 9 (`test_stream_releases_on_error`).
5. **Правка даты отчёта на дату не конец квартала** (2025-10-31): квартал `NULL`, документ `NEEDS_CHECK`, подтверждение —
409 `PERIOD_REQUIRED`. Тест — Task 7 (`test_confirm_after_non_quarter_date_edit`).
---
## Структура файлов
| Путь (от `api/`) | Ответственность |
|---|---|
| `rules/statement_checks.yaml` | копия словаря §7 tabular-parser (D-4) |
| `src/domain/statement_checks.py` | `load_check_rules`, `run_checks`, `cross_form_check` — чистые функции §7 |
| `src/domain/statement_mapping.py` | `statement_period`, `quarter_of`, `edition_for`, `build_statement`, `statement_status` |
| `src/domain/osv_mapping.py` | `counterparty_check` обобщается: `(inn, name, owner_inn, owner_names)` |
| `src/services/result_mapping.py` | ветка форм в `map_file`, `_load_result` без требования ОСВ, очистка форм файла |
| `src/repositories/statements.py` | загрузка формы документа, ячеек, пары для межформенной проверки, вставка строк |
| `src/schemas/statements.py` | `StatementOut`, `StatementPatch`, `ChecksAcceptIn`, вложенные модели |
| `src/services/statements.py` | `StatementService`: `get`, `patch`, `accept_checks`; правило статуса после правки |
| `src/api/endpoints/files/statements.py` | `GET/PATCH /documents/{id}/statement`, `POST …/checks/accept` |
| `src/services/documents.py` | условия подтверждения форм, `reviewed_by/at`, `comments_count`, комментарии |
| `src/api/endpoints/files/comments.py` | `POST/GET /documents/{id}/comments` |
| `src/core/storage.py`, `src/core/clients.py` | `ObjectStorage.stream` — поток объекта частями |
| `src/api/endpoints/files/read.py` | `GET /files/{id}/content` |
| `src/api/endpoints/users.py` | `GET /users/brief` |
| `src/services/legal_entities.py`, `src/repositories/directories.py`, `src/schemas/directories.py` | `documents_count` |
| `src/domain/checklist_csv.py`, `src/api/endpoints/consolidations/checklist.py` | CSV чек-листа |
| `tests/…` | по задаче; golden форм — `tests/test_mapping_statements.py` |
---
### Task 0: Подготовка окружения
**Files:** нет изменений.
- [ ] **Step 1: Окружение и базовая проверка**
```bash
cd /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3b/arm-core
make venv
make up
make check
cd api && .venv/bin/pytest -m integration -q -p no:cacheprovider tests/test_api_documents.py tests/test_result_mapping.py
```
Ожидание: `make check` зелёный; интеграционные тесты документов и маппинга зелёные. Если `make up` занят другим проектом —
compose-проект общий (`arm-ka`), достаточно, чтобы контейнеры postgres/nats/minio были healthy (`docker ps`).
- [ ] **Step 2: Коммита нет** — задача только проверяет окружение; в отчёте указать число тестов.
---
### Task 1: Проверки §7 — словарь и чистые функции
**Files:**
- Create: `api/rules/statement_checks.yaml`, `api/src/domain/statement_checks.py`
- Test: `api/tests/test_domain_statement_checks.py`
**Interfaces:**
- Produces:
- `@dataclass(frozen=True) RelationRule(form: Literal["BALANCE","INCOME"], relation: str, target: str, terms: tuple[tuple[int, str], ...], strict: bool, abs_terms: frozenset[str])`.
- `@dataclass(frozen=True) CheckRules(tolerance: Decimal, rounding_tolerance_percent: Decimal, subline_check: bool, relations: tuple[RelationRule, ...])`; метод `relations_for(form) -> tuple[RelationRule, ...]`.
- `load_check_rules(path: Path | None = None) -> CheckRules` (по умолчанию `api/rules/statement_checks.yaml`), кэш `functools.lru_cache` на путь; битый файл — `ValueError` с именем файла.
- `@dataclass(frozen=True) FailedCheck(column_index: int, relation: str, expected: Decimal, actual: Decimal)`.
- `@dataclass(frozen=True) ChecksReport(failed: tuple[FailedCheck, ...], soft_failed: tuple[FailedCheck, ...])`.
- `run_checks(form: str, lines: Sequence[tuple[str, Mapping[int, Decimal | None]]], column_indexes: Sequence[int], rules: CheckRules) -> ChecksReport` — `lines` — `(line_code, {column_index: amount})` в порядке строк документа.
- `cross_form_check(balance_1370: tuple[Decimal | None, Decimal | None], income_2400: Decimal | None, tolerance: Decimal) -> FailedCheck | None` — `(1370 на отчётную дату, 1370 на начало года)`; любой `None` → `None` (не проверялось); `FailedCheck(column_index=-1, relation="1370 − 1370(начало года) = 2400", expected=Δ1370, actual=2400)`.
- [ ] **Step 1: Скопировать словарь**
```bash
cp /Users/andrey.epifancev/projects/awp-ca/tabular-parser/api/rules/statement_checks.yaml api/rules/statement_checks.yaml
```
Дописать в начало файла две строки комментария (данные не трогать):
```yaml
# КОПИЯ tabular-parser/api/rules/statement_checks.yaml (Срез 3b, design D-4): меняется в обоих репозиториях одновременно.
# Межрепозиторного теста идентичности нет сознательно (решение №10); расхождение копий — известный риск (03-architecture).
```
- [ ] **Step 2: Падающие тесты** — `api/tests/test_domain_statement_checks.py`:
```python
"""Проверки §7 ядра (spec statement-checks; design D-4, D-5): перенос проверок tabular-parser."""
from decimal import Decimal
import pytest
from src.domain.statement_checks import (
ChecksReport,
FailedCheck,
cross_form_check,
load_check_rules,
run_checks,
)
D = Decimal
RULES = load_check_rules()
def _lines(*items: tuple[str, dict[int, str | None]]) -> list[tuple[str, dict[int, Decimal | None]]]:
return [(code, {i: (D(v) if v is not None else None) for i, v in values.items()}) for code, values in items]
def test_rules_loaded_from_copy() -> None:
assert RULES.tolerance == D("1")
assert RULES.rounding_tolerance_percent == D("0.001")
relations = [rule.relation for rule in RULES.relations_for("BALANCE")]
assert "1600 = 1100 + 1200" in relations and "1600 = 1700" in relations
income = {rule.relation: rule for rule in RULES.relations_for("INCOME")}
assert income["2100 = 2110 - 2120"].abs_terms == frozenset({"2120"})
assert income["2400 = 2300 - 2410 + 2420"].strict is False
def test_hard_violation() -> None:
report = run_checks("BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "1000"})), [0], RULES)
assert FailedCheck(0, "1600 = 1100 + 1200", D("1000"), D("900")) in report.failed
def test_relation_skipped_without_target() -> None:
report = run_checks("BALANCE", _lines(("1110", {0: "5"}), ("1150", {0: "7"})), [0], RULES)
assert report == ChecksReport(failed=(), soft_failed=())
def test_tolerance_one_unit() -> None:
report = run_checks("BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"})), [0], RULES)
assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed)
def test_abs_terms_minus_sign() -> None:
report = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "-60"}), ("2100", {0: "40"})), [0], RULES)
assert all(item.relation != "2100 = 2110 - 2120" for item in report.failed)
def test_rounding_goes_soft() -> None:
# расхождение 5 при числах ~1 000 000: 5 ≤ 1 000 000 × 0,001 % = 10 → мягкое «в пределах округления»
report = run_checks(
"BALANCE", _lines(("1100", {0: "500000"}), ("1200", {0: "500000"}), ("1600", {0: "1000005"})), [0], RULES
)
assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed)
assert any(item.relation == "1600 = 1100 + 1200" for item in report.soft_failed)
def test_soft_relation_not_hard() -> None:
report = run_checks("INCOME", _lines(("2300", {0: "100"}), ("2410", {0: "20"}), ("2400", {0: "50"})), [0], RULES)
assert all(item.relation != "2400 = 2300 - 2410 + 2420" for item in report.failed)
assert any(item.relation == "2400 = 2300 - 2410 + 2420" for item in report.soft_failed)
def test_sublines_soft() -> None:
report = run_checks("BALANCE", _lines(("1150", {0: "10"}), ("11501", {0: "6"}), ("11502", {0: "2"})), [0], RULES)
assert any(item.relation == "1150 = 11501 + 11502" for item in report.soft_failed)
assert report.failed == ()
def test_per_column_and_null_as_zero() -> None:
report = run_checks(
"BALANCE",
_lines(("1100", {0: "300", 1: None}), ("1200", {0: "600", 1: "5"}), ("1600", {0: "900", 1: "5"})),
[0, 1],
RULES,
)
assert report.failed == ()
@pytest.mark.parametrize(
("b1370", "i2400", "expected"),
[
((D("500"), D("300")), D("200"), None),
((D("500"), D("300")), D("150"), FailedCheck(-1, "1370 − 1370(начало года) = 2400", D("200"), D("150"))),
((None, D("300")), D("200"), None),
((D("500"), D("300")), None, None),
],
)
def test_cross_form(b1370: tuple[Decimal | None, Decimal | None], i2400: Decimal | None, expected: FailedCheck | None) -> None:
assert cross_form_check(b1370, i2400, D("1")) == expected
def test_broken_rules_file(tmp_path) -> None:
bad = tmp_path / "statement_checks.yaml"
bad.write_text("tolerance: 1\nrelations:\n - {form: BALANCE, relation: '16A0 = 1100', strict: true}\n", "utf-8")
with pytest.raises(ValueError, match="statement_checks.yaml"):
load_check_rules(bad)
```
- [ ] **Step 3: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/test_domain_statement_checks.py -q` → `ModuleNotFoundError`.
- [ ] **Step 4: Реализация** — `api/src/domain/statement_checks.py`:
```python
"""Логические проверки форм ББ/ОФР §7 в ядре (spec statement-checks; design D-4, D-5).
Перенос `tabular-parser/api/src/parser/statements/checks.py` по семантике: соотношение по каждой колонке, только если в
колонке есть его цель; отсутствующий код и пустая сумма — 0; правая часть считается как записано и с `abs_terms` по
модулю, выполнено, если хоть один вариант в пределах `tolerance`; жёсткое нарушение не больше
`rounding_tolerance_percent` от наибольшего по модулю числа соотношения — мягкое; «сумма пятизначных подстрок =
четырёхзначный родитель» — мягкая. Результат не хранится (DBML).
"""
import functools
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Literal
import yaml
__all__ = [
"CROSS_FORM_RELATION",
"CheckRules",
"ChecksReport",
"FailedCheck",
"RelationRule",
"cross_form_check",
"load_check_rules",
"run_checks",
]
Form = Literal["BALANCE", "INCOME"]
RULES_PATH = Path(__file__).resolve().parents[2] / "rules" / "statement_checks.yaml"
CROSS_FORM_RELATION = "1370 − 1370(начало года) = 2400"
# диапазоны четырёхзначных кодов форм для генерации подстрок (§12 п. 2; в tabular-parser — statement_headers.yaml)
CODE_RANGES: dict[str, tuple[int, int]] = {"BALANCE": (1100, 1799), "INCOME": (2100, 2999)}
_ZERO = Decimal("0")
_HUNDRED = Decimal("100")
_PARENT_LEN = 4
_GROUP_LEN = 3
_RELATION = re.compile(r"^\s*(?P<target>\d{4})\s*=\s*(?P<rhs>\d{4,5}(\s*[+\-]\s*\d{4,5})*)\s*$")
_TERM = re.compile(r"([+\-])?\s*(\d{4,5})")
@dataclass(frozen=True)
class RelationRule:
form: Form
relation: str
target: str
terms: tuple[tuple[int, str], ...]
strict: bool
abs_terms: frozenset[str] = frozenset()
@dataclass(frozen=True)
class CheckRules:
tolerance: Decimal
rounding_tolerance_percent: Decimal
subline_check: bool
relations: tuple[RelationRule, ...]
def relations_for(self, form: str) -> tuple[RelationRule, ...]:
return tuple(rule for rule in self.relations if rule.form == form)
@dataclass(frozen=True)
class FailedCheck:
column_index: int
relation: str
expected: Decimal
actual: Decimal
@dataclass(frozen=True)
class ChecksReport:
failed: tuple[FailedCheck, ...]
soft_failed: tuple[FailedCheck, ...]
def _parse_relation(form: Form, relation: str, strict: bool, abs_terms: Sequence[str]) -> RelationRule:
found = _RELATION.match(relation)
if found is None:
raise ValueError(f"соотношение «{relation}»: ожидается «<код> = <код> [+|-] <код> …»")
terms = tuple((-1 if sign == "-" else 1, code) for sign, code in _TERM.findall(found.group("rhs")))
unknown = set(abs_terms) - {code for _, code in terms}
if unknown:
raise ValueError(f"abs_terms {sorted(unknown)} нет в правой части «{relation}»")
return RelationRule(form, relation, found.group("target"), terms, strict, frozenset(abs_terms))
@functools.lru_cache(maxsize=4)
def load_check_rules(path: Path | None = None) -> CheckRules:
"""Словарь §7 из yaml; любая ошибка — `ValueError` с именем файла."""
source = path or RULES_PATH
try:
data = yaml.safe_load(source.read_text(encoding="utf-8"))
relations = tuple(
_parse_relation(item["form"], item["relation"], bool(item["strict"]), item.get("abs_terms", ()))
for item in data["relations"]
)
for rule in relations:
if rule.form not in CODE_RANGES:
raise ValueError(f"форма {rule.form!r}: ожидается BALANCE или INCOME")
return CheckRules(
tolerance=Decimal(str(data["tolerance"])),
rounding_tolerance_percent=Decimal(str(data.get("rounding_tolerance_percent", 0))),
subline_check=bool(data.get("subline_check", True)),
relations=relations,
)
except (OSError, KeyError, TypeError, ValueError, InvalidOperation, yaml.YAMLError) as exc:
raise ValueError(f"словарь проверок {source.name}: {exc}") from exc
def _rhs(amounts: Mapping[str, Decimal], rule: RelationRule, *, absolute: bool) -> Decimal:
total = _ZERO
for sign, code in rule.terms:
value = amounts.get(code, _ZERO)
if absolute and code in rule.abs_terms:
value = abs(value)
total += sign * value
return total
def _holds(amounts: Mapping[str, Decimal], rule: RelationRule, tolerance: Decimal) -> tuple[bool, Decimal, Decimal]:
expected = amounts.get(rule.target, _ZERO)
plain = _rhs(amounts, rule, absolute=False)
if abs(expected - plain) <= tolerance:
return True, expected, plain
if rule.abs_terms:
with_abs = _rhs(amounts, rule, absolute=True)
if abs(expected - with_abs) <= tolerance:
return True, expected, with_abs
if abs(expected - with_abs) < abs(expected - plain):
return False, expected, with_abs
return False, expected, plain
def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]:
"""«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками (по порядку строк, M-9 2b)."""
low, high = CODE_RANGES[form]
parents = {code for code in codes if len(code) == _PARENT_LEN}
children: dict[str, list[str]] = {}
last_parent: str | None = None
for code in codes:
if len(code) == _PARENT_LEN:
last_parent = code
continue
if len(code) != _PARENT_LEN + 1:
continue
if last_parent is not None and last_parent[:_GROUP_LEN] == code[:_GROUP_LEN]:
parent = last_parent
elif code[:_PARENT_LEN] in parents:
parent = code[:_PARENT_LEN]
else:
continue
children.setdefault(parent, []).append(code)
rules: list[RelationRule] = []
for parent, kids in children.items():
if parent.isdigit() and low <= int(parent) <= high:
relation = f"{parent} = {' + '.join(kids)}"
rules.append(RelationRule(form, relation, parent, tuple((1, kid) for kid in kids), False)) # type: ignore[arg-type]
return rules
def run_checks(
form: str,
lines: Sequence[tuple[str, Mapping[int, Decimal | None]]],
column_indexes: Sequence[int],
rules: CheckRules,
) -> ChecksReport:
"""Проверки §7 формы `form` по колонкам `column_indexes`; `lines` — `(код, {колонка: сумма})` по порядку строк."""
explicit = list(rules.relations_for(form))
sublines = _subline_rules([code for code, _ in lines], form) if rules.subline_check else []
hard: list[FailedCheck] = []
soft: list[FailedCheck] = []
for column_index in column_indexes:
amounts = {code: (values.get(column_index) or _ZERO) for code, values in lines}
for rule in [*explicit, *sublines]:
if rule not in sublines and rule.target not in amounts:
continue
holds, expected, actual = _holds(amounts, rule, rules.tolerance)
if holds:
continue
failed = FailedCheck(column_index, rule.relation, expected, actual)
scale = max([abs(expected), *(abs(amounts.get(code, _ZERO)) for _, code in rule.terms)])
rounding = scale * rules.rounding_tolerance_percent / _HUNDRED
if rule.strict and abs(expected - actual) > rounding:
hard.append(failed)
else:
soft.append(failed)
return ChecksReport(failed=tuple(hard), soft_failed=tuple(soft))
def cross_form_check(
balance_1370: tuple[Decimal | None, Decimal | None], income_2400: Decimal | None, tolerance: Decimal
) -> FailedCheck | None:
"""Межформенная §7 (мягкая, D-5): `1370(отчётная дата) − 1370(начало года) = 2400(период)`; неполные данные — None."""
current, year_start = balance_1370
if current is None or year_start is None or income_2400 is None:
return None
delta = current - year_start
if abs(delta - income_2400) <= tolerance:
return None
return FailedCheck(-1, CROSS_FORM_RELATION, delta, income_2400)
```
Примечание: `RelationRule` у подстрок строится с `form` типа `str`; если mypy ругается — привести `form` к `Form` через
`cast(Form, form)` вместо `# type: ignore`.
- [ ] **Step 5: Тесты и линт** — `cd api && .venv/bin/pytest tests/test_domain_statement_checks.py -q && cd .. && make lint` → PASS.
- [ ] **Step 6: Commit**
```bash
git add api/rules/statement_checks.yaml api/src/domain/statement_checks.py api/tests/test_domain_statement_checks.py
git commit -m "feat(statements): проверки §7 в ядре — перенос tabular-parser, словарь statement_checks.yaml (D-4, D-5)"
```
---
### Task 2: Чистые функции маппинга формы
**Files:**
- Create: `api/src/domain/statement_mapping.py`
- Modify: `api/src/domain/osv_mapping.py` (обобщить `counterparty_check`)
- Test: `api/tests/test_domain_statement_mapping.py`, `api/tests/test_domain_osv_mapping.py` (адаптация вызовов)
**Interfaces:**
- Consumes: `src.contract.result.StatementDocument` (`header`, `columns`, `lines`), `ChecksReport`, `run_checks`, `load_check_rules` (Task 1).
- Produces (в `src/domain/statement_mapping.py`):
- `quarter_of(day: date) -> PeriodQuarter | None` — `Q1..Q4` только для 31.03, 30.06, 30.09, 31.12.
- `statement_period(report_date: date | None, column_dates: Sequence[date | None]) -> date | None` — дата отчёта, иначе максимальная дата колонок, иначе `None`.
- `edition_for(period_date: date | None) -> str | None` — `"66n"` при ≤ 2024-12-31, `"fsbu4"` позже, `None` без даты.
- `@dataclass ValueDraft(line_code: str, column_index: int, amount: Decimal | None, string_value: str | None, confidence: Decimal | None, is_recognized: bool | None)`.
- `@dataclass LineDraft(line_code: str, line_code_confidence: Decimal | None, line_name: str, line_name_confidence: Decimal | None)`.
- `@dataclass StatementDraft(document_type: DocumentType, statement_type: StatementType, locator: str, period_date: date | None, period_year: int | None, period_quarter: PeriodQuarter | None, header: dict[str, Any], columns: list[tuple[int, str, date | None, bool | None]], lines: list[LineDraft], values: list[ValueDraft], warnings: list[str])` — `header` — поля `financial_statement_meta` (ключи — имена колонок ORM), `columns` — `(column_index, label, period_date, is_current)`.
- `build_statement(doc: StatementDocument) -> StatementDraft` — D-2 (значение на каждую пару строка × колонка, повтор кода, значение вне колонок, период, `is_current`).
- `statement_status(*, job_done: bool, checks_passed: bool, counterparty: DocumentCheckResult, period_known: bool, values_ok: bool, has_lines: bool, warnings: Sequence[str]) -> DocumentStatus` — D-6.
- `values_ok(values: Iterable[tuple[bool, bool | None, Decimal | None]]) -> bool` — по кортежам `(is_modified, is_recognized, confidence)`.
- Produces (в `src/domain/osv_mapping.py`): `counterparty_check(inn: str | None, name: str | None, owner_inn: str, owner_names_normalized: frozenset[str]) -> DocumentCheckResult`; вызовы для ОСВ передают `header.organization_inn`, `header.organization_name`.
- [ ] **Step 1: Падающие тесты** — `api/tests/test_domain_statement_mapping.py`:
```python
"""Чистые функции маппинга формы (spec osv-result-mapping «Документ формы ББ/ОФР»; design D-2, D-3, D-6, D-14)."""
import datetime
from decimal import Decimal
import pytest
from src.contract.result import StatementDocument
from src.domain.statement_mapping import (
build_statement,
edition_for,
quarter_of,
statement_period,
statement_status,
values_ok,
)
from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementType
d = datetime.date
@pytest.mark.parametrize(
("day", "quarter"),
[(d(2025, 3, 31), PeriodQuarter.Q1), (d(2025, 6, 30), PeriodQuarter.Q2), (d(2025, 9, 30), PeriodQuarter.Q3),
(d(2024, 12, 31), PeriodQuarter.Q4), (d(2025, 10, 31), None), (d(2025, 6, 29), None)],
)
def test_quarter_of(day: datetime.date, quarter: PeriodQuarter | None) -> None:
assert quarter_of(day) is quarter
def test_period_and_edition() -> None:
assert statement_period(d(2025, 9, 30), [d(2024, 12, 31)]) == d(2025, 9, 30)
assert statement_period(None, [d(2024, 12, 31), d(2025, 6, 30), None]) == d(2025, 6, 30)
assert statement_period(None, [None]) is None
assert edition_for(d(2024, 12, 31)) == "66n" and edition_for(d(2025, 3, 31)) == "fsbu4" and edition_for(None) is None
def _doc(**overrides: object) -> StatementDocument:
data: dict[str, object] = {
"document_type": "BALANCE",
"locator": "sheet:ББ",
"header": {"form_name": "Бухгалтерский баланс", "inn": "7700000000", "inn_confidence": 100,
"report_date": "2025-09-30", "report_date_text": "на 30 сентября 2025 г.", "units": "тыс. руб.",
"organization_name": "ООО Пример", "okud": "0710001"},
"columns": [{"column_index": 0, "label": "На 30.09.2025", "period_date": "2025-09-30"},
{"column_index": 1, "label": "На 31.12.2024", "period_date": "2024-12-31"}],
"lines": [
{"line_code": "1150", "line_code_confidence": 100, "line_name": "ОС", "line_name_confidence": 100,
"values": [{"column_index": 0, "amount": "10", "confidence": 100, "is_recognized": True}]},
{"line_code": "1150", "line_name": "дубль", "values": []},
{"line_code": "1600", "line_name": "БАЛАНС",
"values": [{"column_index": 5, "amount": "1", "confidence": 100, "is_recognized": True}]},
],
}
data.update(overrides)
return StatementDocument.model_validate(data)
def test_build_statement_cells_and_header() -> None:
draft = build_statement(_doc())
assert draft.document_type is DocumentType.BALANCE and draft.statement_type is StatementType.balance
assert (draft.period_year, draft.period_quarter, draft.period_date) == (2025, PeriodQuarter.Q3, d(2025, 9, 30))
assert draft.header["inn"] == "7700000000" and draft.header["inn_original_value"] == "7700000000"
assert draft.header["inn_verified"] is True
assert draft.header["report_date_original_value"] == "на 30 сентября 2025 г."
assert draft.header["units"] == draft.header["units_original_value"] == "тыс. руб."
assert draft.columns == [(0, "На 30.09.2025", d(2025, 9, 30), True), (1, "На 31.12.2024", d(2024, 12, 31), False)]
assert [line.line_code for line in draft.lines] == ["1150", "1600"] # дубль 1150 отброшен
cells = {(v.line_code, v.column_index): v for v in draft.values}
assert len(cells) == 4 # 2 строки × 2 колонки
assert cells[("1150", 0)].amount == Decimal("10") and cells[("1150", 1)].amount is None
assert cells[("1150", 1)].is_recognized is None and cells[("1600", 0)].confidence is None
assert any("1150" in w and "повтор" in w for w in draft.warnings)
assert any("колонк" in w and "5" in w for w in draft.warnings)
def test_value_outside_columns_skipped() -> None:
"""Review Focus 3: значение с column_index вне колонок — пропущено с предупреждением, остальное смаплено."""
draft = build_statement(_doc())
assert all(v.column_index in (0, 1) for v in draft.values)
def test_period_from_columns_and_non_quarter() -> None:
no_date = _doc(header={"form_name": "ББ", "report_date": None})
draft = build_statement(no_date)
assert (draft.period_year, draft.period_quarter) == (2025, PeriodQuarter.Q3)
assert draft.header["report_date"] is None
october = _doc(header={"form_name": "ББ", "report_date": "2025-10-31"})
draft = build_statement(october)
assert (draft.period_year, draft.period_quarter) == (2025, None)
assert any("конец квартала" in w for w in draft.warnings)
def test_no_dates_at_all() -> None:
draft = build_statement(_doc(header={"form_name": "ББ"}, columns=[{"column_index": 0, "label": ""}]))
assert (draft.period_year, draft.period_quarter, draft.period_date) == (None, None, None)
assert draft.columns == [(0, "", None, None)]
def test_status_rules() -> None:
ok = dict(job_done=True, checks_passed=True, counterparty=DocumentCheckResult.MATCH, period_known=True,
values_ok=True, has_lines=True, warnings=[])
assert statement_status(**ok) is DocumentStatus.RECOGNIZED # type: ignore[arg-type]
for key, bad in [("job_done", False), ("checks_passed", False), ("counterparty", DocumentCheckResult.MISMATCH),
("period_known", False), ("values_ok", False), ("has_lines", False), ("warnings", ["x"])]:
assert statement_status(**{**ok, key: bad}) is DocumentStatus.NEEDS_CHECK # type: ignore[arg-type]
assert statement_status(**{**ok, "counterparty": DocumentCheckResult.UNKNOWN}) is DocumentStatus.RECOGNIZED # type: ignore[arg-type]
def test_values_ok() -> None:
hundred = Decimal("100")
assert values_ok([(False, True, hundred), (False, None, None)])
assert not values_ok([(False, False, hundred)])
assert not values_ok([(False, True, Decimal("99"))])
assert values_ok([(True, False, Decimal("10"))]) # правленое аналитиком значение не тянет статус вниз
```
В `api/tests/test_domain_osv_mapping.py` найти вызовы `counterparty_check(header, …)` (`grep -n counterparty_check`) и
переписать на `counterparty_check(header.organization_inn, header.organization_name, …)`; добавить тест:
```python
def test_counterparty_check_plain_values() -> None:
names = frozenset({normalize_alias("ООО Ромашка")})
assert counterparty_check("7700000000", None, "7700000000", names) is DocumentCheckResult.MATCH
assert counterparty_check(None, "ООО «Ромашка»", "7700000001", names) is DocumentCheckResult.MATCH
assert counterparty_check("3121200351", "мусор", "7700000000", names) is DocumentCheckResult.MISMATCH
assert counterparty_check(None, None, "7700000000", names) is DocumentCheckResult.UNKNOWN
```
- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/test_domain_statement_mapping.py tests/test_domain_osv_mapping.py -q`.
- [ ] **Step 3: `counterparty_check`** в `src/domain/osv_mapping.py`:
```python
def counterparty_check(
inn: str | None, name: str | None, owner_inn: str, owner_names_normalized: frozenset[str]
) -> DocumentCheckResult:
"""D-8 Среза 2c, общий для ОСВ и форм (Срез 3b): организация в шапке — владелец файла?
ИНН шапки есть → `MATCH`, если его цифры равны ИНН владельца, иначе `MISMATCH`. ИНН нет →
`normalize_alias(name)` среди нормализованных имени и алиасов владельца → `MATCH`, иначе `MISMATCH`.
Нет ни ИНН, ни имени → `UNKNOWN`.
"""
header_inn = _NON_DIGITS_RE.sub("", inn or "")
if header_inn:
return (
DocumentCheckResult.MATCH
if header_inn == _NON_DIGITS_RE.sub("", owner_inn)
else DocumentCheckResult.MISMATCH
)
normalized = normalize_alias(name)
if not normalized:
return DocumentCheckResult.UNKNOWN
return DocumentCheckResult.MATCH if normalized in owner_names_normalized else DocumentCheckResult.MISMATCH
```
В `src/services/result_mapping.py` вызов заменить на
`counterparty_check(sheet.header.organization_inn, sheet.header.organization_name, owner.inn, owner_names)`; убрать
неиспользуемый импорт `OsvHeader` из `osv_mapping.py`, если он больше не нужен.
- [ ] **Step 4: `statement_mapping.py`**:
```python
"""Чистые функции маппинга формы ББ/ОФР (`statementDocument` v1.2) в черновики `financial_statement_*`
(spec osv-result-mapping «Документ формы ББ/ОФР», «Статус документа формы»; design D-2, D-3, D-6, D-14)."""
import datetime
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from src.contract.result import StatementDocument
from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementType
__all__ = [
"LineDraft",
"StatementDraft",
"ValueDraft",
"build_statement",
"edition_for",
"quarter_of",
"statement_period",
"statement_status",
"values_ok",
]
FULL_CONFIDENCE = Decimal("100")
EDITION_BOUNDARY = datetime.date(2024, 12, 31)
_QUARTER_ENDS = {(3, 31): PeriodQuarter.Q1, (6, 30): PeriodQuarter.Q2, (9, 30): PeriodQuarter.Q3, (12, 31): PeriodQuarter.Q4}
_STATEMENT_TYPES = {DocumentType.BALANCE: StatementType.balance, DocumentType.INCOME: StatementType.income}
@dataclass
class ValueDraft:
line_code: str
column_index: int
amount: Decimal | None
string_value: str | None
confidence: Decimal | None
is_recognized: bool | None
@dataclass
class LineDraft:
line_code: str
line_code_confidence: Decimal | None
line_name: str
line_name_confidence: Decimal | None
@dataclass
class StatementDraft:
document_type: DocumentType
statement_type: StatementType
locator: str
period_date: datetime.date | None
period_year: int | None
period_quarter: PeriodQuarter | None
header: dict[str, Any]
columns: list[tuple[int, str, datetime.date | None, bool | None]]
lines: list[LineDraft]
values: list[ValueDraft]
warnings: list[str] = field(default_factory=list)
def quarter_of(day: datetime.date) -> PeriodQuarter | None:
return _QUARTER_ENDS.get((day.month, day.day))
def statement_period(report_date: datetime.date | None, column_dates: Sequence[datetime.date | None]) -> datetime.date | None:
"""D-2: дата отчёта, иначе самая поздняя дата колонок."""
if report_date is not None:
return report_date
dates = [day for day in column_dates if day is not None]
return max(dates) if dates else None
def edition_for(period_date: datetime.date | None) -> str | None:
"""D-14: редакция формы для шаблона фронта."""
if period_date is None:
return None
return "66n" if period_date <= EDITION_BOUNDARY else "fsbu4"
def _decimal(value: float | None) -> Decimal | None:
return Decimal(str(value)) if value is not None else None
def build_statement(doc: StatementDocument) -> StatementDraft:
"""Черновик формы `BALANCE`/`INCOME` (D-2): шапка, колонки, строки (повтор кода — первая), значение на каждую ячейку."""
document_type = DocumentType(doc.document_type)
header = doc.header
warnings: list[str] = []
column_dates = [column.period_date for column in doc.columns]
period_date = statement_period(header.report_date, column_dates)
period_year = period_date.year if period_date is not None else None
period_quarter = quarter_of(period_date) if period_date is not None else None
if period_date is None:
warnings.append(f"{doc.locator}: период формы не определён (нет даты отчёта и дат колонок)")
elif period_quarter is None:
warnings.append(f"{doc.locator}: дата {period_date.isoformat()} не конец квартала — квартал не определён")
latest = max((day for day in column_dates if day is not None), default=None)
columns = [
(
column.column_index,
column.label,
column.period_date,
None if latest is None else column.period_date == latest
and column.column_index == min(c.column_index for c in doc.columns if c.period_date == latest),
)
for column in doc.columns
]
known_columns = {column.column_index for column in doc.columns}
lines: list[LineDraft] = []
values: list[ValueDraft] = []
seen: set[str] = set()
for line in doc.lines:
if line.line_code in seen:
warnings.append(f"{doc.locator}: код {line.line_code} повторяется — оставлена первая строка")
continue
seen.add(line.line_code)
lines.append(
LineDraft(line.line_code, _decimal(line.line_code_confidence), line.line_name, _decimal(line.line_name_confidence))
)
by_column = {}
for value in line.values:
if value.column_index not in known_columns:
warnings.append(
f"{doc.locator}: строка {line.line_code}: значение колонки {value.column_index} вне колонок формы пропущено"
)
continue
by_column.setdefault(value.column_index, value)
for column in doc.columns:
value = by_column.get(column.column_index)
values.append(
ValueDraft(
line.line_code,
column.column_index,
value.amount if value is not None else None,
value.string_value if value is not None else None,
_decimal(value.confidence) if value is not None else None,
value.is_recognized if value is not None else None,
)
)
inn_confidence = _decimal(header.inn_confidence)
meta = {
"inn": header.inn,
"inn_original_value": header.inn,
"inn_confidence": inn_confidence,
"inn_verified": inn_confidence == FULL_CONFIDENCE if header.inn is not None else None,
"inn_modified": False,
"organization_name": header.organization_name,
"report_date": header.report_date,
"report_date_original_value": header.report_date_text,
"report_date_verified": header.report_date is not None,
"report_date_modified": False,
"units": header.units,
"units_original_value": header.units,
"units_verified": header.units is not None,
"units_modified": False,
"okud": header.okud,
"okpo": header.okpo,
"okopf": header.okopf,
"okfs": header.okfs,
"address": header.address,
}
return StatementDraft(
document_type=document_type,
statement_type=_STATEMENT_TYPES[document_type],
locator=doc.locator,
period_date=period_date,
period_year=period_year,
period_quarter=period_quarter,
header=meta,
columns=columns,
lines=lines,
values=values,
warnings=warnings,
)
def values_ok(values: Iterable[tuple[bool, bool | None, Decimal | None]]) -> bool:
"""D-6: каждое значение правлено аналитиком или распознано без сомнений (`is_recognized` не false, уверенность 100/NULL)."""
return all(
modified or (recognized is not False and (confidence is None or confidence == FULL_CONFIDENCE))
for modified, recognized, confidence in values
)
def statement_status( # noqa: PLR0913 - условия D-6 перечислены явно
*,
job_done: bool,
checks_passed: bool,
counterparty: DocumentCheckResult,
period_known: bool,
values_ok: bool,
has_lines: bool,
warnings: Sequence[str],
) -> DocumentStatus:
"""D-6: `RECOGNIZED` только при всех условиях; иначе `NEEDS_CHECK`."""
recognized = (
job_done and checks_passed and counterparty != DocumentCheckResult.MISMATCH and period_known
and values_ok and has_lines and not warnings
)
return DocumentStatus.RECOGNIZED if recognized else DocumentStatus.NEEDS_CHECK
```
Упростить вычисление `is_current` при ревью можно, но семантика фиксирована: `True` у колонки с самой поздней датой (при
равенстве — с меньшим `column_index`), `False` у остальных при наличии дат, `None` у всех без дат. Тип `confidence` в
контракте (`Confidence = float | None`) — поэтому `_decimal` через `str`.
- [ ] **Step 5: Тесты и линт** — `cd api && .venv/bin/pytest tests/test_domain_statement_mapping.py tests/test_domain_osv_mapping.py -q && cd .. && make lint` → PASS.
- [ ] **Step 6: Commit**
```bash
git add api/src/domain/statement_mapping.py api/src/domain/osv_mapping.py api/src/services/result_mapping.py api/tests/test_domain_statement_mapping.py api/tests/test_domain_osv_mapping.py
git commit -m "feat(statements): чистые функции маппинга формы — период, черновик, статус; counterparty_check общий (D-2, D-6)"
```
---
### Task 3: Помощники golden-тестов форм
**Files:**
- Modify: `api/tests/fixtures_helpers.py`
- Test: `api/tests/test_fixtures_marker.py` (дописать проверки помощников)
**Interfaces:**
- Produces:
- `ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> RecognitionResult` — эталон нормализатора
`fixtures/ocr/expected_normalize/<name>.json` (`{status, warnings, metrics, error, documents}`) как `RecognitionResult`:
`schema_version = "1.2"`, `service = {"name": "scan-gateway", "version": "golden"}`, `status`, `documents`,
`error` и `metrics` как есть (если не `None`).
- `map_golden_snapshot(..., result_builder=golden_result)` — новый необязательный именованный параметр
`result_builder: Callable[..., RecognitionResult]`; возвращает **все** документы файла, отсортированные по
`(document_type, osv_account_code)` (было — только по `osv_account_code`).
- Константы: `GOLDEN_STATEMENTS = Path("golden") / "expected_statement_parse"`,
`OCR_EXPECTED = Path("ocr") / "expected_normalize"`.
- [ ] **Step 1: Падающий тест** — в `api/tests/test_fixtures_marker.py` дописать:
```python
@pytest.mark.fixtures
def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None:
import uuid
from tests.fixtures_helpers import OCR_EXPECTED, ocr_result
job_id, file_id = uuid.uuid4(), uuid.uuid4()
result = ocr_result(fixtures_dir / OCR_EXPECTED / "aep_6.json", job_id=job_id, file_id=file_id)
assert result.job_id == job_id and result.file_id == file_id
assert result.service.name == "scan-gateway"
assert [doc.document_type for doc in result.documents] == ["BALANCE", "INCOME"]
```
- [ ] **Step 2: Убедиться, что падает** — `cd api && .venv/bin/pytest tests/test_fixtures_marker.py -q -k ocr_result`.
- [ ] **Step 3: Реализация** в `api/tests/fixtures_helpers.py`:
```python
GOLDEN_STATEMENTS = Path("golden") / "expected_statement_parse"
OCR_EXPECTED = Path("ocr") / "expected_normalize"
def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult":
"""Эталон нормализатора scan-gateway (`ocr/expected_normalize/<name>.json`) как `RecognitionResult` контракта v1.2."""
from src.contract.result import RecognitionResult # noqa: PLC0415 - цикл с conftest
data = json.loads(path.read_text("utf-8"))
payload: dict[str, object] = {
"schema_version": "1.2",
"job_id": str(job_id),
"file_id": str(file_id),
"service": {"name": "scan-gateway", "version": "golden"},
"status": data["status"],
"documents": data["documents"],
}
if data.get("error"):
payload["error"] = data["error"]
if data.get("metrics"):
payload["metrics"] = data["metrics"]
return RecognitionResult.model_validate(payload)
```
В `map_golden_snapshot` добавить параметр `result_builder: "Callable[..., RecognitionResult] | None" = None` (по
умолчанию `golden_result`), использовать `(result_builder or golden_result)(path, job_id=job.id, file_id=file.id)`;
сортировку документов заменить на `.order_by(Document.document_type, Document.osv_account_code)`. Существующие вызовы
не меняются (ОСВ-документы — те же; сортировка по типу для ОСВ ничего не меняет).
- [ ] **Step 4: Тесты** — `cd api && .venv/bin/pytest tests/test_fixtures_marker.py -q && cd .. && make lint` → PASS.
- [ ] **Step 5: Commit**
```bash
git add api/tests/fixtures_helpers.py api/tests/test_fixtures_marker.py
git commit -m "test(statements): помощники golden — ocr_result, все документы файла в map_golden_snapshot"
```
---
### Task 4: Маппинг форм в `map_file`
**Files:**
- Create: `api/src/repositories/statements.py`
- Modify: `api/src/services/result_mapping.py` (`_load_result`, `_clear_file`, `map_file`)
- Test: `api/tests/test_mapping_statements.py` (новый), `api/tests/test_result_mapping.py` (сценарий «нет документов ОСВ» → «нет документов»)
**Interfaces:**
- Consumes: `build_statement`, `statement_status`, `values_ok` (Task 2); `run_checks`, `load_check_rules` (Task 1);
`counterparty_check` (Task 2); `ocr_result`, `map_golden_snapshot`, `GOLDEN_STATEMENTS`, `OCR_EXPECTED` (Task 3).
- Produces:
- `repositories/statements.py`:
- `async def insert_statement(session, *, document_id: uuid.UUID, job_id: uuid.UUID, draft: StatementDraft) -> FinancialStatementMeta` — вставляет meta, колонки, строки и значения (пакетами по 1000), возвращает meta.
- `async def delete_file_statements(session, file_id: uuid.UUID) -> None` — удаляет `financial_statement_value/line/column/meta` документов файла (в порядке FK).
- `async def statement_amounts(session, meta_id: uuid.UUID) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]` — индексы колонок и `(line_code, {column_index: amount})` по порядку строк (по `line_code`) — вход `run_checks`.
- `async def value_flags(session, meta_id: uuid.UUID) -> list[tuple[bool, bool | None, Decimal | None]]` — `(is_modified, is_recognized, confidence)` всех значений — вход `values_ok`.
- `services/result_mapping.py`: константа `NO_DOCUMENTS = "в результате нет документов"`; `MappingFatal(NO_DOCUMENTS)` вместо прежнего текста; формы маппятся после листов ОСВ.
- Статус формы при маппинге: `checks_passed = not run_checks(...).failed` (акцепта при маппинге нет).
- [ ] **Step 1: Падающие тесты** — `api/tests/test_mapping_statements.py`:
```python
"""Маппинг форм ББ/ОФР (spec osv-result-mapping «Цикл маппинга…», «Документ формы ББ/ОФР», «Повтор формы в файле и
прочие документы», «Статус документа формы»; design D-1…D-3, D-6) на golden-снимках fixtures."""
import uuid
from decimal import Decimal
from pathlib import Path
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, FileStatus, PeriodQuarter
from src.models.files import Document, StoredFile
from src.models.statements import (
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
from tests.factories import make_legal_entity
from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, map_golden_snapshot, ocr_result
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3"
NPZ = GOLDEN_STATEMENTS / "npz" / "2025-Q3"
SYNTH = GOLDEN_STATEMENTS / "synthetic"
async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta:
return (
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
).scalar_one()
async def _count(session: AsyncSession, model: type, meta_id: uuid.UUID) -> int:
if model is FinancialStatementValue:
stmt = (
select(func.count())
.select_from(FinancialStatementValue)
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
.where(FinancialStatementLine.meta_id == meta_id)
)
else:
stmt = select(func.count()).select_from(model).where(model.meta_id == meta_id)
return (await session.execute(stmt)).scalar_one()
async def _owner(session: AsyncSession, path: Path) -> object:
"""Владелец файла с ИНН шапки формы (MATCH), чтобы чистые формы могли быть RECOGNIZED."""
import json
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 test_file_with_forms_only(seeded: AsyncSession, fixtures_dir: Path) -> None:
path = fixtures_dir / MEZH / "1_Форма1_бух_09.json"
owner = await _owner(seeded, path)
documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
assert [d.document_type for d in documents] == [DocumentType.BALANCE]
balance = documents[0]
assert (balance.period_year, balance.period_quarter) == (2025, PeriodQuarter.Q3)
assert balance.reporting_date.isoformat() == "2025-09-30"
assert balance.osv_account_code is None and balance.is_cumulative is None
meta = await _meta(seeded, balance)
lines = await _count(seeded, FinancialStatementLine, meta.id)
columns = await _count(seeded, FinancialStatementColumn, meta.id)
assert lines == 37
assert await _count(seeded, FinancialStatementValue, meta.id) == lines * columns
file = await seeded.get(StoredFile, balance.stored_file_id)
assert file.status is FileStatus.PROCESSED
async def test_clean_income_recognized(seeded: AsyncSession, fixtures_dir: Path) -> None:
path = fixtures_dir / MEZH / "1_Форма2_бух_09.json"
owner = await _owner(seeded, path)
(income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
assert income.document_type is DocumentType.INCOME
assert income.counterparty_check is DocumentCheckResult.MATCH
assert income.status is DocumentStatus.RECOGNIZED
async def test_book_with_two_forms(seeded: AsyncSession, fixtures_dir: Path) -> None:
path = next((fixtures_dir / NPZ).glob("*.json"))
owner = await _owner(seeded, path)
documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
by_type = {d.document_type: d for d in documents}
assert set(by_type) == {DocumentType.BALANCE, DocumentType.INCOME}
assert await _count(seeded, FinancialStatementLine, (await _meta(seeded, by_type[DocumentType.BALANCE])).id) == 132
assert await _count(seeded, FinancialStatementLine, (await _meta(seeded, by_type[DocumentType.INCOME])).id) == 43
async def test_disclosures_merged(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)
types = [d.document_type for d in documents]
assert types.count(DocumentType.DISCLOSURE) == 1 and DocumentType.INCOME in types
disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE)
assert disclosure.status is DocumentStatus.RECOGNIZED and disclosure.locator.count(",") == 4
async def test_form_without_codes(seeded: AsyncSession, fixtures_dir: Path) -> None:
owner = await make_legal_entity(seeded, name="ООО Без кодов")
(balance,) = await map_golden_snapshot(seeded, entity=owner, path=fixtures_dir / SYNTH / "st03_no_codes.json", uploaded_by=None)
assert balance.status is DocumentStatus.NEEDS_CHECK
meta = await _meta(seeded, balance)
assert await _count(seeded, FinancialStatementLine, meta.id) == 0
assert await _count(seeded, FinancialStatementColumn, meta.id) >= 1
async def test_mixed_book_osv_and_balance(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 / "st04_mixed_book.json", uploaded_by=None)
assert {d.document_type for d in documents} == {DocumentType.OSV, DocumentType.BALANCE}
async def test_ocr_form_without_report_date(seeded: AsyncSession, fixtures_dir: Path) -> None:
owner = await make_legal_entity(seeded, name="ООО Дорстроймонтаж")
documents = await map_golden_snapshot(
seeded, entity=owner, path=fixtures_dir / OCR_EXPECTED / "aep_6.json", uploaded_by=None, result_builder=ocr_result
)
balance = next(d for d in documents if d.document_type is DocumentType.BALANCE)
assert (balance.period_year, balance.period_quarter) == (2025, PeriodQuarter.Q2)
meta = await _meta(seeded, balance)
assert meta.report_date is None and meta.inn is None
current = (
await seeded.execute(
select(FinancialStatementColumn.period_date).where(
FinancialStatementColumn.meta_id == meta.id, FinancialStatementColumn.is_current.is_(True)
)
)
).scalar_one()
assert current.isoformat() == "2025-06-30"
assert balance.status is DocumentStatus.NEEDS_CHECK
async def test_ocr_noisy_form_mismatch(seeded: AsyncSession, fixtures_dir: Path) -> None:
owner = await make_legal_entity(seeded, name="ООО Другое", inn="7700000000")
documents = await map_golden_snapshot(
seeded, entity=owner, path=fixtures_dir / OCR_EXPECTED / "aep_3.json", uploaded_by=None, result_builder=ocr_result
)
balance = next(d for d in documents if d.document_type is DocumentType.BALANCE)
assert balance.counterparty_check is DocumentCheckResult.MISMATCH and balance.status is DocumentStatus.NEEDS_CHECK
async def test_all_statement_snapshots_map(seeded: AsyncSession, fixtures_dir: Path) -> None:
"""Все golden форм (Excel и OCR, кроме ответов-ошибок) маппятся без исключений; у каждой формы значения на все ячейки."""
owner = await make_legal_entity(seeded, name="ООО Все формы")
excel = sorted((fixtures_dir / GOLDEN_STATEMENTS).rglob("*.json"))
ocr = sorted(p for p in (fixtures_dir / OCR_EXPECTED).glob("*.json"))
assert len(excel) >= 25
mapped = 0
for path in excel:
await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
mapped += 1
import json
for path in ocr:
if json.loads(path.read_text("utf-8"))["status"] == "error":
continue
await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None, result_builder=ocr_result)
mapped += 1
assert mapped >= 27
metas = (await seeded.execute(select(FinancialStatementMeta.id))).scalars().all()
for meta_id in metas:
lines = await _count(seeded, FinancialStatementLine, meta_id)
columns = await _count(seeded, FinancialStatementColumn, meta_id)
assert await _count(seeded, FinancialStatementValue, meta_id) == lines * columns
```
В `api/tests/test_result_mapping.py` найти сценарий с текстом «нет документов ОСВ» (`grep -n "нет документов ОСВ"`): для
результата **без документов** ожидается `"в результате нет документов"`; результат только с `BALANCE` больше не ошибка —
если в файле есть тест «результат без ОСВ → ERROR» с формой, переписать его на `documents = []`.
- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/test_mapping_statements.py -q -p no:cacheprovider` (нужен `make up`).
- [ ] **Step 3: Репозиторий** — `api/src/repositories/statements.py`:
```python
"""Таблицы `financial_statement_*` (Срез 3b): вставка формы при маппинге, очистка файла, чтение сумм и флагов значений.
Только SQL, без правил: правила — `domain/statement_mapping.py`, `domain/statement_checks.py`."""
import uuid
from collections.abc import Sequence
from decimal import Decimal
from sqlalchemy import delete, insert, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.statement_mapping import StatementDraft
from src.models.files import Document
from src.models.statements import (
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
BATCH = 1000
async def insert_statement(
session: AsyncSession, *, document_id: uuid.UUID, job_id: uuid.UUID, draft: StatementDraft
) -> FinancialStatementMeta:
meta = FinancialStatementMeta(
id=uuid.uuid4(),
document_id=document_id,
recognition_job_id=job_id,
statement_type=draft.statement_type,
**draft.header,
)
session.add(meta)
await session.flush()
column_ids: dict[int, uuid.UUID] = {}
columns = []
for column_index, label, period_date, is_current in draft.columns:
column_ids[column_index] = uuid.uuid4()
columns.append(
{"id": column_ids[column_index], "meta_id": meta.id, "column_index": column_index, "label": label,
"period_date": period_date, "is_current": is_current}
)
if columns:
await session.execute(insert(FinancialStatementColumn).values(columns))
line_ids: dict[str, uuid.UUID] = {}
lines = []
for line in draft.lines:
line_ids[line.line_code] = uuid.uuid4()
lines.append(
{"id": line_ids[line.line_code], "meta_id": meta.id, "line_code": line.line_code,
"line_code_original_value": line.line_code, "line_code_confidence": line.line_code_confidence,
"line_code_modified": False, "line_name": line.line_name, "line_name_original_value": line.line_name,
"line_name_confidence": line.line_name_confidence, "line_name_modified": False}
)
for start in range(0, len(lines), BATCH):
await session.execute(insert(FinancialStatementLine).values(lines[start : start + BATCH]))
values = [
{"id": uuid.uuid4(), "line_id": line_ids[value.line_code], "column_id": column_ids[value.column_index],
"amount": value.amount, "string_value": value.string_value, "confidence": value.confidence,
"is_recognized": value.is_recognized, "is_modified": False}
for value in draft.values
]
for start in range(0, len(values), BATCH):
await session.execute(insert(FinancialStatementValue).values(values[start : start + BATCH]))
return meta
async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> None:
documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery()
metas = select(FinancialStatementMeta.id).where(FinancialStatementMeta.document_id.in_(documents)).scalar_subquery()
lines = select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id.in_(metas)).scalar_subquery()
await session.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id.in_(lines)))
await session.execute(delete(FinancialStatementLine).where(FinancialStatementLine.meta_id.in_(metas)))
await session.execute(delete(FinancialStatementColumn).where(FinancialStatementColumn.meta_id.in_(metas)))
await session.execute(delete(FinancialStatementMeta).where(FinancialStatementMeta.document_id.in_(documents)))
async def statement_amounts(
session: AsyncSession, meta_id: uuid.UUID
) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]:
columns = (
await session.execute(
select(FinancialStatementColumn.id, FinancialStatementColumn.column_index)
.where(FinancialStatementColumn.meta_id == meta_id)
.order_by(FinancialStatementColumn.column_index)
)
).all()
index_by_id = {column_id: index for column_id, index in columns}
rows = (
await session.execute(
select(FinancialStatementLine.line_code, FinancialStatementValue.column_id, FinancialStatementValue.amount)
.join(FinancialStatementValue, FinancialStatementValue.line_id == FinancialStatementLine.id)
.where(FinancialStatementLine.meta_id == meta_id)
.order_by(FinancialStatementLine.line_code)
)
).all()
by_code: dict[str, dict[int, Decimal | None]] = {}
for code, column_id, amount in rows:
by_code.setdefault(code or "", {})[index_by_id[column_id]] = amount
return [index for _, index in columns], list(by_code.items())
async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[bool, bool | None, Decimal | None]]:
rows = await session.execute(
select(FinancialStatementValue.is_modified, FinancialStatementValue.is_recognized, FinancialStatementValue.confidence)
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
.where(FinancialStatementLine.meta_id == meta_id)
)
return [(bool(modified), recognized, confidence) for modified, recognized, confidence in rows]
```
Примечание к порядку строк для подстрок §7: `statement_amounts` сортирует по `line_code` — пятизначные коды идут сразу
за своим четырёхзначным родителем (1150, 11501, 11502, 1160), этого достаточно правилу M-9 (`_subline_rules`).
- [ ] **Step 4: `result_mapping.py`**
1. `NO_DOCUMENTS = "в результате нет документов"`; в `_load_result` и `map_file` проверка
`if not result.documents: raise MappingFatal(NO_DOCUMENTS)` вместо проверки `_osv_documents`. Docstring модуля
(пункт про `MappingFatal`) — «нет документов».
2. `_clear_file`: перед удалением документов вызвать `await statements_repo.delete_file_statements(session, file_id)`.
3. В `map_file` после цикла по листам ОСВ и до `session.add_all(documents)` — сбор форм; после вставки строк ОСВ — вставка
форм. Код ветки (вставить в `map_file`, `statement_docs` — `[d for d in result.documents if isinstance(d, StatementDocument)]`):
```python
rules = load_check_rules()
forms: list[tuple[Document, StatementDraft]] = []
seen_forms: set[DocumentType] = set()
other_locators: dict[DocumentType, list[str]] = {}
for doc in statement_docs:
doc_type = DocumentType(doc.document_type)
if doc_type in (DocumentType.DISCLOSURE, DocumentType.OTHER):
other_locators.setdefault(doc_type, []).append(doc.locator)
continue
if doc_type in seen_forms:
warning = f"{doc.locator}: вторая форма {doc_type.value} в файле пропущена (OQ-77)"
warnings.append(warning)
first = next(document for document, _ in forms if document.document_type is doc_type)
first.status = DocumentStatus.NEEDS_CHECK
continue
seen_forms.add(doc_type)
draft = build_statement(doc)
warnings.extend(draft.warnings)
check = counterparty_check(doc.header.inn, doc.header.organization_name, owner.inn, owner_names)
document = Document(
id=uuid.uuid4(),
stored_file_id=file.id,
legal_entity_id=file.legal_entity_id,
document_type=doc_type,
osv_account_code=None,
period_year=draft.period_year,
period_quarter=draft.period_quarter,
is_cumulative=None,
reporting_date=draft.period_date,
locator=doc.locator,
status=DocumentStatus.NEEDS_CHECK, # уточняется после вставки значений (нужны проверки по суммам)
period_check=DocumentCheckResult.UNKNOWN,
counterparty_check=check,
created_at=now,
updated_at=now,
)
forms.append((document, draft))
for doc_type, locators in other_locators.items():
documents.append(
Document(
id=uuid.uuid4(), stored_file_id=file.id, legal_entity_id=file.legal_entity_id, document_type=doc_type,
locator=",".join(locators), status=DocumentStatus.RECOGNIZED, period_check=DocumentCheckResult.UNKNOWN,
created_at=now, updated_at=now,
)
)
documents.extend(document for document, _ in forms)
```
После `session.add_all(documents)`, `flush` и вставки строк ОСВ:
```python
for document, draft in forms:
meta = await statements_repo.insert_statement(session, document_id=document.id, job_id=job.id, draft=draft)
column_indexes, amounts = await statements_repo.statement_amounts(session, meta.id)
report = run_checks(document.document_type.value, amounts, column_indexes, rules)
if document.status is not DocumentStatus.NEEDS_CHECK or not any(
w.startswith(f"{draft.locator}: вторая форма") for w in warnings
):
document.status = statement_status(
job_done=job_done,
checks_passed=not report.failed,
counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN,
period_known=document.period_year is not None and document.period_quarter is not None,
values_ok=values_ok(await statements_repo.value_flags(session, meta.id)),
has_lines=bool(draft.lines),
warnings=draft.warnings,
)
```
Проще и надёжнее хранить признак «у формы был повтор» в словаре `duplicated: set[DocumentType]` и передавать в
`statement_status` `warnings=[*draft.warnings, *(["повтор формы"] if document.document_type in duplicated else [])]` —
так и сделать вместо разбора строк предупреждений (код выше — иллюстрация места вставки).
`job.group_schema` по-прежнему строится только из листов ОСВ (`sheets`). Импорты: `StatementDocument` из
`src.contract.result`; `build_statement`, `statement_status`, `values_ok`, `StatementDraft` из `src.domain.statement_mapping`;
`run_checks`, `load_check_rules` из `src.domain.statement_checks`; `from src.repositories import statements as statements_repo`.
Если в `map_file` листов ОСВ нет, `sheets` пуст — цикл ОСВ не выполняется, `entries` пуст, вставка строк ОСВ пропускается
(проверить, что `insert(...).values([])` не вызывается).
- [ ] **Step 5: Тесты** — `cd api && .venv/bin/pytest tests/test_mapping_statements.py tests/test_result_mapping.py tests/test_mapping_golden.py -q -p no:cacheprovider && cd .. && make lint` → PASS. `test_mapping_golden` (ОСВ) не должен измениться: снимок `st04_mixed_book` в `expected_osv_parse` не лежит.
- [ ] **Step 6: Commit**
```bash
git add api/src/repositories/statements.py api/src/services/result_mapping.py api/tests/test_mapping_statements.py api/tests/test_result_mapping.py
git commit -m "feat(statements): маппинг форм ББ/ОФР в map_file — meta, колонки, строки, значения, статус (D-1…D-3, D-6)"
```
---
### Task 5: API формы — `GET /documents/{id}/statement`
**Files:**
- Create: `api/src/schemas/statements.py`, `api/src/services/statements.py`, `api/src/api/endpoints/files/statements.py`
- Modify: `api/src/repositories/statements.py` (чтение формы, пара для межформенной), `api/src/api/endpoints/files/__init__.py` (роутер), `api/src/api/dependencies/files.py` (`StatementServiceDep`), `api/tests/test_openapi_routes.py`
- Test: `api/tests/test_api_statements.py` (новый)
**Interfaces:**
- Consumes: `run_checks`, `cross_form_check`, `load_check_rules`, `FailedCheck` (Task 1); `edition_for` (Task 2); `statement_amounts` (Task 4); `files_repo.get_document` (видимость).
- Produces:
- Схемы (`src/schemas/statements.py`): `HeaderFieldOut(value: str | None, original_value: str | None, confidence: Decimal | None = None, verified: bool | None, modified: bool | None)`; `StatementHeaderOut(inn: HeaderFieldOut, report_date: HeaderFieldOut, units: HeaderFieldOut, organization_name, okud, okpo, okopf, okfs, address: str | None)`; `ColumnOut(id, column_index, label, period_date, is_current)`; `ValueOut(id, column_id, column_index, amount: Decimal | None, string_value, confidence, is_recognized, is_modified, original_amount, modified_by, modified_at)`; `LineOut(id, line_code, line_code_confidence, line_name, line_name_confidence, values: list[ValueOut])`; `FailedCheckOut(column_index: int, relation: str, expected: Decimal, actual: Decimal)`; `AcceptedOut(by: uuid.UUID, at: datetime, comment: str)`; `ChecksOut(failed: list[FailedCheckOut], soft_failed: list[FailedCheckOut], cross_form: FailedCheckOut | None, accepted: AcceptedOut | None)`; `StatementDocumentOut(id, document_type, status, period_year, period_quarter, reporting_date, counterparty_check, locator, file_id)`; `StatementOut(document: StatementDocumentOut, edition: str | None, header: StatementHeaderOut, columns: list[ColumnOut], lines: list[LineOut], checks: ChecksOut)`.
- `StatementService(session, audit: RequestAudit, scope: VisibilityScope)`: `async def get(document_id) -> StatementOut`; служебные `async def _visible_statement(document_id) -> tuple[Document, FinancialStatementMeta]` (404 / 409 `DOCUMENT_NOT_STATEMENT`), `async def build_out(document, meta) -> StatementOut`, `async def checks_report(document, meta) -> tuple[ChecksReport, FailedCheck | None]`.
- Коды ошибок (константы модуля): `DOCUMENT_NOT_STATEMENT`, `DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `NOTHING_TO_ACCEPT`, `INVALID_INN` (используются в Task 6).
- `repositories/statements.py`: `async def get_meta(session, document_id) -> FinancialStatementMeta | None`; `async def load_form(session, meta_id) -> tuple[list[FinancialStatementColumn], list[tuple[FinancialStatementLine, list[FinancialStatementValue]]]]` (строки по `line_code`, значения по `column_index`); `async def pair_amounts(session, document: Document) -> tuple[tuple[Decimal | None, Decimal | None], Decimal | None]` — для межформенной: `(1370 на отчётную дату, 1370 на 31.12 прошлого года)` из ББ и `2400` текущей колонки ОФР (D-5; пара — документ другого типа формы того же ЮЛ, года и квартала, файл не `SUPERSEDED`, подтверждённый первым, затем последний по `created_at`).
- [ ] **Step 1: Падающие тесты** — `api/tests/test_api_statements.py` (часть GET; Task 6 и 7 допишут правку и подтверждение):
```python
"""API формы ББ/ОФР (spec documents-api «Форма ББ/ОФР для экрана проверки», «Правка формы», «Акцепт проверок формы»,
«Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14)."""
import json
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import DocumentType
from src.models.files import Document
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_legal_entity
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
KA_RF = "ka_rf@armka.local"
RKA = "rka@armka.local" # только DOCUMENT_VIEW
ROSTOV = "Ростовский региональный филиал"
MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json"
MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
async def rostov_entity(session: AsyncSession, path: Path, name: str = "ООО Межениновская") -> LegalEntity:
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(session, name=f"ГК {name}", office=office)
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=name, inn=inn, group=group)
async def form(session: AsyncSession, fixtures_dir: Path, path: Path = MEZH_BALANCE) -> Document:
entity = await rostov_entity(session, fixtures_dir / path)
documents = await map_golden_snapshot(session, entity=entity, path=fixtures_dir / path, uploaded_by=None)
return next(d for d in documents if d.document_type in (DocumentType.BALANCE, DocumentType.INCOME))
async def test_get_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.get(f"/documents/{document.id}/statement")
assert response.status_code == 200, response.text
body = response.json()
assert body["edition"] == "fsbu4"
assert body["document"]["document_type"] == "BALANCE" and body["document"]["period_quarter"] == "Q3"
assert len(body["lines"]) == 37
codes = [line["line_code"] for line in body["lines"]]
assert codes == sorted(codes)
columns = len(body["columns"])
assert all(len(line["values"]) == columns for line in body["lines"])
assert body["checks"]["failed"] == [] and body["checks"]["accepted"] is None
assert set(body["header"]["inn"]) == {"value", "original_value", "confidence", "verified", "modified"}
async def test_statement_of_osv_is_conflict(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
from tests.factories import make_document, make_stored_file
from src.models.enums import FileStatus, RecognitionStatus
entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО ОСВ")
file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done)
osv = await make_document(seeded, file=file, job=job)
client = await auth_client(KA_RF)
response = await client.get(f"/documents/{osv.id}/statement")
assert response.status_code == 409 and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT"
async def test_statement_hidden_is_404(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client("client_manager@armka.local") # другой филиал
assert (await client.get(f"/documents/{document.id}/statement")).status_code == 404
async def test_cross_form_check(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE)
balance = next(
d for d in await map_golden_snapshot(seeded, entity=entity, path=fixtures_dir / MEZH_BALANCE, uploaded_by=None)
)
await map_golden_snapshot(seeded, entity=entity, path=fixtures_dir / MEZH_INCOME, uploaded_by=None)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{balance.id}/statement")).json()
cross = body["checks"]["cross_form"]
assert cross is None or cross["relation"] == "1370 − 1370(начало года) = 2400"
```
Доменные ошибки отдаются как `{"detail": {"code", "message", …}}` (CLAUDE.md ядра, «Ошибки»): код — `response.json()["detail"]["code"]`.
- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/test_api_statements.py -q -p no:cacheprovider`.
- [ ] **Step 3: Репозиторий** — дописать в `src/repositories/statements.py`:
```python
async def get_meta(session: AsyncSession, document_id: uuid.UUID) -> FinancialStatementMeta | None:
stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id)
return (await session.execute(stmt)).scalar_one_or_none()
async def load_form(
session: AsyncSession, meta_id: uuid.UUID
) -> tuple[list[FinancialStatementColumn], list[tuple[FinancialStatementLine, list[FinancialStatementValue]]]]:
columns = list(
(
await session.execute(
select(FinancialStatementColumn)
.where(FinancialStatementColumn.meta_id == meta_id)
.order_by(FinancialStatementColumn.column_index)
)
).scalars()
)
order = {column.id: column.column_index for column in columns}
lines = list(
(
await session.execute(
select(FinancialStatementLine)
.where(FinancialStatementLine.meta_id == meta_id)
.order_by(FinancialStatementLine.line_code)
)
).scalars()
)
values = (
await session.execute(
select(FinancialStatementValue).where(FinancialStatementValue.line_id.in_([line.id for line in lines]))
)
).scalars()
by_line: dict[uuid.UUID, list[FinancialStatementValue]] = {line.id: [] for line in lines}
for value in values:
by_line[value.line_id].append(value)
for items in by_line.values():
items.sort(key=lambda value: order[value.column_id])
return columns, [(line, by_line[line.id]) for line in lines]
```
`pair_amounts` — реализация по Interfaces: найти пару запросом `select(Document).join(StoredFile).where(Document.legal_entity_id
== document.legal_entity_id, Document.period_year == document.period_year, Document.period_quarter ==
document.period_quarter, Document.document_type == <другой тип>, StoredFile.status != FileStatus.SUPERSEDED)
.order_by((Document.status == DocumentStatus.CONFIRMED).desc(), Document.created_at.desc()).limit(1)`; затем у ББ взять
значения строки `1370` в колонке `is_current` и в колонке с `period_date = date(year − 1, 12, 31)`; у ОФР — строку `2400`
в колонке `is_current`. Нет пары, строки или колонки — соответствующий `None`. Если сам `document` — ОФР, роли меняются.
- [ ] **Step 4: Схемы, сервис, маршрут**
`src/services/statements.py` — каркас (методы правки и акцепта добавит Task 6):
```python
"""Форма ББ/ОФР для экрана «Проверка распознавания» (spec documents-api; design D-5, D-7, D-8, D-14)."""
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.common.exceptions import ConflictError, NotFoundError
from src.domain.policy import VisibilityScope
from src.domain.statement_checks import ChecksReport, FailedCheck, cross_form_check, load_check_rules, run_checks
from src.domain.statement_mapping import edition_for
from src.models.enums import DocumentType
from src.models.files import Document
from src.models.statements import FinancialStatementMeta
from src.repositories import files as files_repo
from src.repositories import statements as repo
from src.schemas import statements as schemas
DOCUMENT_NOT_FOUND = "документ не найден"
DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT"
DOCUMENT_READONLY = "DOCUMENT_READONLY"
UNKNOWN_CELL = "UNKNOWN_CELL"
DUPLICATE_CELL = "DUPLICATE_CELL"
NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT"
INVALID_INN = "INVALID_INN"
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
class StatementService:
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
self._session = session
self._audit = audit
self._scope = scope
async def _visible_statement(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]:
document = await files_repo.get_document(self._session, self._scope, document_id)
if document is None:
raise NotFoundError(DOCUMENT_NOT_FOUND)
meta = await repo.get_meta(self._session, document.id) if document.document_type in FORM_TYPES else None
if meta is None:
raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР")
return document, meta
async def checks_report(self, document: Document, meta: FinancialStatementMeta) -> tuple[ChecksReport, FailedCheck | None]:
rules = load_check_rules()
column_indexes, amounts = await repo.statement_amounts(self._session, meta.id)
report = run_checks(document.document_type.value, amounts, column_indexes, rules)
balance_1370, income_2400 = await repo.pair_amounts(self._session, document)
return report, cross_form_check(balance_1370, income_2400, rules.tolerance)
async def get(self, document_id: uuid.UUID) -> schemas.StatementOut:
document, meta = await self._visible_statement(document_id)
return await self.build_out(document, meta)
async def build_out(self, document: Document, meta: FinancialStatementMeta) -> schemas.StatementOut:
columns, lines = await repo.load_form(self._session, meta.id)
report, cross = await self.checks_report(document, meta)
return schemas.statement_out(
document, meta, columns, lines, report, cross, edition=edition_for(document.reporting_date)
)
```
`src/schemas/statements.py` — модели из Interfaces (`BaseModel`, `ConfigDict(from_attributes=True)` где строятся из ORM)
и функция `statement_out(document, meta, columns, lines, report, cross, *, edition) -> StatementOut`, собирающая ответ:
`header.inn = HeaderFieldOut(value=meta.inn, original_value=meta.inn_original_value, confidence=meta.inn_confidence,
verified=meta.inn_verified, modified=meta.inn_modified)`, `header.report_date` — `value` = `meta.report_date.isoformat()`
или `None`, `original_value` = `meta.report_date_original_value`; `header.units` аналогично; `values[].column_index` —
по `column_id`; `checks.accepted` — `AcceptedOut`, если `meta.checks_accepted_at` не пуст. Суммы — `Decimal` (в JSON
строкой, как `EntryOut`).
`src/api/endpoints/files/statements.py` — `router = APIRouter(prefix="/documents")`, маршрут
`GET /{document_id}/statement` (`response_model=StatementOut`, `dependencies=[Depends(require("DOCUMENT_VIEW"))]`,
`responses` 404/409), вызывающий `service.get`. Подключить роутер в `src/api/endpoints/files/__init__.py` рядом с
`documents.router`; `StatementServiceDep` в `src/api/dependencies/files.py` — как `DocumentServiceDep`. В
`tests/test_openapi_routes.py` добавить `"/api/v1/documents/{document_id}/statement": {"get", "patch"}` и
`"/api/v1/documents/{document_id}/statement/checks/accept": {"post"}` (PATCH и accept появятся в Task 6 — до тех пор
временно `{"get"}` и без accept; Task 6 дополняет).
- [ ] **Step 5: Тесты** — `cd api && .venv/bin/pytest tests/test_api_statements.py tests/test_openapi_routes.py -q -p no:cacheprovider && cd .. && make lint` → PASS.
- [ ] **Step 6: Commit**
```bash
git add api/src/schemas/statements.py api/src/services/statements.py api/src/api/endpoints/files api/src/api/dependencies/files.py api/src/repositories/statements.py api/tests/test_api_statements.py api/tests/test_openapi_routes.py
git commit -m "feat(statements): GET /documents/{id}/statement — шапка, колонки, строки, проверки §7, редакция (D-5, D-14)"
```
---
### Task 6: Правка формы и акцепт проверок
**Files:**
- Modify: `api/src/services/statements.py` (`patch`, `accept_checks`, `recompute_status`), `api/src/schemas/statements.py` (`StatementPatch`, `HeaderPatch`, `CellPatch`, `ChecksAcceptIn`), `api/src/api/endpoints/files/statements.py` (PATCH, POST accept), `api/src/repositories/statements.py` (`lock_cells`), `api/tests/test_openapi_routes.py`
- Test: `api/tests/test_api_statements.py` (дописать)
**Interfaces:**
- Consumes: `StatementService._visible_statement`, `build_out`, `checks_report` (Task 5); `files_repo.lock_file`, `files_repo.lock_document`; `statement_status`, `values_ok`, `quarter_of` (Task 2); `counterparty_check` (Task 2); `CounterpartyIndex.owner_names` (существующий, `repositories/counterparties.py`); `RequestAudit.record`.
- Produces:
- Схемы: `CellPatch(line_id: uuid.UUID, column_id: uuid.UUID, amount: Decimal | None = Field(max_digits=18, decimal_places=2))`; `HeaderPatch(inn: str | None = None, report_date: date | None = None, units: str | None = Field(None, min_length=1, max_length=50))` — поле отсутствует ≠ `null` (`model_fields_set`); `StatementPatch(header: HeaderPatch | None = None, values: list[CellPatch] = [])`; `ChecksAcceptIn(comment: str = Field(min_length=1, max_length=2000))` (после `strip`).
- `StatementService.patch(document_id, body: StatementPatch) -> StatementOut`; `StatementService.accept_checks(document_id, body: ChecksAcceptIn) -> StatementOut`; `StatementService.recompute_status(document, meta) -> None` (D-6 с учётом акцепта; `CONFIRMED` не трогает); `StatementService.hard_failures(document, meta) -> tuple[FailedCheck, ...]` (жёсткие нарушения, пустой кортеж при акцепте) — используется Task 7.
- `repo.lock_cells(session, meta_id, cells: Sequence[tuple[uuid.UUID, uuid.UUID]]) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]` — значения этого `meta_id` по `(line_id, column_id)`; чужие ячейки в словарь не попадают.
- [ ] **Step 1: Падающие тесты** — дописать в `api/tests/test_api_statements.py`:
```python
from decimal import Decimal
from src.models.audit_ex import AuditEvent
from src.models.enums import AuditAction, DocumentStatus
from src.models.statements import FinancialStatementMeta, FinancialStatementValue
async def _first_cell(client, document_id) -> tuple[dict, dict]:
body = (await client.get(f"/documents/{document_id}/statement")).json()
line = next(line for line in body["lines"] if line["values"][0]["amount"] is not None)
return line, line["values"][0]
async def test_patch_value_and_revert(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
original = cell["amount"]
new = str(Decimal(original) + 20)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": new}]},
)
assert response.status_code == 200, response.text
edited = next(l for l in response.json()["lines"] if l["id"] == line["id"])["values"][0]
assert Decimal(edited["amount"]) == Decimal(new) and Decimal(edited["original_amount"]) == Decimal(original)
assert edited["is_modified"] is True and edited["modified_by"] is not None
event = (
await seeded.execute(
select(AuditEvent).where(AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT)
)
).scalars().one()
assert event.before_value and event.after_value
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": original}]},
)
reverted = next(l for l in response.json()["lines"] if l["id"] == line["id"])["values"][0]
assert reverted["is_modified"] is False and Decimal(reverted["original_amount"]) == Decimal(original)
async def test_patch_report_date_recomputes_period(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-06-30"}})
body = response.json()
assert body["document"]["period_quarter"] == "Q2" and body["header"]["report_date"]["modified"] is True
assert body["header"]["report_date"]["original_value"] is not None # исходник не тронут
async def test_patch_inn_validates_and_rechecks(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
bad = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "12345"}})
assert bad.status_code == 422 and bad.json()["detail"]["code"] == "INVALID_INN"
other = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "7700000000"}})
assert other.json()["document"]["counterparty_check"] == "MISMATCH"
assert other.json()["document"]["status"] == "NEEDS_CHECK"
async def test_patch_duplicate_cell_rejected(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
"""Review Focus 1."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": "1"}
response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]})
assert response.status_code == 422 and response.json()["detail"]["code"] == "DUPLICATE_CELL"
async def test_patch_amount_precision_rejected(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
"""Review Focus 2: больше двух знаков или больше 16 цифр до запятой — 422 схемы, данные не меняются."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
for amount in ("1.234", "12345678901234567"):
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": amount}]},
)
assert response.status_code == 422
async def test_patch_unknown_cell(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
import uuid
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": str(uuid.uuid4()), "column_id": str(uuid.uuid4()), "amount": "1"}]},
)
assert response.status_code == 422 and response.json()["detail"]["code"] == "UNKNOWN_CELL"
async def test_patch_requires_edit_right(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(RKA)
assert (await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})).status_code == 403
async def test_empty_patch_no_audit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
assert (await client.patch(f"/documents/{document.id}/statement", json={})).status_code == 200
count = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().all()
assert count == []
async def _break_balance(seeded: AsyncSession, client, document_id) -> None:
body = (await client.get(f"/documents/{document_id}/statement")).json()
line_1600 = next(line for line in body["lines"] if line["line_code"] == "1600")
cell = line_1600["values"][0]
await client.patch(
f"/documents/{document_id}/statement",
json={"values": [{"line_id": line_1600["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"] or 0) + 1000)}]},
)
async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
nothing = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "ок"})
assert nothing.status_code == 409 and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT"
await _break_balance(seeded, client, document.id)
broken = (await client.get(f"/documents/{document.id}/statement")).json()
assert any(item["relation"].startswith("1600") for item in broken["checks"]["failed"])
assert broken["document"]["status"] == "NEEDS_CHECK"
accepted = await client.post(
f"/documents/{document.id}/statement/checks/accept", json={"comment": "округление в форме клиента"}
)
assert accepted.status_code == 200
assert accepted.json()["checks"]["accepted"]["comment"] == "округление в форме клиента"
await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})
after = (await client.get(f"/documents/{document.id}/statement")).json()
assert after["checks"]["accepted"] is None and after["document"]["status"] == "NEEDS_CHECK"
async def test_patch_confirmed_readonly(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
assert (await client.post(f"/documents/{document.id}/confirm")).status_code == 200
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == 409 and response.json()["detail"]["code"] == "DOCUMENT_READONLY"
```
- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/test_api_statements.py -q -p no:cacheprovider` (новые тесты — 405/404 до реализации).
- [ ] **Step 3: Схемы** — в `src/schemas/statements.py`:
```python
class CellPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
line_id: uuid.UUID
column_id: uuid.UUID
amount: Decimal | None = Field(max_digits=18, decimal_places=2)
class HeaderPatch(BaseModel):
"""Отсутствующее поле не меняется; `null` у `report_date`/`inn` — очистить значение."""
model_config = ConfigDict(extra="forbid")
inn: str | None = None
report_date: datetime.date | None = None
units: str | None = Field(None, min_length=1, max_length=50)
class StatementPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
header: HeaderPatch | None = None
values: list[CellPatch] = Field(default_factory=list, max_length=5000)
class ChecksAcceptIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
comment: str = Field(min_length=1, max_length=2000)
```
- [ ] **Step 4: Сервис** — в `StatementService` (`src/services/statements.py`):
```python
async def _editable(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]:
"""Видимость → блокировки «файл → документ» → проверки D-7 (порядок 409 как у подтверждения)."""
visible, _ = await self._visible_statement(document_id)
file = await files_repo.lock_file(self._session, visible.stored_file_id)
document = await files_repo.lock_document(self._session, document_id)
if file is None or document is None:
raise NotFoundError(DOCUMENT_NOT_FOUND)
if file.status is FileStatus.SUPERSEDED:
raise ConflictError(FILE_SUPERSEDED, "файл документа заменён новой версией")
if document.status is DocumentStatus.CONFIRMED:
raise ConflictError(DOCUMENT_READONLY, "подтверждённая форма только для чтения: загрузите исправленный файл")
if document.status not in CONFIRMABLE_STATUSES:
raise ConflictError(DOCUMENT_NOT_CONFIRMABLE, f"форма в статусе {document.status.value} не правится")
meta = await repo.get_meta(self._session, document.id)
if meta is None:
raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР")
return document, meta
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
if meta.checks_accepted_at is not None:
return ()
report, _ = await self.checks_report(document, meta)
return report.failed
async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None:
"""D-6 после правки или акцепта; `CONFIRMED` не трогает. Предупреждения маппинга файла не учитываются."""
if document.status is DocumentStatus.CONFIRMED:
return
job = await self._session.get(RecognitionJob, meta.recognition_job_id)
has_lines = bool((await repo.statement_amounts(self._session, meta.id))[1])
document.status = statement_status(
job_done=job is not None and job.status is RecognitionStatus.done,
checks_passed=not await self.hard_failures(document, meta),
counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN,
period_known=document.period_year is not None and document.period_quarter is not None,
values_ok=values_ok(await repo.value_flags(self._session, meta.id)),
has_lines=has_lines,
warnings=(),
)
document.updated_at = utc_now()
async def patch(self, document_id: uuid.UUID, body: StatementPatch) -> StatementOut:
document, meta = await self._editable(document_id)
keys = [(cell.line_id, cell.column_id) for cell in body.values]
if len(keys) != len(set(keys)):
raise DomainValidationError(DUPLICATE_CELL, "ячейка указана в запросе дважды")
cells = await repo.lock_cells(self._session, meta.id, keys)
if len(cells) != len(keys):
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
before: dict[str, object] = {}
after: dict[str, object] = {}
now = utc_now()
user_id = self._audit.user_id
for cell in body.values:
value = cells[(cell.line_id, cell.column_id)]
if value.amount == cell.amount:
continue
key = f"value:{value.id}"
before[key], after[key] = value.amount, cell.amount
if not value.is_modified and value.original_amount is None:
value.original_amount = value.amount
value.amount = cell.amount
value.is_modified = value.amount != value.original_amount
value.modified_by, value.modified_at = user_id, now
header = body.header
if header is not None:
fields = header.model_fields_set
if "inn" in fields and header.inn != meta.inn:
if header.inn is not None and not (header.inn.isdigit() and len(header.inn) in (10, 12)):
raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр")
before["inn"], after["inn"] = meta.inn, header.inn
meta.inn, meta.inn_modified = header.inn, True
owner = await self._session.get(LegalEntity, document.legal_entity_id)
index = await CounterpartyIndex.load(self._session)
document.counterparty_check = counterparty_check(
meta.inn, meta.organization_name, owner.inn, index.owner_names(owner)
)
if "report_date" in fields and header.report_date != meta.report_date:
before["report_date"], after["report_date"] = meta.report_date, header.report_date
meta.report_date, meta.report_date_modified = header.report_date, True
columns, _ = await repo.load_form(self._session, meta.id)
period = statement_period(meta.report_date, [column.period_date for column in columns])
document.reporting_date = period
document.period_year = period.year if period is not None else None
document.period_quarter = quarter_of(period) if period is not None else None
if "units" in fields and header.units is not None and header.units != meta.units:
before["units"], after["units"] = meta.units, header.units
meta.units, meta.units_modified = header.units, True
if not after:
return await self.build_out(document, meta)
if meta.checks_accepted_at is not None:
before["checks_accepted"] = {"by": meta.checks_accepted_by, "at": meta.checks_accepted_at}
after["checks_accepted"] = None
meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None
await self._session.flush()
await self.recompute_status(document, meta)
await self._audit.record(
action=AuditAction.RECOGNIZED_DATA_EDIT,
section=AuditSection.RECOGNITION,
object_type=OBJECT_TYPE,
object_id=document.id,
before_value=before,
after_value=after,
)
return await self.build_out(document, meta)
async def accept_checks(self, document_id: uuid.UUID, body: ChecksAcceptIn) -> StatementOut:
document, meta = await self._editable(document_id)
if not await self.hard_failures(document, meta):
raise ConflictError(NOTHING_TO_ACCEPT, "непройденных жёстких проверок нет")
now = utc_now()
meta.checks_accepted_by, meta.checks_accepted_at, meta.checks_accepted_comment = self._audit.user_id, now, body.comment
await self._session.flush()
await self.recompute_status(document, meta)
await self._audit.record(
action=AuditAction.RECOGNIZED_DATA_EDIT,
section=AuditSection.RECOGNITION,
object_type=OBJECT_TYPE,
object_id=document.id,
before_value={"checks_accepted": None},
after_value={"checks_accepted": {"by": self._audit.user_id, "at": now, "comment": body.comment}},
)
return await self.build_out(document, meta)
```
Импорты и константы: `OBJECT_TYPE = "document"`; `FILE_SUPERSEDED`, `DOCUMENT_NOT_CONFIRMABLE`, `CONFIRMABLE_STATUSES` —
переиспользовать из `src.services.documents` (не дублировать строки); `DomainValidationError` из `src.common.exceptions`;
`utc_now` из `src.common.clock`; `CounterpartyIndex` из `src.repositories.counterparties`; `LegalEntity`, `RecognitionJob`,
модели и enum'ы. Если `CounterpartyIndex.owner_names` требует загрузки всего индекса и это дорого — допустимо собрать
имена владельца напрямую (`legal_entity.name` + его алиасы через `normalize_alias`), повторив логику `owner_names`.
`repo.lock_cells`:
```python
async def lock_cells(
session: AsyncSession, meta_id: uuid.UUID, cells: Sequence[tuple[uuid.UUID, uuid.UUID]]
) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]:
if not cells:
return {}
line_ids = {line_id for line_id, _ in cells}
stmt = (
select(FinancialStatementValue)
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
.where(FinancialStatementLine.meta_id == meta_id, FinancialStatementValue.line_id.in_(line_ids))
.with_for_update(of=FinancialStatementValue)
)
wanted = set(cells)
return {
(value.line_id, value.column_id): value
for value in (await session.execute(stmt)).scalars()
if (value.line_id, value.column_id) in wanted
}
```
- [ ] **Step 5: Маршруты** — в `src/api/endpoints/files/statements.py`: `PATCH /{document_id}/statement`
(`DOCUMENT_EDIT`, `response_model=StatementOut`, `responses` 404/409/422) → `service.patch`; `POST
/{document_id}/statement/checks/accept` (`DOCUMENT_EDIT`) → `service.accept_checks`. В `tests/test_openapi_routes.py`:
`"/api/v1/documents/{document_id}/statement": {"get", "patch"}`, `"/api/v1/documents/{document_id}/statement/checks/accept": {"post"}`.
- [ ] **Step 6: Тесты и линт** — `cd api && .venv/bin/pytest tests/test_api_statements.py tests/test_openapi_routes.py -q -p no:cacheprovider && cd .. && make lint` → PASS (кроме `test_patch_confirmed_readonly`, если подтверждение формы ещё не проверяет период — тест проходит: у формы Межениновской период есть).
- [ ] **Step 7: Commit**
```bash
git add api/src/services/statements.py api/src/schemas/statements.py api/src/api/endpoints/files/statements.py api/src/repositories/statements.py api/tests/test_api_statements.py api/tests/test_openapi_routes.py
git commit -m "feat(statements): правка формы (значения, ИНН, дата, единицы) и акцепт проверок — аудит, пересчёт статуса (D-7, D-8)"
```
---
### Task 7: Подтверждение формы и сквозной сценарий чек-листа
**Files:**
- Modify: `api/src/services/documents.py` (`confirm`), `api/src/api/endpoints/files/documents.py` (описание 409)
- Test: `api/tests/test_api_statements.py` (дописать), `api/tests/test_consolidation_documents.py` (форма двигает версию)
**Interfaces:**
- Consumes: `StatementService.hard_failures` (Task 6), `repo.get_meta` (Task 5).
- Produces: коды `PERIOD_REQUIRED`, `CHECKS_NOT_PASSED` (константы в `services/documents.py`); `ConflictError(CHECKS_NOT_PASSED, …, failures=[{column_index, relation, expected, actual}])`.
- [ ] **Step 1: Падающие тесты** — дописать в `api/tests/test_api_statements.py`:
```python
async def test_confirm_requires_checks(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
await _break_balance(seeded, client, document.id)
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == 409 and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED"
assert blocked.json()["detail"]["failures"]
await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "округление"})
confirmed = await client.post(f"/documents/{document.id}/confirm")
assert confirmed.status_code == 200 and confirmed.json()["status"] == "CONFIRMED"
meta = (await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))).scalar_one()
assert meta.reviewed_by is not None and meta.reviewed_at is not None
async def test_confirm_after_non_quarter_date_edit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
"""Review Focus 5: дата не конец квартала → квартал NULL, NEEDS_CHECK, PERIOD_REQUIRED; исправление даты снимает."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
body = (await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}})).json()
assert body["document"]["period_quarter"] is None and body["document"]["status"] == "NEEDS_CHECK"
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == 409 and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED"
await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-09-30"}})
assert (await client.post(f"/documents/{document.id}/confirm")).status_code == 200
async def test_confirmed_form_in_checklist(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
from tests.factories import make_version
document = await form(seeded, fixtures_dir)
entity = await seeded.get(LegalEntity, document.legal_entity_id)
version = await make_version(seeded, entities=[entity], year=2025, quarter="Q3")
client = await auth_client(KA_RF)
assert (await client.post(f"/documents/{document.id}/confirm")).status_code == 200
checklist = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json()
balance_index = next(i for i, rule in enumerate(checklist["rules"]) if rule["document_type"] == "BALANCE")
row = next(row for row in checklist["rows"] if row["legal_entity_id"] == str(entity.id))
assert row["cells"][balance_index]["status"] == "CONFIRMED"
```
Сигнатуру `make_version` проверить в `tests/factories.py` (`sed -n '/^async def make_version/,/^ \"\"\"/p'`) и
подставить реальные параметры (периметр, год, квартал, статус `INITIATED`). В `tests/test_consolidation_documents.py`
добавить сценарий спеки «Форма двигает версию»: версия в `DOCUMENTS_PROCESSING`, `map_golden_snapshot` формы
`mezheninovskaya/…/1_Форма1_бух_09.json` с `uploaded_by` = пользователь (документ `NEEDS_CHECK` — ИНН владельца
отличается), затем вызов `on_documents_mapped` как в существующем тесте для ОСВ → версия `DATA_CORRECTION`, привязки нет.
- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/test_api_statements.py -q -p no:cacheprovider -k "confirm"`.
- [ ] **Step 3: Реализация** — в `DocumentService.confirm` после проверки `CONFIRMABLE_STATUSES` и до смены статуса:
```python
if document.document_type in FORM_TYPES:
meta = await statements_repo.get_meta(self._session, document.id)
if meta is not None:
if document.period_year is None or document.period_quarter is None:
raise ConflictError(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта")
failures = await StatementService(self._session, self._audit, self._scope).hard_failures(document, meta)
if failures:
raise ConflictError(
CHECKS_NOT_PASSED,
"не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием",
failures=[
{"column_index": f.column_index, "relation": f.relation, "expected": str(f.expected), "actual": str(f.actual)}
for f in failures
],
)
```
и после установки `confirmed_by/at` — `meta.reviewed_by, meta.reviewed_at = self._audit.user_id, now` (если `meta`).
Импорт `StatementService` внутри метода (`from src.services.statements import StatementService # noqa: PLC0415`) —
`services/statements.py` импортирует константы из `services/documents.py` (цикл). `FORM_TYPES` — из
`services.statements`. Описание 409 маршрута `confirm` дополнить кодами.
- [ ] **Step 4: Тесты и линт** — `cd api && .venv/bin/pytest tests/test_api_statements.py tests/test_api_documents.py tests/test_consolidation_documents.py -q -p no:cacheprovider && cd .. && make lint` → PASS.
- [ ] **Step 5: Commit**
```bash
git add api/src/services/documents.py api/src/api/endpoints/files/documents.py api/tests/test_api_statements.py api/tests/test_consolidation_documents.py
git commit -m "feat(statements): подтверждение формы — период и проверки §7, reviewed_by; форма в чек-листе и в переходе версии (D-9)"
```
---
### Task 8: Комментарии к документу
**Files:**
- Create: `api/src/api/endpoints/files/comments.py`, `api/src/schemas/comments.py`
- Modify: `api/src/services/documents.py` (`comments`, `add_comment`, `comments_count` в `file_out`/`document_out`), `api/src/repositories/files.py` (`comments_count_by_document`), `api/src/schemas/files.py` (`DocumentBrief.comments_count`), `api/src/schemas/documents.py` (`DocumentOut.comments_count`), `api/src/services/primary_files.py` (передача счётчиков в `file_out`), `api/src/api/endpoints/files/__init__.py`, `api/tests/test_openapi_routes.py`
- Test: `api/tests/test_api_comments.py`
**Interfaces:**
- Produces: `CommentIn(text: str = Field(min_length=1, max_length=4000))` со `str_strip_whitespace`; `CommentAuthorOut(id, full_name)`; `CommentOut(id, text, author: CommentAuthorOut, created_at)`; `DocumentService.add_comment(document_id, body) -> CommentOut`, `DocumentService.comments(document_id) -> list[CommentOut]`; `files_repo.comments_count_by_document(session, document_ids) -> dict[uuid.UUID, int]`; `COMMENT_OBJECT_TYPE = "document"`.
- [ ] **Step 1: Падающие тесты** — `api/tests/test_api_comments.py`:
```python
"""Комментарии к документу (spec documents-api «Комментарии к документу», «Файлы организации и карточка файла»; D-11)."""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.enums import DocumentStatus, FileStatus, RecognitionStatus
from tests.conftest import AuthClientFactory
from tests.factories import make_document, make_stored_file
from tests.test_api_documents import KA_RF, RKA, _rostov_entity
pytestmark = pytest.mark.integration
async def _doc(session: AsyncSession, status: DocumentStatus = DocumentStatus.CONFIRMED):
entity = await _rostov_entity(session, name="ООО Комментарии")
file, job = await make_stored_file(session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done)
return file, await make_document(session, file=file, job=job, status=status)
async def test_add_and_list(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
file, document = await _doc(seeded)
client = await auth_client(KA_RF)
created = await client.post(f"/documents/{document.id}/comments", json={"text": " нет подписи руководителя "})
assert created.status_code == 201, created.text
assert created.json()["text"] == "нет подписи руководителя"
assert created.json()["author"]["full_name"]
await client.post(f"/documents/{document.id}/comments", json={"text": "второй"})
listed = (await client.get(f"/documents/{document.id}/comments")).json()
assert [item["text"] for item in listed] == ["нет подписи руководителя", "второй"]
card = (await client.get(f"/files/{file.id}")).json()
assert card["documents"][0]["comments_count"] == 2
assert (await client.get(f"/documents/{document.id}")).json()["comments_count"] == 2
async def test_validation_and_rights(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
_, document = await _doc(seeded)
client = await auth_client(KA_RF)
assert (await client.post(f"/documents/{document.id}/comments", json={"text": " "})).status_code == 422
assert (await client.post(f"/documents/{document.id}/comments", json={"text": "x" * 4001})).status_code == 422
reader = await auth_client(RKA)
assert (await reader.post(f"/documents/{document.id}/comments", json={"text": "можно?"})).status_code == 403
assert (await reader.get(f"/documents/{document.id}/comments")).status_code == 200
stranger = await auth_client("client_manager@armka.local")
assert (await stranger.get(f"/documents/{document.id}/comments")).status_code == 404
```
- [ ] **Step 2: Убедиться, что падают.**
- [ ] **Step 3: Реализация.** Модель `Comment` — `src/models/audit_ex.py` (поля `object_type`, `object_id`, `text`,
`author_id`, `created_at`). `add_comment`: видимость `_visible` → `Comment(object_type="document", object_id=document.id,
text=body.text, author_id=self._audit.user_id, created_at=utc_now())` → `flush` → ответ с ФИО автора (`AppUser.full_name`).
`comments`: `select(Comment, AppUser.full_name).join(AppUser, AppUser.id == Comment.author_id).where(object_type ==
"document", object_id == document.id).order_by(Comment.created_at, Comment.id)`. `comments_count_by_document`:
`select(Comment.object_id, func.count()).where(Comment.object_type == "document", Comment.object_id.in_(ids)).group_by(…)`.
`file_out(file, job, documents, counts: Mapping[uuid.UUID, int] | None = None)` — `DocumentBrief(..., comments_count=counts.get(id, 0))`;
во всех местах построения карточек файла (`primary_files._cards`, `DocumentService`, загрузка) передать счётчики
(`grep -n "file_out(" src -r`). `DocumentOut.comments_count` — в `document_out`. Маршруты: `POST
/documents/{document_id}/comments` (`status_code=201`, `DOCUMENT_EDIT`), `GET …/comments` (`DOCUMENT_VIEW`,
`response_model=list[CommentOut]`). В `tests/test_openapi_routes.py` — `"/api/v1/documents/{document_id}/comments": {"get", "post"}`.
- [ ] **Step 4: Тесты** — `cd api && .venv/bin/pytest tests/test_api_comments.py tests/test_api_files_read.py tests/test_api_documents.py tests/test_openapi_routes.py -q -p no:cacheprovider && cd .. && make lint` → PASS.
- [ ] **Step 5: Commit** — `git add -A api/src api/tests && git commit -m "feat(documents): комментарии к документу и comments_count в карточке (D-11)"` (проверить `git status`, что в коммит не попало лишнее).
---
### Task 9: Отдача исходного файла потоком
**Files:**
- Modify: `api/src/core/storage.py` (`ObjectStorage.stream`), `api/src/core/clients.py` (`S3Storage.stream`), `api/tests/conftest.py` (`FakeStorage.stream`), `api/src/services/primary_files.py` (`content`), `api/src/api/endpoints/files/read.py` (маршрут), `api/src/api/endpoints/consolidations/export.py` (вынести `content_disposition` в `src/common/filenames.py`, если его там нет), `api/tests/test_openapi_routes.py`
- Test: `api/tests/test_api_files_content.py`, `api/tests/test_clients.py` (поток S3Storage на фейковом клиенте)
**Interfaces:**
- Produces:
- `ObjectStorage.stream(obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]` — асинхронный генератор; нет объекта — `ObjectNotFoundError` **до** первой порции (ошибка видна до отправки заголовков). Реализация S3: `get_object` → `response.content.iter_chunked(chunk_size)`; в `finally` — `response.close()` и `await response.release()`.
- `PrimaryFilesService.content(file_id, disposition: Literal["attachment","inline"]) -> tuple[AsyncIterator[bytes], str, str]` — поток, `Content-Type`, `Content-Disposition`; 404 `FILE_CONTENT_NOT_FOUND`, если объекта нет.
- `content_disposition(filename: str, *, disposition: str = "attachment", ascii_fallback: str) -> str` в `src/common/filenames.py`.
- MIME: `PDF` `application/pdf`, `PNG` `image/png`, `JPG` `image/jpeg`, `XLS` `application/vnd.ms-excel`, `XLSX` `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `XLSB` `application/vnd.ms-excel.sheet.binary.macroEnabled.12`.
- [ ] **Step 1: Падающие тесты** — `api/tests/test_api_files_content.py`:
```python
"""Исходный файл потоком (spec documents-api «Исходный файл»; design D-10)."""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.files import S3Object
from tests.conftest import AuthClientFactory, FakeStorage
from tests.test_api_documents import KA_RF, _rostov_entity
from tests.factories import make_stored_file
from src.models.enums import FileStatus, FileType, RecognitionStatus
pytestmark = pytest.mark.integration
PDF = b"%PDF-1.4\n" + bytes(range(256)) * 4096 # ~1 МиБ: проверка порционной отдачи
async def _file(session: AsyncSession, storage: FakeStorage, name: str, file_type: FileType):
entity = await _rostov_entity(session, name="ООО Файлы")
file, _ = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, original_name=name
)
file.file_type = file_type
key = (await session.execute(select(S3Object.key).where(S3Object.id == file.s3_object_id))).scalar_one()
storage.objects[key] = PDF
await session.flush()
return file
async def test_inline_pdf(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
storage = FakeStorage()
app.state.storage = storage
file = await _file(seeded, storage, "aep_6.pdf", FileType.PDF)
client: AsyncClient = await auth_client(KA_RF)
response = await client.get(f"/files/{file.id}/content", params={"disposition": "inline"})
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
assert response.headers["content-disposition"] == "inline; filename=\"file.pdf\"; filename*=UTF-8''aep_6.pdf"
assert response.content == PDF
async def test_cyrillic_name_and_missing(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
storage = FakeStorage()
app.state.storage = storage
file = await _file(seeded, storage, "ОСВ_66 за 2024.xls", FileType.XLS)
client = await auth_client(KA_RF)
response = await client.get(f"/files/{file.id}/content")
assert response.headers["content-disposition"].startswith('attachment; filename="file.xls"; filename*=UTF-8\'\'%D0%9E')
storage.objects.clear()
missing = await client.get(f"/files/{file.id}/content")
assert missing.status_code == 404 and missing.json()["detail"]["code"] == "FILE_CONTENT_NOT_FOUND"
async def test_stream_releases_on_error() -> None:
"""Review Focus 4: при обрыве потребителя ответ S3 закрывается и отпускается."""
from src.core.clients import S3Storage
class Content:
async def iter_chunked(self, size: int):
yield b"a" * size
yield b"b" * size
class Response:
def __init__(self) -> None:
self.content = Content()
self.closed = self.released = False
def close(self) -> None:
self.closed = True
async def release(self) -> None:
self.released = True
response = Response()
class Client:
async def get_object(self, bucket: str, name: str) -> Response:
return response
storage = S3Storage(Client(), "arm-ka") # type: ignore[arg-type]
stream = storage.stream("uploads/x", chunk_size=4)
assert await stream.__anext__() == b"aaaa"
await stream.aclose() # потребитель оборвал чтение
assert response.closed and response.released
```
Если фикстура `app` в conftest не допускает подмену `app.state.storage` после создания — сделать это так же, как в
`tests/test_api_files_upload.py` (`grep -n "state.storage" tests/test_api_files_upload.py`).
- [ ] **Step 2: Убедиться, что падают.**
- [ ] **Step 3: Реализация.** `S3Storage.stream`:
```python
async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]:
"""Объект порциями; `NoSuchKey` → `ObjectNotFoundError` до первой порции; ответ отпускается и при обрыве."""
try:
response = await self.client.get_object(self.bucket, obj_name)
except S3Error as error:
if error.code == NO_SUCH_KEY:
raise ObjectNotFoundError(obj_name) from error
raise
try:
async for chunk in response.content.iter_chunked(chunk_size):
yield chunk
finally:
response.close()
await response.release()
```
Чтобы ошибка «нет объекта» возникала до заголовков ответа, сервис вызывает `stream`, берёт первую порцию
(`await anext(iterator, b"")`) и отдаёт `StreamingResponse` из генератора «первая порция + остальные». `FakeStorage.stream`
— генератор по `self.objects[obj_name]` порциями `chunk_size`, `KeyError` → `ObjectNotFoundError`. Маршрут `GET
/files/{file_id}/content` (`DOCUMENT_VIEW`, `disposition: Literal["attachment","inline"] = "attachment"`) возвращает
`StreamingResponse(iterator, media_type=..., headers={"Content-Disposition": ...})`; файл ищется с видимостью
(`files_repo.get_file`), ключ — `S3Object.key` по `stored_file.s3_object_id`; файл `SUPERSEDED` разрешён. В
`test_openapi_routes.py` — `"/api/v1/files/{file_id}/content": {"get"}`.
- [ ] **Step 4: Тесты** — `cd api && .venv/bin/pytest tests/test_api_files_content.py tests/test_api_osv_export.py tests/test_openapi_routes.py -q -p no:cacheprovider && cd .. && make lint` → PASS.
- [ ] **Step 5: Commit** — `git commit -m "feat(files): GET /files/{id}/content — исходный файл потоком из S3, RFC 5987 (D-10)"` (добавить изменённые файлы явно).
---
### Task 10: Справочник имён пользователей
**Files:**
- Create: `api/src/api/endpoints/users.py`
- Modify: `api/src/repositories/users.py` (`users_brief`), `api/src/schemas/admin.py` или новый `api/src/schemas/users.py` (`UserBriefOut`), `api/src/api/router.py`, `api/tests/test_openapi_routes.py`
- Test: `api/tests/test_api_users_brief.py`
**Interfaces:**
- Produces: `GET /api/v1/users/brief?ids=<uuid>,<uuid>` → `list[UserBriefOut(id, full_name, is_active)]`; `users_repo.users_brief(session, ids: Sequence[uuid.UUID]) -> list[AppUser]`; ошибка `TOO_MANY_IDS` (422, `DomainValidationError`), `MAX_BRIEF_IDS = 200`.
- [ ] **Step 1: Падающие тесты** — `api/tests/test_api_users_brief.py`:
```python
"""Краткий справочник пользователей (spec users-admin «Краткий справочник пользователей»; D-12)."""
import uuid
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.access import AppUser
from tests.conftest import AuthClientFactory
pytestmark = pytest.mark.integration
async def test_brief_for_non_admin(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
users = (await seeded.execute(select(AppUser).order_by(AppUser.email).limit(2))).scalars().all()
unknown = uuid.uuid4()
client = await auth_client("ka_rf@armka.local") # без USERS_ADMIN
ids = ",".join([*(str(user.id) for user in users), str(unknown)])
response = await client.get("/users/brief", params={"ids": ids})
assert response.status_code == 200
body = {item["id"]: item for item in response.json()}
assert set(body) == {str(user.id) for user in users}
assert all(set(item) == {"id", "full_name", "is_active"} for item in body.values())
async def test_brief_limits(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
client = await auth_client("ka_rf@armka.local")
too_many = ",".join(str(uuid.uuid4()) for _ in range(201))
response = await client.get("/users/brief", params={"ids": too_many})
assert response.status_code == 422 and response.json()["detail"]["code"] == "TOO_MANY_IDS"
assert (await client.get("/users/brief", params={"ids": "not-a-uuid"})).status_code == 422
```
- [ ] **Step 2: Убедиться, что падают.**
- [ ] **Step 3: Реализация.** Маршрут `router = APIRouter(prefix="/users", tags=["users"])`, `GET /brief`, зависимость
`Annotated[Principal, Depends(current_user)]` (как `me.py`), параметр `ids: str = Query(...)`; разбор — `split(",")`,
пустые пропускаются, `uuid.UUID(...)` с `ValueError` → `DomainValidationError("INVALID_IDS", "…")` (422), больше 200 —
`TOO_MANY_IDS`. Репозиторий: `select(AppUser).where(AppUser.id.in_(ids))`. Подключить `users.router` в `build_api_router`
**до** `admin.router` (пути не пересекаются: `/users/brief` против `/admin/users`). `test_openapi_routes.py` — путь
`"/api/v1/users/brief": {"get"}` и префикс `"/api/v1/users"` в `CHECKED_PREFIXES`.
- [ ] **Step 4: Тесты и линт** — PASS.
- [ ] **Step 5: Commit** — `git commit -m "feat(users): GET /users/brief — имена пользователей для всех аутентифицированных (D-12)"`.
---
### Task 11: `documents_count` у ЮЛ
**Files:**
- Modify: `api/src/repositories/directories.py` (`documents_count_by_entity`), `api/src/services/legal_entities.py` (`page`, `get`), `api/src/schemas/directories.py` (`LegalEntityOut.documents_count: int = 0`), endpoint списка — если он строит `LegalEntityOut.model_validate(entity)` напрямую, передавать счётчик
- Test: `api/tests/test_api_legal_entities.py` (дописать)
**Interfaces:**
- Produces: `async def documents_count_by_entity(session, entity_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, int]` — `count(document.id)` по `document.legal_entity_id` с `join(StoredFile)` и `StoredFile.status != SUPERSEDED`.
- [ ] **Step 1: Падающий тест** — в `api/tests/test_api_legal_entities.py`:
```python
async def test_documents_count(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
from src.models.enums import DocumentType, FileStatus, RecognitionStatus
from tests.factories import make_document, make_legal_entity, make_stored_file
entity = await make_legal_entity(seeded, name="ООО Счётчик") # монозаёмщик, видим ГО
live, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done)
await make_document(seeded, file=live, job=job, account="60")
await make_document(seeded, file=live, job=job, account="62")
old, old_job = await make_stored_file(seeded, entity=entity, status=FileStatus.SUPERSEDED, job_status=RecognitionStatus.done)
await make_document(seeded, file=old, job=old_job, account="66")
client = await auth_client("admin@armka.local")
listed = (await client.get("/directories/legal-entities", params={"q": "Счётчик"})).json()
assert listed["items"][0]["documents_count"] == 2
card = (await client.get(f"/directories/legal-entities/{entity.id}")).json()
assert card["documents_count"] == 2
```
Пользователь с видимостью всех ЮЛ — подобрать из существующих тестов файла (`grep -n "auth_client(" tests/test_api_legal_entities.py | head -3`); `make_document` с разными `account` — чтобы не нарушить уникальность документа в файле.
- [ ] **Step 2–4:** убедиться, что падает; реализовать подзапрос на страницу (`ids` страницы → словарь счётчиков) и в карточке; тесты и линт — PASS.
- [ ] **Step 5: Commit** — `git commit -m "feat(directories): documents_count в списке и карточке ЮЛ (D-13)"`.
---
### Task 12: Чек-лист в CSV
**Files:**
- Create: `api/src/domain/checklist_csv.py`
- Modify: `api/src/api/endpoints/consolidations/checklist.py` (параметр `format`), `api/src/services/consolidations.py` (имя ГК для имени файла, если не доступно в `ChecklistOut`)
- Test: `api/tests/test_domain_checklist_csv.py`, `api/tests/test_api_consolidation_checklist.py` (дописать)
**Interfaces:**
- Produces: `render_checklist_csv(checklist: ChecklistOut) -> bytes` — UTF-8 с BOM, `;`, `\r\n`; заголовок «Организация;ИНН;<правило>…»; подписи `CELL_LABELS = {CONFIRMED: "Загружен", PROCESSING: "Обработка", NEEDS_CHECK: "Требуется проверка", RECOGNIZED: "Требуется проверка", ERROR: "Ошибка загрузки", MISSING: "Отсутствует"}`; правило: `BALANCE` «Бух. баланс», `INCOME` «ОФР», `OSV` «ОСВ <account>», иначе `document_type`; при `missing_quarters` — « (нет: Q1, Q2)». Значения с `;` или `"` экранируются (`csv.writer` с `delimiter=";"`). `checklist_filename(group_name: str, year: int, quarter: str) -> str` = `checklist-<ГК>-<год>-<квартал>.csv`.
- [ ] **Step 1: Падающие тесты** — `api/tests/test_domain_checklist_csv.py`:
```python
"""CSV чек-листа (spec consolidations-api «Чек-лист комплекта»; D-13)."""
import uuid
from src.domain.checklist import CellStatus
from src.domain.checklist_csv import checklist_filename, render_checklist_csv
from src.models.enums import DocumentType, PeriodQuarter
from src.schemas.consolidations import ChecklistCellOut, ChecklistOut, ChecklistRowOut, ChecklistRuleOut
def test_render() -> None:
checklist = ChecklistOut(
rules=[
ChecklistRuleOut(document_type=DocumentType.BALANCE, account=None, is_mandatory=True),
ChecklistRuleOut(document_type=DocumentType.OSV, account="60", is_mandatory=True),
],
rows=[
ChecklistRowOut(
legal_entity_id=uuid.uuid4(), legal_entity_name='ООО "Альфа; Бета"', legal_entity_inn="7700000000",
cells=[
ChecklistCellOut(status=CellStatus.CONFIRMED, missing_quarters=[], document_ids=[]),
ChecklistCellOut(status=CellStatus.MISSING, missing_quarters=[PeriodQuarter.Q2], document_ids=[]),
],
)
],
)
content = render_checklist_csv(checklist)
assert content.startswith("".encode())
lines = content.decode("utf-8-sig").splitlines()
assert lines[0] == "Организация;ИНН;Бух. баланс;ОСВ 60"
assert lines[1] == '"ООО ""Альфа; Бета""";7700000000;Загружен;Отсутствует (нет: Q2)'
def test_filename() -> None:
assert checklist_filename("Русский бисквит", 2025, "Q3") == "checklist-Русский бисквит-2025-Q3.csv"
```
В `tests/test_api_consolidation_checklist.py` — сценарий «Чек-лист в CSV»: `GET …/checklist?format=csv` → 200,
`content-type` начинается с `text/csv`, тело начинается с BOM, первая строка — заголовок, `content-disposition` содержит
`filename*=UTF-8''checklist-`.
- [ ] **Step 2: Убедиться, что падают.**
- [ ] **Step 3: Реализация** — `src/domain/checklist_csv.py` на `csv.writer(io.StringIO(), delimiter=";", lineterminator="\r\n")`,
результат `("" + buffer.getvalue()).encode("utf-8")`. Маршрут: параметр `format: Literal["json", "csv"] = "json"`;
при `csv` — `Response(content=render_checklist_csv(checklist), media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": content_disposition(checklist_filename(...), disposition="attachment",
ascii_fallback="checklist.csv")})`; у маршрута `response_model=ChecklistOut` сохраняется для JSON (для CSV вернуть
`Response` — FastAPI пропустит валидацию). Имя ГК, год и квартал — из карточки версии (`service.get_version` или
аналог; при отсутствии метода — добавить в `ConsolidationService` лёгкий `version_title(version_id) -> tuple[str, int, str]`).
- [ ] **Step 4: Тесты и линт** — PASS.
- [ ] **Step 5: Commit** — `git commit -m "feat(consolidations): чек-лист в CSV — подписи макета, BOM, «;» (D-13)"`.
---
### Task 13: README, CLAUDE.md, tasks.md, полная проверка
**Files:**
- Modify: `README.md` (разделы API документов и ошибок), `CLAUDE.md` (блок «Первичные документы» — формы, §7, правка; карта `domain/`), `openspec/changes/slice-3b-statements-api/tasks.md`
- [ ] **Step 1: README** — в разделе эндпоинтов документов добавить: `GET/PATCH /documents/{id}/statement`, `POST
…/statement/checks/accept`, `POST/GET /documents/{id}/comments`, `GET /files/{id}/content`, `GET /users/brief`,
`documents_count`, `?format=csv` чек-листа; в каталоге ошибок «Ошибки API» — `DOCUMENT_NOT_STATEMENT`, `DOCUMENT_READONLY`,
`UNKNOWN_CELL`, `DUPLICATE_CELL`, `INVALID_INN` (форма), `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`, `CHECKS_NOT_PASSED`,
`FILE_CONTENT_NOT_FOUND`, `TOO_MANY_IDS`, `INVALID_IDS`. Сообщение «в результате нет документов» вместо «нет документов ОСВ».
- [ ] **Step 2: CLAUDE.md** — в «Первичные документы»: формы ББ/ОФР маппятся в `map_file` (`domain/statement_mapping.py`,
`repositories/statements.py`), проверки §7 — `domain/statement_checks.py` + копия `rules/statement_checks.yaml` (меняется
в обоих репозиториях), правка формы — `services/statements.py` (только до подтверждения; аудит `RECOGNIZED_DATA_EDIT`).
- [ ] **Step 3: tasks.md** — перенести задачи 0–15 плана с отметками `[x]` по факту; `openspec validate slice-3b-statements-api --strict` → valid.
- [ ] **Step 4: Полная проверка** — `make check-full` (включает `dbml-check`, `test-integration`, `e2e`, `docker-build`,
`web-check`) → зелёный; вывод — в отчёт задачи (пойдёт в описание PR). При красном `e2e` из-за отсутствия профиля
`services` — это skip, не ошибка.
- [ ] **Step 5: Commit** — `git commit -m "docs(arm-core): README, CLAUDE.md, tasks.md под Срез 3b"`.
---
### Task 14: Документы workspace (bounded-PR в docs)
**Files (репозиторий docs, worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/slice-3b/docs`):**
`context/05-domain-rules.md` (§7, §9.3, §9.5), `context/07-open-questions.md`, `context/03-architecture.md`,
`context/09-delivery-plan.md`, `dev/api-contract-slice-3b.md` (новый), `dev/README.md` (ссылка), `dev/qa-guide.md`.
- [ ] **Step 1: Issue и worktree**
```bash
cd /Users/andrey.epifancev/projects/awp-ca
tea issues create --login home --repo RSHB/arm-ka-docs --title "Срез 3b (docs): §7/§9 по факту, контракт API форм и мелких дыр, OQ" \
--description "Сопровождение change arm-core slice-3b-statements-api (#36): §7 (проверки в ядре, межформенная мягкая, акцепт), §9.3/§9.5 (формы ББ/ОФР, правка до подтверждения, условия подтверждения), dev/api-contract-slice-3b.md, OQ (аудит скачивания первички), 03-architecture (копия словаря §7), 09-delivery-plan (3b реализован)."
# номер — <D>
rm /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3b/docs # симлинк
git -C /Users/andrey.epifancev/projects/awp-ca/docs worktree add /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3b/docs -b feat/<D>-slice-3b-docs main
touch /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3b/docs/.bounded
```
- [ ] **Step 2: Правки**
1. §7: «Ядро (Срез 3b) считает §7 по текущим суммам формы словарём `arm-core/api/rules/statement_checks.yaml` (копия
словаря tabular-parser): те же жёсткие и мягкие соотношения, допуск ±1, округление 0,001 %, `abs_terms`, подстроки.
Межформенная `1370 − 1370(начало года) = 2400` — мягкая (не блокирует подтверждение). Акцепт — с комментарием,
сбрасывается любой правкой формы.»
2. §9.3: статус документа формы — правило D-6 спеки 3b; §9.5 п. 2 — формы маппятся (вместо «игнорируются»), период ББ/ОФР
по дате отчёта или самой поздней дате колонок; новый пункт «Правка формы» — значения и ИНН/дата/единицы только до
подтверждения, исправление после — новым файлом; п. 4 — условия подтверждения формы (`PERIOD_REQUIRED`, `CHECKS_NOT_PASSED`).
3. `07-open-questions.md`: новый OQ (следующий номер после последнего): «Журналировать ли скачивание первички и CSV
(ТЗ 8.2 „выгрузка“)? Сейчас не пишется, как экспорт сводной ОСВ» — допущение принято; OQ-23, OQ-36, OQ-77, OQ-140,
OQ-145 — дописать «Срез 3b: допущение реализовано».
4. `03-architecture.md`: раздел Среза 3b (маппинг форм в `map_file`, `statement_checks.yaml` — копия, риск расхождения).
5. `09-delivery-plan.md`: Срез 3 → 3a закрыт, 3b реализован (бэкенд и API, UI отдельно); статусы 2a и 2c — «закрыт» (сейчас «ожидает merge/ревью»).
6. `dev/api-contract-slice-3b.md` — по образцу `dev/api-contract-slice-4.md`: общее (коды ошибок, формат денег),
эндпоинты (таблица), схемы (`StatementOut`, `StatementPatch`, `CommentOut`, `UserBriefOut`), нюансы (подтверждённая
форма только для чтения; `is_modified` снимается возвратом к исходному; акцепт сбрасывается правкой; редакция формы;
CSV — BOM и «;»), сквозной сценарий экрана «Проверка распознавания». Ссылка в `dev/README.md`.
7. `dev/qa-guide.md`: как проверить форму на стенде (загрузка `fixtures/statements-xlsx/mezheninovskaya/...`, просмотр
формы, правка, акцепт, подтверждение, чек-лист).
- [ ] **Step 3: Проверка, коммит, PR** — `make validate-contracts` в worktree docs; коммит
`docs(context): Срез 3b — §7/§9 по факту, контракт API форм, OQ (#<D>)`; push; `tea pulls create --login home --repo
RSHB/arm-ka-docs --head feat/<D>-slice-3b-docs --base main` с «Closes #<D>», списком «Rulings I made» и атрибуцией.
---
### Task 15: Завершение — финальное ревью и PR
- [ ] **Step 1:** `git fetch origin && git merge --no-edit origin/main` (force-push запрещён), `make check-full`,
`openspec validate slice-3b-statements-api --strict`.
- [ ] **Step 2:** финальное ревью всей ветки (opus): `git diff origin/main...HEAD`; исправление critical/important;
повторный `make check-full`.
- [ ] **Step 3:** PR arm-core: `tea pulls create --login home --repo RSHB/arm-core --head feat/36-slice-3b-statements-api
--base main --title "Срез 3b: формы ББ/ОФР в ядре и API до сводной ОСВ"` с «Closes #36», ссылкой на change и PR docs,
выводом `make check-full`, таблицей всех находок (маркер, суть, статус), «Rulings I made», deferred, атрибуцией.
- [ ] **Step 4:** отчёт: ссылки на PR, находки, рулинги, deferred, что осталось владельцу (мерж docs и arm-core; OQ об аудите скачивания).