# Полный состав строк ББ/ОФР — план реализации > **Для исполнителей-агентов:** ОБЯЗАТЕЛЬНЫЙ 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-команды — `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/ …` (или cwd внутри этого worktree); относительный `git -C arm-core` из корня workspace указывает на main-checkout — хук guard-main справедливо блокирует (O-1 первого запуска, 09.10.2026). Исключение — `git -C /Users/andrey.epifancev/projects/awp-ca/ fetch` и `worktree add` в подготовке задач 6–7 (не commit/push). Коммиты в worktree на ветке фичи разрешены владельцем 09.10.2026. - 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 /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_domain_statement_lines.py -q` Expected: FAIL — `ModuleNotFoundError: No module named 'src.domain.statement_lines'`. - [ ] **Step 3: Создать справочник** `api/rules/statement_lines.yaml` (точно этот текст): ```yaml # Справочник строк форм ББ/ОФР по редакциям: полный состав формы для экрана проверки (05-domain-rules §7 «Ядро», # design D-2 change statement-template-lines). Порядок строк в списке — порядок формы. # Редакция выбирается по дате периода документа (edition_for): 66n — до 2024-12-31 включительно, fsbu4 — позже. # Источник обеих редакций — шаблоны банка fixtures/statements-xlsx/templates/ («ББ/ОФР до 01.01.2025», «… после # 01.01.2025»; решение владельца 09.10.2026, данные OQ-150 для ядра); склейки наименований с заголовками разделов и # сносками исправлены (design R-2). Копия словаря scan-gateway (api/rules/statement_lines.yaml) с ним не совпадает: # сведение копий — follow-up. editions: - id: 66n forms: BALANCE: - {code: "1110", name: "Нематериальные активы"} - {code: "1120", name: "Результаты исследований и разработок"} - {code: "1130", name: "Нематериальные поисковые активы"} - {code: "1140", name: "Материальные поисковые активы"} - {code: "1150", name: "Основные средства"} - {code: "1160", name: "Доходные вложения в материальные ценности"} - {code: "1170", name: "Финансовые вложения"} - {code: "1180", name: "Отложенные налоговые активы"} - {code: "1190", name: "Прочие внеоборотные активы"} - {code: "1100", name: "Итого по разделу I"} - {code: "1210", name: "Запасы"} - {code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям"} - {code: "1230", name: "Дебиторская задолженность"} - {code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)"} - {code: "1250", name: "Денежные средства и денежные эквиваленты"} - {code: "1260", name: "Прочие оборотные активы"} - {code: "1200", name: "Итого по разделу II"} - {code: "1600", name: "БАЛАНС"} - {code: "1310", name: "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)"} - {code: "1320", name: "Собственные акции, выкупленные у акционеров"} - {code: "1340", name: "Переоценка внеоборотных активов"} - {code: "1350", name: "Добавочный капитал (без переоценки)"} - {code: "1360", name: "Резервный капитал"} - {code: "1370", name: "Нераспределенная прибыль (непокрытый убыток)"} - {code: "1300", name: "Итого по разделу III"} - {code: "1410", name: "Заемные средства"} - {code: "1420", name: "Отложенные налоговые обязательства"} - {code: "1430", name: "Оценочные обязательства"} - {code: "1450", name: "Прочие обязательства"} - {code: "1400", name: "Итого по разделу IV"} - {code: "1510", name: "Заемные средства"} - {code: "1520", name: "Кредиторская задолженность"} - {code: "1530", name: "Доходы будущих периодов"} - {code: "1540", name: "Оценочные обязательства"} - {code: "1550", name: "Прочие обязательства"} - {code: "1500", name: "Итого по разделу V"} - {code: "1700", name: "БАЛАНС"} INCOME: - {code: "2110", name: "Выручка"} - {code: "2120", name: "Себестоимость продаж"} - {code: "2100", name: "Валовая прибыль (убыток)"} - {code: "2210", name: "Коммерческие расходы"} - {code: "2220", name: "Управленческие расходы"} - {code: "2200", name: "Прибыль (убыток) от продаж"} - {code: "2310", name: "Доходы от участия в других организациях"} - {code: "2320", name: "Проценты к получению"} - {code: "2330", name: "Проценты к уплате"} - {code: "2340", name: "Прочие доходы"} - {code: "2350", name: "Прочие расходы"} - {code: "2300", name: "Прибыль (убыток) до налогообложения"} - {code: "2410", name: "Налог на прибыль"} - {code: "2411", name: "в т.ч. текущий налог на прибыль"} - {code: "2412", name: "отложенный налог на прибыль"} - {code: "2460", name: "Прочее"} - {code: "2400", name: "Чистая прибыль (убыток)"} - {code: "2510", name: "Результат от переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток) периода"} - {code: "2520", name: "Результат от прочих операций, не включаемый в чистую прибыль (убыток) периода"} - {code: "2500", name: "Совокупный финансовый результат периода"} - {code: "2900", name: "Базовая прибыль (убыток) на акцию"} - {code: "2910", name: "Разводненная прибыль (убыток) на акцию"} - id: fsbu4 forms: BALANCE: - {code: "1110", name: "Нематериальные активы"} - {code: "1150", name: "Основные средства"} - {code: "1160", name: "Инвестиционная недвижимость"} - {code: "1170", name: "Финансовые вложения"} - {code: "1180", name: "Отложенные налоговые активы"} - {code: "1190", name: "Прочие внеоборотные активы"} - {code: "1100", name: "Итого по разделу I"} - {code: "1210", name: "Запасы"} - {code: "1215", name: "Долгосрочные активы к продаже"} - {code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям"} - {code: "1230", name: "Дебиторская задолженность"} - {code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)"} - {code: "1250", name: "Денежные средства и денежные эквиваленты"} - {code: "1260", name: "Прочие оборотные активы"} - {code: "1200", name: "Итого по разделу II"} - {code: "1600", name: "БАЛАНС"} - {code: "1310", name: "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)"} - {code: "1320", name: "Собственные акции, принадлежащие обществу, задолженность акционеров по оплате акций"} - {code: "1340", name: "Накопленная дооценка внеоборотных активов"} - {code: "1350", name: "Добавочный капитал (без накопленной дооценки)"} - {code: "1360", name: "Резервный капитал"} - {code: "1370", name: "Нераспределенная прибыль (непокрытый убыток)"} - {code: "1300", name: "Итого по разделу III"} - {code: "1410", name: "Заемные средства"} - {code: "1420", name: "Отложенные налоговые обязательства"} - {code: "1430", name: "Оценочные обязательства"} - {code: "1450", name: "Прочие долгосрочные обязательства"} - {code: "1400", name: "Итого по разделу IV"} - {code: "1510", name: "Заемные средства"} - {code: "1520", name: "Кредиторская задолженность"} - {code: "1540", name: "Оценочные обязательства"} - {code: "1550", name: "Прочие краткосрочные обязательства"} - {code: "1500", name: "Итого по разделу V"} - {code: "1700", name: "БАЛАНС"} INCOME: - {code: "2110", name: "Выручка"} - {code: "2120", name: "Себестоимость продаж"} - {code: "2100", name: "Валовая прибыль (убыток)"} - {code: "2210", name: "Коммерческие расходы"} - {code: "2220", name: "Управленческие расходы"} - {code: "2200", name: "Прибыль (убыток) от продаж"} - {code: "2310", name: "Доходы от участия в других организациях"} - {code: "2320", name: "Проценты к получению"} - {code: "2330", name: "Проценты к уплате"} - {code: "2340", name: "Прочие доходы"} - {code: "2350", name: "Прочие расходы"} - {code: "2300", name: "Прибыль (убыток) от продолжающейся деятельности до налогообложения"} - {code: "2410", name: "Налог на прибыль организаций"} - {code: "2411", name: "текущий налог на прибыль организаций"} - {code: "2412", name: "отложенный налог на прибыль организаций"} - {code: "2420", name: "Прибыль (убыток) от прекращаемой деятельности"} - {code: "2400", name: "Чистая прибыль (убыток)"} - {code: "2510", name: "Результат переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток)"} - {code: "2520", name: "Результат прочих операций, не включаемый в чистую прибыль (убыток)"} - {code: "2530", name: "Налог на прибыль организаций, относящийся к результатам переоценки внеоборотных активов и прочих операций, не включаемых в чистую прибыль (убыток)"} - {code: "2500", name: "Совокупный финансовый результат"} - {code: "2900", name: "Базовая прибыль (убыток) на акцию, руб. коп."} - {code: "2910", name: "Разводненная прибыль (убыток) на акцию, руб. коп."} ``` - [ ] **Step 4: Создать загрузчик** `api/src/domain/statement_lines.py`: ```python """Справочник строк форм ББ/ОФР по редакциям — полный состав формы для экрана проверки (05-domain-rules §7 «Ядро»; change statement-template-lines, design D-2). Данные — `api/rules/statement_lines.yaml`; порядок строк в списке — порядок формы. Редакцию выбирает `statement_mapping.edition_for`; здесь — только состав. Справочник ничего не убирает и не переименовывает в форме: строки источника, которых в нём нет, остаются как есть. """ import functools import re from dataclasses import dataclass from pathlib import Path import yaml __all__ = ["RULES_PATH", "LineCatalog", "TemplateLine", "load_line_catalog"] RULES_PATH = Path(__file__).resolve().parents[2] / "rules" / "statement_lines.yaml" FORMS = ("BALANCE", "INCOME") _CODE = re.compile(r"^\d{4}$") @dataclass(frozen=True) class TemplateLine: code: str name: str @dataclass(frozen=True) class LineCatalog: editions: dict[str, dict[str, tuple[TemplateLine, ...]]] def lines(self, edition: str | None, form: str) -> tuple[TemplateLine, ...]: """Строки формы `form` редакции `edition` в порядке формы; без редакции или для чужой формы — пусто.""" if edition is None: return () return self.editions.get(edition, {}).get(form, ()) def _form_lines(edition: str, form: str, items: object) -> tuple[TemplateLine, ...]: if not isinstance(items, list): raise ValueError(f"редакция {edition}: нет списка строк формы {form}") lines = tuple(TemplateLine(str(item["code"]), " ".join(str(item["name"]).split())) for item in items) for line in lines: if not _CODE.fullmatch(line.code): raise ValueError(f"редакция {edition}, форма {form}: код {line.code!r} — не четыре цифры") codes = [line.code for line in lines] if len(codes) != len(set(codes)): raise ValueError(f"редакция {edition}, форма {form}: повтор кода") return lines @functools.lru_cache(maxsize=4) def load_line_catalog(path: Path | None = None) -> LineCatalog: """Справочник из yaml; любая ошибка формата — `ValueError` с именем файла.""" source = path or RULES_PATH try: data = yaml.safe_load(source.read_text(encoding="utf-8")) editions: dict[str, dict[str, tuple[TemplateLine, ...]]] = {} for edition in data["editions"]: edition_id = str(edition["id"]) forms = edition["forms"] editions[edition_id] = {form: _form_lines(edition_id, form, forms.get(form)) for form in FORMS} return LineCatalog(editions=editions) except (OSError, KeyError, TypeError, AttributeError, ValueError, yaml.YAMLError) as exc: raise ValueError(f"справочник строк {source.name}: {exc}") from exc ``` - [ ] **Step 5: Запустить — проходит** Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_domain_statement_lines.py -q` Expected: PASS (все тесты). - [ ] **Step 6: Линтеры** Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make lint` Expected: ruff и mypy без ошибок (длинные строки yaml линтер не проверяет). - [ ] **Step 7: Коммит** ```bash git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core add api/rules/statement_lines.yaml api/src/domain/statement_lines.py api/tests/test_domain_statement_lines.py git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core commit -m "feat(statements): справочник строк форм ББ/ОФР по редакциям (#80)" ``` (В конце сообщения — строки атрибуции из system-reminder сессии.) --- ### Task 2: GET достраивает форму (arm-core, schemas + service) **Модель:** sonnet (интеграция). **Files:** - Modify: `arm-core/api/src/schemas/statements.py` (`ValueOut`, `LineOut`, `statement_out`) - Modify: `arm-core/api/src/services/statements.py` (`build_out`) - Test: `arm-core/api/tests/test_api_statements.py` (изменить `test_get_statement`, добавить новые) **Interfaces:** - Consumes: `load_line_catalog().lines(edition, form) -> tuple[TemplateLine, ...]` (Task 1). - Produces: `ValueOut.id: uuid.UUID | None`, `LineOut.id: uuid.UUID | None`, `LineOut.origin: Literal["source", "template"]`; `statement_out(document, meta, columns, lines, report, cross, *, edition, template)` — `template: Sequence[TemplateLine]`. Функция `line_origin(line: FinancialStatementLine) -> Literal["source", "template"]` в `schemas/statements.py`. - [ ] **Step 1: Написать падающие тесты.** В `api/tests/test_api_statements.py`: 1) Заменить константу и тело `test_get_statement`: ```python MEZH_BALANCE_LINES = 37 MEZH_BALANCE_TEMPLATE_ONLY = {"1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н) MEZH_INCOME_TEMPLATE_ONLY = {"2420"} ``` ```python async def test_get_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) response = await client.get(f"/documents/{document.id}/statement") assert response.status_code == HTTPStatus.OK, response.text body = response.json() assert body["edition"] == "fsbu4" assert body["document"]["document_type"] == "BALANCE" and body["document"]["period_quarter"] == "Q3" source = [line for line in body["lines"] if line["origin"] == "source"] template = [line for line in body["lines"] if line["origin"] == "template"] assert len(source) == MEZH_BALANCE_LINES and all(line["id"] for line in source) assert {line["line_code"] for line in template} == MEZH_BALANCE_TEMPLATE_ONLY assert all(line["id"] is None for line in template) codes = [line["line_code"] for line in body["lines"]] assert codes == sorted(codes) columns = len(body["columns"]) assert all(len(line["values"]) == columns for line in body["lines"]) assert body["checks"]["failed"] == [] and body["checks"]["accepted"] is None assert set(body["header"]["inn"]) == {"value", "original_value", "confidence", "verified", "modified"} ``` 2) В `test_get_statement_shape_and_decimal_strings` заменить строку проверки ключей строки на: ```python assert set(line) == {"id", "origin", "line_code", "line_code_confidence", "line_name", "line_name_confidence", "values"} ``` 3) Добавить хелперы и тесты после `test_get_income_statement`: ```python async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: return ( await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) ).scalar_one() async def drop_line(session: AsyncSession, document: Document, line_code: str) -> dict[int, Decimal | None]: """Удалить строку формы в БД теста (как будто источник её потерял); вернуть её суммы по `column_index`.""" meta = await _meta(session, document) line = ( await session.execute( select(FinancialStatementLine).where( FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code ) ) ).scalar_one() rows = ( await session.execute( select(FinancialStatementColumn.column_index, FinancialStatementValue.amount) .join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id) .where(FinancialStatementValue.line_id == line.id) ) ).all() await session.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id == line.id)) await session.execute(delete(FinancialStatementLine).where(FinancialStatementLine.id == line.id)) await session.flush() return dict(rows) async def drop_value(session: AsyncSession, document: Document, line_code: str, column_index: int) -> None: """Удалить одну ячейку строки в БД теста.""" meta = await _meta(session, document) line_id = ( await session.execute( select(FinancialStatementLine.id).where( FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code ) ) ).scalar_one() column_id = ( await session.execute( select(FinancialStatementColumn.id).where( FinancialStatementColumn.meta_id == meta.id, FinancialStatementColumn.column_index == column_index ) ) ).scalar_one() await session.execute( delete(FinancialStatementValue).where( FinancialStatementValue.line_id == line_id, FinancialStatementValue.column_id == column_id ) ) await session.flush() async def db_line_count(session: AsyncSession, document: Document) -> int: meta = await _meta(session, document) return len( (await session.execute(select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta.id))).all() ) async def test_get_income_statement_template_lines( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir, MEZH_INCOME) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() template = {line["line_code"] for line in body["lines"] if line["origin"] == "template"} assert template == MEZH_INCOME_TEMPLATE_ONLY async def test_get_completes_lost_line_without_writing( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Строка, потерянная источником (1700), достроена из справочника; статус и проверки — как без достройки.""" document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") lines_in_db = await db_line_count(seeded, document) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() line = next(line for line in body["lines"] if line["line_code"] == "1700") assert line["id"] is None and line["origin"] == "template" and line["line_name"] == "БАЛАНС" assert line["line_code_confidence"] is None and line["line_name_confidence"] is None assert len(line["values"]) == len(body["columns"]) assert all( v["id"] is None and v["amount"] is None and v["is_recognized"] is None and v["is_modified"] is False and v["original_amount"] is None and v["confidence"] is None for v in line["values"] ) assert [v["column_index"] for v in line["values"]] == sorted(c["column_index"] for c in body["columns"]) relations = {(f["relation"], f["column_index"]) for f in body["checks"]["failed"]} # цель 1700 не проверяется; «1600 = 1700» (цель 1600 есть, 1700 = 0) нарушено — как и без достройки assert not any(relation.startswith("1700 =") for relation, _ in relations) assert {index for relation, index in relations if relation == "1600 = 1700"} == {0, 1, 2} assert body["document"]["status"] == document.status.value # статус пересчитывается только правкой assert await db_line_count(seeded, document) == lines_in_db async def test_get_completes_missing_cell( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_value(seeded, document, "1150", 1) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() line = next(line for line in body["lines"] if line["line_code"] == "1150") assert line["origin"] == "source" and len(line["values"]) == len(body["columns"]) missing = next(v for v in line["values"] if v["column_index"] == 1) assert missing["id"] is None and missing["amount"] is None assert [v["column_index"] for v in line["values"]] == sorted(v["column_index"] for v in line["values"]) async def test_get_without_edition_completes_cells_only( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_value(seeded, document, "1150", 1) document.reporting_date = None await seeded.flush() client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() assert body["edition"] is None assert len(body["lines"]) == MEZH_BALANCE_LINES assert all(line["origin"] == "source" for line in body["lines"]) assert all(len(line["values"]) == len(body["columns"]) for line in body["lines"]) ``` В импорты файла добавить `delete` к `from sqlalchemy import select, update` → `from sqlalchemy import delete, select, update`. - [ ] **Step 2: Запустить — падают** Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make up` (если стенд PostgreSQL/NATS ещё не поднят), затем `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q -k "get_"` Expected: FAIL — `KeyError: 'origin'` / неверное число строк. - [ ] **Step 3: Реализовать в `api/src/schemas/statements.py`.** В импорты добавить `from typing import Annotated, Literal` (заменить текущий `from typing import Annotated`) и `from src.domain.statement_lines import TemplateLine`. Изменить модели и сборку: ```python LineOrigin = Literal["source", "template"] class ValueOut(BaseModel): id: uuid.UUID | None # None — ячейка достроена в ответе, в БД её нет column_id: uuid.UUID column_index: int amount: Decimal | None string_value: str | None confidence: Decimal | None is_recognized: bool | None is_modified: bool original_amount: Decimal | None modified_by: uuid.UUID | None modified_at: datetime.datetime | None class LineOut(BaseModel): id: uuid.UUID | None # None — строка достроена из справочника редакции, в БД её нет origin: LineOrigin line_code: str | None line_code_confidence: Decimal | None line_name: str | None line_name_confidence: Decimal | None values: list[ValueOut] ``` Добавить функции рядом с `_value_out`: ```python def line_origin(line: FinancialStatementLine) -> LineOrigin: """`template` — строка создана правкой аналитика (маппинг всегда пишет `line_code_original_value`).""" return "template" if line.line_code_original_value is None else "source" def _empty_value(column: FinancialStatementColumn) -> ValueOut: """Ячейка, которой нет в БД (design D-3): всё пусто, не распознана и не правлена.""" return ValueOut( id=None, column_id=column.id, column_index=column.column_index, amount=None, string_value=None, confidence=None, is_recognized=None, is_modified=False, original_amount=None, modified_by=None, modified_at=None, ) def _lines_out( columns: Sequence[FinancialStatementColumn], lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], template: Sequence[TemplateLine], ) -> list[LineOut]: """Строки БД с ячейками по всем колонкам + строки справочника, которых нет в форме (design D-3), по коду.""" column_index = {column.id: column.column_index for column in columns} out: list[LineOut] = [] for line, values in lines: present = {value.column_id for value in values} cells = [_value_out(value, column_index) for value in values] cells += [_empty_value(column) for column in columns if column.id not in present] cells.sort(key=lambda cell: cell.column_index) out.append( LineOut( id=line.id, origin=line_origin(line), line_code=line.line_code, line_code_confidence=line.line_code_confidence, line_name=line.line_name, line_name_confidence=line.line_name_confidence, values=cells, ) ) codes = {line.line_code for line, _ in lines} for item in template: if item.code in codes: continue out.append( LineOut( id=None, origin="template", line_code=item.code, line_code_confidence=None, line_name=item.name, line_name_confidence=None, values=[_empty_value(column) for column in columns], ) ) out.sort(key=lambda line: (line.line_code is None, line.line_code or "")) return out ``` В `statement_out`: добавить keyword-параметр `template: Sequence[TemplateLine]` после `edition`, убрать локальную `column_index` (больше не нужна) и заменить блок `lines=[LineOut(...) for line, values in lines]` на `lines=_lines_out(columns, lines, template),`. Docstring дополнить: «строки достроены по справочнику редакции (D-3)». - [ ] **Step 4: Реализовать в `api/src/services/statements.py`.** Импорт `from src.domain.statement_lines import load_line_catalog`; `build_out`: ```python async def build_out(self, document: Document, meta: FinancialStatementMeta) -> schemas.StatementOut: """Ответ формы по текущему состоянию БД, достроенный по справочнику редакции (design D-3; используется и ответами правки/акцепта). В БД ничего не пишет.""" columns, lines = await repo.load_form(self._session, meta.id) report, cross = await self.checks_report(document, meta) edition = edition_for(document.reporting_date) template = load_line_catalog().lines(edition, document.document_type.value) return schemas.statement_out( document, meta, columns, lines, report, cross, edition=edition, template=template ) ``` - [ ] **Step 5: Запустить — проходят** Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q` Expected: PASS (все, включая старые PATCH-тесты: `_first_cell` берёт строку с суммой — это строка источника). - [ ] **Step 6: `make check`** Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make check` Expected: lint + unit-тесты зелёные. - [ ] **Step 7: Коммит** ```bash git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core add api/src/schemas/statements.py api/src/services/statements.py api/tests/test_api_statements.py git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core commit -m "feat(statements): GET формы достраивает строки справочника и ячейки (#80)" ``` --- ### Task 3: PATCH создаёт строку и ячейку (arm-core) **Модель:** sonnet (интеграция). **Files:** - Modify: `arm-core/api/src/schemas/statements.py` (`CellPatch`) - Modify: `arm-core/api/src/repositories/statements.py` (новые `form_lines`, `column_ids`, `add_line`, `add_value`; `__all__`) - Modify: `arm-core/api/src/services/statements.py` (`_apply_cells`, вызов в `patch`) - Test: `arm-core/api/tests/test_api_statements.py` **Interfaces:** - Consumes: `load_line_catalog`, `TemplateLine` (Task 1); `drop_line`, `drop_value`, `db_line_count` (тестовые хелперы Task 2). - Produces: `CellPatch(line_id: uuid.UUID | None = None, line_code: str | None = None, column_id: uuid.UUID, amount)` с валидатором «ровно одно»; репозиторий: `form_lines(session, meta_id) -> list[FinancialStatementLine]`, `column_ids(session, meta_id) -> set[uuid.UUID]`, `add_line(session, meta_id, code, name) -> FinancialStatementLine`, `add_value(session, line_id, column_id) -> FinancialStatementValue`. - [ ] **Step 1: Написать падающие тесты** (в конец `api/tests/test_api_statements.py`): ```python async def _column(client, document_id, index: int) -> str: body = (await client.get(f"/documents/{document_id}/statement")).json() return next(c["id"] for c in body["columns"] if c["column_index"] == index) async def _db_line(session: AsyncSession, document: Document, code: str) -> FinancialStatementLine | None: meta = await _meta(session, document) return ( await session.execute( select(FinancialStatementLine) .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == code) .execution_options(populate_existing=True) ) ).scalar_one_or_none() async def _edit_events(session: AsyncSession, document: Document) -> list[AuditEvent]: return list( ( await session.execute( select(AuditEvent).where( AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT ) ) ).scalars() ) async def test_patch_creates_template_line( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": "1700", "column_id": col0, "amount": "2371333"}]}, ) assert response.status_code == HTTPStatus.OK, response.text line = next(item for item in response.json()["lines"] if item["line_code"] == "1700") assert line["id"] is not None and line["origin"] == "template" and line["line_name"] == "БАЛАНС" cell = next(v for v in line["values"] if v["column_id"] == col0) assert Decimal(cell["amount"]) == Decimal("2371333") and cell["original_amount"] is None assert cell["is_modified"] is True and cell["is_recognized"] is None and cell["modified_by"] is not None other = [v for v in line["values"] if v["column_id"] != col0] assert other and all(v["id"] is None for v in other) # остальные колонки не созданы db_line = await _db_line(seeded, document, "1700") assert db_line is not None and db_line.line_code_original_value is None and db_line.line_name_original_value is None (event,) = await _edit_events(seeded, document) key = f"value:{cell['id']}" assert event.before_value == {key: None} and Decimal(event.after_value[key]) == Decimal("2371333") async def test_patch_two_cells_of_new_line_create_one_line( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 2.""" document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") before = await db_line_count(seeded, document) client = await auth_client(KA_RF) col0, col1 = await _column(client, document.id, 0), await _column(client, document.id, 1) response = await client.patch( f"/documents/{document.id}/statement", json={ "values": [ {"line_code": "1700", "column_id": col0, "amount": "1"}, {"line_code": "1700", "column_id": col1, "amount": "2"}, ] }, ) assert response.status_code == HTTPStatus.OK, response.text assert await db_line_count(seeded, document) == before + 1 line = next(item for item in response.json()["lines"] if item["line_code"] == "1700") assert sum(1 for v in line["values"] if v["id"] is not None) == 2 # noqa: PLR2004 async def test_patch_repeat_line_code_after_creation_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 3: второй «Сохранить» со старым черновиком по коду — 422, строка не задвоена.""" document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) item = {"line_code": "1700", "column_id": col0, "amount": "1"} assert (await client.patch(f"/documents/{document.id}/statement", json={"values": [item]})).status_code == HTTPStatus.OK count = await db_line_count(seeded, document) response = await client.patch(f"/documents/{document.id}/statement", json={"values": [{**item, "amount": "5"}]}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "UNKNOWN_CELL" assert "line_id" in response.json()["detail"]["message"] assert await db_line_count(seeded, document) == count async def test_patch_line_code_existing_or_unknown_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) count = await db_line_count(seeded, document) for code in ("1150", "1105"): # 1150 есть в форме; 1105 нет в справочнике fsbu4 response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": code, "column_id": col0, "amount": "1"}]}, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, code assert response.json()["detail"]["code"] == "UNKNOWN_CELL" assert await db_line_count(seeded, document) == count assert await _edit_events(seeded, document) == [] async def test_patch_line_code_without_edition_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") document.reporting_date = None await seeded.flush() client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": "1700", "column_id": col0, "amount": "1"}]}, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "UNKNOWN_CELL" async def test_patch_null_into_missing_cell_is_noop( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") count = await db_line_count(seeded, document) client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": "1700", "column_id": col0, "amount": None}]}, ) assert response.status_code == HTTPStatus.OK, response.text assert await db_line_count(seeded, document) == count assert await _edit_events(seeded, document) == [] async def test_patch_creates_missing_cell_of_existing_line( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_value(seeded, document, "1150", 1) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() line = next(item for item in body["lines"] if item["line_code"] == "1150") col1 = next(c["id"] for c in body["columns"] if c["column_index"] == 1) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_id": line["id"], "column_id": col1, "amount": "77"}]}, ) assert response.status_code == HTTPStatus.OK, response.text cell = next( v for v in next(i for i in response.json()["lines"] if i["id"] == line["id"])["values"] if v["column_id"] == col1 ) assert cell["id"] is not None and Decimal(cell["amount"]) == Decimal("77") and cell["original_amount"] is None assert cell["is_modified"] is True async def test_patch_bad_column_rolls_back_new_line( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 1: одна ячейка корректна (новая строка), другая — чужая колонка: 422, строка не создана.""" document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") count = await db_line_count(seeded, document) client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) response = await client.patch( f"/documents/{document.id}/statement", json={ "values": [ {"line_code": "1700", "column_id": col0, "amount": "1"}, {"line_code": "1700", "column_id": str(uuid.uuid4()), "amount": "2"}, ] }, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "UNKNOWN_CELL" assert await db_line_count(seeded, document) == count async def test_patch_cell_address_must_be_exactly_one( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) line, cell = await _first_cell(client, document.id) for item in ( {"line_id": line["id"], "line_code": "1700", "column_id": cell["column_id"], "amount": "1"}, {"column_id": cell["column_id"], "amount": "1"}, ): response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item]}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert isinstance(response.json()["detail"], list) # 422 валидации тела — массив async def test_patch_duplicate_line_code_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) item = {"line_code": "1700", "column_id": col0, "amount": "1"} response = await client.patch( f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]} ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "DUPLICATE_CELL" ``` - [ ] **Step 2: Запустить — падают** Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q -k "patch"` Expected: новые тесты FAIL (422 массивом на `line_code` — поле запрещено `extra="forbid"`), старые PASS. - [ ] **Step 3: `CellPatch`** в `api/src/schemas/statements.py` (импорт `model_validator` из `pydantic`): ```python class CellPatch(BaseModel): """Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить. Ячейка адресуется ровно одним из: `line_id` — строка формы (если ячейки в колонке нет, она создаётся); `line_code` — строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4).""" model_config = ConfigDict(extra="forbid") line_id: uuid.UUID | None = None line_code: str | None = Field(None, max_length=10) column_id: uuid.UUID amount: Decimal | None = Field(max_digits=18, decimal_places=2) @model_validator(mode="after") def _one_address(self) -> "CellPatch": if (self.line_id is None) == (self.line_code is None): raise ValueError("укажите ровно одно из line_id и line_code") return self ``` - [ ] **Step 4: Репозиторий** `api/src/repositories/statements.py` — добавить в `__all__` `"add_line"`, `"add_value"`, `"column_ids"`, `"form_lines"` (по алфавиту) и функции после `lock_cells`: ```python async def form_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[FinancialStatementLine]: """Строки формы (без значений) — адресация ячеек правки по `line_id` и `line_code`.""" stmt = select(FinancialStatementLine).where(FinancialStatementLine.meta_id == meta_id) return list((await session.execute(stmt)).scalars()) async def column_ids(session: AsyncSession, meta_id: uuid.UUID) -> set[uuid.UUID]: """Id колонок формы.""" stmt = select(FinancialStatementColumn.id).where(FinancialStatementColumn.meta_id == meta_id) return set((await session.execute(stmt)).scalars()) async def add_line(session: AsyncSession, meta_id: uuid.UUID, code: str, name: str) -> FinancialStatementLine: """Строка справочника, созданная правкой аналитика: `*_original_value = NULL` — признак «не из источника».""" line = FinancialStatementLine( id=uuid.uuid4(), meta_id=meta_id, line_code=code, line_code_original_value=None, line_code_confidence=None, line_code_modified=False, line_name=name, line_name_original_value=None, line_name_confidence=None, line_name_modified=False, ) session.add(line) await session.flush() return line async def add_value(session: AsyncSession, line_id: uuid.UUID, column_id: uuid.UUID) -> FinancialStatementValue: """Пустая ячейка, созданная правкой: не распознана (`is_recognized = NULL`), исходной суммы нет.""" value = FinancialStatementValue( id=uuid.uuid4(), line_id=line_id, column_id=column_id, amount=None, string_value=None, confidence=None, is_recognized=None, is_modified=False, original_amount=None, ) session.add(value) await session.flush() return value ``` - [ ] **Step 5: Сервис** `api/src/services/statements.py`. Импорты: `from src.domain.statement_lines import TemplateLine, load_line_catalog`; `from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue`. Константа `LINE_EXISTS = "строка уже есть в форме — передайте line_id"`. Заменить `_apply_cells` целиком: ```python async def _apply_cells( self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch, before: dict[str, Any], after: dict[str, Any], ) -> None: """Значения `body.values` (D-7 Среза 3b; design D-4 statement-template-lines): `original_amount` при первой правке, `is_modified`, `modified_by/at`. Ячейка, которой нет в БД, создаётся при первой непустой правке; строка по `line_code` — из справочника редакции документа. Вся адресация проверяется до первой вставки.""" if not body.values: return keys = [(cell.line_id or cell.line_code, cell.column_id) for cell in body.values] if len(keys) != len(set(keys)): raise DomainValidationError(DUPLICATE_CELL, "ячейка указана в запросе дважды") columns = await repo.column_ids(self._session, meta.id) lines = await repo.form_lines(self._session, meta.id) by_id = {line.id: line for line in lines} codes = {line.line_code for line in lines} template = { item.code: item for item in load_line_catalog().lines(edition_for(document.reporting_date), document.document_type.value) } targets: list[tuple[schemas.CellPatch, FinancialStatementLine | None, TemplateLine | None]] = [] for cell in body.values: if cell.column_id not in columns: raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") if cell.line_id is not None: line = by_id.get(cell.line_id) if line is None: raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") targets.append((cell, line, None)) continue if cell.line_code in codes: raise DomainValidationError(UNKNOWN_CELL, LINE_EXISTS) item = template.get(cell.line_code or "") if item is None: raise DomainValidationError(UNKNOWN_CELL, "строки с таким кодом нет в форме этой редакции") targets.append((cell, None, item)) existing = await repo.lock_cells( self._session, meta.id, [(line.id, cell.column_id) for cell, line, _ in targets if line is not None] ) created: dict[str, FinancialStatementLine] = {} now = utc_now() for cell, line, item in targets: value: FinancialStatementValue | None = ( existing.get((line.id, cell.column_id)) if line is not None else None ) if value is None: if cell.amount is None: continue # пустая сумма в несуществующую ячейку — не изменение if line is None and item is not None: line = created.get(item.code) or await repo.add_line(self._session, meta.id, item.code, item.name) created[item.code] = line if line is None: raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") value = await repo.add_value(self._session, line.id, cell.column_id) if value.amount == cell.amount: continue key = f"value:{value.id}" before[key], after[key] = value.amount, cell.amount if not value.is_modified and value.original_amount is None: value.original_amount = value.amount value.amount = cell.amount value.is_modified = value.amount != value.original_amount value.modified_by, value.modified_at = self._audit.user_id, now ``` В `patch` заменить вызов на `await self._apply_cells(document, meta, body, before, after)`. Почему так: вся адресация (колонка, строка, код) проверяется в первом цикле — вставок до него нет, поэтому 422 ничего не оставляет даже без отката (Review Focus 1). Две ячейки одной новой строки делят её через `created` (Review Focus 2). Повтор `line_code` после создания упирается в `codes` (Review Focus 3); параллельные правки одной формы сериализует блокировка документа в `_editable`. - [ ] **Step 6: Запустить — проходят** Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q` Expected: PASS. - [ ] **Step 7: `make check`, коммит** Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make check` — зелёный. ```bash git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core add api/src/schemas/statements.py api/src/repositories/statements.py api/src/services/statements.py api/tests/test_api_statements.py git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core commit -m "feat(statements): правка по line_code создаёт строку и ячейку формы (#80)" ``` --- ### Task 4: Пустые созданные строки вне проверок §7 (arm-core) **Модель:** sonnet. **Files:** - Modify: `arm-core/api/src/repositories/statements.py` (`statement_amounts`) - Test: `arm-core/api/tests/test_api_statements.py` **Interfaces:** - Consumes: хелперы тестов Task 2/3 (`drop_line`, `_column`). - Produces: `statement_amounts` — тот же тип результата; строки с `line_code_original_value IS NULL` и только пустыми суммами в результат не попадают. - [ ] **Step 1: Падающий тест** (в конец `test_api_statements.py`): ```python async def test_template_line_checks_follow_filled_amounts( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 4 (spec statement-checks): заполненная в одной колонке строка проверяется по всем колонкам, очищенная — снова не участвует.""" document = await form(seeded, fixtures_dir) sums = await drop_line(seeded, document, "1700") assert sums[1] not in (None, Decimal(0)) and sums[2] not in (None, Decimal(0)) # колонки 1, 2 непустые client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": "1700", "column_id": col0, "amount": str(sums[0])}]}, ) failed = response.json()["checks"]["failed"] relation = "1700 = 1300 + 1400 + 1500" assert sorted(f["column_index"] for f in failed if f["relation"] == relation) == [1, 2] assert all(f["column_index"] != 0 for f in failed) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_id": next(i["id"] for i in response.json()["lines"] if i["line_code"] == "1700"), "column_id": col0, "amount": None}]}, ) assert response.status_code == HTTPStatus.OK, response.text after_clear = response.json()["checks"]["failed"] assert not any(f["relation"].startswith("1700 =") for f in after_clear) # очищенная 1700 снова не цель assert {f["column_index"] for f in after_clear if f["relation"] == "1600 = 1700"} == {0, 1, 2} ``` Строки соотношений — из `api/rules/statement_checks.yaml`: `"1700 = 1300 + 1400 + 1500"`, `"1600 = 1700"` (обе жёсткие). Суммы 1700 Межениновской: колонки 0/1/2 — 2186927.83 / 2056189.00 / 2260179.00. После заполнения колонки 0 нарушения `1700 = …` и `1600 = 1700` остаются в колонках 1 и 2 (там 1700 пустая = 0). - [ ] **Step 2: Запустить — падает** Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q -k template_line_checks` Expected: FAIL на последнем assert — очищенная 1700 (сумма `None` = 0) даёт нарушение в обеих колонках. - [ ] **Step 3: Реализация** — заменить тело `statement_amounts` после выборки колонок: ```python rows = ( await session.execute( select( FinancialStatementLine.line_code, FinancialStatementLine.line_code_original_value, FinancialStatementValue.column_id, FinancialStatementValue.amount, ) .join(FinancialStatementValue, FinancialStatementValue.line_id == FinancialStatementLine.id) .where(FinancialStatementLine.meta_id == meta_id) .order_by(FinancialStatementLine.line_code) ) ).all() by_code: dict[str, dict[int, Decimal | None]] = {} added: set[str] = set() # строки, созданные правкой аналитика (design D-5) for code, original, column_id, amount in rows: by_code.setdefault(code or "", {})[index_by_id[column_id]] = amount if original is None: added.add(code or "") lines = [ (code, amounts) for code, amounts in by_code.items() if code not in added or any(amount is not None for amount in amounts.values()) ] return [index for _, index in columns], lines ``` Docstring дополнить: «Созданные правкой строки (`line_code_original_value IS NULL`) без единой суммы не участвуют (spec statement-checks, design D-5).» - [ ] **Step 4: Запустить — проходит; `make check`** Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core/api && .venv/bin/pytest tests/test_api_statements.py tests/test_form_confirmable.py tests/test_auto_confirm.py -q` затем `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make check`. Expected: PASS. - [ ] **Step 5: Коммит** ```bash git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core add api/src/repositories/statements.py api/tests/test_api_statements.py git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core commit -m "feat(statements): пустые созданные строки не участвуют в проверках §7 (#80)" ``` --- ### Task 5: Документация arm-core и PR (arm-core) **Модель:** sonnet. **Files:** - Modify: `arm-core/README.md` (раздел про форму ББ/ОФР ~стр. 540–580; таблица «Ошибки API», строка `UNKNOWN_CELL` ~стр. 858) - Modify: `arm-core/CLAUDE.md` (абзац «Формы ББ/ОФР (Срез 3b)») - Modify: `arm-core/openspec/changes/statement-template-lines/design.md` (раздел решений — если исполнение добавило рулинги) - [ ] **Step 1: README.** В описание `GET …/statement` добавить: «Форма достраивается по справочнику строк редакции (`api/rules/statement_lines.yaml`): недостающие строки (`id = null`, `origin = template`) и ячейки (`id = null`); в БД не пишется.» В описание `PATCH`: «`values[]` адресует ячейку `line_id` или `line_code` (ровно одно): ячейка, которой нет, создаётся при первой непустой правке; строка по `line_code` — только код справочника редакции, которого нет в форме.» Строку таблицы ошибок заменить на: `| UNKNOWN_CELL | 422 | PATCH …/statement: line_id/column_id не этой формы; line_code уже есть в форме (передайте line_id), нет в справочнике редакции или редакция не определена |` - [ ] **Step 2: CLAUDE.md.** В абзац «Формы ББ/ОФР (Срез 3b)» после фразы о проверках §7 вставить: «Полный состав формы — `domain/statement_lines.py` по справочнику `api/rules/statement_lines.yaml` (редакции `66n`/`fsbu4` из шаблонов банка; копия словаря scan-gateway с ним не совпадает): `GET …/statement` достраивает строки и ячейки с `id = null`, `PATCH` по `line_code` создаёт строку (`line_code_original_value = NULL` — «не из источника»); пустые созданные строки не участвуют в §7 (`statement_amounts`). Решения — `openspec/changes/statement-template-lines/design.md`.» - [ ] **Step 3: Полная проверка** Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make check-full` Expected: все цели зелёные; вывод сохранить для описания PR. Если `e2e` пропущен (нет профиля `services`) — так и указать в PR. - [ ] **Step 4: Коммит, push, PR** ```bash git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core add README.md CLAUDE.md openspec/changes/statement-template-lines/design.md git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core commit -m "docs: полный состав строк ББ/ОФР в README и CLAUDE.md (#80)" git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core push -u origin feat/80-statement-template-lines tea pulls create --login home --repo RSHB/arm-core --head feat/80-statement-template-lines --base main \ --title "Полный состав строк ББ/ОФР на экране проверки (#80)" --description "<описание>" ``` Описание PR: что сделано (по задачам 1–4), «Closes #80», вывод `make check-full`, список «Rulings I made» (R-1…R-8 из design.md + рулинги исполнения), таблица находок с маркерами и статусами, `deferred` — отдельным списком. --- ### Task 6: Фронт arm-web — шаблонные строки и выделение «БАЛАНС» (arm-web) **Модель:** sonnet (TS/React; владелец слаб во фронте — объяснения решений в PR простым языком). **Подготовка (до кода):** ```bash cd /Users/andrey.epifancev/projects/awp-ca git -C /Users/andrey.epifancev/projects/awp-ca/arm-web fetch origin tea issues create --login home --repo RSHB/arm-web --title "Шаблонные строки ББ/ОФР и выделение строк «БАЛАНС»" \ --description "Фронт к RSHB/arm-core#80: строки формы с id = null (origin = template) редактируются, правка уходит по line_code; строки 1600/1700 выделены цветом. Спека — arm-core openspec/changes/statement-template-lines." # номер issue → git -C /Users/andrey.epifancev/projects/awp-ca/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 /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-web && openspec new change statement-template-lines ``` **Files:** - Create: `arm-web/openspec/changes/statement-template-lines/{proposal.md,design.md,specs/document-review-ui/spec.md}` - Modify: `arm-web/src/types/api.ts` (`ValueOut`, `LineOut`, `StatementPatch`) - Modify: `arm-web/src/widgets/DocumentReview/statementGrid.ts` (+ `statementGrid.test.ts`) - Modify: `arm-web/src/widgets/DocumentReview/formLayout.ts` (+ `formLayout.test.ts`) - Modify: `arm-web/src/widgets/DocumentReview/StatementTable.tsx` - Modify: `arm-web/src/widgets/DocumentReview/StatementReview.tsx` (вызов `draftToPatch`) **Interfaces:** - Consumes: API Task 2/3: `LineOut.id: string | null`, `LineOut.origin: 'source' | 'template'`, `ValueOut.id: string | null`; PATCH `values[]` элемент `{line_id, column_id, amount}` или `{line_code, column_id, amount}`. - Produces: `GridRow { key: string; lineId: string | null; code: string | null; name: string | null; origin: 'source' | 'template'; cells: Record }`; `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 /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-web && make check` Expected: biome, `tsc --noEmit`, vitest, `rsbuild build` — зелёные. Ошибки tsc в файлах, которых задача не касалась и которые были до неё, — не чинить, отметить в отчёте (O-маркер). - [ ] **Step 9: Коммит, push, PR в `test`** ```bash git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-web add -A src openspec/changes/statement-template-lines git -C /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-web commit -m "feat(review): шаблонные строки ББ/ОФР и выделение строк «БАЛАНС» (#)" git -C /Users/andrey.epifancev/projects/awp-ca/.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 /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#." # номер → rm .wt/stmt-template/docs # это симлинк на main-checkout; вместо него — worktree git -C /Users/andrey.epifancev/projects/awp-ca/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 /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, гайды (#)" git -C /Users/andrey.epifancev/projects/awp-ca/.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`.