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