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