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

96 KiB
Raw Blame History

Полный состав строк ББ/ОФР — план реализации

Для исполнителей-агентов: ОБЯЗАТЕЛЬНЫЙ 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:

"""Справочник строк форм ББ/ОФР по редакциям (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 (точно этот текст):
# Справочник строк форм ББ/ОФР по редакциям: полный состав формы для экрана проверки (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:
"""Справочник строк форм ББ/ОФР по редакциям — полный состав формы для экрана проверки (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: Коммит
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:
MEZH_BALANCE_LINES = 37
MEZH_BALANCE_TEMPLATE_ONLY = {"1215"}  # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н)
MEZH_INCOME_TEMPLATE_ONLY = {"2420"}
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"}
  1. В test_get_statement_shape_and_decimal_strings заменить строку проверки ключей строки на:
    assert set(line) == {"id", "origin", "line_code", "line_code_confidence", "line_name", "line_name_confidence", "values"}
  1. Добавить хелперы и тесты после test_get_income_statement:
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. Изменить модели и сборку:

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:

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:
    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: Коммит
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):

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):
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:
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 целиком:
    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 — зелёный.

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):

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 после выборки колонок:
    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: Коммит
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
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 простым языком).

Подготовка (до кода):

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:

## 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:
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'.)

	it('пустая достроенная ячейка не требует внимания', () => {
		expect(cellFlags(v(null, 'c', 0, { amount: null, confidence: null, is_recognized: null }))).toEqual({
			attention: false,
			modified: false,
		});
	});
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' (как сейчас), добавить:

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:
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[];
}
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:
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 };
}
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:

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:
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
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 #», зависит от RSHB/arm-core PR (Task 5), объяснение решений простым языком (почему ключ строки по коду, почему тело PATCH собирается по строкам, почему фиолетовый), вывод make check, скриншоты — после Task 8.


Task 7: Документация docs (arm-ka-docs)

Модель: sonnet.

Подготовка:

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

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.