Относительный `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
96 KiB
Полный состав строк ББ/ОФР — план реализации
Для исполнителей-агентов: ОБЯЗАТЕЛЬНЫЙ 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. GETMUST 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(код не меняется, сообщения по-русски); повтор ячейки — 422DUPLICATE_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
- Запрос, где одна ячейка корректна (новая строка по
line_code), а другая — нет (чужая колонка): 422, и строка НЕ создана (вся валидация до первой вставки) — тест в Task 3. - Две ячейки одной новой строки в одном запросе (колонки 0 и 1 для
1700) — одна строка в БД, два значения — Task 3. - Повторная правка тем же
line_codeпосле того, как строка уже создана (второй клик «Сохранить» со старым черновиком): 422UNKNOWN_CELL«передайте line_id», данные не задвоены — Task 3. - Аналитик очистил созданную шаблонную строку целиком — нарушение по ней исчезает; заполнил только одну колонку — нарушение в пустой колонке есть — Task 4.
- Фронт: черновик смешанных строк (существующая по
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:
- Заменить константу и тело
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"}
- В
test_get_statement_shape_and_decimal_stringsзаменить строку проверки ключей строки на:
assert set(line) == {"id", "origin", "line_code", "line_code_confidence", "line_name", "line_name_confidence", "values"}
- Добавить хелперы и тесты после
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; PATCHvalues[]элемент{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) } }(для раздела — как сейчас); вrenderfontWeight: lineWeight(r); -
колонка
code:onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: lineBackground(r) } }), вrenderfontWeight: 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", …}]}→ 422UNKNOWN_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.