arm-core/docs/superpowers/plans/2026-10-09-statement-template-lines.md
Andrey Epifancev 381c373467 plan: абсолютные пути worktree в git-командах (#80)
Относительный `git -C arm-core` из корня workspace указывал на main-checkout
(O-1 первого запуска контроллера).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qoydUVv8g1qHxc123u2RR
2026-10-09 11:28:03 +04:00

1645 lines
96 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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.

# Полный состав строк ББ/ОФР — план реализации
> **Для исполнителей-агентов:** ОБЯЗАТЕЛЬНЫЙ SUB-SKILL: superpowers:subagent-driven-development (рекомендуется) или
> superpowers:executing-plans. Шаги — чекбоксы (`- [ ]`).
**Goal:** на экране проверки ББ/ОФР показывать все строки формы своей редакции и давать аналитику заполнять отсутствующие
строки и ячейки; строки «БАЛАНС» (1600/1700) выделять цветом.
**Architecture:** справочник строк по редакциям — данные `arm-core/api/rules/statement_lines.yaml` + загрузчик
`domain/statement_lines.py`. `GET …/statement` достраивает ответ (строки справочника и недостающие ячейки с `id = null`),
в БД ничего не пишет. `PATCH …/statement` адресует ячейку `line_id` или `line_code` и создаёт строку/ячейку при первой
непустой правке. Проверки §7 не видят созданных правкой строк, у которых все суммы пустые. arm-web строит ключи строк по
`id ?? code`, отправляет `line_code` и красит строки «БАЛАНС».
**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, pydantic v2, pytest-asyncio, PyYAML; React 18 + TS, antd 6, vitest.
**Spec:** `arm-core/openspec/changes/statement-template-lines/` (`proposal.md`, `design.md`, `specs/documents-api/spec.md`,
`specs/statement-checks/spec.md`). Issue RSHB/arm-core#80.
## Global Constraints
- Язык комментариев, docstring, коммитов, документации — русский; идентификаторы — английские, как в DBML и API.
- DBML v5.17 и контракты обмена не меняются, миграций нет. Поля вне DBML не вводить.
- Деньги — `Decimal` / `numeric(18,2)`; в JSON — строки.
- Признак строки «не из источника» — `financial_statement_line.line_code_original_value IS NULL` (маппинг всегда пишет
`line_code_original_value = line_code`).
- Редакция — существующий `edition_for(document.reporting_date)`: `66n` (≤ 2024-12-31), `fsbu4`, `None`.
- `GET` MUST NOT писать в БД. Достроенная ячейка: `id = null`, `amount = null`, `string_value = null`, `confidence = null`,
`is_recognized = null`, `is_modified = false`, `original_amount = null`, `modified_by = null`, `modified_at = null`.
- Ошибки адресации ячейки — 422 `UNKNOWN_CELL` (код не меняется, сообщения по-русски); повтор ячейки — 422 `DUPLICATE_CELL`.
- Аудит — одно `RECOGNIZED_DATA_EDIT` на запрос, ключ ячейки `value:<id>` (у созданной — id новой ячейки, `before = null`).
- Порядок блокировок «файл `FOR UPDATE` → документ `FOR NO KEY UPDATE`» не меняется (`StatementService._editable`).
- arm-core: `make check` зелёный после каждой задачи backend; `make check-full` — перед PR. arm-web: `make check` перед PR.
- arm-web: все PR — в ветку `test`, не в `main`. Фронт: цвет строки «БАЛАНС» `#f9f0ff`, шрифт 700; подсветка ячейки
(«исправлено» `#e6f4ff`, «требует проверки» `#fff7e6`) перекрывает фон строки.
- **Git и пути — только абсолютные.** Все git-команды — `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/<repo> …` (или cwd внутри
этого worktree); относительный `git -C arm-core` из корня workspace указывает на main-checkout — хук guard-main
справедливо блокирует (O-1 первого запуска, 09.10.2026). Исключение — `git -C /Users/andrey.epifancev/projects/awp-ca/<repo> fetch` и `worktree add`
в подготовке задач 6–7 (не commit/push). Коммиты в worktree на ветке фичи разрешены владельцем 09.10.2026.
- Git — всегда `git -C <repo>` или cwd внутри worktree; в `main` не коммитить (хук guard-main); хуки не обходить — при
отказе хука остановиться и сообщить.
## Review Focus
1. Запрос, где одна ячейка корректна (новая строка по `line_code`), а другая — нет (чужая колонка): 422, и строка НЕ
создана (вся валидация до первой вставки) — тест в Task 3.
2. Две ячейки одной новой строки в одном запросе (колонки 0 и 1 для `1700`) — одна строка в БД, два значения — Task 3.
3. Повторная правка тем же `line_code` после того, как строка уже создана (второй клик «Сохранить» со старым черновиком):
422 `UNKNOWN_CELL` «передайте line_id», данные не задвоены — Task 3.
4. Аналитик очистил созданную шаблонную строку целиком — нарушение по ней исчезает; заполнил только одну колонку —
нарушение в пустой колонке есть — Task 4.
5. Фронт: черновик смешанных строк (существующая по `id` и шаблонная по коду) превращается в корректное тело PATCH;
пустая достроенная ячейка не подсвечивается «требует проверки» — Task 6 (vitest).
---
### Task 1: Справочник строк форм по редакциям (arm-core, domain)
**Модель:** haiku (готовый код).
**Files:**
- Create: `arm-core/api/rules/statement_lines.yaml`
- Create: `arm-core/api/src/domain/statement_lines.py`
- Test: `arm-core/api/tests/test_domain_statement_lines.py`
**Interfaces:**
- Produces: `TemplateLine(code: str, name: str)` (frozen dataclass); `LineCatalog.lines(edition: str | None, form: str) ->
tuple[TemplateLine, ...]` (пустой кортеж для `None`/неизвестной редакции); `load_line_catalog(path: Path | None = None) ->
LineCatalog` (кэшируется; ошибка формата — `ValueError` с именем файла).
- [ ] **Step 1: Написать падающий тест** `api/tests/test_domain_statement_lines.py`:
```python
"""Справочник строк форм ББ/ОФР по редакциям (spec documents-api «Форма ББ/ОФР для экрана проверки»; design D-2)."""
from pathlib import Path
import pytest
from src.domain.statement_lines import TemplateLine, load_line_catalog
CATALOG = load_line_catalog()
@pytest.mark.parametrize(
("edition", "form", "count", "first", "last"),
[
("66n", "BALANCE", 37, "1110", "1700"),
("66n", "INCOME", 22, "2110", "2910"),
("fsbu4", "BALANCE", 34, "1110", "1700"),
("fsbu4", "INCOME", 23, "2110", "2910"),
],
)
def test_edition_composition(edition: str, form: str, count: int, first: str, last: str) -> None:
lines = CATALOG.lines(edition, form)
assert len(lines) == count
assert lines[0].code == first and lines[-1].code == last
assert len({line.code for line in lines}) == count
def test_names_fixed_from_templates() -> None:
names = {line.code: line.name for line in CATALOG.lines("fsbu4", "BALANCE")}
assert names["1110"] == "Нематериальные активы"
assert names["1210"] == "Запасы"
assert names["1310"] == "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)"
assert names["1410"] == names["1510"] == "Заемные средства"
assert names["1600"] == names["1700"] == "БАЛАНС"
old = {line.code: line.name for line in CATALOG.lines("66n", "BALANCE")}
assert old["1160"] == "Доходные вложения в материальные ценности"
assert "1105" not in old and "1215" not in old and "1215" in names
income = {line.code: line.name for line in CATALOG.lines("fsbu4", "INCOME")}
assert "2420" in income and "2460" not in income
assert all("\n" not in line.name for e in ("66n", "fsbu4") for f in ("BALANCE", "INCOME") for line in CATALOG.lines(e, f))
def test_no_edition_means_no_lines() -> None:
assert CATALOG.lines(None, "BALANCE") == ()
assert CATALOG.lines("unknown", "BALANCE") == ()
assert CATALOG.lines("66n", "OSV") == ()
def test_template_line_is_value_object() -> None:
assert TemplateLine("1700", "БАЛАНС") == TemplateLine("1700", "БАЛАНС")
@pytest.mark.parametrize(
("body", "message"),
[
("editions:\n - id: x\n forms:\n BALANCE: [{code: '11', name: a}]\n INCOME: []\n", "код"),
(
"editions:\n - id: x\n forms:\n BALANCE: [{code: '1110', name: a}, {code: '1110', name: b}]\n"
" INCOME: []\n",
"повтор",
),
("editions:\n - id: x\n forms:\n BALANCE: []\n", "INCOME"),
("editions: [\n", "statement_lines"),
],
)
def test_invalid_catalog_is_value_error(tmp_path: Path, body: str, message: str) -> None:
path = tmp_path / "statement_lines.yaml"
path.write_text(body, encoding="utf-8")
with pytest.raises(ValueError, match=message):
load_line_catalog(path)
```
- [ ] **Step 2: Запустить — падает**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_domain_statement_lines.py -q`
Expected: FAIL — `ModuleNotFoundError: No module named 'src.domain.statement_lines'`.
- [ ] **Step 3: Создать справочник** `api/rules/statement_lines.yaml` (точно этот текст):
```yaml
# Справочник строк форм ББ/ОФР по редакциям: полный состав формы для экрана проверки (05-domain-rules §7 «Ядро»,
# design D-2 change statement-template-lines). Порядок строк в списке — порядок формы.
# Редакция выбирается по дате периода документа (edition_for): 66n — до 2024-12-31 включительно, fsbu4 — позже.
# Источник обеих редакций — шаблоны банка fixtures/statements-xlsx/templates/ («ББ/ОФР до 01.01.2025», «… после
# 01.01.2025»; решение владельца 09.10.2026, данные OQ-150 для ядра); склейки наименований с заголовками разделов и
# сносками исправлены (design R-2). Копия словаря scan-gateway (api/rules/statement_lines.yaml) с ним не совпадает:
# сведение копий — follow-up.
editions:
- id: 66n
forms:
BALANCE:
- {code: "1110", name: "Нематериальные активы"}
- {code: "1120", name: "Результаты исследований и разработок"}
- {code: "1130", name: "Нематериальные поисковые активы"}
- {code: "1140", name: "Материальные поисковые активы"}
- {code: "1150", name: "Основные средства"}
- {code: "1160", name: "Доходные вложения в материальные ценности"}
- {code: "1170", name: "Финансовые вложения"}
- {code: "1180", name: "Отложенные налоговые активы"}
- {code: "1190", name: "Прочие внеоборотные активы"}
- {code: "1100", name: "Итого по разделу I"}
- {code: "1210", name: "Запасы"}
- {code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям"}
- {code: "1230", name: "Дебиторская задолженность"}
- {code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)"}
- {code: "1250", name: "Денежные средства и денежные эквиваленты"}
- {code: "1260", name: "Прочие оборотные активы"}
- {code: "1200", name: "Итого по разделу II"}
- {code: "1600", name: "БАЛАНС"}
- {code: "1310", name: "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)"}
- {code: "1320", name: "Собственные акции, выкупленные у акционеров"}
- {code: "1340", name: "Переоценка внеоборотных активов"}
- {code: "1350", name: "Добавочный капитал (без переоценки)"}
- {code: "1360", name: "Резервный капитал"}
- {code: "1370", name: "Нераспределенная прибыль (непокрытый убыток)"}
- {code: "1300", name: "Итого по разделу III"}
- {code: "1410", name: "Заемные средства"}
- {code: "1420", name: "Отложенные налоговые обязательства"}
- {code: "1430", name: "Оценочные обязательства"}
- {code: "1450", name: "Прочие обязательства"}
- {code: "1400", name: "Итого по разделу IV"}
- {code: "1510", name: "Заемные средства"}
- {code: "1520", name: "Кредиторская задолженность"}
- {code: "1530", name: "Доходы будущих периодов"}
- {code: "1540", name: "Оценочные обязательства"}
- {code: "1550", name: "Прочие обязательства"}
- {code: "1500", name: "Итого по разделу V"}
- {code: "1700", name: "БАЛАНС"}
INCOME:
- {code: "2110", name: "Выручка"}
- {code: "2120", name: "Себестоимость продаж"}
- {code: "2100", name: "Валовая прибыль (убыток)"}
- {code: "2210", name: "Коммерческие расходы"}
- {code: "2220", name: "Управленческие расходы"}
- {code: "2200", name: "Прибыль (убыток) от продаж"}
- {code: "2310", name: "Доходы от участия в других организациях"}
- {code: "2320", name: "Проценты к получению"}
- {code: "2330", name: "Проценты к уплате"}
- {code: "2340", name: "Прочие доходы"}
- {code: "2350", name: "Прочие расходы"}
- {code: "2300", name: "Прибыль (убыток) до налогообложения"}
- {code: "2410", name: "Налог на прибыль"}
- {code: "2411", name: "в т.ч. текущий налог на прибыль"}
- {code: "2412", name: "отложенный налог на прибыль"}
- {code: "2460", name: "Прочее"}
- {code: "2400", name: "Чистая прибыль (убыток)"}
- {code: "2510", name: "Результат от переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток) периода"}
- {code: "2520", name: "Результат от прочих операций, не включаемый в чистую прибыль (убыток) периода"}
- {code: "2500", name: "Совокупный финансовый результат периода"}
- {code: "2900", name: "Базовая прибыль (убыток) на акцию"}
- {code: "2910", name: "Разводненная прибыль (убыток) на акцию"}
- id: fsbu4
forms:
BALANCE:
- {code: "1110", name: "Нематериальные активы"}
- {code: "1150", name: "Основные средства"}
- {code: "1160", name: "Инвестиционная недвижимость"}
- {code: "1170", name: "Финансовые вложения"}
- {code: "1180", name: "Отложенные налоговые активы"}
- {code: "1190", name: "Прочие внеоборотные активы"}
- {code: "1100", name: "Итого по разделу I"}
- {code: "1210", name: "Запасы"}
- {code: "1215", name: "Долгосрочные активы к продаже"}
- {code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям"}
- {code: "1230", name: "Дебиторская задолженность"}
- {code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)"}
- {code: "1250", name: "Денежные средства и денежные эквиваленты"}
- {code: "1260", name: "Прочие оборотные активы"}
- {code: "1200", name: "Итого по разделу II"}
- {code: "1600", name: "БАЛАНС"}
- {code: "1310", name: "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)"}
- {code: "1320", name: "Собственные акции, принадлежащие обществу, задолженность акционеров по оплате акций"}
- {code: "1340", name: "Накопленная дооценка внеоборотных активов"}
- {code: "1350", name: "Добавочный капитал (без накопленной дооценки)"}
- {code: "1360", name: "Резервный капитал"}
- {code: "1370", name: "Нераспределенная прибыль (непокрытый убыток)"}
- {code: "1300", name: "Итого по разделу III"}
- {code: "1410", name: "Заемные средства"}
- {code: "1420", name: "Отложенные налоговые обязательства"}
- {code: "1430", name: "Оценочные обязательства"}
- {code: "1450", name: "Прочие долгосрочные обязательства"}
- {code: "1400", name: "Итого по разделу IV"}
- {code: "1510", name: "Заемные средства"}
- {code: "1520", name: "Кредиторская задолженность"}
- {code: "1540", name: "Оценочные обязательства"}
- {code: "1550", name: "Прочие краткосрочные обязательства"}
- {code: "1500", name: "Итого по разделу V"}
- {code: "1700", name: "БАЛАНС"}
INCOME:
- {code: "2110", name: "Выручка"}
- {code: "2120", name: "Себестоимость продаж"}
- {code: "2100", name: "Валовая прибыль (убыток)"}
- {code: "2210", name: "Коммерческие расходы"}
- {code: "2220", name: "Управленческие расходы"}
- {code: "2200", name: "Прибыль (убыток) от продаж"}
- {code: "2310", name: "Доходы от участия в других организациях"}
- {code: "2320", name: "Проценты к получению"}
- {code: "2330", name: "Проценты к уплате"}
- {code: "2340", name: "Прочие доходы"}
- {code: "2350", name: "Прочие расходы"}
- {code: "2300", name: "Прибыль (убыток) от продолжающейся деятельности до налогообложения"}
- {code: "2410", name: "Налог на прибыль организаций"}
- {code: "2411", name: "текущий налог на прибыль организаций"}
- {code: "2412", name: "отложенный налог на прибыль организаций"}
- {code: "2420", name: "Прибыль (убыток) от прекращаемой деятельности"}
- {code: "2400", name: "Чистая прибыль (убыток)"}
- {code: "2510", name: "Результат переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток)"}
- {code: "2520", name: "Результат прочих операций, не включаемый в чистую прибыль (убыток)"}
- {code: "2530", name: "Налог на прибыль организаций, относящийся к результатам переоценки внеоборотных активов и прочих операций, не включаемых в чистую прибыль (убыток)"}
- {code: "2500", name: "Совокупный финансовый результат"}
- {code: "2900", name: "Базовая прибыль (убыток) на акцию, руб. коп."}
- {code: "2910", name: "Разводненная прибыль (убыток) на акцию, руб. коп."}
```
- [ ] **Step 4: Создать загрузчик** `api/src/domain/statement_lines.py`:
```python
"""Справочник строк форм ББ/ОФР по редакциям — полный состав формы для экрана проверки (05-domain-rules §7 «Ядро»;
change statement-template-lines, design D-2).
Данные — `api/rules/statement_lines.yaml`; порядок строк в списке — порядок формы. Редакцию выбирает
`statement_mapping.edition_for`; здесь — только состав. Справочник ничего не убирает и не переименовывает в форме:
строки источника, которых в нём нет, остаются как есть.
"""
import functools
import re
from dataclasses import dataclass
from pathlib import Path
import yaml
__all__ = ["RULES_PATH", "LineCatalog", "TemplateLine", "load_line_catalog"]
RULES_PATH = Path(__file__).resolve().parents[2] / "rules" / "statement_lines.yaml"
FORMS = ("BALANCE", "INCOME")
_CODE = re.compile(r"^\d{4}$")
@dataclass(frozen=True)
class TemplateLine:
code: str
name: str
@dataclass(frozen=True)
class LineCatalog:
editions: dict[str, dict[str, tuple[TemplateLine, ...]]]
def lines(self, edition: str | None, form: str) -> tuple[TemplateLine, ...]:
"""Строки формы `form` редакции `edition` в порядке формы; без редакции или для чужой формы — пусто."""
if edition is None:
return ()
return self.editions.get(edition, {}).get(form, ())
def _form_lines(edition: str, form: str, items: object) -> tuple[TemplateLine, ...]:
if not isinstance(items, list):
raise ValueError(f"редакция {edition}: нет списка строк формы {form}")
lines = tuple(TemplateLine(str(item["code"]), " ".join(str(item["name"]).split())) for item in items)
for line in lines:
if not _CODE.fullmatch(line.code):
raise ValueError(f"редакция {edition}, форма {form}: код {line.code!r} — не четыре цифры")
codes = [line.code for line in lines]
if len(codes) != len(set(codes)):
raise ValueError(f"редакция {edition}, форма {form}: повтор кода")
return lines
@functools.lru_cache(maxsize=4)
def load_line_catalog(path: Path | None = None) -> LineCatalog:
"""Справочник из yaml; любая ошибка формата — `ValueError` с именем файла."""
source = path or RULES_PATH
try:
data = yaml.safe_load(source.read_text(encoding="utf-8"))
editions: dict[str, dict[str, tuple[TemplateLine, ...]]] = {}
for edition in data["editions"]:
edition_id = str(edition["id"])
forms = edition["forms"]
editions[edition_id] = {form: _form_lines(edition_id, form, forms.get(form)) for form in FORMS}
return LineCatalog(editions=editions)
except (OSError, KeyError, TypeError, AttributeError, ValueError, yaml.YAMLError) as exc:
raise ValueError(f"справочник строк {source.name}: {exc}") from exc
```
- [ ] **Step 5: Запустить — проходит**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_domain_statement_lines.py -q`
Expected: PASS (все тесты).
- [ ] **Step 6: Линтеры**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make lint`
Expected: ruff и mypy без ошибок (длинные строки yaml линтер не проверяет).
- [ ] **Step 7: Коммит**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core add api/rules/statement_lines.yaml api/src/domain/statement_lines.py api/tests/test_domain_statement_lines.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core commit -m "feat(statements): справочник строк форм ББ/ОФР по редакциям (#80)"
```
(В конце сообщения — строки атрибуции из system-reminder сессии.)
---
### Task 2: GET достраивает форму (arm-core, schemas + service)
**Модель:** sonnet (интеграция).
**Files:**
- Modify: `arm-core/api/src/schemas/statements.py` (`ValueOut`, `LineOut`, `statement_out`)
- Modify: `arm-core/api/src/services/statements.py` (`build_out`)
- Test: `arm-core/api/tests/test_api_statements.py` (изменить `test_get_statement`, добавить новые)
**Interfaces:**
- Consumes: `load_line_catalog().lines(edition, form) -> tuple[TemplateLine, ...]` (Task 1).
- Produces: `ValueOut.id: uuid.UUID | None`, `LineOut.id: uuid.UUID | None`, `LineOut.origin: Literal["source", "template"]`;
`statement_out(document, meta, columns, lines, report, cross, *, edition, template)` — `template:
Sequence[TemplateLine]`. Функция `line_origin(line: FinancialStatementLine) -> Literal["source", "template"]` в
`schemas/statements.py`.
- [ ] **Step 1: Написать падающие тесты.** В `api/tests/test_api_statements.py`:
1) Заменить константу и тело `test_get_statement`:
```python
MEZH_BALANCE_LINES = 37
MEZH_BALANCE_TEMPLATE_ONLY = {"1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н)
MEZH_INCOME_TEMPLATE_ONLY = {"2420"}
```
```python
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 == HTTPStatus.OK, response.text
body = response.json()
assert body["edition"] == "fsbu4"
assert body["document"]["document_type"] == "BALANCE" and body["document"]["period_quarter"] == "Q3"
source = [line for line in body["lines"] if line["origin"] == "source"]
template = [line for line in body["lines"] if line["origin"] == "template"]
assert len(source) == MEZH_BALANCE_LINES and all(line["id"] for line in source)
assert {line["line_code"] for line in template} == MEZH_BALANCE_TEMPLATE_ONLY
assert all(line["id"] is None for line in template)
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"}
```
2) В `test_get_statement_shape_and_decimal_strings` заменить строку проверки ключей строки на:
```python
assert set(line) == {"id", "origin", "line_code", "line_code_confidence", "line_name", "line_name_confidence", "values"}
```
3) Добавить хелперы и тесты после `test_get_income_statement`:
```python
async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta:
return (
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
).scalar_one()
async def drop_line(session: AsyncSession, document: Document, line_code: str) -> dict[int, Decimal | None]:
"""Удалить строку формы в БД теста (как будто источник её потерял); вернуть её суммы по `column_index`."""
meta = await _meta(session, document)
line = (
await session.execute(
select(FinancialStatementLine).where(
FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code
)
)
).scalar_one()
rows = (
await session.execute(
select(FinancialStatementColumn.column_index, FinancialStatementValue.amount)
.join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id)
.where(FinancialStatementValue.line_id == line.id)
)
).all()
await session.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id == line.id))
await session.execute(delete(FinancialStatementLine).where(FinancialStatementLine.id == line.id))
await session.flush()
return dict(rows)
async def drop_value(session: AsyncSession, document: Document, line_code: str, column_index: int) -> None:
"""Удалить одну ячейку строки в БД теста."""
meta = await _meta(session, document)
line_id = (
await session.execute(
select(FinancialStatementLine.id).where(
FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code
)
)
).scalar_one()
column_id = (
await session.execute(
select(FinancialStatementColumn.id).where(
FinancialStatementColumn.meta_id == meta.id, FinancialStatementColumn.column_index == column_index
)
)
).scalar_one()
await session.execute(
delete(FinancialStatementValue).where(
FinancialStatementValue.line_id == line_id, FinancialStatementValue.column_id == column_id
)
)
await session.flush()
async def db_line_count(session: AsyncSession, document: Document) -> int:
meta = await _meta(session, document)
return len(
(await session.execute(select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta.id))).all()
)
async def test_get_income_statement_template_lines(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir, MEZH_INCOME)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
template = {line["line_code"] for line in body["lines"] if line["origin"] == "template"}
assert template == MEZH_INCOME_TEMPLATE_ONLY
async def test_get_completes_lost_line_without_writing(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Строка, потерянная источником (1700), достроена из справочника; статус и проверки — как без достройки."""
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
lines_in_db = await db_line_count(seeded, document)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
line = next(line for line in body["lines"] if line["line_code"] == "1700")
assert line["id"] is None and line["origin"] == "template" and line["line_name"] == "БАЛАНС"
assert line["line_code_confidence"] is None and line["line_name_confidence"] is None
assert len(line["values"]) == len(body["columns"])
assert all(
v["id"] is None
and v["amount"] is None
and v["is_recognized"] is None
and v["is_modified"] is False
and v["original_amount"] is None
and v["confidence"] is None
for v in line["values"]
)
assert [v["column_index"] for v in line["values"]] == sorted(c["column_index"] for c in body["columns"])
relations = {(f["relation"], f["column_index"]) for f in body["checks"]["failed"]}
# цель 1700 не проверяется; «1600 = 1700» (цель 1600 есть, 1700 = 0) нарушено — как и без достройки
assert not any(relation.startswith("1700 =") for relation, _ in relations)
assert {index for relation, index in relations if relation == "1600 = 1700"} == {0, 1, 2}
assert body["document"]["status"] == document.status.value # статус пересчитывается только правкой
assert await db_line_count(seeded, document) == lines_in_db
async def test_get_completes_missing_cell(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_value(seeded, document, "1150", 1)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
line = next(line for line in body["lines"] if line["line_code"] == "1150")
assert line["origin"] == "source" and len(line["values"]) == len(body["columns"])
missing = next(v for v in line["values"] if v["column_index"] == 1)
assert missing["id"] is None and missing["amount"] is None
assert [v["column_index"] for v in line["values"]] == sorted(v["column_index"] for v in line["values"])
async def test_get_without_edition_completes_cells_only(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_value(seeded, document, "1150", 1)
document.reporting_date = None
await seeded.flush()
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
assert body["edition"] is None
assert len(body["lines"]) == MEZH_BALANCE_LINES
assert all(line["origin"] == "source" for line in body["lines"])
assert all(len(line["values"]) == len(body["columns"]) for line in body["lines"])
```
В импорты файла добавить `delete` к `from sqlalchemy import select, update` → `from sqlalchemy import delete, select, update`.
- [ ] **Step 2: Запустить — падают**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make up` (если стенд PostgreSQL/NATS ещё не поднят), затем
`cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q -k "get_"`
Expected: FAIL — `KeyError: 'origin'` / неверное число строк.
- [ ] **Step 3: Реализовать в `api/src/schemas/statements.py`.**
В импорты добавить `from typing import Annotated, Literal` (заменить текущий `from typing import Annotated`) и
`from src.domain.statement_lines import TemplateLine`. Изменить модели и сборку:
```python
LineOrigin = Literal["source", "template"]
class ValueOut(BaseModel):
id: uuid.UUID | None # None — ячейка достроена в ответе, в БД её нет
column_id: uuid.UUID
column_index: int
amount: Decimal | None
string_value: str | None
confidence: Decimal | None
is_recognized: bool | None
is_modified: bool
original_amount: Decimal | None
modified_by: uuid.UUID | None
modified_at: datetime.datetime | None
class LineOut(BaseModel):
id: uuid.UUID | None # None — строка достроена из справочника редакции, в БД её нет
origin: LineOrigin
line_code: str | None
line_code_confidence: Decimal | None
line_name: str | None
line_name_confidence: Decimal | None
values: list[ValueOut]
```
Добавить функции рядом с `_value_out`:
```python
def line_origin(line: FinancialStatementLine) -> LineOrigin:
"""`template` — строка создана правкой аналитика (маппинг всегда пишет `line_code_original_value`)."""
return "template" if line.line_code_original_value is None else "source"
def _empty_value(column: FinancialStatementColumn) -> ValueOut:
"""Ячейка, которой нет в БД (design D-3): всё пусто, не распознана и не правлена."""
return ValueOut(
id=None,
column_id=column.id,
column_index=column.column_index,
amount=None,
string_value=None,
confidence=None,
is_recognized=None,
is_modified=False,
original_amount=None,
modified_by=None,
modified_at=None,
)
def _lines_out(
columns: Sequence[FinancialStatementColumn],
lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]],
template: Sequence[TemplateLine],
) -> list[LineOut]:
"""Строки БД с ячейками по всем колонкам + строки справочника, которых нет в форме (design D-3), по коду."""
column_index = {column.id: column.column_index for column in columns}
out: list[LineOut] = []
for line, values in lines:
present = {value.column_id for value in values}
cells = [_value_out(value, column_index) for value in values]
cells += [_empty_value(column) for column in columns if column.id not in present]
cells.sort(key=lambda cell: cell.column_index)
out.append(
LineOut(
id=line.id,
origin=line_origin(line),
line_code=line.line_code,
line_code_confidence=line.line_code_confidence,
line_name=line.line_name,
line_name_confidence=line.line_name_confidence,
values=cells,
)
)
codes = {line.line_code for line, _ in lines}
for item in template:
if item.code in codes:
continue
out.append(
LineOut(
id=None,
origin="template",
line_code=item.code,
line_code_confidence=None,
line_name=item.name,
line_name_confidence=None,
values=[_empty_value(column) for column in columns],
)
)
out.sort(key=lambda line: (line.line_code is None, line.line_code or ""))
return out
```
В `statement_out`: добавить keyword-параметр `template: Sequence[TemplateLine]` после `edition`, убрать локальную
`column_index` (больше не нужна) и заменить блок `lines=[LineOut(...) for line, values in lines]` на
`lines=_lines_out(columns, lines, template),`. Docstring дополнить: «строки достроены по справочнику редакции (D-3)».
- [ ] **Step 4: Реализовать в `api/src/services/statements.py`.** Импорт `from src.domain.statement_lines import
load_line_catalog`; `build_out`:
```python
async def build_out(self, document: Document, meta: FinancialStatementMeta) -> schemas.StatementOut:
"""Ответ формы по текущему состоянию БД, достроенный по справочнику редакции (design D-3; используется и
ответами правки/акцепта). В БД ничего не пишет."""
columns, lines = await repo.load_form(self._session, meta.id)
report, cross = await self.checks_report(document, meta)
edition = edition_for(document.reporting_date)
template = load_line_catalog().lines(edition, document.document_type.value)
return schemas.statement_out(
document, meta, columns, lines, report, cross, edition=edition, template=template
)
```
- [ ] **Step 5: Запустить — проходят**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q`
Expected: PASS (все, включая старые PATCH-тесты: `_first_cell` берёт строку с суммой — это строка источника).
- [ ] **Step 6: `make check`**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make check`
Expected: lint + unit-тесты зелёные.
- [ ] **Step 7: Коммит**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core add api/src/schemas/statements.py api/src/services/statements.py api/tests/test_api_statements.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core commit -m "feat(statements): GET формы достраивает строки справочника и ячейки (#80)"
```
---
### Task 3: PATCH создаёт строку и ячейку (arm-core)
**Модель:** sonnet (интеграция).
**Files:**
- Modify: `arm-core/api/src/schemas/statements.py` (`CellPatch`)
- Modify: `arm-core/api/src/repositories/statements.py` (новые `form_lines`, `column_ids`, `add_line`, `add_value`; `__all__`)
- Modify: `arm-core/api/src/services/statements.py` (`_apply_cells`, вызов в `patch`)
- Test: `arm-core/api/tests/test_api_statements.py`
**Interfaces:**
- Consumes: `load_line_catalog`, `TemplateLine` (Task 1); `drop_line`, `drop_value`, `db_line_count` (тестовые хелперы Task 2).
- Produces: `CellPatch(line_id: uuid.UUID | None = None, line_code: str | None = None, column_id: uuid.UUID, amount)` с
валидатором «ровно одно»; репозиторий: `form_lines(session, meta_id) -> list[FinancialStatementLine]`,
`column_ids(session, meta_id) -> set[uuid.UUID]`, `add_line(session, meta_id, code, name) -> FinancialStatementLine`,
`add_value(session, line_id, column_id) -> FinancialStatementValue`.
- [ ] **Step 1: Написать падающие тесты** (в конец `api/tests/test_api_statements.py`):
```python
async def _column(client, document_id, index: int) -> str:
body = (await client.get(f"/documents/{document_id}/statement")).json()
return next(c["id"] for c in body["columns"] if c["column_index"] == index)
async def _db_line(session: AsyncSession, document: Document, code: str) -> FinancialStatementLine | None:
meta = await _meta(session, document)
return (
await session.execute(
select(FinancialStatementLine)
.where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == code)
.execution_options(populate_existing=True)
)
).scalar_one_or_none()
async def _edit_events(session: AsyncSession, document: Document) -> list[AuditEvent]:
return list(
(
await session.execute(
select(AuditEvent).where(
AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT
)
)
).scalars()
)
async def test_patch_creates_template_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1700", "column_id": col0, "amount": "2371333"}]},
)
assert response.status_code == HTTPStatus.OK, response.text
line = next(item for item in response.json()["lines"] if item["line_code"] == "1700")
assert line["id"] is not None and line["origin"] == "template" and line["line_name"] == "БАЛАНС"
cell = next(v for v in line["values"] if v["column_id"] == col0)
assert Decimal(cell["amount"]) == Decimal("2371333") and cell["original_amount"] is None
assert cell["is_modified"] is True and cell["is_recognized"] is None and cell["modified_by"] is not None
other = [v for v in line["values"] if v["column_id"] != col0]
assert other and all(v["id"] is None for v in other) # остальные колонки не созданы
db_line = await _db_line(seeded, document, "1700")
assert db_line is not None and db_line.line_code_original_value is None and db_line.line_name_original_value is None
(event,) = await _edit_events(seeded, document)
key = f"value:{cell['id']}"
assert event.before_value == {key: None} and Decimal(event.after_value[key]) == Decimal("2371333")
async def test_patch_two_cells_of_new_line_create_one_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 2."""
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
before = await db_line_count(seeded, document)
client = await auth_client(KA_RF)
col0, col1 = await _column(client, document.id, 0), await _column(client, document.id, 1)
response = await client.patch(
f"/documents/{document.id}/statement",
json={
"values": [
{"line_code": "1700", "column_id": col0, "amount": "1"},
{"line_code": "1700", "column_id": col1, "amount": "2"},
]
},
)
assert response.status_code == HTTPStatus.OK, response.text
assert await db_line_count(seeded, document) == before + 1
line = next(item for item in response.json()["lines"] if item["line_code"] == "1700")
assert sum(1 for v in line["values"] if v["id"] is not None) == 2 # noqa: PLR2004
async def test_patch_repeat_line_code_after_creation_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 3: второй «Сохранить» со старым черновиком по коду — 422, строка не задвоена."""
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
item = {"line_code": "1700", "column_id": col0, "amount": "1"}
assert (await client.patch(f"/documents/{document.id}/statement", json={"values": [item]})).status_code == HTTPStatus.OK
count = await db_line_count(seeded, document)
response = await client.patch(f"/documents/{document.id}/statement", json={"values": [{**item, "amount": "5"}]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
assert "line_id" in response.json()["detail"]["message"]
assert await db_line_count(seeded, document) == count
async def test_patch_line_code_existing_or_unknown_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
count = await db_line_count(seeded, document)
for code in ("1150", "1105"): # 1150 есть в форме; 1105 нет в справочнике fsbu4
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": code, "column_id": col0, "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, code
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
assert await db_line_count(seeded, document) == count
assert await _edit_events(seeded, document) == []
async def test_patch_line_code_without_edition_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
document.reporting_date = None
await seeded.flush()
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1700", "column_id": col0, "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
async def test_patch_null_into_missing_cell_is_noop(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
count = await db_line_count(seeded, document)
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1700", "column_id": col0, "amount": None}]},
)
assert response.status_code == HTTPStatus.OK, response.text
assert await db_line_count(seeded, document) == count
assert await _edit_events(seeded, document) == []
async def test_patch_creates_missing_cell_of_existing_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_value(seeded, document, "1150", 1)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
line = next(item for item in body["lines"] if item["line_code"] == "1150")
col1 = next(c["id"] for c in body["columns"] if c["column_index"] == 1)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": col1, "amount": "77"}]},
)
assert response.status_code == HTTPStatus.OK, response.text
cell = next(
v for v in next(i for i in response.json()["lines"] if i["id"] == line["id"])["values"] if v["column_id"] == col1
)
assert cell["id"] is not None and Decimal(cell["amount"]) == Decimal("77") and cell["original_amount"] is None
assert cell["is_modified"] is True
async def test_patch_bad_column_rolls_back_new_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 1: одна ячейка корректна (новая строка), другая — чужая колонка: 422, строка не создана."""
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
count = await db_line_count(seeded, document)
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={
"values": [
{"line_code": "1700", "column_id": col0, "amount": "1"},
{"line_code": "1700", "column_id": str(uuid.uuid4()), "amount": "2"},
]
},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
assert await db_line_count(seeded, document) == count
async def test_patch_cell_address_must_be_exactly_one(
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)
for item in (
{"line_id": line["id"], "line_code": "1700", "column_id": cell["column_id"], "amount": "1"},
{"column_id": cell["column_id"], "amount": "1"},
):
response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert isinstance(response.json()["detail"], list) # 422 валидации тела — массив
async def test_patch_duplicate_line_code_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
item = {"line_code": "1700", "column_id": col0, "amount": "1"}
response = await client.patch(
f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]}
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "DUPLICATE_CELL"
```
- [ ] **Step 2: Запустить — падают**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q -k "patch"`
Expected: новые тесты FAIL (422 массивом на `line_code` — поле запрещено `extra="forbid"`), старые PASS.
- [ ] **Step 3: `CellPatch`** в `api/src/schemas/statements.py` (импорт `model_validator` из `pydantic`):
```python
class CellPatch(BaseModel):
"""Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить.
Ячейка адресуется ровно одним из: `line_id` — строка формы (если ячейки в колонке нет, она создаётся); `line_code` —
строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4)."""
model_config = ConfigDict(extra="forbid")
line_id: uuid.UUID | None = None
line_code: str | None = Field(None, max_length=10)
column_id: uuid.UUID
amount: Decimal | None = Field(max_digits=18, decimal_places=2)
@model_validator(mode="after")
def _one_address(self) -> "CellPatch":
if (self.line_id is None) == (self.line_code is None):
raise ValueError("укажите ровно одно из line_id и line_code")
return self
```
- [ ] **Step 4: Репозиторий** `api/src/repositories/statements.py` — добавить в `__all__` `"add_line"`, `"add_value"`,
`"column_ids"`, `"form_lines"` (по алфавиту) и функции после `lock_cells`:
```python
async def form_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[FinancialStatementLine]:
"""Строки формы (без значений) — адресация ячеек правки по `line_id` и `line_code`."""
stmt = select(FinancialStatementLine).where(FinancialStatementLine.meta_id == meta_id)
return list((await session.execute(stmt)).scalars())
async def column_ids(session: AsyncSession, meta_id: uuid.UUID) -> set[uuid.UUID]:
"""Id колонок формы."""
stmt = select(FinancialStatementColumn.id).where(FinancialStatementColumn.meta_id == meta_id)
return set((await session.execute(stmt)).scalars())
async def add_line(session: AsyncSession, meta_id: uuid.UUID, code: str, name: str) -> FinancialStatementLine:
"""Строка справочника, созданная правкой аналитика: `*_original_value = NULL` — признак «не из источника»."""
line = FinancialStatementLine(
id=uuid.uuid4(),
meta_id=meta_id,
line_code=code,
line_code_original_value=None,
line_code_confidence=None,
line_code_modified=False,
line_name=name,
line_name_original_value=None,
line_name_confidence=None,
line_name_modified=False,
)
session.add(line)
await session.flush()
return line
async def add_value(session: AsyncSession, line_id: uuid.UUID, column_id: uuid.UUID) -> FinancialStatementValue:
"""Пустая ячейка, созданная правкой: не распознана (`is_recognized = NULL`), исходной суммы нет."""
value = FinancialStatementValue(
id=uuid.uuid4(),
line_id=line_id,
column_id=column_id,
amount=None,
string_value=None,
confidence=None,
is_recognized=None,
is_modified=False,
original_amount=None,
)
session.add(value)
await session.flush()
return value
```
- [ ] **Step 5: Сервис** `api/src/services/statements.py`. Импорты: `from src.domain.statement_lines import TemplateLine,
load_line_catalog`; `from src.models.statements import FinancialStatementLine, FinancialStatementMeta,
FinancialStatementValue`. Константа `LINE_EXISTS = "строка уже есть в форме — передайте line_id"`. Заменить
`_apply_cells` целиком:
```python
async def _apply_cells(
self,
document: Document,
meta: FinancialStatementMeta,
body: schemas.StatementPatch,
before: dict[str, Any],
after: dict[str, Any],
) -> None:
"""Значения `body.values` (D-7 Среза 3b; design D-4 statement-template-lines): `original_amount` при первой
правке, `is_modified`, `modified_by/at`. Ячейка, которой нет в БД, создаётся при первой непустой правке; строка
по `line_code` — из справочника редакции документа. Вся адресация проверяется до первой вставки."""
if not body.values:
return
keys = [(cell.line_id or cell.line_code, cell.column_id) for cell in body.values]
if len(keys) != len(set(keys)):
raise DomainValidationError(DUPLICATE_CELL, "ячейка указана в запросе дважды")
columns = await repo.column_ids(self._session, meta.id)
lines = await repo.form_lines(self._session, meta.id)
by_id = {line.id: line for line in lines}
codes = {line.line_code for line in lines}
template = {
item.code: item
for item in load_line_catalog().lines(edition_for(document.reporting_date), document.document_type.value)
}
targets: list[tuple[schemas.CellPatch, FinancialStatementLine | None, TemplateLine | None]] = []
for cell in body.values:
if cell.column_id not in columns:
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
if cell.line_id is not None:
line = by_id.get(cell.line_id)
if line is None:
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
targets.append((cell, line, None))
continue
if cell.line_code in codes:
raise DomainValidationError(UNKNOWN_CELL, LINE_EXISTS)
item = template.get(cell.line_code or "")
if item is None:
raise DomainValidationError(UNKNOWN_CELL, "строки с таким кодом нет в форме этой редакции")
targets.append((cell, None, item))
existing = await repo.lock_cells(
self._session, meta.id, [(line.id, cell.column_id) for cell, line, _ in targets if line is not None]
)
created: dict[str, FinancialStatementLine] = {}
now = utc_now()
for cell, line, item in targets:
value: FinancialStatementValue | None = (
existing.get((line.id, cell.column_id)) if line is not None else None
)
if value is None:
if cell.amount is None:
continue # пустая сумма в несуществующую ячейку — не изменение
if line is None and item is not None:
line = created.get(item.code) or await repo.add_line(self._session, meta.id, item.code, item.name)
created[item.code] = line
if line is None:
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
value = await repo.add_value(self._session, 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 = self._audit.user_id, now
```
В `patch` заменить вызов на `await self._apply_cells(document, meta, body, before, after)`.
Почему так: вся адресация (колонка, строка, код) проверяется в первом цикле — вставок до него нет, поэтому 422 ничего не
оставляет даже без отката (Review Focus 1). Две ячейки одной новой строки делят её через `created` (Review Focus 2).
Повтор `line_code` после создания упирается в `codes` (Review Focus 3); параллельные правки одной формы сериализует
блокировка документа в `_editable`.
- [ ] **Step 6: Запустить — проходят**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q`
Expected: PASS.
- [ ] **Step 7: `make check`, коммит**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make check` — зелёный.
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core add api/src/schemas/statements.py api/src/repositories/statements.py api/src/services/statements.py api/tests/test_api_statements.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core commit -m "feat(statements): правка по line_code создаёт строку и ячейку формы (#80)"
```
---
### Task 4: Пустые созданные строки вне проверок §7 (arm-core)
**Модель:** sonnet.
**Files:**
- Modify: `arm-core/api/src/repositories/statements.py` (`statement_amounts`)
- Test: `arm-core/api/tests/test_api_statements.py`
**Interfaces:**
- Consumes: хелперы тестов Task 2/3 (`drop_line`, `_column`).
- Produces: `statement_amounts` — тот же тип результата; строки с `line_code_original_value IS NULL` и только пустыми
суммами в результат не попадают.
- [ ] **Step 1: Падающий тест** (в конец `test_api_statements.py`):
```python
async def test_template_line_checks_follow_filled_amounts(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 4 (spec statement-checks): заполненная в одной колонке строка проверяется по всем колонкам,
очищенная — снова не участвует."""
document = await form(seeded, fixtures_dir)
sums = await drop_line(seeded, document, "1700")
assert sums[1] not in (None, Decimal(0)) and sums[2] not in (None, Decimal(0)) # колонки 1, 2 непустые
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1700", "column_id": col0, "amount": str(sums[0])}]},
)
failed = response.json()["checks"]["failed"]
relation = "1700 = 1300 + 1400 + 1500"
assert sorted(f["column_index"] for f in failed if f["relation"] == relation) == [1, 2]
assert all(f["column_index"] != 0 for f in failed)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": next(i["id"] for i in response.json()["lines"] if i["line_code"] == "1700"),
"column_id": col0, "amount": None}]},
)
assert response.status_code == HTTPStatus.OK, response.text
after_clear = response.json()["checks"]["failed"]
assert not any(f["relation"].startswith("1700 =") for f in after_clear) # очищенная 1700 снова не цель
assert {f["column_index"] for f in after_clear if f["relation"] == "1600 = 1700"} == {0, 1, 2}
```
Строки соотношений — из `api/rules/statement_checks.yaml`: `"1700 = 1300 + 1400 + 1500"`, `"1600 = 1700"` (обе жёсткие).
Суммы 1700 Межениновской: колонки 0/1/2 — 2186927.83 / 2056189.00 / 2260179.00. После заполнения колонки 0 нарушения
`1700 = …` и `1600 = 1700` остаются в колонках 1 и 2 (там 1700 пустая = 0).
- [ ] **Step 2: Запустить — падает**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q -k template_line_checks`
Expected: FAIL на последнем assert — очищенная 1700 (сумма `None` = 0) даёт нарушение в обеих колонках.
- [ ] **Step 3: Реализация** — заменить тело `statement_amounts` после выборки колонок:
```python
rows = (
await session.execute(
select(
FinancialStatementLine.line_code,
FinancialStatementLine.line_code_original_value,
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]] = {}
added: set[str] = set() # строки, созданные правкой аналитика (design D-5)
for code, original, column_id, amount in rows:
by_code.setdefault(code or "", {})[index_by_id[column_id]] = amount
if original is None:
added.add(code or "")
lines = [
(code, amounts)
for code, amounts in by_code.items()
if code not in added or any(amount is not None for amount in amounts.values())
]
return [index for _, index in columns], lines
```
Docstring дополнить: «Созданные правкой строки (`line_code_original_value IS NULL`) без единой суммы не участвуют
(spec statement-checks, design D-5).»
- [ ] **Step 4: Запустить — проходит; `make check`**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_api_statements.py tests/test_form_confirmable.py tests/test_auto_confirm.py -q`
затем `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make check`. Expected: PASS.
- [ ] **Step 5: Коммит**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core add api/src/repositories/statements.py api/tests/test_api_statements.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core commit -m "feat(statements): пустые созданные строки не участвуют в проверках §7 (#80)"
```
---
### Task 5: Документация arm-core и PR (arm-core)
**Модель:** sonnet.
**Files:**
- Modify: `arm-core/README.md` (раздел про форму ББ/ОФР ~стр. 540–580; таблица «Ошибки API», строка `UNKNOWN_CELL` ~стр. 858)
- Modify: `arm-core/CLAUDE.md` (абзац «Формы ББ/ОФР (Срез 3b)»)
- Modify: `arm-core/openspec/changes/statement-template-lines/design.md` (раздел решений — если исполнение добавило рулинги)
- [ ] **Step 1: README.** В описание `GET …/statement` добавить: «Форма достраивается по справочнику строк редакции
(`api/rules/statement_lines.yaml`): недостающие строки (`id = null`, `origin = template`) и ячейки (`id = null`); в БД
не пишется.» В описание `PATCH`: «`values[]` адресует ячейку `line_id` или `line_code` (ровно одно): ячейка, которой
нет, создаётся при первой непустой правке; строка по `line_code` — только код справочника редакции, которого нет в
форме.» Строку таблицы ошибок заменить на:
`| UNKNOWN_CELL | 422 | PATCH …/statement: line_id/column_id не этой формы; line_code уже есть в форме (передайте line_id), нет в справочнике редакции или редакция не определена |`
- [ ] **Step 2: CLAUDE.md.** В абзац «Формы ББ/ОФР (Срез 3b)» после фразы о проверках §7 вставить:
«Полный состав формы — `domain/statement_lines.py` по справочнику `api/rules/statement_lines.yaml` (редакции `66n`/`fsbu4`
из шаблонов банка; копия словаря scan-gateway с ним не совпадает): `GET …/statement` достраивает строки и ячейки с
`id = null`, `PATCH` по `line_code` создаёт строку (`line_code_original_value = NULL` — «не из источника»); пустые
созданные строки не участвуют в §7 (`statement_amounts`). Решения — `openspec/changes/statement-template-lines/design.md`.»
- [ ] **Step 3: Полная проверка**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make check-full`
Expected: все цели зелёные; вывод сохранить для описания PR. Если `e2e` пропущен (нет профиля `services`) — так и
указать в PR.
- [ ] **Step 4: Коммит, push, PR**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core add README.md CLAUDE.md openspec/changes/statement-template-lines/design.md
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core commit -m "docs: полный состав строк ББ/ОФР в README и CLAUDE.md (#80)"
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core push -u origin feat/80-statement-template-lines
tea pulls create --login home --repo RSHB/arm-core --head feat/80-statement-template-lines --base main \
--title "Полный состав строк ББ/ОФР на экране проверки (#80)" --description "<описание>"
```
Описание PR: что сделано (по задачам 1–4), «Closes #80», вывод `make check-full`, список «Rulings I made» (R-1…R-8 из
design.md + рулинги исполнения), таблица находок с маркерами и статусами, `deferred` — отдельным списком.
---
### Task 6: Фронт arm-web — шаблонные строки и выделение «БАЛАНС» (arm-web)
**Модель:** sonnet (TS/React; владелец слаб во фронте — объяснения решений в PR простым языком).
**Подготовка (до кода):**
```bash
cd /Users/andrey.epifancev/projects/awp-ca
git -C /Users/andrey.epifancev/projects/awp-ca/arm-web fetch origin
tea issues create --login home --repo RSHB/arm-web --title "Шаблонные строки ББ/ОФР и выделение строк «БАЛАНС»" \
--description "Фронт к RSHB/arm-core#80: строки формы с id = null (origin = template) редактируются, правка уходит по line_code; строки 1600/1700 выделены цветом. Спека — arm-core openspec/changes/statement-template-lines."
# номер issue → <N>
git -C /Users/andrey.epifancev/projects/awp-ca/arm-web worktree add -b feat/<N>-statement-template-lines ../.wt/stmt-template/arm-web origin/test
ln -s ../../web-proto/arm-web/node_modules .wt/stmt-template/arm-web/node_modules
cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-web && openspec new change statement-template-lines
```
**Files:**
- Create: `arm-web/openspec/changes/statement-template-lines/{proposal.md,design.md,specs/document-review-ui/spec.md}`
- Modify: `arm-web/src/types/api.ts` (`ValueOut`, `LineOut`, `StatementPatch`)
- Modify: `arm-web/src/widgets/DocumentReview/statementGrid.ts` (+ `statementGrid.test.ts`)
- Modify: `arm-web/src/widgets/DocumentReview/formLayout.ts` (+ `formLayout.test.ts`)
- Modify: `arm-web/src/widgets/DocumentReview/StatementTable.tsx`
- Modify: `arm-web/src/widgets/DocumentReview/StatementReview.tsx` (вызов `draftToPatch`)
**Interfaces:**
- Consumes: API Task 2/3: `LineOut.id: string | null`, `LineOut.origin: 'source' | 'template'`, `ValueOut.id: string | null`;
PATCH `values[]` элемент `{line_id, column_id, amount}` или `{line_code, column_id, amount}`.
- Produces: `GridRow { key: string; lineId: string | null; code: string | null; name: string | null; origin: 'source' |
'template'; cells: Record<string, ValueOut | undefined> }`; `cellKey(rowKey: string, columnId: string)`;
`draftToPatch(draft: Record<string, string | null>, rows: GridRow[])`; `LayoutRow` (line) получает `grand: boolean`.
- [ ] **Step 1: OpenSpec change arm-web.** `proposal.md` — 10–15 строк: зачем (ссылка на arm-core#80), что меняется
(шаблонные строки редактируются, правка по `line_code`, цвет строк «БАЛАНС»), Non-goals (порядок строк, правка
кода/наименования). `design.md` — решения D-6 и R-7 из design.md arm-core дословно. `specs/document-review-ui/spec.md`:
```markdown
## MODIFIED Requirements
### Requirement: Форма ББ/ОФР — просмотр и правка
Экран SHALL показывать `GET /documents/{id}/statement`: шапку (ИНН, дата отчёта, единицы — значение и исходное при
правке; наименование, ОКУД/ОКПО) и таблицу «строка (код, наименование) × колонки» с суммами из `values[]`, включая
строки и ячейки, достроенные сервером по справочнику редакции (`id = null`). Ячейки с `confidence < 100` или
`is_recognized = false` SHALL подсвечиваться как «требует внимания», с `is_modified = true` — как «исправлено» с
исходным значением `original_amount` в подсказке; пустая достроенная ячейка не подсвечивается. Строки «БАЛАНС» формы
`BALANCE` (коды 1600 и 1700) SHALL выделяться фоном `#f9f0ff` и жирным шрифтом; подсветка ячейки перекрывает фон строки.
Единицы (`header.units`) SHALL выводиться над таблицей. Пока документ не `CONFIRMED`, ячейка SHALL быть редактируемой:
ввод проверяется по формату суммы (до 2 знаков после точки, знак минус допустим, пустое — `null`), сохранение
отправляет `PATCH /documents/{id}/statement {values: [...]}` суммой-строкой: ячейка строки из БД — `{line_id,
column_id, amount}`, ячейка достроенной строки (`id = null`) — `{line_code, column_id, amount}`; ответ заменяет
состояние экрана. Правка шапки (ИНН, дата отчёта, единицы) SHALL отправляться тем же `PATCH` в `header`;
`inn: null` не отправляется.
#### Scenario: Исправление ячейки
- **WHEN** аналитик меняет значение ячейки и сохраняет
- **THEN** ячейка помечена «исправлено», проверки пересчитаны по ответу сервера, акцепт снят
#### Scenario: Заполнение строки, которой не было в распознавании
- **WHEN** у формы нет строки 1700 из источника, аналитик вводит сумму в достроенную строку 1700 и сохраняет
- **THEN** уходит `{line_code: "1700", column_id, amount}`, по ответу строка получила `id`, ячейка помечена «исправлено»
#### Scenario: Строки «БАЛАНС»
- **WHEN** открыта форма `BALANCE`
- **THEN** строки 1600 и 1700 выделены цветом и жирным, у ОФР выделения нет
#### Scenario: Неверный формат суммы
- **WHEN** аналитик вводит «12,3,4»
- **THEN** запрос не отправляется, поле показывает ошибку формата
```
Run: `openspec validate statement-template-lines --strict` — valid.
- [ ] **Step 2: Падающие тесты vitest.** В `statementGrid.test.ts` заменить блоки `buildStatementGrid` и `draftToPatch` и
дополнить `cellFlags`:
```ts
describe('buildStatementGrid', () => {
it('колонки по column_index, ячейки по column_id', () => {
const grid = buildStatementGrid(statement);
expect(grid.columns.map((c) => c.id)).toEqual(['c1', 'c2']);
expect(grid.rows[0]).toMatchObject({ key: 'l1', lineId: 'l1', code: '1600', name: 'Баланс', origin: 'source' });
expect(grid.rows[0].cells.c1?.id).toBe('v1');
expect(grid.rows[0].cells.c2?.id).toBe('v2');
});
it('достроенная строка: ключ по коду, lineId = null', () => {
const withTemplate = {
...statement,
lines: [
...statement.lines,
{ id: null, origin: 'template', line_code: '1700', line_name: 'БАЛАНС', values: [v(null, 'c1', 0, { amount: null, confidence: null, is_recognized: null })] },
],
} as unknown as StatementOut;
const row = buildStatementGrid(withTemplate).rows[1];
expect(row).toMatchObject({ key: 'code-1700', lineId: null, code: '1700', origin: 'template' });
});
});
```
(в хелпере `v` сменить тип первого параметра на `id: string | null`; в объекте `statement` строке `l1` добавить
`origin: 'source'`.)
```ts
it('пустая достроенная ячейка не требует внимания', () => {
expect(cellFlags(v(null, 'c', 0, { amount: null, confidence: null, is_recognized: null }))).toEqual({
attention: false,
modified: false,
});
});
```
```ts
describe('draftToPatch', () => {
const rows = buildStatementGrid({
...statement,
lines: [
...statement.lines,
{ id: null, origin: 'template', line_code: '1700', line_name: 'БАЛАНС', values: [] },
],
} as unknown as StatementOut).rows;
it('строка из БД — line_id, достроенная — line_code', () => {
expect(draftToPatch({ [cellKey('l1', 'c1')]: '10.5', [cellKey('code-1700', 'c1')]: null }, rows)).toEqual([
{ line_id: 'l1', column_id: 'c1', amount: '10.5' },
{ line_code: '1700', column_id: 'c1', amount: null },
]);
});
it('неизвестная строка — ошибка, а не тихий пропуск', () => {
expect(() => draftToPatch({ [cellKey('ghost', 'c1')]: '1' }, rows)).toThrow();
});
});
```
В `formLayout.test.ts`: хелпер `row` дополнить `origin: 'source'` и `lineId: code ?? 'x'` (как сейчас), добавить:
```ts
describe('formLayout — строки «БАЛАНС»', () => {
it('1600 и 1700 у баланса помечены grand, остальные итоги — нет', () => {
const layout = formLayout('BALANCE', ['1100', '1150', '1600', '1300', '1310', '1700'].map((c) => row(c)));
const grand = layout.filter((r) => r.kind === 'line' && r.grand).map((r) => (r.kind === 'line' ? r.row.code : ''));
expect(grand).toEqual(['1600', '1700']);
});
it('у ОФР grand нет', () => {
const layout = formLayout('INCOME', ['2110', '2100', '2400'].map((c) => row(c)));
expect(layout.some((r) => r.kind === 'line' && r.grand)).toBe(false);
});
});
```
Run: `make test` — новые тесты FAIL (нет `origin`, `grand`, другая сигнатура `draftToPatch`).
- [ ] **Step 3: Типы** `src/types/api.ts`:
```ts
export interface ValueOut {
id: string | null; // null — ячейка достроена сервером, в БД её нет
column_id: string;
column_index: number;
amount: string | null;
string_value: string | null;
confidence: string | null;
is_recognized: boolean | null;
is_modified: boolean;
original_amount: string | null;
}
export interface LineOut {
id: string | null; // null — строка достроена по справочнику редакции
origin: 'source' | 'template';
line_code: string | null;
line_name: string | null;
values: ValueOut[];
}
```
```ts
export type CellPatch = ({ line_id: string } | { line_code: string }) & { column_id: string; amount: string | null };
export interface StatementPatch {
header?: { inn?: string; report_date?: string; units?: string };
values?: CellPatch[];
}
```
- [ ] **Step 4: `statementGrid.ts`:**
```ts
import type { CellPatch, ColumnOut, StatementOut, ValueOut } from '../../types/api';
export type GridRow = {
key: string;
lineId: string | null;
code: string | null;
name: string | null;
origin: 'source' | 'template';
cells: Record<string, ValueOut | undefined>;
};
/** Ключ строки: id из БД, у достроенной строки (id = null) — код; двоеточия в ключе нет (разделитель cellKey). */
const rowKey = (id: string | null, code: string | null) => id ?? `code-${code ?? ''}`;
export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] } {
const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index);
const rows = statement.lines.map((line) => {
const cells: Record<string, ValueOut | undefined> = {};
for (const value of line.values) {
cells[value.column_id] = value;
}
return { key: rowKey(line.id, line.line_code), lineId: line.id, code: line.line_code, name: line.line_name, origin: line.origin, cells };
});
return { columns, rows };
}
```
```ts
export const cellKey = (rowKeyValue: string, columnId: string) => `${rowKeyValue}:${columnId}`;
/** Черновик → тело PATCH: строка из БД адресуется line_id, достроенная — line_code (сервер создаст её). */
export function draftToPatch(draft: Record<string, string | null>, rows: GridRow[]): CellPatch[] {
const byKey = new Map(rows.map((row) => [row.key, row]));
return Object.entries(draft).map(([key, amount]) => {
const sep = key.lastIndexOf(':');
const row = byKey.get(key.slice(0, sep));
const column_id = key.slice(sep + 1);
if (!row) {
throw new Error(`строка черновика ${key} не найдена в форме`);
}
return row.lineId ? { line_id: row.lineId, column_id, amount } : { line_code: row.code ?? '', column_id, amount };
});
}
```
`cellFlags` не меняется: у пустой достроенной ячейки `confidence = null`, `is_recognized = null` — внимания нет.
- [ ] **Step 5: `StatementReview.tsx`** — в `save` заменить `body.values = draftToPatch(values);` на
`body.values = draftToPatch(values, grid?.rows ?? []);`.
- [ ] **Step 6: `formLayout.ts`** — тип строки и `lineRow`:
```ts
export type LayoutRow =
| { kind: 'section'; key: string; title: string; level: 1 | 2 }
| { kind: 'line'; key: string; row: GridRow; total: boolean; grand: boolean; displayName: string; fullName: string | null };
// строки «БАЛАНС» по активу (1600) и пассиву (1700) выделяются цветом (просьба владельца 09.10.2026)
const GRAND_TOTALS = new Set(['1600', '1700']);
function lineRow(row: GridRow, prefixes: string[], balance: boolean): LayoutRow {
const code = codeNum(row);
return {
kind: 'line',
key: row.key,
row,
total: code !== null && isTotal(code),
grand: balance && GRAND_TOTALS.has(row.code ?? ''),
...stripSectionPrefixes(row.name, prefixes),
};
}
```
Вызовы: в `balanceLayout` — `lineRow(r, BALANCE_PREFIXES, true)` (три места), в `incomeLayout` — `lineRow(r, [], false)`.
- [ ] **Step 7: `StatementTable.tsx`:**
```ts
const GRAND_BG = '#f9f0ff'; // строки «БАЛАНС»: фиолетовый не занят (серый — разделы, синий — исправлено, оранжевый — внимание)
const lineWeight = (r: LayoutRow) => (r.kind === 'line' ? (r.grand ? 700 : r.total ? 600 : undefined) : undefined);
const lineBackground = (r: LayoutRow) => (r.kind === 'line' && r.grand ? GRAND_BG : undefined);
```
- колонка `name`: `onCell` для строки-линии возвращает `{ style: { background: lineBackground(r) } }` (для раздела —
как сейчас); в `render` `fontWeight: lineWeight(r)`;
- колонка `code`: `onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: lineBackground(r) } })`,
в `render` `fontWeight: lineWeight(r)`;
- колонки сумм: `style: { background: cellBackground(r.row.cells[col.id]) ?? lineBackground(r), fontWeight: lineWeight(r) }`;
- `const key = cellKey(row.key, col.id);` вместо `cellKey(row.lineId, col.id)`.
- [ ] **Step 8: Проверки**
Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-web && make check`
Expected: biome, `tsc --noEmit`, vitest, `rsbuild build` — зелёные. Ошибки tsc в файлах, которых задача не касалась и
которые были до неё, — не чинить, отметить в отчёте (O-маркер).
- [ ] **Step 9: Коммит, push, PR в `test`**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-web add -A src openspec/changes/statement-template-lines
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-web commit -m "feat(review): шаблонные строки ББ/ОФР и выделение строк «БАЛАНС» (#<N>)"
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-web push -u origin feat/<N>-statement-template-lines
tea pulls create --login home --repo RSHB/arm-web --head feat/<N>-statement-template-lines --base test \
--title "Шаблонные строки ББ/ОФР и выделение «БАЛАНС» (#<N>)" --description "<описание>"
```
Описание: «Closes #<N>», зависит от RSHB/arm-core PR (Task 5), объяснение решений простым языком (почему ключ строки по
коду, почему тело PATCH собирается по строкам, почему фиолетовый), вывод `make check`, скриншоты — после Task 8.
---
### Task 7: Документация docs (arm-ka-docs)
**Модель:** sonnet.
**Подготовка:**
```bash
cd /Users/andrey.epifancev/projects/awp-ca
git -C /Users/andrey.epifancev/projects/awp-ca/docs fetch origin
tea issues create --login home --repo RSHB/arm-ka-docs --title "Полный состав строк ББ/ОФР: правило §7, OQ-150, гайды" \
--description "Документация к RSHB/arm-core#80 и arm-web#<N>."
# номер → <D>
rm .wt/stmt-template/docs # это симлинк на main-checkout; вместо него — worktree
git -C /Users/andrey.epifancev/projects/awp-ca/docs worktree add -b feat/<D>-statement-template-lines ../.wt/stmt-template/docs origin/main
```
**Files:**
- Modify: `docs/context/05-domain-rules.md` (§7 абзац «Ядро (Срез 3b, реализовано)»; §11 «Маппинг (Срез 3)» — пометка
про строки)
- Modify: `docs/context/07-open-questions.md` (OQ-150)
- Modify: `docs/dev/frontend-api-guide.md` (строка `PATCH …/statement` ~стр. 380, раздел формы, строка ошибок ~595)
- Modify: `docs/dev/api-contract-slice-3b.md` (~стр. 35 и 80)
- Modify: `docs/dev/qa-guide.md` (F8 ~стр. 542 + новая строка проверки; §8 UI-чек-лист — новые пункты)
- [ ] **Step 1: §7.** После абзаца «Ядро (Срез 3b, реализовано)…» добавить абзац:
«**Полный состав формы (arm-core#80, решение владельца 09.10.2026).** Экран проверки показывает все строки формы
редакции документа (`edition`): справочник `arm-core/api/rules/statement_lines.yaml` (66н и ФСБУ 4/2023, ББ и ОФР, из
шаблонов банка `fixtures/statements-xlsx/templates/`). Строки и ячейки, которых нет в источнике, ядро достраивает в
ответе (`id = null`, `origin = template`) и в БД не пишет; первая непустая правка создаёт строку
(`line_code_original_value = NULL` — признак «не из источника») и ячейку как правку аналитика (аудит
`RECOGNIZED_DATA_EDIT`). Пустая добавленная строка не влияет на статус, проверки §7 и автоподтверждение; созданная
правкой строка участвует в проверках, пока у неё есть хоть одна сумма. Без редакции (нет даты периода) строки не
достраиваются. Строки «БАЛАНС» (1600, 1700) на экране выделяются цветом.»
- [ ] **Step 2: §11.** В пункт «`financial_statement_line` (`cells[1]` код, `cells[0]` имя; только строки с кодом)»
дописать: «; недостающие строки формы ядро достраивает на экране по справочнику редакции, §7».
- [ ] **Step 3: OQ-150** — в колонку статуса дописать: «**09.10.2026, решение владельца:** состав `fsbu4` (и `66n`) для
ядра принят из шаблонов банка — `arm-core/api/rules/statement_lines.yaml` (arm-core#80); словарь scan-gateway — follow-up,
копии расходятся (66н scan-gateway — словарь старого ядра).»
- [ ] **Step 4: Гайды.** frontend-api-guide и api-contract-slice-3b: `values?: [{line_id | line_code, column_id,
amount}]`; в описании `StatementOut` — `lines[].origin`, `id = null` у достроенных строк и ячеек; `UNKNOWN_CELL` — новые
случаи (код уже есть → передайте `line_id`; код вне справочника; нет редакции). qa-guide: в F8 добавить случай
`{"values":[{"line_code":"1105", …}]}` → 422 `UNKNOWN_CELL`; новая строка: «удалить строку 1700 нельзя через API —
проверить на документе без 1700 (РБ 1 кв. 2025 до OQ-156 или вручную): строка видна пустой, ввод суммы → «Сохранить» →
строка получила id, проверки пересчитаны; очистка → строка снова пустая, нарушений по ней нет»; §8 UI-чек-лист:
«строки 1600/1700 выделены фиолетовым и жирным», «пустые достроенные строки редактируются и не подсвечены».
- [ ] **Step 5: Коммит, push, PR**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/docs add context dev
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/docs commit -m "docs: полный состав строк ББ/ОФР — §7, OQ-150, гайды (#<D>)"
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/docs push -u origin feat/<D>-statement-template-lines
tea pulls create --login home --repo RSHB/arm-ka-docs --head feat/<D>-statement-template-lines --base main \
--title "Полный состав строк ББ/ОФР: §7, OQ-150, гайды (#<D>)" --description "Closes #<D>. К RSHB/arm-core#80, arm-web#<N>."
```
---
### Task 8: Проверка на стенде и сверка с макетом (выполняет сессия, не SDD-контроллер)
**Модель:** сессия (Playwright, стенд владельца).
- [ ] **Step 1:** Поднять arm-core из `.wt/stmt-template/arm-core` поверх БД стенда `.wt/web-proto` (порядок — `docs/dev/qa-guide.md`
§4; не пересоздавать стенд без согласия владельца), dev-сервер arm-web из `.wt/stmt-template/arm-web` (`make start`).
- [ ] **Step 2:** Открыть ББ «Русского бисквита» за 1 кв. 2025 и ББ 2024 Q3 (aep_6): все строки формы на месте, пустые
достроенные строки без подсветки, 1600/1700 — фиолетовые и жирные. Скриншоты 1600×1000 в `awp-ca/.playwright-mcp/`,
сравнить с `docs/mockup/screens/41-document-check.png` и `41-02-document-check-edit.png`.
- [ ] **Step 3:** Сценарий: «Редактировать» → ввести сумму в пустую строку (например, 1530 у 66н или 1215 у fsbu4) →
«Сохранить» → ячейка «исправлено», проверки пересчитаны; очистить → строка снова пустая; «Подтвердить данные» работает.
- [ ] **Step 4:** Расхождения с макетом и находки — в отчёт с маркерами `O-n`; скриншоты — в PR arm-web.
## Порядок исполнения и PR
Task 1 → 2 → 3 → 4 → 5 (PR arm-core) → 6 (PR arm-web в `test`) → 7 (PR docs) → 8 (сессия). Финальное ревью всей ветки
(opus) — после Task 7, по трём PR. Мерж — только по команде владельца. При «закрывай»: архив OpenSpec в arm-core (через PR,
как прошлые) и в arm-web (в `test`), follow-up «Follow-ups from #80» с пунктом «заполнить `fsbu4` в словаре scan-gateway и
свести копии справочника» (в RSHB/arm-scan-gateway) и прочими `deferred`.