From 5c7d5393c54b090af84610e4cc1bcc778859f5e0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 10:17:23 +0400 Subject: [PATCH 01/11] =?UTF-8?q?openspec:=20change=20statement-template-l?= =?UTF-8?q?ines=20=E2=80=94=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=81=D0=BE=D1=81=D1=82=D0=B0=D0=B2=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=91=D0=91/=D0=9E=D0=A4=D0=A0=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спека: достройка формы по справочнику строк редакции в GET …/statement, создание строки и ячейки при первой правке по line_code, пустые шаблонные строки вне проверок §7 и статуса. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qoydUVv8g1qHxc123u2RR --- .../statement-template-lines/.openspec.yaml | 2 + .../statement-template-lines/design.md | 141 ++++++++++++++++++ .../statement-template-lines/proposal.md | 67 +++++++++ .../specs/documents-api/spec.md | 103 +++++++++++++ .../specs/statement-checks/spec.md | 45 ++++++ 5 files changed, 358 insertions(+) create mode 100644 openspec/changes/statement-template-lines/.openspec.yaml create mode 100644 openspec/changes/statement-template-lines/design.md create mode 100644 openspec/changes/statement-template-lines/proposal.md create mode 100644 openspec/changes/statement-template-lines/specs/documents-api/spec.md create mode 100644 openspec/changes/statement-template-lines/specs/statement-checks/spec.md diff --git a/openspec/changes/statement-template-lines/.openspec.yaml b/openspec/changes/statement-template-lines/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/statement-template-lines/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/statement-template-lines/design.md b/openspec/changes/statement-template-lines/design.md new file mode 100644 index 0000000..1852287 --- /dev/null +++ b/openspec/changes/statement-template-lines/design.md @@ -0,0 +1,141 @@ +# Design: полный состав строк ББ/ОФР на экране проверки + +## Context + +Форма ББ/ОФР в ядре — `financial_statement_meta` → `financial_statement_column` (колонки-периоды) → +`financial_statement_line` (только строки с кодом, `(meta_id, line_code)` уникален) → `financial_statement_value` +(`(line_id, column_id)` уникален). Строки и ячейки пишет только маппинг (`repositories/statements.insert_statement`), ровно +то, что пришло в `statementDocument` от scan-gateway или tabular-parser. Экран проверки читает форму +`GET /documents/{id}/statement` (`services/statements.StatementService.build_out`, порядок строк — по коду), правит +`PATCH …/statement` (`_apply_cells`: `lock_cells` по `(line_id, column_id)` под `FOR UPDATE`; чужая или отсутствующая +ячейка — 422 `UNKNOWN_CELL`). Проверки §7 (`domain/statement_checks.run_checks`) считают цель присутствующей, если её +код есть среди строк формы; пустая сумма — 0. Статус D-6 (`statement_status`) учитывает `values_ok` по всем ячейкам и +`has_lines`. Редакция формы — `edition_for(document.reporting_date)`: `66n` (≤ 2024-12-31) / `fsbu4` / `None`. +Раскладку «как на бумаге» по разделам делает фронт (`arm-web/src/widgets/DocumentReview/formLayout.ts`). + +Справочник «код → наименование» по редакциям есть только в scan-gateway (`api/rules/statement_lines.yaml`): `66n` +заполнен (из старого ядра), `fsbu4` пуст (OQ-150). + +## Goals / Non-Goals + +**Goals:** аналитик видит все строки формы своей редакции и может заполнить любую, включая потерянную источником; +уже загруженные документы получают это без миграции; распознанные данные не меняются; пустые строки не меняют статус, +проверки и автоподтверждение. + +**Non-Goals:** см. proposal. + +## Decisions + +### D-1. Достраивать в ответе, а не в БД (подход B, решение владельца 09.10.2026) +БД хранит только пришедшее из источника; достройка — чистая функция `domain/statement_lines.complete_form` поверх +прочитанной формы в `build_out`. Альтернатива «дописать строки при маппинге» отклонена: нужна data-миграция для уже +загруженных документов, в БД появляются строки, которых не было в первичке, и проверки §7 начали бы видеть пустые итоги +(пустая 1700 при непустых 1300–1500 — жёсткое нарушение), то есть изменился бы статус документов. + +### D-2. Справочник — данные ядра `api/rules/statement_lines.yaml` +```yaml +editions: + - id: 66n + valid_until: 2024-12-31 + forms: + BALANCE: [{code: "1110", name: "Нематериальные активы"}, ...] # порядок формы + INCOME: [...] + - id: fsbu4 + valid_from: 2025-01-01 + forms: {BALANCE: [...], INCOME: [...]} +``` +Список, а не словарь — порядок строк формы сохраняется (пригодится фронту и будущему порядку в ответе; сейчас ответ +сортируется по коду, как раньше). Загрузка — один раз при импорте модуля (как `statement_checks.yaml`), валидация +pydantic: коды — четыре цифры, без повторов внутри формы, обе формы в обеих редакциях. Редакция выбирается существующим +`edition_for` — новой логики выбора нет. + +Состав (решение владельца 09.10.2026 — из шаблонов `../fixtures/statements-xlsx/templates/`): +- **66n, ББ** («ББ до 01.01.2025»): 1110, 1120, 1130, 1140, 1150, 1160, 1170, 1180, 1190, 1100, 1210, 1220, 1230, 1240, + 1250, 1260, 1200, 1600, 1310, 1320, 1340, 1350, 1360, 1370, 1300, 1410, 1420, 1430, 1450, 1400, 1510, 1520, 1530, + 1540, 1550, 1500, 1700 (37). +- **66n, ОФР** («ОФР до 01.01.2025»): 2110, 2120, 2100, 2210, 2220, 2200, 2310, 2320, 2330, 2340, 2350, 2300, 2410, + 2411, 2412, 2460, 2400, 2510, 2520, 2500, 2900, 2910 (22). +- **fsbu4, ББ** («ББ после 01.01.2025»): 1110, 1150, 1160, 1170, 1180, 1190, 1100, 1210, 1215, 1220, 1230, 1240, 1250, + 1260, 1200, 1600, 1310, 1320, 1340, 1350, 1360, 1370, 1300, 1410, 1420, 1430, 1450, 1400, 1510, 1520, 1540, 1550, + 1500, 1700 (34). +- **fsbu4, ОФР** («ОФР после 01.01.2025»): 2110, 2120, 2100, 2210, 2220, 2200, 2310, 2320, 2330, 2340, 2350, 2300, + 2410, 2411, 2412, 2420, 2400, 2510, 2520, 2530, 2500, 2900, 2910 (23). + +Наименования — из тех же шаблонов; склейки с заголовками разделов и сносками исправляются (шаблон склеил объединённые +ячейки): 1110 «АКТИВ» → «Нематериальные активы», 1210 «II. Оборотные активы» → «Запасы», 1310 «ПАССИВ» → «Уставный +капитал (складочный капитал, уставный фонд, вклады товарищей)», 1410 «IV. Долгосрочные обязательства» и 1510 «V. +Краткосрочные обязательства» → «Заемные средства», 2411 «в том числе:» → «текущий налог на прибыль», хвосты сносок +(«Выручка 5», «Налог на прибыль 7», «( / )») и префикс «Справочно» у 2900 убираются, переносы строк — в пробел. + +Строки источника, которых нет в справочнике (пятизначные 11101, коды чужой редакции, 1105 «Гудвил» у OCR), остаются как +есть: справочник только добавляет, ничего не убирает и не переименовывает. + +### D-3. Достройка ответа +`complete_form(form_type, edition, columns, lines) -> lines'`: +- для каждой строки из БД — недостающие ячейки по всем `columns` (`id = null`, `amount = null`, `string_value = null`, + `confidence = null`, `is_recognized = null`, `is_modified = false`, `original_amount = null`, `modified_* = null`); +- при `edition ≠ None` — строки справочника, кода которых нет среди строк формы: `id = null`, `line_code`, `line_name` из + справочника, `*_confidence = null`, `origin = "template"`, ячейки по всем колонкам как выше; +- `origin` строки из БД: `template`, если `line_code_original_value IS NULL` (строка создана правкой, D-4), иначе `source` + (маппинг всегда пишет `line_code_original_value = line_code`); +- итог — по `line_code` (как сейчас). +Форма без колонок (OCR без шапки) получает строки без ячеек — заполнить нечего, пока не появятся колонки (их ядро не +создаёт; Non-goal). + +### D-4. Правка по коду строки +`CellPatch`: `line_id: uuid | None`, `line_code: str | None` (ровно одно, иначе 422 валидации тела), `column_id`, `amount`. +`_apply_cells` (под существующим порядком блокировок «файл `FOR UPDATE` → документ `FOR NO KEY UPDATE`», шапка с +`refresh`): +1. Колонка должна принадлежать форме, иначе 422 `UNKNOWN_CELL`. +2. `line_id`: строка формы (иначе `UNKNOWN_CELL`); если у неё нет ячейки в колонке — ячейка создаётся. +3. `line_code`: если строка с таким кодом в форме уже есть — 422 `UNKNOWN_CELL` с сообщением «строка уже есть в форме — + передайте line_id» (фронт после ответа знает `id`); если кода нет в справочнике редакции документа (или редакция не + определена) — 422 `UNKNOWN_CELL`. Иначе создаётся `financial_statement_line` (`line_code`, `line_name` из + справочника, `line_code_original_value = NULL`, `line_name_original_value = NULL`, `*_confidence = NULL`, + `*_modified = false`) и ячейка. Две ячейки одной новой строки в одном запросе создают одну строку. +4. Создаваемая ячейка: `original_amount = NULL`, `is_recognized = NULL`, `confidence = NULL`, `string_value = NULL`; + затем общая логика правки: `amount`, `is_modified = (amount ≠ original_amount)`, `modified_by/at`. +5. `amount = null` для несуществующей ячейки — не изменение: ничего не создаётся, в аудит не попадает. +6. Повтор ячейки в запросе (по `(line_id|line_code, column_id)`) — 422 `DUPLICATE_CELL`, как сейчас. + +Гонки двух правок одной формы исключены блокировкой документа: второй запрос ждёт и видит строку, созданную первым +(получит 422 «передайте line_id» и перечитает форму). Уникальный индекс `(meta_id, line_code)` — страховка. + +Аудит — одно `RECOGNIZED_DATA_EDIT` на запрос, как сейчас; в `before/after` ячейки созданной строки ключ — +`line_code` + `column_index`, `before.amount = null`. + +### D-5. Пустые шаблонные строки вне проверок и статуса +Достроенные строки в БД нет — `statement_amounts`, `value_flags`, `has_lines`, `_line_amount` (межформенная) их не +видят. Созданная правкой строка (`line_code_original_value IS NULL`) участвует в `statement_amounts`, только если у неё +есть хотя бы одна непустая сумма (фильтр в запросе). Так очищенная до пустоты строка снова «как будто её нет», а +частично заполненная (1700 есть в текущей колонке, нет в прошлой) проверяется по всем колонкам, как строка источника, — +расхождение в незаполненной колонке подскажет аналитику заполнить и её. `values_ok` её ячейки не тревожат: +`is_recognized = NULL` и `confidence = NULL` — «без сомнений», правленные — `is_modified`. + +### D-6. Фронт (arm-web, PR в `test`) +`statementGrid`: ключ строки — `line.id ?? "code:" + line_code`, ключ ячейки — `rowKey:column_id`; `draftToPatch` +отправляет `line_id` или `line_code` по ключу. Достроенная пустая ячейка (`id = null`) без подсветки «требует проверки» +(`is_recognized = null`), введённое значение подсвечивается как правка. `formLayout` уже раскладывает строки по разделам — +меняется только то, что разделы теперь всегда полные. Сверка с макетом [41] (PNG `docs/mockup/screens/`) в Playwright и +ручной прогон сценария: пустая строка → ввод → «Сохранить» → проверки пересчитаны. + +## Risks / Trade-offs + +- **Две копии справочника (ядро и scan-gateway) расходятся**: в ядре 66n — по шаблону банка (без 1105, 1215, 1330 и с + «Доходные вложения в материальные ценности» у 1160), в scan-gateway — словарь старого ядра. Наименования строк + источника не трогаются, так что расхождение видно только в наименовании достроенных строк. Follow-up: заполнить + `fsbu4` в scan-gateway и свести копии. +- **Шаблоны банка — не нормативный текст.** Если в ФСБУ 4/2023 окажутся строки, которых нет в шаблоне, они не + достроятся; правится данными (`statement_lines.yaml`), без кода. +- **Ответ GET растёт** на 30–40 строк × колонки — несущественно. + +## Decisions made without discussion + +| # | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-1 | 09.10.2026 | Состав **66n** в ядре — из шаблонов банка «до 01.01.2025», а не копия словаря scan-gateway (в чате согласовано «копия») | В словаре scan-gateway у 66n коды и имена эпохи ФСБУ (1105 «Гудвил», 1215, 1160 «Инвестиционная недвижимость»), а шаблон банка — тот же источник, что выбран для fsbu4; одна методика для обеих редакций | Неверное наименование или лишняя/недостающая достроенная строка у отчётности до 2025 года; правится данными | +| R-2 | 09.10.2026 | Наименования склеенных строк шаблона исправлены по форме (1110, 1210, 1310, 1410, 1510, 2411, сноски) | Склейка — артефакт объединённых ячеек шаблона, а не наименование строки | Неточное наименование достроенной строки; правится данными | +| R-3 | 09.10.2026 | Созданная правкой строка участвует в проверках §7, только пока у неё есть непустая сумма | Решение владельца «пустая строка не влияет на проверки» должно сохраняться и после очистки | Аналитик очистил итог — нарушение исчезло вместе с ним; так же ведёт себя отсутствующая строка источника | +| R-4 | 09.10.2026 | Без редакции (нет даты периода) строки не достраиваются, недостающие ячейки — достраиваются | Не из чего выбрать состав; дата периода нужна и для подтверждения (`PERIOD_REQUIRED`) — после её правки строки появятся | Аналитик не видит пустых строк, пока не введёт дату отчёта | +| R-5 | 09.10.2026 | `line_code` для кода, уже существующего в форме, — 422 `UNKNOWN_CELL`, а не тихая правка существующей строки | Один способ адресации существующей ячейки (`line_id`), второй запрос в гонке не перезапишет чужую правку молча | Лишний круг фронта: перечитать форму и повторить | +| R-6 | 09.10.2026 | Ячейка, которой нет у существующей строки, создаётся при правке по `line_id` | Та же проблема, что с отсутствующей строкой; отдельного API не требует | — | diff --git a/openspec/changes/statement-template-lines/proposal.md b/openspec/changes/statement-template-lines/proposal.md new file mode 100644 index 0000000..dcaaa72 --- /dev/null +++ b/openspec/changes/statement-template-lines/proposal.md @@ -0,0 +1,67 @@ +# Proposal: полный состав строк ББ/ОФР на экране проверки + +**Срез:** доработка Среза 3b (`../docs/context/09-delivery-plan.md`) — экран проверки форм ББ/ОФР. +**Репозитории:** arm-core (этот change, issue RSHB/arm-core#80); arm-web — PR в `test` (раскладка, правка по коду строки); +`../docs` — bounded-PR: правило в `05-domain-rules.md` §7 «Ядро» и §11/§12, OQ-150 в `07-open-questions.md`, гайды +`dev/frontend-api-guide.md`, `dev/qa-guide.md`. Follow-up: словарь `fsbu4` в scan-gateway (OQ-150 для нормализатора). +DBML и контракты обмена не меняются, миграций нет. + +## Why + +Ядро хранит только строки формы, пришедшие от OCR или разбора Excel (`financial_statement_line`), и правит только +существующую ячейку (`PATCH …/statement` по `line_id` + `column_id`, иначе 422 `UNKNOWN_CELL`). Если источник строку +потерял — пример: «Русский бисквит», 1 кв. 2025, ИС склеила «1500\n1700» и строка 1700 пропала (до OQ-156), — аналитик +не может её заполнить, хотя форма бухгалтерской отчётности обязана её содержать. То же с ячейкой, которой нет у +существующей строки в какой-то колонке. Решение владельца 09.10.2026: на экране проверки всегда показывать все строки +формы своей редакции и давать их заполнять. + +## What Changes + +- **Справочник строк форм по редакциям** — `api/rules/statement_lines.yaml` (данные, не код): редакции `66n` + (≤ 2024-12-31) и `fsbu4` (с 2025-01-01), для каждой — ББ и ОФР: коды в порядке формы и наименования. Источник обеих + редакций — шаблоны банка `../fixtures/statements-xlsx/templates/` («ББ/ОФР до 01.01.2025», «ББ/ОФР после 01.01.2025») + с исправленными склейками наименований и заголовков разделов (решение владельца 09.10.2026: данные OQ-150 — из + шаблонов). +- **`GET /documents/{id}/statement` достраивает форму.** К строкам из БД добавляются строки справочника редакции + `edition`, которых в форме нет, и недостающие ячейки у существующих строк по всем колонкам формы. У достроенной строки + `id = null`, `origin = "template"`, наименование — из справочника; у достроенной ячейки `id = null`, `amount = null`, + `is_recognized = null`, `is_modified = false`. Новое поле `LineOut.origin`: `source` — строка пришла из источника, + `template` — строка из справочника (достроенная или созданная правкой аналитика). Без `edition` (нет даты периода) + строки не достраиваются, недостающие ячейки — достраиваются. +- **`PATCH …/statement` создаёт строку и ячейку при первой правке.** Элемент `values[]` адресует ячейку либо + `line_id` + `column_id` (как сейчас; если у строки нет ячейки в этой колонке — ячейка создаётся), либо `line_code` + + `column_id` — для строки справочника редакции, которой нет в форме: ядро создаёт `financial_statement_line` + (`line_code_original_value = NULL` — признак «не из источника», наименование из справочника) и + `financial_statement_value` (`original_amount = NULL`, `is_recognized = NULL`, `confidence = NULL`). Дальше — как любая + правка: `is_modified`, `modified_by/at`, один `audit_event(RECOGNIZED_DATA_EDIT)` с `before = null`, сброс акцепта, + пересчёт статуса. +- **Пустая шаблонная строка не влияет на статус и проверки** (решение владельца 09.10.2026). Достроенные строки в БД + не пишутся и в проверки §7, `values_ok` (D-6) и автоподтверждение не попадают. Созданная правкой строка (`origin = + template`) участвует в проверках §7, только пока у неё есть хотя бы одна непустая сумма; очищенная до пустоты — + снова как будто её нет. + +Таблицы DBML v5.17: `financial_statement_line` (`line_code_original_value` NULL — признак строки не из источника), +`financial_statement_value`, `financial_statement_meta` (сброс акцепта), `document` (статус), `audit_event`. Новых полей +нет. + +## Non-goals + +- Запись достроенных строк в БД при маппинге и миграция данных — нет: БД хранит то, что пришло (первичка). +- Обязательность заполнения: пустая строка не блокирует подтверждение и автоподтверждение; полноту ловят проверки §7. +- Заполнение `fsbu4` в словаре scan-gateway (нормализатор, golden-эталоны) — follow-up issue. +- Строки без кода (OQ-36, OQ-141, OQ-149), пятизначные подстроки в справочнике, отдельные формы для упрощённой + отчётности малых предприятий — нет. +- Порядок строк в ответе — по коду, как сейчас; раскладка по разделам — на фронте (`formLayout.ts`). +- Удаление созданной строки аналитиком — нет (очистка суммы до `null` возвращает её в состояние «пустая шаблонная»). +- Правка кода и наименования строки — нет (решение Среза 3b). + +## Impact + +- Код: `domain/statement_lines.py` (новый: загрузка справочника, достройка формы — чистые функции), + `api/rules/statement_lines.yaml` (новый), `schemas/statements.py` (`LineOut.id`/`ValueOut.id` nullable, + `LineOut.origin`, `CellPatch` с `line_id | line_code`), `services/statements.py` (`build_out`, `_apply_cells`), + `repositories/statements.py` (создание строки/ячейки, исключение пустых шаблонных строк в `statement_amounts`). +- Спеки (дельты): `documents-api` («Форма ББ/ОФР для экрана проверки», «Правка формы»), `statement-checks` + («Соотношения §7 по колонкам»). +- Фронт arm-web: ключ шаблонной строки, отправка `line_code`, отсутствие подсветки «требует проверки» у пустых + достроенных ячеек. diff --git a/openspec/changes/statement-template-lines/specs/documents-api/spec.md b/openspec/changes/statement-template-lines/specs/documents-api/spec.md new file mode 100644 index 0000000..98b9e80 --- /dev/null +++ b/openspec/changes/statement-template-lines/specs/documents-api/spec.md @@ -0,0 +1,103 @@ +## MODIFIED Requirements + +### Requirement: Форма ББ/ОФР для экрана проверки +`GET /api/v1/documents/{document_id}/statement` (`DOCUMENT_VIEW`, policy по ЮЛ) SHALL отдавать для документа +`BALANCE`/`INCOME`: `document` (`id`, `document_type`, `status`, `period_year`, `period_quarter`, `reporting_date`, +`counterparty_check`, `locator`, `file_id`); `edition` (`66n` при дате периода ≤ 2024-12-31, `fsbu4` — позже, `null` без +даты); `header` — для `inn`, `report_date`, `units`: `value`, `original_value`, `confidence` (у ИНН), `verified`, +`modified`; плюс `organization_name`, `okud`, `okpo`, `okopf`, `okfs`, `address`; `columns[]` (`id`, `column_index`, +`label`, `period_date`, `is_current`); `lines[]` по `line_code` (`id`, `origin`, `line_code`, `line_code_confidence`, +`line_name`, `line_name_confidence`, `values[]` по `column_index`: `id`, `column_id`, `amount`, `string_value`, +`confidence`, `is_recognized`, `is_modified`, `original_amount`, `modified_by`, `modified_at`); `checks` (`failed`, +`soft_failed`, `cross_form`, `accepted`: `by`, `at`, `comment` или `null`). Документ другого типа — 409 +`DOCUMENT_NOT_STATEMENT`; чужой или несуществующий — 404. + +Форма SHALL достраиваться в ответе по справочнику строк редакции `edition` (`api/rules/statement_lines.yaml`, свой +список кодов и наименований для `BALANCE` и `INCOME` каждой редакции): строки справочника, кода которых нет в форме, +добавляются с `id = null`, `origin = "template"`, наименованием из справочника и `*_confidence = null`; у каждой строки +SHALL быть значение по каждой колонке формы — недостающее добавляется с `id = null`, `amount = null`, +`string_value = null`, `confidence = null`, `is_recognized = null`, `is_modified = false`, `original_amount = null`, +`modified_by = null`, `modified_at = null`. При `edition = null` строки не достраиваются, недостающие значения — +достраиваются. Строка из БД имеет `origin = "source"`, если пришла из источника, и `"template"`, если создана правкой +аналитика (`line_code_original_value IS NULL`). Строки источника, которых нет в справочнике, отдаются как есть. +Достроенные строки и значения MUST NOT записываться в БД при чтении. + +#### Scenario: Форма для экрана +- **WHEN** запрошена форма `BALANCE` из `mezheninovskaya/2025-Q3/1_Форма1_бух_09` +- **THEN** `edition = fsbu4`, все 37 строк файла — `origin = source` с `id`, каждый код справочника `fsbu4` / `BALANCE` + есть среди `lines`, у каждой строки значения по всем колонкам, `checks.failed` пуст + +#### Scenario: Строка, потерянная источником +- **WHEN** у формы `BALANCE` за 2025 Q1 в БД нет строки 1700 (OCR склеил «1500\n1700» до OQ-156) +- **THEN** в `lines` есть 1700: `id = null`, `origin = template`, `line_name = "БАЛАНС"`, значения по всем колонкам + `id = null`, `amount = null`, `is_recognized = null`; статус документа и `checks` — те же, что без достройки + +#### Scenario: Недостающая ячейка +- **WHEN** у строки 1150 в БД есть значение только для колонки 0, а у формы две колонки +- **THEN** у 1150 два значения; значение колонки 1 — `id = null`, `amount = null` + +#### Scenario: Нет даты периода +- **WHEN** у формы OCR не определена дата периода (`edition = null`) +- **THEN** `lines` — только строки из БД, у каждой значения по всем колонкам + +#### Scenario: Не форма +- **WHEN** запрошена форма документа `OSV` +- **THEN** 409 `DOCUMENT_NOT_STATEMENT` + +### Requirement: Правка формы +`PATCH /api/v1/documents/{document_id}/statement` (`DOCUMENT_EDIT`, policy) SHALL принимать частичное тело `header` +(`inn` — 10 или 12 цифр, иначе 422 `INVALID_INN`; `report_date` — дата; `units` — непустая строка ≤ 50) и `values[]` +(ровно одно из `line_id` | `line_code`, плюс `column_id`, `amount` — число с ≤ 2 знаками или `null`) и в одной +транзакции: при первой правке значения записывать `original_amount` = прежняя сумма; ставить `amount`, +`is_modified = (amount ≠ original_amount)`, `modified_by/at`; для поля шапки — `*_modified = true`, `*_original_value` +не менять; после `inn` — пересчитать `counterparty_check`; после `report_date` — пересчитать `period_year`, +`period_quarter`, `reporting_date`; сбросить акцепт проверок; пересчитать статус; записать одно +`audit_event(RECOGNIZED_DATA_EDIT, RECOGNITION)` с `before/after` изменённых полей и ячеек; вернуть форму, как `GET`. +Документ `CONFIRMED` — 409 `DOCUMENT_READONLY`; файл `SUPERSEDED` — 409 `FILE_SUPERSEDED`; не форма — 409 +`DOCUMENT_NOT_STATEMENT`; ячейка не этого документа — 422 `UNKNOWN_CELL`. Запрос без изменений — 200 без аудита. +Блокировки — «файл `FOR UPDATE` → документ `FOR NO KEY UPDATE`». + +Ячейка, которой нет в БД, SHALL создаваться при правке: +- по `line_id` строки формы и `column_id` колонки формы, если у строки нет значения в этой колонке; +- по `line_code`, если строки с этим кодом в форме нет, а код есть в справочнике редакции документа для типа формы: + создаётся `financial_statement_line` (`line_code`, `line_name` из справочника, `line_code_original_value = NULL`, + `line_name_original_value = NULL`, `*_confidence = NULL`, `*_modified = false`); несколько ячеек одной новой строки в + запросе создают одну строку. +Созданная ячейка — `original_amount = NULL`, `is_recognized = NULL`, `confidence = NULL`, `string_value = NULL`, далее +общая логика правки. `line_code`, строка с которым в форме уже есть, код вне справочника редакции или редакция не +определена — 422 `UNKNOWN_CELL`; колонка не этой формы — 422 `UNKNOWN_CELL`; одна ячейка дважды в запросе — 422 +`DUPLICATE_CELL`. `amount = null` для ячейки, которой нет в БД, — не изменение: ничего не создаётся и не аудируется. + +#### Scenario: Правка значения +- **WHEN** аналитик меняет сумму строки 1150 колонки 0 с 100 на 120 +- **THEN** `amount = 120`, `original_amount = 100`, `is_modified = true`, аудит `RECOGNIZED_DATA_EDIT` с `before 100`, `after 120` + +#### Scenario: Возврат к исходному +- **WHEN** затем та же ячейка меняется обратно на 100 +- **THEN** `is_modified = false`, `original_amount = 100` + +#### Scenario: Заполнение строки, которой нет в форме +- **WHEN** у формы `BALANCE` редакции `fsbu4` нет строки 1700, аналитик передаёт `{line_code: "1700", column_id: <колонка 0>, amount: 2371333}` +- **THEN** в БД создана строка 1700 (`line_code_original_value = NULL`, `line_name = "БАЛАНС"`) и значение + `amount = 2371333`, `original_amount = NULL`, `is_modified = true`; в ответе у 1700 `id` заполнен, `origin = template`; + аудит `RECOGNIZED_DATA_EDIT` с `before.amount = null`; акцепт сброшен, статус пересчитан + +#### Scenario: Код, который уже есть в форме +- **WHEN** передан `line_code: "1150"`, а строка 1150 в форме есть +- **THEN** 422 `UNKNOWN_CELL`, данные не изменились + +#### Scenario: Код вне справочника +- **WHEN** передан `line_code: "1105"` для формы редакции `66n` +- **THEN** 422 `UNKNOWN_CELL` + +#### Scenario: Пустая сумма в несуществующую ячейку +- **WHEN** передан `{line_code: "1530", column_id: <колонка 0>, amount: null}` и строки 1530 нет +- **THEN** 200, строка не создана, аудита нет + +#### Scenario: Правка даты отчёта +- **WHEN** у формы OCR без периода дата отчёта правится на 2025-06-30 +- **THEN** `period_year = 2025`, `period_quarter = Q2`, `report_date_modified = true`, `report_date_original_value` не изменился + +#### Scenario: Подтверждённая форма +- **WHEN** правится форма `CONFIRMED` +- **THEN** 409 `DOCUMENT_READONLY`, данные не изменились diff --git a/openspec/changes/statement-template-lines/specs/statement-checks/spec.md b/openspec/changes/statement-template-lines/specs/statement-checks/spec.md new file mode 100644 index 0000000..6d38e0a --- /dev/null +++ b/openspec/changes/statement-template-lines/specs/statement-checks/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: Соотношения §7 по колонкам +Проверки SHALL считаться чистой функцией по текущим `financial_statement_value.amount` документа для каждой колонки +отдельно по словарю `api/rules/statement_checks.yaml` (копия словаря tabular-parser): соотношение проверяется, только +если присутствует его левая часть; отсутствующий код и пустая сумма — 0; выполнено, если `|левая − правая| ≤ tolerance` +(1, в единицах формы) хотя бы в одном варианте правой части — как записано или с кодами `abs_terms` по модулю. Нарушение +жёсткого (`strict`) соотношения, не превышающее `rounding_tolerance_percent` (0,001 %) от наибольшего по модулю числа +соотношения, SHALL считаться мягким («в пределах округления»). Мягкие: соотношения `strict: false` и «сумма пятизначных +подстрок = четырёхзначный родитель». Результат — `{failed: [...], soft_failed: [...]}`, элемент — `{column_index, +relation, expected, actual}`; MUST NOT храниться в БД. + +Присутствующими SHALL считаться строки формы из БД, кроме строк, созданных правкой аналитика +(`line_code_original_value IS NULL`), у которых все суммы пустые; строки, достроенные в ответе по справочнику +(`documents-api`, `id = null`), в проверках MUST NOT участвовать. Созданная правкой строка с хотя бы одной непустой +суммой проверяется по всем колонкам, пустые суммы — 0. + +#### Scenario: Сходящийся баланс +- **WHEN** проверяется форма `mezheninovskaya/2025-Q3/1_Форма1_бух_09` +- **THEN** `failed` пуст + +#### Scenario: Нарушение жёсткого соотношения +- **WHEN** у баланса 1600 = 1000, 1100 = 300, 1200 = 600 в колонке 0 +- **THEN** `failed` содержит `{column_index: 0, relation: "1600 = 1100 + 1200", expected: 1000, actual: 900}` + +#### Scenario: Расходы со знаком минус +- **WHEN** ОФР 2110 = 100, 2120 = −60, 2100 = 40 +- **THEN** `2100 = 2110 − 2120` выполнено через вариант с `abs_terms` + +#### Scenario: Правка исправляет нарушение +- **WHEN** аналитик правит 1200 с 600 на 700 в форме из предыдущего сценария +- **THEN** проверка по текущим суммам: `failed` пуст + +#### Scenario: Пустая шаблонная строка не проверяется +- **WHEN** в форме нет строки 1700 (в ответе она достроена пустой), 1300 + 1400 + 1500 = 2 371 333 в колонке 0 +- **THEN** `1700 = 1300 + 1400 + 1500` и `1600 = 1700` не проверяются, `failed` — как без достройки + +#### Scenario: Заполненная шаблонная строка проверяется +- **WHEN** аналитик заполнил 1700 = 2 371 333 только в колонке 0, а в колонке 1 1300 + 1400 + 1500 = 2 000 000 +- **THEN** в колонке 0 соотношение выполнено; в колонке 1 `failed` содержит `1700 = 1300 + 1400 + 1500` с `expected 0`, + `actual 2000000` + +#### Scenario: Очищенная шаблонная строка +- **WHEN** затем аналитик очищает 1700 в колонке 0 (`amount = null`) +- **THEN** строка 1700 снова не участвует в проверках -- 2.47.2 From f540c7f661f5bde66376e8a8374171324a0e2fa0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 10:29:23 +0400 Subject: [PATCH 02/11] =?UTF-8?q?openspec:=20statement-template-lines=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20=C2=AB=D0=91=D0=90?= =?UTF-8?q?=D0=9B=D0=90=D0=9D=D0=A1=C2=BB=20=D1=86=D0=B2=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Просьба владельца: 1600/1700 сливаются с обычными строками. R-7 в design.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qoydUVv8g1qHxc123u2RR --- openspec/changes/statement-template-lines/design.md | 8 +++++++- openspec/changes/statement-template-lines/proposal.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/openspec/changes/statement-template-lines/design.md b/openspec/changes/statement-template-lines/design.md index 1852287..1fa5976 100644 --- a/openspec/changes/statement-template-lines/design.md +++ b/openspec/changes/statement-template-lines/design.md @@ -116,7 +116,12 @@ pydantic: коды — четыре цифры, без повторов внут `statementGrid`: ключ строки — `line.id ?? "code:" + line_code`, ключ ячейки — `rowKey:column_id`; `draftToPatch` отправляет `line_id` или `line_code` по ключу. Достроенная пустая ячейка (`id = null`) без подсветки «требует проверки» (`is_recognized = null`), введённое значение подсвечивается как правка. `formLayout` уже раскладывает строки по разделам — -меняется только то, что разделы теперь всегда полные. Сверка с макетом [41] (PNG `docs/mockup/screens/`) в Playwright и +меняется только то, что разделы теперь всегда полные. **Строки «БАЛАНС» (1600 по активу, 1700 по пассиву) выделяются +цветом** (просьба владельца 09.10.2026: сейчас итог баланса сливается с обычными строками и «Итого по разделу»): фон +всей строки — светло-синий (`#e6f4ff`, токен antd `colorPrimaryBg`; отличается от серого фона заголовков разделов), +шрифт 700 у наименования, кода и сумм. Подсветка ячейки «требует проверки» / «правлено» перекрывает фон строки — +сигнал о данных важнее оформления. Признак — `formLayout` (`LayoutRow.grand: true` для 1600/1700 у `BALANCE`), цвет — +`StatementTable`. Сверка с макетом [41] (PNG `docs/mockup/screens/`) в Playwright и ручной прогон сценария: пустая строка → ввод → «Сохранить» → проверки пересчитаны. ## Risks / Trade-offs @@ -139,3 +144,4 @@ pydantic: коды — четыре цифры, без повторов внут | R-4 | 09.10.2026 | Без редакции (нет даты периода) строки не достраиваются, недостающие ячейки — достраиваются | Не из чего выбрать состав; дата периода нужна и для подтверждения (`PERIOD_REQUIRED`) — после её правки строки появятся | Аналитик не видит пустых строк, пока не введёт дату отчёта | | R-5 | 09.10.2026 | `line_code` для кода, уже существующего в форме, — 422 `UNKNOWN_CELL`, а не тихая правка существующей строки | Один способ адресации существующей ячейки (`line_id`), второй запрос в гонке не перезапишет чужую правку молча | Лишний круг фронта: перечитать форму и повторить | | R-6 | 09.10.2026 | Ячейка, которой нет у существующей строки, создаётся при правке по `line_id` | Та же проблема, что с отсутствующей строкой; отдельного API не требует | — | +| R-7 | 09.10.2026 | Цвет строк «БАЛАНС» — светло-синий `#e6f4ff` + жирный 700; подсветка ячейки перекрывает фон строки; ОФР не выделяется | Владелец просил выделить баланс по активу и пассиву, цвет не задал; в макете [41] строки «БАЛАНС» не видно; синий не спорит с серым фоном разделов и жёлтым «требует проверки» | Цвет не понравится — правка одной константы | diff --git a/openspec/changes/statement-template-lines/proposal.md b/openspec/changes/statement-template-lines/proposal.md index dcaaa72..9ba7ce7 100644 --- a/openspec/changes/statement-template-lines/proposal.md +++ b/openspec/changes/statement-template-lines/proposal.md @@ -64,4 +64,4 @@ DBML и контракты обмена не меняются, миграций - Спеки (дельты): `documents-api` («Форма ББ/ОФР для экрана проверки», «Правка формы»), `statement-checks` («Соотношения §7 по колонкам»). - Фронт arm-web: ключ шаблонной строки, отправка `line_code`, отсутствие подсветки «требует проверки» у пустых - достроенных ячеек. + достроенных ячеек; выделение цветом строк «БАЛАНС» (1600 актив, 1700 пассив) — просьба владельца 09.10.2026. -- 2.47.2 From dba7273a2d7e4ad93c4a563e83793681da3c0eaa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 10:39:23 +0400 Subject: [PATCH 03/11] =?UTF-8?q?plan:=20statement-template-lines=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8;=20=D1=83=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BF=D0=B5=D0=BA?= =?UTF-8?q?=D0=B8=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спека: «1600 = 1700» проверяется при потерянной 1700 (цель 1600 есть); цвет строк «БАЛАНС» — #f9f0ff (синий занят «исправлено»); R-8 has_lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qoydUVv8g1qHxc123u2RR --- .../2026-10-09-statement-template-lines.md | 1640 +++++++++++++++++ .../statement-template-lines/design.md | 13 +- .../specs/documents-api/spec.md | 1 + .../specs/statement-checks/spec.md | 3 +- 4 files changed, 1650 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-09-statement-template-lines.md diff --git a/docs/superpowers/plans/2026-10-09-statement-template-lines.md b/docs/superpowers/plans/2026-10-09-statement-template-lines.md new file mode 100644 index 0000000..abfae31 --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-statement-template-lines.md @@ -0,0 +1,1640 @@ +# Полный состав строк ББ/ОФР — план реализации + +> **Для исполнителей-агентов:** ОБЯЗАТЕЛЬНЫЙ SUB-SKILL: superpowers:subagent-driven-development (рекомендуется) или +> superpowers:executing-plans. Шаги — чекбоксы (`- [ ]`). + +**Goal:** на экране проверки ББ/ОФР показывать все строки формы своей редакции и давать аналитику заполнять отсутствующие +строки и ячейки; строки «БАЛАНС» (1600/1700) выделять цветом. + +**Architecture:** справочник строк по редакциям — данные `arm-core/api/rules/statement_lines.yaml` + загрузчик +`domain/statement_lines.py`. `GET …/statement` достраивает ответ (строки справочника и недостающие ячейки с `id = null`), +в БД ничего не пишет. `PATCH …/statement` адресует ячейку `line_id` или `line_code` и создаёт строку/ячейку при первой +непустой правке. Проверки §7 не видят созданных правкой строк, у которых все суммы пустые. arm-web строит ключи строк по +`id ?? code`, отправляет `line_code` и красит строки «БАЛАНС». + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, pydantic v2, pytest-asyncio, PyYAML; React 18 + TS, antd 6, vitest. + +**Spec:** `arm-core/openspec/changes/statement-template-lines/` (`proposal.md`, `design.md`, `specs/documents-api/spec.md`, +`specs/statement-checks/spec.md`). Issue RSHB/arm-core#80. + +## Global Constraints + +- Язык комментариев, docstring, коммитов, документации — русский; идентификаторы — английские, как в DBML и API. +- DBML v5.17 и контракты обмена не меняются, миграций нет. Поля вне DBML не вводить. +- Деньги — `Decimal` / `numeric(18,2)`; в JSON — строки. +- Признак строки «не из источника» — `financial_statement_line.line_code_original_value IS NULL` (маппинг всегда пишет + `line_code_original_value = line_code`). +- Редакция — существующий `edition_for(document.reporting_date)`: `66n` (≤ 2024-12-31), `fsbu4`, `None`. +- `GET` MUST NOT писать в БД. Достроенная ячейка: `id = null`, `amount = null`, `string_value = null`, `confidence = null`, + `is_recognized = null`, `is_modified = false`, `original_amount = null`, `modified_by = null`, `modified_at = null`. +- Ошибки адресации ячейки — 422 `UNKNOWN_CELL` (код не меняется, сообщения по-русски); повтор ячейки — 422 `DUPLICATE_CELL`. +- Аудит — одно `RECOGNIZED_DATA_EDIT` на запрос, ключ ячейки `value:` (у созданной — id новой ячейки, `before = null`). +- Порядок блокировок «файл `FOR UPDATE` → документ `FOR NO KEY UPDATE`» не меняется (`StatementService._editable`). +- arm-core: `make check` зелёный после каждой задачи backend; `make check-full` — перед PR. arm-web: `make check` перед PR. +- arm-web: все PR — в ветку `test`, не в `main`. Фронт: цвет строки «БАЛАНС» `#f9f0ff`, шрифт 700; подсветка ячейки + («исправлено» `#e6f4ff`, «требует проверки» `#fff7e6`) перекрывает фон строки. +- Git — всегда `git -C ` или cwd внутри worktree; в `main` не коммитить (хук guard-main); хуки не обходить — при + отказе хука остановиться и сообщить. + +## Review Focus + +1. Запрос, где одна ячейка корректна (новая строка по `line_code`), а другая — нет (чужая колонка): 422, и строка НЕ + создана (вся валидация до первой вставки) — тест в Task 3. +2. Две ячейки одной новой строки в одном запросе (колонки 0 и 1 для `1700`) — одна строка в БД, два значения — Task 3. +3. Повторная правка тем же `line_code` после того, как строка уже создана (второй клик «Сохранить» со старым черновиком): + 422 `UNKNOWN_CELL` «передайте line_id», данные не задвоены — Task 3. +4. Аналитик очистил созданную шаблонную строку целиком — нарушение по ней исчезает; заполнил только одну колонку — + нарушение в пустой колонке есть — Task 4. +5. Фронт: черновик смешанных строк (существующая по `id` и шаблонная по коду) превращается в корректное тело PATCH; + пустая достроенная ячейка не подсвечивается «требует проверки» — Task 6 (vitest). + +--- + +### Task 1: Справочник строк форм по редакциям (arm-core, domain) + +**Модель:** haiku (готовый код). + +**Files:** +- Create: `arm-core/api/rules/statement_lines.yaml` +- Create: `arm-core/api/src/domain/statement_lines.py` +- Test: `arm-core/api/tests/test_domain_statement_lines.py` + +**Interfaces:** +- Produces: `TemplateLine(code: str, name: str)` (frozen dataclass); `LineCatalog.lines(edition: str | None, form: str) -> + tuple[TemplateLine, ...]` (пустой кортеж для `None`/неизвестной редакции); `load_line_catalog(path: Path | None = None) -> + LineCatalog` (кэшируется; ошибка формата — `ValueError` с именем файла). + +- [ ] **Step 1: Написать падающий тест** `api/tests/test_domain_statement_lines.py`: + +```python +"""Справочник строк форм ББ/ОФР по редакциям (spec documents-api «Форма ББ/ОФР для экрана проверки»; design D-2).""" + +from pathlib import Path + +import pytest + +from src.domain.statement_lines import TemplateLine, load_line_catalog + +CATALOG = load_line_catalog() + + +@pytest.mark.parametrize( + ("edition", "form", "count", "first", "last"), + [ + ("66n", "BALANCE", 37, "1110", "1700"), + ("66n", "INCOME", 22, "2110", "2910"), + ("fsbu4", "BALANCE", 34, "1110", "1700"), + ("fsbu4", "INCOME", 23, "2110", "2910"), + ], +) +def test_edition_composition(edition: str, form: str, count: int, first: str, last: str) -> None: + lines = CATALOG.lines(edition, form) + assert len(lines) == count + assert lines[0].code == first and lines[-1].code == last + assert len({line.code for line in lines}) == count + + +def test_names_fixed_from_templates() -> None: + names = {line.code: line.name for line in CATALOG.lines("fsbu4", "BALANCE")} + assert names["1110"] == "Нематериальные активы" + assert names["1210"] == "Запасы" + assert names["1310"] == "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)" + assert names["1410"] == names["1510"] == "Заемные средства" + assert names["1600"] == names["1700"] == "БАЛАНС" + old = {line.code: line.name for line in CATALOG.lines("66n", "BALANCE")} + assert old["1160"] == "Доходные вложения в материальные ценности" + assert "1105" not in old and "1215" not in old and "1215" in names + income = {line.code: line.name for line in CATALOG.lines("fsbu4", "INCOME")} + assert "2420" in income and "2460" not in income + assert all("\n" not in line.name for e in ("66n", "fsbu4") for f in ("BALANCE", "INCOME") for line in CATALOG.lines(e, f)) + + +def test_no_edition_means_no_lines() -> None: + assert CATALOG.lines(None, "BALANCE") == () + assert CATALOG.lines("unknown", "BALANCE") == () + assert CATALOG.lines("66n", "OSV") == () + + +def test_template_line_is_value_object() -> None: + assert TemplateLine("1700", "БАЛАНС") == TemplateLine("1700", "БАЛАНС") + + +@pytest.mark.parametrize( + ("body", "message"), + [ + ("editions:\n - id: x\n forms:\n BALANCE: [{code: '11', name: a}]\n INCOME: []\n", "код"), + ( + "editions:\n - id: x\n forms:\n BALANCE: [{code: '1110', name: a}, {code: '1110', name: b}]\n" + " INCOME: []\n", + "повтор", + ), + ("editions:\n - id: x\n forms:\n BALANCE: []\n", "INCOME"), + ("editions: [\n", "statement_lines"), + ], +) +def test_invalid_catalog_is_value_error(tmp_path: Path, body: str, message: str) -> None: + path = tmp_path / "statement_lines.yaml" + path.write_text(body, encoding="utf-8") + with pytest.raises(ValueError, match=message): + load_line_catalog(path) +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `cd arm-core/api && .venv/bin/pytest tests/test_domain_statement_lines.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'src.domain.statement_lines'`. + +- [ ] **Step 3: Создать справочник** `api/rules/statement_lines.yaml` (точно этот текст): + +```yaml +# Справочник строк форм ББ/ОФР по редакциям: полный состав формы для экрана проверки (05-domain-rules §7 «Ядро», +# design D-2 change statement-template-lines). Порядок строк в списке — порядок формы. +# Редакция выбирается по дате периода документа (edition_for): 66n — до 2024-12-31 включительно, fsbu4 — позже. +# Источник обеих редакций — шаблоны банка fixtures/statements-xlsx/templates/ («ББ/ОФР до 01.01.2025», «… после +# 01.01.2025»; решение владельца 09.10.2026, данные OQ-150 для ядра); склейки наименований с заголовками разделов и +# сносками исправлены (design R-2). Копия словаря scan-gateway (api/rules/statement_lines.yaml) с ним не совпадает: +# сведение копий — follow-up. +editions: + - id: 66n + forms: + BALANCE: + - {code: "1110", name: "Нематериальные активы"} + - {code: "1120", name: "Результаты исследований и разработок"} + - {code: "1130", name: "Нематериальные поисковые активы"} + - {code: "1140", name: "Материальные поисковые активы"} + - {code: "1150", name: "Основные средства"} + - {code: "1160", name: "Доходные вложения в материальные ценности"} + - {code: "1170", name: "Финансовые вложения"} + - {code: "1180", name: "Отложенные налоговые активы"} + - {code: "1190", name: "Прочие внеоборотные активы"} + - {code: "1100", name: "Итого по разделу I"} + - {code: "1210", name: "Запасы"} + - {code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям"} + - {code: "1230", name: "Дебиторская задолженность"} + - {code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)"} + - {code: "1250", name: "Денежные средства и денежные эквиваленты"} + - {code: "1260", name: "Прочие оборотные активы"} + - {code: "1200", name: "Итого по разделу II"} + - {code: "1600", name: "БАЛАНС"} + - {code: "1310", name: "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)"} + - {code: "1320", name: "Собственные акции, выкупленные у акционеров"} + - {code: "1340", name: "Переоценка внеоборотных активов"} + - {code: "1350", name: "Добавочный капитал (без переоценки)"} + - {code: "1360", name: "Резервный капитал"} + - {code: "1370", name: "Нераспределенная прибыль (непокрытый убыток)"} + - {code: "1300", name: "Итого по разделу III"} + - {code: "1410", name: "Заемные средства"} + - {code: "1420", name: "Отложенные налоговые обязательства"} + - {code: "1430", name: "Оценочные обязательства"} + - {code: "1450", name: "Прочие обязательства"} + - {code: "1400", name: "Итого по разделу IV"} + - {code: "1510", name: "Заемные средства"} + - {code: "1520", name: "Кредиторская задолженность"} + - {code: "1530", name: "Доходы будущих периодов"} + - {code: "1540", name: "Оценочные обязательства"} + - {code: "1550", name: "Прочие обязательства"} + - {code: "1500", name: "Итого по разделу V"} + - {code: "1700", name: "БАЛАНС"} + INCOME: + - {code: "2110", name: "Выручка"} + - {code: "2120", name: "Себестоимость продаж"} + - {code: "2100", name: "Валовая прибыль (убыток)"} + - {code: "2210", name: "Коммерческие расходы"} + - {code: "2220", name: "Управленческие расходы"} + - {code: "2200", name: "Прибыль (убыток) от продаж"} + - {code: "2310", name: "Доходы от участия в других организациях"} + - {code: "2320", name: "Проценты к получению"} + - {code: "2330", name: "Проценты к уплате"} + - {code: "2340", name: "Прочие доходы"} + - {code: "2350", name: "Прочие расходы"} + - {code: "2300", name: "Прибыль (убыток) до налогообложения"} + - {code: "2410", name: "Налог на прибыль"} + - {code: "2411", name: "в т.ч. текущий налог на прибыль"} + - {code: "2412", name: "отложенный налог на прибыль"} + - {code: "2460", name: "Прочее"} + - {code: "2400", name: "Чистая прибыль (убыток)"} + - {code: "2510", name: "Результат от переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток) периода"} + - {code: "2520", name: "Результат от прочих операций, не включаемый в чистую прибыль (убыток) периода"} + - {code: "2500", name: "Совокупный финансовый результат периода"} + - {code: "2900", name: "Базовая прибыль (убыток) на акцию"} + - {code: "2910", name: "Разводненная прибыль (убыток) на акцию"} + - id: fsbu4 + forms: + BALANCE: + - {code: "1110", name: "Нематериальные активы"} + - {code: "1150", name: "Основные средства"} + - {code: "1160", name: "Инвестиционная недвижимость"} + - {code: "1170", name: "Финансовые вложения"} + - {code: "1180", name: "Отложенные налоговые активы"} + - {code: "1190", name: "Прочие внеоборотные активы"} + - {code: "1100", name: "Итого по разделу I"} + - {code: "1210", name: "Запасы"} + - {code: "1215", name: "Долгосрочные активы к продаже"} + - {code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям"} + - {code: "1230", name: "Дебиторская задолженность"} + - {code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)"} + - {code: "1250", name: "Денежные средства и денежные эквиваленты"} + - {code: "1260", name: "Прочие оборотные активы"} + - {code: "1200", name: "Итого по разделу II"} + - {code: "1600", name: "БАЛАНС"} + - {code: "1310", name: "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)"} + - {code: "1320", name: "Собственные акции, принадлежащие обществу, задолженность акционеров по оплате акций"} + - {code: "1340", name: "Накопленная дооценка внеоборотных активов"} + - {code: "1350", name: "Добавочный капитал (без накопленной дооценки)"} + - {code: "1360", name: "Резервный капитал"} + - {code: "1370", name: "Нераспределенная прибыль (непокрытый убыток)"} + - {code: "1300", name: "Итого по разделу III"} + - {code: "1410", name: "Заемные средства"} + - {code: "1420", name: "Отложенные налоговые обязательства"} + - {code: "1430", name: "Оценочные обязательства"} + - {code: "1450", name: "Прочие долгосрочные обязательства"} + - {code: "1400", name: "Итого по разделу IV"} + - {code: "1510", name: "Заемные средства"} + - {code: "1520", name: "Кредиторская задолженность"} + - {code: "1540", name: "Оценочные обязательства"} + - {code: "1550", name: "Прочие краткосрочные обязательства"} + - {code: "1500", name: "Итого по разделу V"} + - {code: "1700", name: "БАЛАНС"} + INCOME: + - {code: "2110", name: "Выручка"} + - {code: "2120", name: "Себестоимость продаж"} + - {code: "2100", name: "Валовая прибыль (убыток)"} + - {code: "2210", name: "Коммерческие расходы"} + - {code: "2220", name: "Управленческие расходы"} + - {code: "2200", name: "Прибыль (убыток) от продаж"} + - {code: "2310", name: "Доходы от участия в других организациях"} + - {code: "2320", name: "Проценты к получению"} + - {code: "2330", name: "Проценты к уплате"} + - {code: "2340", name: "Прочие доходы"} + - {code: "2350", name: "Прочие расходы"} + - {code: "2300", name: "Прибыль (убыток) от продолжающейся деятельности до налогообложения"} + - {code: "2410", name: "Налог на прибыль организаций"} + - {code: "2411", name: "текущий налог на прибыль организаций"} + - {code: "2412", name: "отложенный налог на прибыль организаций"} + - {code: "2420", name: "Прибыль (убыток) от прекращаемой деятельности"} + - {code: "2400", name: "Чистая прибыль (убыток)"} + - {code: "2510", name: "Результат переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток)"} + - {code: "2520", name: "Результат прочих операций, не включаемый в чистую прибыль (убыток)"} + - {code: "2530", name: "Налог на прибыль организаций, относящийся к результатам переоценки внеоборотных активов и прочих операций, не включаемых в чистую прибыль (убыток)"} + - {code: "2500", name: "Совокупный финансовый результат"} + - {code: "2900", name: "Базовая прибыль (убыток) на акцию, руб. коп."} + - {code: "2910", name: "Разводненная прибыль (убыток) на акцию, руб. коп."} +``` + +- [ ] **Step 4: Создать загрузчик** `api/src/domain/statement_lines.py`: + +```python +"""Справочник строк форм ББ/ОФР по редакциям — полный состав формы для экрана проверки (05-domain-rules §7 «Ядро»; +change statement-template-lines, design D-2). + +Данные — `api/rules/statement_lines.yaml`; порядок строк в списке — порядок формы. Редакцию выбирает +`statement_mapping.edition_for`; здесь — только состав. Справочник ничего не убирает и не переименовывает в форме: +строки источника, которых в нём нет, остаются как есть. +""" + +import functools +import re +from dataclasses import dataclass +from pathlib import Path + +import yaml + +__all__ = ["RULES_PATH", "LineCatalog", "TemplateLine", "load_line_catalog"] + +RULES_PATH = Path(__file__).resolve().parents[2] / "rules" / "statement_lines.yaml" +FORMS = ("BALANCE", "INCOME") +_CODE = re.compile(r"^\d{4}$") + + +@dataclass(frozen=True) +class TemplateLine: + code: str + name: str + + +@dataclass(frozen=True) +class LineCatalog: + editions: dict[str, dict[str, tuple[TemplateLine, ...]]] + + def lines(self, edition: str | None, form: str) -> tuple[TemplateLine, ...]: + """Строки формы `form` редакции `edition` в порядке формы; без редакции или для чужой формы — пусто.""" + if edition is None: + return () + return self.editions.get(edition, {}).get(form, ()) + + +def _form_lines(edition: str, form: str, items: object) -> tuple[TemplateLine, ...]: + if not isinstance(items, list): + raise ValueError(f"редакция {edition}: нет списка строк формы {form}") + lines = tuple(TemplateLine(str(item["code"]), " ".join(str(item["name"]).split())) for item in items) + for line in lines: + if not _CODE.fullmatch(line.code): + raise ValueError(f"редакция {edition}, форма {form}: код {line.code!r} — не четыре цифры") + codes = [line.code for line in lines] + if len(codes) != len(set(codes)): + raise ValueError(f"редакция {edition}, форма {form}: повтор кода") + return lines + + +@functools.lru_cache(maxsize=4) +def load_line_catalog(path: Path | None = None) -> LineCatalog: + """Справочник из yaml; любая ошибка формата — `ValueError` с именем файла.""" + source = path or RULES_PATH + try: + data = yaml.safe_load(source.read_text(encoding="utf-8")) + editions: dict[str, dict[str, tuple[TemplateLine, ...]]] = {} + for edition in data["editions"]: + edition_id = str(edition["id"]) + forms = edition["forms"] + editions[edition_id] = {form: _form_lines(edition_id, form, forms.get(form)) for form in FORMS} + return LineCatalog(editions=editions) + except (OSError, KeyError, TypeError, AttributeError, ValueError, yaml.YAMLError) as exc: + raise ValueError(f"справочник строк {source.name}: {exc}") from exc +``` + +- [ ] **Step 5: Запустить — проходит** + +Run: `cd arm-core/api && .venv/bin/pytest tests/test_domain_statement_lines.py -q` +Expected: PASS (все тесты). + +- [ ] **Step 6: Линтеры** + +Run: `cd arm-core && make lint` +Expected: ruff и mypy без ошибок (длинные строки yaml линтер не проверяет). + +- [ ] **Step 7: Коммит** + +```bash +git -C arm-core add api/rules/statement_lines.yaml api/src/domain/statement_lines.py api/tests/test_domain_statement_lines.py +git -C arm-core commit -m "feat(statements): справочник строк форм ББ/ОФР по редакциям (#80)" +``` +(В конце сообщения — строки атрибуции из system-reminder сессии.) + +--- + +### Task 2: GET достраивает форму (arm-core, schemas + service) + +**Модель:** sonnet (интеграция). + +**Files:** +- Modify: `arm-core/api/src/schemas/statements.py` (`ValueOut`, `LineOut`, `statement_out`) +- Modify: `arm-core/api/src/services/statements.py` (`build_out`) +- Test: `arm-core/api/tests/test_api_statements.py` (изменить `test_get_statement`, добавить новые) + +**Interfaces:** +- Consumes: `load_line_catalog().lines(edition, form) -> tuple[TemplateLine, ...]` (Task 1). +- Produces: `ValueOut.id: uuid.UUID | None`, `LineOut.id: uuid.UUID | None`, `LineOut.origin: Literal["source", "template"]`; + `statement_out(document, meta, columns, lines, report, cross, *, edition, template)` — `template: + Sequence[TemplateLine]`. Функция `line_origin(line: FinancialStatementLine) -> Literal["source", "template"]` в + `schemas/statements.py`. + +- [ ] **Step 1: Написать падающие тесты.** В `api/tests/test_api_statements.py`: + +1) Заменить константу и тело `test_get_statement`: + +```python +MEZH_BALANCE_LINES = 37 +MEZH_BALANCE_TEMPLATE_ONLY = {"1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н) +MEZH_INCOME_TEMPLATE_ONLY = {"2420"} +``` + +```python +async def test_get_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + response = await client.get(f"/documents/{document.id}/statement") + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["edition"] == "fsbu4" + assert body["document"]["document_type"] == "BALANCE" and body["document"]["period_quarter"] == "Q3" + source = [line for line in body["lines"] if line["origin"] == "source"] + template = [line for line in body["lines"] if line["origin"] == "template"] + assert len(source) == MEZH_BALANCE_LINES and all(line["id"] for line in source) + assert {line["line_code"] for line in template} == MEZH_BALANCE_TEMPLATE_ONLY + assert all(line["id"] is None for line in template) + codes = [line["line_code"] for line in body["lines"]] + assert codes == sorted(codes) + columns = len(body["columns"]) + assert all(len(line["values"]) == columns for line in body["lines"]) + assert body["checks"]["failed"] == [] and body["checks"]["accepted"] is None + assert set(body["header"]["inn"]) == {"value", "original_value", "confidence", "verified", "modified"} +``` + +2) В `test_get_statement_shape_and_decimal_strings` заменить строку проверки ключей строки на: + +```python + assert set(line) == {"id", "origin", "line_code", "line_code_confidence", "line_name", "line_name_confidence", "values"} +``` + +3) Добавить хелперы и тесты после `test_get_income_statement`: + +```python +async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: + return ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) + ).scalar_one() + + +async def drop_line(session: AsyncSession, document: Document, line_code: str) -> dict[int, Decimal | None]: + """Удалить строку формы в БД теста (как будто источник её потерял); вернуть её суммы по `column_index`.""" + meta = await _meta(session, document) + line = ( + await session.execute( + select(FinancialStatementLine).where( + FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code + ) + ) + ).scalar_one() + rows = ( + await session.execute( + select(FinancialStatementColumn.column_index, FinancialStatementValue.amount) + .join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id) + .where(FinancialStatementValue.line_id == line.id) + ) + ).all() + await session.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id == line.id)) + await session.execute(delete(FinancialStatementLine).where(FinancialStatementLine.id == line.id)) + await session.flush() + return dict(rows) + + +async def drop_value(session: AsyncSession, document: Document, line_code: str, column_index: int) -> None: + """Удалить одну ячейку строки в БД теста.""" + meta = await _meta(session, document) + line_id = ( + await session.execute( + select(FinancialStatementLine.id).where( + FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code + ) + ) + ).scalar_one() + column_id = ( + await session.execute( + select(FinancialStatementColumn.id).where( + FinancialStatementColumn.meta_id == meta.id, FinancialStatementColumn.column_index == column_index + ) + ) + ).scalar_one() + await session.execute( + delete(FinancialStatementValue).where( + FinancialStatementValue.line_id == line_id, FinancialStatementValue.column_id == column_id + ) + ) + await session.flush() + + +async def db_line_count(session: AsyncSession, document: Document) -> int: + meta = await _meta(session, document) + return len( + (await session.execute(select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta.id))).all() + ) + + +async def test_get_income_statement_template_lines( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir, MEZH_INCOME) + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{document.id}/statement")).json() + template = {line["line_code"] for line in body["lines"] if line["origin"] == "template"} + assert template == MEZH_INCOME_TEMPLATE_ONLY + + +async def test_get_completes_lost_line_without_writing( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Строка, потерянная источником (1700), достроена из справочника; статус и проверки — как без достройки.""" + document = await form(seeded, fixtures_dir) + await drop_line(seeded, document, "1700") + lines_in_db = await db_line_count(seeded, document) + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{document.id}/statement")).json() + line = next(line for line in body["lines"] if line["line_code"] == "1700") + assert line["id"] is None and line["origin"] == "template" and line["line_name"] == "БАЛАНС" + assert line["line_code_confidence"] is None and line["line_name_confidence"] is None + assert len(line["values"]) == len(body["columns"]) + assert all( + v["id"] is None + and v["amount"] is None + and v["is_recognized"] is None + and v["is_modified"] is False + and v["original_amount"] is None + and v["confidence"] is None + for v in line["values"] + ) + assert [v["column_index"] for v in line["values"]] == sorted(c["column_index"] for c in body["columns"]) + relations = {(f["relation"], f["column_index"]) for f in body["checks"]["failed"]} + # цель 1700 не проверяется; «1600 = 1700» (цель 1600 есть, 1700 = 0) нарушено — как и без достройки + assert not any(relation.startswith("1700 =") for relation, _ in relations) + assert {index for relation, index in relations if relation == "1600 = 1700"} == {0, 1, 2} + assert body["document"]["status"] == document.status.value # статус пересчитывается только правкой + assert await db_line_count(seeded, document) == lines_in_db + + +async def test_get_completes_missing_cell( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + await drop_value(seeded, document, "1150", 1) + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{document.id}/statement")).json() + line = next(line for line in body["lines"] if line["line_code"] == "1150") + assert line["origin"] == "source" and len(line["values"]) == len(body["columns"]) + missing = next(v for v in line["values"] if v["column_index"] == 1) + assert missing["id"] is None and missing["amount"] is None + assert [v["column_index"] for v in line["values"]] == sorted(v["column_index"] for v in line["values"]) + + +async def test_get_without_edition_completes_cells_only( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + await drop_value(seeded, document, "1150", 1) + document.reporting_date = None + await seeded.flush() + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{document.id}/statement")).json() + assert body["edition"] is None + assert len(body["lines"]) == MEZH_BALANCE_LINES + assert all(line["origin"] == "source" for line in body["lines"]) + assert all(len(line["values"]) == len(body["columns"]) for line in body["lines"]) +``` + +В импорты файла добавить `delete` к `from sqlalchemy import select, update` → `from sqlalchemy import delete, select, update`. + +- [ ] **Step 2: Запустить — падают** + +Run: `cd arm-core && make up` (если стенд PostgreSQL/NATS ещё не поднят), затем +`cd arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q -k "get_"` +Expected: FAIL — `KeyError: 'origin'` / неверное число строк. + +- [ ] **Step 3: Реализовать в `api/src/schemas/statements.py`.** + +В импорты добавить `from typing import Annotated, Literal` (заменить текущий `from typing import Annotated`) и +`from src.domain.statement_lines import TemplateLine`. Изменить модели и сборку: + +```python +LineOrigin = Literal["source", "template"] + + +class ValueOut(BaseModel): + id: uuid.UUID | None # None — ячейка достроена в ответе, в БД её нет + column_id: uuid.UUID + column_index: int + amount: Decimal | None + string_value: str | None + confidence: Decimal | None + is_recognized: bool | None + is_modified: bool + original_amount: Decimal | None + modified_by: uuid.UUID | None + modified_at: datetime.datetime | None + + +class LineOut(BaseModel): + id: uuid.UUID | None # None — строка достроена из справочника редакции, в БД её нет + origin: LineOrigin + line_code: str | None + line_code_confidence: Decimal | None + line_name: str | None + line_name_confidence: Decimal | None + values: list[ValueOut] +``` + +Добавить функции рядом с `_value_out`: + +```python +def line_origin(line: FinancialStatementLine) -> LineOrigin: + """`template` — строка создана правкой аналитика (маппинг всегда пишет `line_code_original_value`).""" + return "template" if line.line_code_original_value is None else "source" + + +def _empty_value(column: FinancialStatementColumn) -> ValueOut: + """Ячейка, которой нет в БД (design D-3): всё пусто, не распознана и не правлена.""" + return ValueOut( + id=None, + column_id=column.id, + column_index=column.column_index, + amount=None, + string_value=None, + confidence=None, + is_recognized=None, + is_modified=False, + original_amount=None, + modified_by=None, + modified_at=None, + ) + + +def _lines_out( + columns: Sequence[FinancialStatementColumn], + lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], + template: Sequence[TemplateLine], +) -> list[LineOut]: + """Строки БД с ячейками по всем колонкам + строки справочника, которых нет в форме (design D-3), по коду.""" + column_index = {column.id: column.column_index for column in columns} + out: list[LineOut] = [] + for line, values in lines: + present = {value.column_id for value in values} + cells = [_value_out(value, column_index) for value in values] + cells += [_empty_value(column) for column in columns if column.id not in present] + cells.sort(key=lambda cell: cell.column_index) + out.append( + LineOut( + id=line.id, + origin=line_origin(line), + line_code=line.line_code, + line_code_confidence=line.line_code_confidence, + line_name=line.line_name, + line_name_confidence=line.line_name_confidence, + values=cells, + ) + ) + codes = {line.line_code for line, _ in lines} + for item in template: + if item.code in codes: + continue + out.append( + LineOut( + id=None, + origin="template", + line_code=item.code, + line_code_confidence=None, + line_name=item.name, + line_name_confidence=None, + values=[_empty_value(column) for column in columns], + ) + ) + out.sort(key=lambda line: (line.line_code is None, line.line_code or "")) + return out +``` + +В `statement_out`: добавить keyword-параметр `template: Sequence[TemplateLine]` после `edition`, убрать локальную +`column_index` (больше не нужна) и заменить блок `lines=[LineOut(...) for line, values in lines]` на +`lines=_lines_out(columns, lines, template),`. Docstring дополнить: «строки достроены по справочнику редакции (D-3)». + +- [ ] **Step 4: Реализовать в `api/src/services/statements.py`.** Импорт `from src.domain.statement_lines import +load_line_catalog`; `build_out`: + +```python + async def build_out(self, document: Document, meta: FinancialStatementMeta) -> schemas.StatementOut: + """Ответ формы по текущему состоянию БД, достроенный по справочнику редакции (design D-3; используется и + ответами правки/акцепта). В БД ничего не пишет.""" + columns, lines = await repo.load_form(self._session, meta.id) + report, cross = await self.checks_report(document, meta) + edition = edition_for(document.reporting_date) + template = load_line_catalog().lines(edition, document.document_type.value) + return schemas.statement_out( + document, meta, columns, lines, report, cross, edition=edition, template=template + ) +``` + +- [ ] **Step 5: Запустить — проходят** + +Run: `cd arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q` +Expected: PASS (все, включая старые PATCH-тесты: `_first_cell` берёт строку с суммой — это строка источника). + +- [ ] **Step 6: `make check`** + +Run: `cd arm-core && make check` +Expected: lint + unit-тесты зелёные. + +- [ ] **Step 7: Коммит** + +```bash +git -C arm-core add api/src/schemas/statements.py api/src/services/statements.py api/tests/test_api_statements.py +git -C arm-core commit -m "feat(statements): GET формы достраивает строки справочника и ячейки (#80)" +``` + +--- + +### Task 3: PATCH создаёт строку и ячейку (arm-core) + +**Модель:** sonnet (интеграция). + +**Files:** +- Modify: `arm-core/api/src/schemas/statements.py` (`CellPatch`) +- Modify: `arm-core/api/src/repositories/statements.py` (новые `form_lines`, `column_ids`, `add_line`, `add_value`; `__all__`) +- Modify: `arm-core/api/src/services/statements.py` (`_apply_cells`, вызов в `patch`) +- Test: `arm-core/api/tests/test_api_statements.py` + +**Interfaces:** +- Consumes: `load_line_catalog`, `TemplateLine` (Task 1); `drop_line`, `drop_value`, `db_line_count` (тестовые хелперы Task 2). +- Produces: `CellPatch(line_id: uuid.UUID | None = None, line_code: str | None = None, column_id: uuid.UUID, amount)` с + валидатором «ровно одно»; репозиторий: `form_lines(session, meta_id) -> list[FinancialStatementLine]`, + `column_ids(session, meta_id) -> set[uuid.UUID]`, `add_line(session, meta_id, code, name) -> FinancialStatementLine`, + `add_value(session, line_id, column_id) -> FinancialStatementValue`. + +- [ ] **Step 1: Написать падающие тесты** (в конец `api/tests/test_api_statements.py`): + +```python +async def _column(client, document_id, index: int) -> str: + body = (await client.get(f"/documents/{document_id}/statement")).json() + return next(c["id"] for c in body["columns"] if c["column_index"] == index) + + +async def _db_line(session: AsyncSession, document: Document, code: str) -> FinancialStatementLine | None: + meta = await _meta(session, document) + return ( + await session.execute( + select(FinancialStatementLine) + .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == code) + .execution_options(populate_existing=True) + ) + ).scalar_one_or_none() + + +async def _edit_events(session: AsyncSession, document: Document) -> list[AuditEvent]: + return list( + ( + await session.execute( + select(AuditEvent).where( + AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT + ) + ) + ).scalars() + ) + + +async def test_patch_creates_template_line( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + await drop_line(seeded, document, "1700") + client = await auth_client(KA_RF) + col0 = await _column(client, document.id, 0) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_code": "1700", "column_id": col0, "amount": "2371333"}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + line = next(item for item in response.json()["lines"] if item["line_code"] == "1700") + assert line["id"] is not None and line["origin"] == "template" and line["line_name"] == "БАЛАНС" + cell = next(v for v in line["values"] if v["column_id"] == col0) + assert Decimal(cell["amount"]) == Decimal("2371333") and cell["original_amount"] is None + assert cell["is_modified"] is True and cell["is_recognized"] is None and cell["modified_by"] is not None + other = [v for v in line["values"] if v["column_id"] != col0] + assert other and all(v["id"] is None for v in other) # остальные колонки не созданы + db_line = await _db_line(seeded, document, "1700") + assert db_line is not None and db_line.line_code_original_value is None and db_line.line_name_original_value is None + (event,) = await _edit_events(seeded, document) + key = f"value:{cell['id']}" + assert event.before_value == {key: None} and Decimal(event.after_value[key]) == Decimal("2371333") + + +async def test_patch_two_cells_of_new_line_create_one_line( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Review Focus 2.""" + document = await form(seeded, fixtures_dir) + await drop_line(seeded, document, "1700") + before = await db_line_count(seeded, document) + client = await auth_client(KA_RF) + col0, col1 = await _column(client, document.id, 0), await _column(client, document.id, 1) + response = await client.patch( + f"/documents/{document.id}/statement", + json={ + "values": [ + {"line_code": "1700", "column_id": col0, "amount": "1"}, + {"line_code": "1700", "column_id": col1, "amount": "2"}, + ] + }, + ) + assert response.status_code == HTTPStatus.OK, response.text + assert await db_line_count(seeded, document) == before + 1 + line = next(item for item in response.json()["lines"] if item["line_code"] == "1700") + assert sum(1 for v in line["values"] if v["id"] is not None) == 2 # noqa: PLR2004 + + +async def test_patch_repeat_line_code_after_creation_rejected( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Review Focus 3: второй «Сохранить» со старым черновиком по коду — 422, строка не задвоена.""" + document = await form(seeded, fixtures_dir) + await drop_line(seeded, document, "1700") + client = await auth_client(KA_RF) + col0 = await _column(client, document.id, 0) + item = {"line_code": "1700", "column_id": col0, "amount": "1"} + assert (await client.patch(f"/documents/{document.id}/statement", json={"values": [item]})).status_code == HTTPStatus.OK + count = await db_line_count(seeded, document) + response = await client.patch(f"/documents/{document.id}/statement", json={"values": [{**item, "amount": "5"}]}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "UNKNOWN_CELL" + assert "line_id" in response.json()["detail"]["message"] + assert await db_line_count(seeded, document) == count + + +async def test_patch_line_code_existing_or_unknown_rejected( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + col0 = await _column(client, document.id, 0) + count = await db_line_count(seeded, document) + for code in ("1150", "1105"): # 1150 есть в форме; 1105 нет в справочнике fsbu4 + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_code": code, "column_id": col0, "amount": "1"}]}, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, code + assert response.json()["detail"]["code"] == "UNKNOWN_CELL" + assert await db_line_count(seeded, document) == count + assert await _edit_events(seeded, document) == [] + + +async def test_patch_line_code_without_edition_rejected( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + await drop_line(seeded, document, "1700") + document.reporting_date = None + await seeded.flush() + client = await auth_client(KA_RF) + col0 = await _column(client, document.id, 0) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_code": "1700", "column_id": col0, "amount": "1"}]}, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "UNKNOWN_CELL" + + +async def test_patch_null_into_missing_cell_is_noop( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + await drop_line(seeded, document, "1700") + count = await db_line_count(seeded, document) + client = await auth_client(KA_RF) + col0 = await _column(client, document.id, 0) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_code": "1700", "column_id": col0, "amount": None}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + assert await db_line_count(seeded, document) == count + assert await _edit_events(seeded, document) == [] + + +async def test_patch_creates_missing_cell_of_existing_line( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + await drop_value(seeded, document, "1150", 1) + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{document.id}/statement")).json() + line = next(item for item in body["lines"] if item["line_code"] == "1150") + col1 = next(c["id"] for c in body["columns"] if c["column_index"] == 1) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": col1, "amount": "77"}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + cell = next( + v for v in next(i for i in response.json()["lines"] if i["id"] == line["id"])["values"] if v["column_id"] == col1 + ) + assert cell["id"] is not None and Decimal(cell["amount"]) == Decimal("77") and cell["original_amount"] is None + assert cell["is_modified"] is True + + +async def test_patch_bad_column_rolls_back_new_line( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Review Focus 1: одна ячейка корректна (новая строка), другая — чужая колонка: 422, строка не создана.""" + document = await form(seeded, fixtures_dir) + await drop_line(seeded, document, "1700") + count = await db_line_count(seeded, document) + client = await auth_client(KA_RF) + col0 = await _column(client, document.id, 0) + response = await client.patch( + f"/documents/{document.id}/statement", + json={ + "values": [ + {"line_code": "1700", "column_id": col0, "amount": "1"}, + {"line_code": "1700", "column_id": str(uuid.uuid4()), "amount": "2"}, + ] + }, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "UNKNOWN_CELL" + assert await db_line_count(seeded, document) == count + + +async def test_patch_cell_address_must_be_exactly_one( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + for item in ( + {"line_id": line["id"], "line_code": "1700", "column_id": cell["column_id"], "amount": "1"}, + {"column_id": cell["column_id"], "amount": "1"}, + ): + response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item]}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert isinstance(response.json()["detail"], list) # 422 валидации тела — массив + + +async def test_patch_duplicate_line_code_rejected( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + await drop_line(seeded, document, "1700") + client = await auth_client(KA_RF) + col0 = await _column(client, document.id, 0) + item = {"line_code": "1700", "column_id": col0, "amount": "1"} + response = await client.patch( + f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "DUPLICATE_CELL" +``` + +- [ ] **Step 2: Запустить — падают** + +Run: `cd arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q -k "patch"` +Expected: новые тесты FAIL (422 массивом на `line_code` — поле запрещено `extra="forbid"`), старые PASS. + +- [ ] **Step 3: `CellPatch`** в `api/src/schemas/statements.py` (импорт `model_validator` из `pydantic`): + +```python +class CellPatch(BaseModel): + """Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить. + + Ячейка адресуется ровно одним из: `line_id` — строка формы (если ячейки в колонке нет, она создаётся); `line_code` — + строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4).""" + + model_config = ConfigDict(extra="forbid") + + line_id: uuid.UUID | None = None + line_code: str | None = Field(None, max_length=10) + column_id: uuid.UUID + amount: Decimal | None = Field(max_digits=18, decimal_places=2) + + @model_validator(mode="after") + def _one_address(self) -> "CellPatch": + if (self.line_id is None) == (self.line_code is None): + raise ValueError("укажите ровно одно из line_id и line_code") + return self +``` + +- [ ] **Step 4: Репозиторий** `api/src/repositories/statements.py` — добавить в `__all__` `"add_line"`, `"add_value"`, +`"column_ids"`, `"form_lines"` (по алфавиту) и функции после `lock_cells`: + +```python +async def form_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[FinancialStatementLine]: + """Строки формы (без значений) — адресация ячеек правки по `line_id` и `line_code`.""" + stmt = select(FinancialStatementLine).where(FinancialStatementLine.meta_id == meta_id) + return list((await session.execute(stmt)).scalars()) + + +async def column_ids(session: AsyncSession, meta_id: uuid.UUID) -> set[uuid.UUID]: + """Id колонок формы.""" + stmt = select(FinancialStatementColumn.id).where(FinancialStatementColumn.meta_id == meta_id) + return set((await session.execute(stmt)).scalars()) + + +async def add_line(session: AsyncSession, meta_id: uuid.UUID, code: str, name: str) -> FinancialStatementLine: + """Строка справочника, созданная правкой аналитика: `*_original_value = NULL` — признак «не из источника».""" + line = FinancialStatementLine( + id=uuid.uuid4(), + meta_id=meta_id, + line_code=code, + line_code_original_value=None, + line_code_confidence=None, + line_code_modified=False, + line_name=name, + line_name_original_value=None, + line_name_confidence=None, + line_name_modified=False, + ) + session.add(line) + await session.flush() + return line + + +async def add_value(session: AsyncSession, line_id: uuid.UUID, column_id: uuid.UUID) -> FinancialStatementValue: + """Пустая ячейка, созданная правкой: не распознана (`is_recognized = NULL`), исходной суммы нет.""" + value = FinancialStatementValue( + id=uuid.uuid4(), + line_id=line_id, + column_id=column_id, + amount=None, + string_value=None, + confidence=None, + is_recognized=None, + is_modified=False, + original_amount=None, + ) + session.add(value) + await session.flush() + return value +``` + +- [ ] **Step 5: Сервис** `api/src/services/statements.py`. Импорты: `from src.domain.statement_lines import TemplateLine, +load_line_catalog`; `from src.models.statements import FinancialStatementLine, FinancialStatementMeta, +FinancialStatementValue`. Константа `LINE_EXISTS = "строка уже есть в форме — передайте line_id"`. Заменить +`_apply_cells` целиком: + +```python + async def _apply_cells( + self, + document: Document, + meta: FinancialStatementMeta, + body: schemas.StatementPatch, + before: dict[str, Any], + after: dict[str, Any], + ) -> None: + """Значения `body.values` (D-7 Среза 3b; design D-4 statement-template-lines): `original_amount` при первой + правке, `is_modified`, `modified_by/at`. Ячейка, которой нет в БД, создаётся при первой непустой правке; строка + по `line_code` — из справочника редакции документа. Вся адресация проверяется до первой вставки.""" + if not body.values: + return + keys = [(cell.line_id or cell.line_code, cell.column_id) for cell in body.values] + if len(keys) != len(set(keys)): + raise DomainValidationError(DUPLICATE_CELL, "ячейка указана в запросе дважды") + columns = await repo.column_ids(self._session, meta.id) + lines = await repo.form_lines(self._session, meta.id) + by_id = {line.id: line for line in lines} + codes = {line.line_code for line in lines} + template = { + item.code: item + for item in load_line_catalog().lines(edition_for(document.reporting_date), document.document_type.value) + } + targets: list[tuple[schemas.CellPatch, FinancialStatementLine | None, TemplateLine | None]] = [] + for cell in body.values: + if cell.column_id not in columns: + raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") + if cell.line_id is not None: + line = by_id.get(cell.line_id) + if line is None: + raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") + targets.append((cell, line, None)) + continue + if cell.line_code in codes: + raise DomainValidationError(UNKNOWN_CELL, LINE_EXISTS) + item = template.get(cell.line_code or "") + if item is None: + raise DomainValidationError(UNKNOWN_CELL, "строки с таким кодом нет в форме этой редакции") + targets.append((cell, None, item)) + existing = await repo.lock_cells( + self._session, meta.id, [(line.id, cell.column_id) for cell, line, _ in targets if line is not None] + ) + created: dict[str, FinancialStatementLine] = {} + now = utc_now() + for cell, line, item in targets: + value: FinancialStatementValue | None = ( + existing.get((line.id, cell.column_id)) if line is not None else None + ) + if value is None: + if cell.amount is None: + continue # пустая сумма в несуществующую ячейку — не изменение + if line is None and item is not None: + line = created.get(item.code) or await repo.add_line(self._session, meta.id, item.code, item.name) + created[item.code] = line + if line is None: + raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") + value = await repo.add_value(self._session, line.id, cell.column_id) + if value.amount == cell.amount: + continue + key = f"value:{value.id}" + before[key], after[key] = value.amount, cell.amount + if not value.is_modified and value.original_amount is None: + value.original_amount = value.amount + value.amount = cell.amount + value.is_modified = value.amount != value.original_amount + value.modified_by, value.modified_at = self._audit.user_id, now +``` + +В `patch` заменить вызов на `await self._apply_cells(document, meta, body, before, after)`. + +Почему так: вся адресация (колонка, строка, код) проверяется в первом цикле — вставок до него нет, поэтому 422 ничего не +оставляет даже без отката (Review Focus 1). Две ячейки одной новой строки делят её через `created` (Review Focus 2). +Повтор `line_code` после создания упирается в `codes` (Review Focus 3); параллельные правки одной формы сериализует +блокировка документа в `_editable`. + +- [ ] **Step 6: Запустить — проходят** + +Run: `cd arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q` +Expected: PASS. + +- [ ] **Step 7: `make check`, коммит** + +Run: `cd arm-core && make check` — зелёный. + +```bash +git -C arm-core add api/src/schemas/statements.py api/src/repositories/statements.py api/src/services/statements.py api/tests/test_api_statements.py +git -C arm-core commit -m "feat(statements): правка по line_code создаёт строку и ячейку формы (#80)" +``` + +--- + +### Task 4: Пустые созданные строки вне проверок §7 (arm-core) + +**Модель:** sonnet. + +**Files:** +- Modify: `arm-core/api/src/repositories/statements.py` (`statement_amounts`) +- Test: `arm-core/api/tests/test_api_statements.py` + +**Interfaces:** +- Consumes: хелперы тестов Task 2/3 (`drop_line`, `_column`). +- Produces: `statement_amounts` — тот же тип результата; строки с `line_code_original_value IS NULL` и только пустыми + суммами в результат не попадают. + +- [ ] **Step 1: Падающий тест** (в конец `test_api_statements.py`): + +```python +async def test_template_line_checks_follow_filled_amounts( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Review Focus 4 (spec statement-checks): заполненная в одной колонке строка проверяется по всем колонкам, + очищенная — снова не участвует.""" + document = await form(seeded, fixtures_dir) + sums = await drop_line(seeded, document, "1700") + assert sums[1] not in (None, Decimal(0)) and sums[2] not in (None, Decimal(0)) # колонки 1, 2 непустые + client = await auth_client(KA_RF) + col0 = await _column(client, document.id, 0) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_code": "1700", "column_id": col0, "amount": str(sums[0])}]}, + ) + failed = response.json()["checks"]["failed"] + relation = "1700 = 1300 + 1400 + 1500" + assert sorted(f["column_index"] for f in failed if f["relation"] == relation) == [1, 2] + assert all(f["column_index"] != 0 for f in failed) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": next(i["id"] for i in response.json()["lines"] if i["line_code"] == "1700"), + "column_id": col0, "amount": None}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + after_clear = response.json()["checks"]["failed"] + assert not any(f["relation"].startswith("1700 =") for f in after_clear) # очищенная 1700 снова не цель + assert {f["column_index"] for f in after_clear if f["relation"] == "1600 = 1700"} == {0, 1, 2} +``` + +Строки соотношений — из `api/rules/statement_checks.yaml`: `"1700 = 1300 + 1400 + 1500"`, `"1600 = 1700"` (обе жёсткие). +Суммы 1700 Межениновской: колонки 0/1/2 — 2186927.83 / 2056189.00 / 2260179.00. После заполнения колонки 0 нарушения +`1700 = …` и `1600 = 1700` остаются в колонках 1 и 2 (там 1700 пустая = 0). + +- [ ] **Step 2: Запустить — падает** + +Run: `cd arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q -k template_line_checks` +Expected: FAIL на последнем assert — очищенная 1700 (сумма `None` = 0) даёт нарушение в обеих колонках. + +- [ ] **Step 3: Реализация** — заменить тело `statement_amounts` после выборки колонок: + +```python + rows = ( + await session.execute( + select( + FinancialStatementLine.line_code, + FinancialStatementLine.line_code_original_value, + FinancialStatementValue.column_id, + FinancialStatementValue.amount, + ) + .join(FinancialStatementValue, FinancialStatementValue.line_id == FinancialStatementLine.id) + .where(FinancialStatementLine.meta_id == meta_id) + .order_by(FinancialStatementLine.line_code) + ) + ).all() + by_code: dict[str, dict[int, Decimal | None]] = {} + added: set[str] = set() # строки, созданные правкой аналитика (design D-5) + for code, original, column_id, amount in rows: + by_code.setdefault(code or "", {})[index_by_id[column_id]] = amount + if original is None: + added.add(code or "") + lines = [ + (code, amounts) + for code, amounts in by_code.items() + if code not in added or any(amount is not None for amount in amounts.values()) + ] + return [index for _, index in columns], lines +``` + +Docstring дополнить: «Созданные правкой строки (`line_code_original_value IS NULL`) без единой суммы не участвуют +(spec statement-checks, design D-5).» + +- [ ] **Step 4: Запустить — проходит; `make check`** + +Run: `cd arm-core/api && .venv/bin/pytest tests/test_api_statements.py tests/test_form_confirmable.py tests/test_auto_confirm.py -q` +затем `cd arm-core && make check`. Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git -C arm-core add api/src/repositories/statements.py api/tests/test_api_statements.py +git -C arm-core commit -m "feat(statements): пустые созданные строки не участвуют в проверках §7 (#80)" +``` + +--- + +### Task 5: Документация arm-core и PR (arm-core) + +**Модель:** sonnet. + +**Files:** +- Modify: `arm-core/README.md` (раздел про форму ББ/ОФР ~стр. 540–580; таблица «Ошибки API», строка `UNKNOWN_CELL` ~стр. 858) +- Modify: `arm-core/CLAUDE.md` (абзац «Формы ББ/ОФР (Срез 3b)») +- Modify: `arm-core/openspec/changes/statement-template-lines/design.md` (раздел решений — если исполнение добавило рулинги) + +- [ ] **Step 1: README.** В описание `GET …/statement` добавить: «Форма достраивается по справочнику строк редакции + (`api/rules/statement_lines.yaml`): недостающие строки (`id = null`, `origin = template`) и ячейки (`id = null`); в БД + не пишется.» В описание `PATCH`: «`values[]` адресует ячейку `line_id` или `line_code` (ровно одно): ячейка, которой + нет, создаётся при первой непустой правке; строка по `line_code` — только код справочника редакции, которого нет в + форме.» Строку таблицы ошибок заменить на: + `| UNKNOWN_CELL | 422 | PATCH …/statement: line_id/column_id не этой формы; line_code уже есть в форме (передайте line_id), нет в справочнике редакции или редакция не определена |` +- [ ] **Step 2: CLAUDE.md.** В абзац «Формы ББ/ОФР (Срез 3b)» после фразы о проверках §7 вставить: + «Полный состав формы — `domain/statement_lines.py` по справочнику `api/rules/statement_lines.yaml` (редакции `66n`/`fsbu4` + из шаблонов банка; копия словаря scan-gateway с ним не совпадает): `GET …/statement` достраивает строки и ячейки с + `id = null`, `PATCH` по `line_code` создаёт строку (`line_code_original_value = NULL` — «не из источника»); пустые + созданные строки не участвуют в §7 (`statement_amounts`). Решения — `openspec/changes/statement-template-lines/design.md`.» +- [ ] **Step 3: Полная проверка** + +Run: `cd arm-core && make check-full` +Expected: все цели зелёные; вывод сохранить для описания PR. Если `e2e` пропущен (нет профиля `services`) — так и +указать в PR. + +- [ ] **Step 4: Коммит, push, PR** + +```bash +git -C arm-core add README.md CLAUDE.md openspec/changes/statement-template-lines/design.md +git -C arm-core commit -m "docs: полный состав строк ББ/ОФР в README и CLAUDE.md (#80)" +git -C arm-core push -u origin feat/80-statement-template-lines +tea pulls create --login home --repo RSHB/arm-core --head feat/80-statement-template-lines --base main \ + --title "Полный состав строк ББ/ОФР на экране проверки (#80)" --description "<описание>" +``` +Описание PR: что сделано (по задачам 1–4), «Closes #80», вывод `make check-full`, список «Rulings I made» (R-1…R-8 из +design.md + рулинги исполнения), таблица находок с маркерами и статусами, `deferred` — отдельным списком. + +--- + +### Task 6: Фронт arm-web — шаблонные строки и выделение «БАЛАНС» (arm-web) + +**Модель:** sonnet (TS/React; владелец слаб во фронте — объяснения решений в PR простым языком). + +**Подготовка (до кода):** +```bash +cd /Users/andrey.epifancev/projects/awp-ca +git -C arm-web fetch origin +tea issues create --login home --repo RSHB/arm-web --title "Шаблонные строки ББ/ОФР и выделение строк «БАЛАНС»" \ + --description "Фронт к RSHB/arm-core#80: строки формы с id = null (origin = template) редактируются, правка уходит по line_code; строки 1600/1700 выделены цветом. Спека — arm-core openspec/changes/statement-template-lines." +# номер issue → +git -C arm-web worktree add -b feat/-statement-template-lines ../.wt/stmt-template/arm-web origin/test +ln -s ../../web-proto/arm-web/node_modules .wt/stmt-template/arm-web/node_modules +cd .wt/stmt-template/arm-web && openspec new change statement-template-lines +``` + +**Files:** +- Create: `arm-web/openspec/changes/statement-template-lines/{proposal.md,design.md,specs/document-review-ui/spec.md}` +- Modify: `arm-web/src/types/api.ts` (`ValueOut`, `LineOut`, `StatementPatch`) +- Modify: `arm-web/src/widgets/DocumentReview/statementGrid.ts` (+ `statementGrid.test.ts`) +- Modify: `arm-web/src/widgets/DocumentReview/formLayout.ts` (+ `formLayout.test.ts`) +- Modify: `arm-web/src/widgets/DocumentReview/StatementTable.tsx` +- Modify: `arm-web/src/widgets/DocumentReview/StatementReview.tsx` (вызов `draftToPatch`) + +**Interfaces:** +- Consumes: API Task 2/3: `LineOut.id: string | null`, `LineOut.origin: 'source' | 'template'`, `ValueOut.id: string | null`; + PATCH `values[]` элемент `{line_id, column_id, amount}` или `{line_code, column_id, amount}`. +- Produces: `GridRow { key: string; lineId: string | null; code: string | null; name: string | null; origin: 'source' | + 'template'; cells: Record }`; `cellKey(rowKey: string, columnId: string)`; + `draftToPatch(draft: Record, rows: GridRow[])`; `LayoutRow` (line) получает `grand: boolean`. + +- [ ] **Step 1: OpenSpec change arm-web.** `proposal.md` — 10–15 строк: зачем (ссылка на arm-core#80), что меняется + (шаблонные строки редактируются, правка по `line_code`, цвет строк «БАЛАНС»), Non-goals (порядок строк, правка + кода/наименования). `design.md` — решения D-6 и R-7 из design.md arm-core дословно. `specs/document-review-ui/spec.md`: + +```markdown +## MODIFIED Requirements + +### Requirement: Форма ББ/ОФР — просмотр и правка +Экран SHALL показывать `GET /documents/{id}/statement`: шапку (ИНН, дата отчёта, единицы — значение и исходное при +правке; наименование, ОКУД/ОКПО) и таблицу «строка (код, наименование) × колонки» с суммами из `values[]`, включая +строки и ячейки, достроенные сервером по справочнику редакции (`id = null`). Ячейки с `confidence < 100` или +`is_recognized = false` SHALL подсвечиваться как «требует внимания», с `is_modified = true` — как «исправлено» с +исходным значением `original_amount` в подсказке; пустая достроенная ячейка не подсвечивается. Строки «БАЛАНС» формы +`BALANCE` (коды 1600 и 1700) SHALL выделяться фоном `#f9f0ff` и жирным шрифтом; подсветка ячейки перекрывает фон строки. +Единицы (`header.units`) SHALL выводиться над таблицей. Пока документ не `CONFIRMED`, ячейка SHALL быть редактируемой: +ввод проверяется по формату суммы (до 2 знаков после точки, знак минус допустим, пустое — `null`), сохранение +отправляет `PATCH /documents/{id}/statement {values: [...]}` суммой-строкой: ячейка строки из БД — `{line_id, +column_id, amount}`, ячейка достроенной строки (`id = null`) — `{line_code, column_id, amount}`; ответ заменяет +состояние экрана. Правка шапки (ИНН, дата отчёта, единицы) SHALL отправляться тем же `PATCH` в `header`; +`inn: null` не отправляется. + +#### Scenario: Исправление ячейки +- **WHEN** аналитик меняет значение ячейки и сохраняет +- **THEN** ячейка помечена «исправлено», проверки пересчитаны по ответу сервера, акцепт снят + +#### Scenario: Заполнение строки, которой не было в распознавании +- **WHEN** у формы нет строки 1700 из источника, аналитик вводит сумму в достроенную строку 1700 и сохраняет +- **THEN** уходит `{line_code: "1700", column_id, amount}`, по ответу строка получила `id`, ячейка помечена «исправлено» + +#### Scenario: Строки «БАЛАНС» +- **WHEN** открыта форма `BALANCE` +- **THEN** строки 1600 и 1700 выделены цветом и жирным, у ОФР выделения нет + +#### Scenario: Неверный формат суммы +- **WHEN** аналитик вводит «12,3,4» +- **THEN** запрос не отправляется, поле показывает ошибку формата +``` + +Run: `openspec validate statement-template-lines --strict` — valid. + +- [ ] **Step 2: Падающие тесты vitest.** В `statementGrid.test.ts` заменить блоки `buildStatementGrid` и `draftToPatch` и +дополнить `cellFlags`: + +```ts +describe('buildStatementGrid', () => { + it('колонки по column_index, ячейки по column_id', () => { + const grid = buildStatementGrid(statement); + expect(grid.columns.map((c) => c.id)).toEqual(['c1', 'c2']); + expect(grid.rows[0]).toMatchObject({ key: 'l1', lineId: 'l1', code: '1600', name: 'Баланс', origin: 'source' }); + expect(grid.rows[0].cells.c1?.id).toBe('v1'); + expect(grid.rows[0].cells.c2?.id).toBe('v2'); + }); + it('достроенная строка: ключ по коду, lineId = null', () => { + const withTemplate = { + ...statement, + lines: [ + ...statement.lines, + { id: null, origin: 'template', line_code: '1700', line_name: 'БАЛАНС', values: [v(null, 'c1', 0, { amount: null, confidence: null, is_recognized: null })] }, + ], + } as unknown as StatementOut; + const row = buildStatementGrid(withTemplate).rows[1]; + expect(row).toMatchObject({ key: 'code-1700', lineId: null, code: '1700', origin: 'template' }); + }); +}); +``` + +(в хелпере `v` сменить тип первого параметра на `id: string | null`; в объекте `statement` строке `l1` добавить +`origin: 'source'`.) + +```ts + it('пустая достроенная ячейка не требует внимания', () => { + expect(cellFlags(v(null, 'c', 0, { amount: null, confidence: null, is_recognized: null }))).toEqual({ + attention: false, + modified: false, + }); + }); +``` + +```ts +describe('draftToPatch', () => { + const rows = buildStatementGrid({ + ...statement, + lines: [ + ...statement.lines, + { id: null, origin: 'template', line_code: '1700', line_name: 'БАЛАНС', values: [] }, + ], + } as unknown as StatementOut).rows; + it('строка из БД — line_id, достроенная — line_code', () => { + expect(draftToPatch({ [cellKey('l1', 'c1')]: '10.5', [cellKey('code-1700', 'c1')]: null }, rows)).toEqual([ + { line_id: 'l1', column_id: 'c1', amount: '10.5' }, + { line_code: '1700', column_id: 'c1', amount: null }, + ]); + }); + it('неизвестная строка — ошибка, а не тихий пропуск', () => { + expect(() => draftToPatch({ [cellKey('ghost', 'c1')]: '1' }, rows)).toThrow(); + }); +}); +``` + +В `formLayout.test.ts`: хелпер `row` дополнить `origin: 'source'` и `lineId: code ?? 'x'` (как сейчас), добавить: + +```ts +describe('formLayout — строки «БАЛАНС»', () => { + it('1600 и 1700 у баланса помечены grand, остальные итоги — нет', () => { + const layout = formLayout('BALANCE', ['1100', '1150', '1600', '1300', '1310', '1700'].map((c) => row(c))); + const grand = layout.filter((r) => r.kind === 'line' && r.grand).map((r) => (r.kind === 'line' ? r.row.code : '')); + expect(grand).toEqual(['1600', '1700']); + }); + it('у ОФР grand нет', () => { + const layout = formLayout('INCOME', ['2110', '2100', '2400'].map((c) => row(c))); + expect(layout.some((r) => r.kind === 'line' && r.grand)).toBe(false); + }); +}); +``` + +Run: `make test` — новые тесты FAIL (нет `origin`, `grand`, другая сигнатура `draftToPatch`). + +- [ ] **Step 3: Типы** `src/types/api.ts`: + +```ts +export interface ValueOut { + id: string | null; // null — ячейка достроена сервером, в БД её нет + column_id: string; + column_index: number; + amount: string | null; + string_value: string | null; + confidence: string | null; + is_recognized: boolean | null; + is_modified: boolean; + original_amount: string | null; +} +export interface LineOut { + id: string | null; // null — строка достроена по справочнику редакции + origin: 'source' | 'template'; + line_code: string | null; + line_name: string | null; + values: ValueOut[]; +} +``` + +```ts +export type CellPatch = ({ line_id: string } | { line_code: string }) & { column_id: string; amount: string | null }; +export interface StatementPatch { + header?: { inn?: string; report_date?: string; units?: string }; + values?: CellPatch[]; +} +``` + +- [ ] **Step 4: `statementGrid.ts`:** + +```ts +import type { CellPatch, ColumnOut, StatementOut, ValueOut } from '../../types/api'; + +export type GridRow = { + key: string; + lineId: string | null; + code: string | null; + name: string | null; + origin: 'source' | 'template'; + cells: Record; +}; + +/** Ключ строки: id из БД, у достроенной строки (id = null) — код; двоеточия в ключе нет (разделитель cellKey). */ +const rowKey = (id: string | null, code: string | null) => id ?? `code-${code ?? ''}`; + +export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] } { + const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index); + const rows = statement.lines.map((line) => { + const cells: Record = {}; + for (const value of line.values) { + cells[value.column_id] = value; + } + return { key: rowKey(line.id, line.line_code), lineId: line.id, code: line.line_code, name: line.line_name, origin: line.origin, cells }; + }); + return { columns, rows }; +} +``` + +```ts +export const cellKey = (rowKeyValue: string, columnId: string) => `${rowKeyValue}:${columnId}`; + +/** Черновик → тело PATCH: строка из БД адресуется line_id, достроенная — line_code (сервер создаст её). */ +export function draftToPatch(draft: Record, rows: GridRow[]): CellPatch[] { + const byKey = new Map(rows.map((row) => [row.key, row])); + return Object.entries(draft).map(([key, amount]) => { + const sep = key.lastIndexOf(':'); + const row = byKey.get(key.slice(0, sep)); + const column_id = key.slice(sep + 1); + if (!row) { + throw new Error(`строка черновика ${key} не найдена в форме`); + } + return row.lineId ? { line_id: row.lineId, column_id, amount } : { line_code: row.code ?? '', column_id, amount }; + }); +} +``` + +`cellFlags` не меняется: у пустой достроенной ячейки `confidence = null`, `is_recognized = null` — внимания нет. + +- [ ] **Step 5: `StatementReview.tsx`** — в `save` заменить `body.values = draftToPatch(values);` на +`body.values = draftToPatch(values, grid?.rows ?? []);`. + +- [ ] **Step 6: `formLayout.ts`** — тип строки и `lineRow`: + +```ts +export type LayoutRow = + | { kind: 'section'; key: string; title: string; level: 1 | 2 } + | { kind: 'line'; key: string; row: GridRow; total: boolean; grand: boolean; displayName: string; fullName: string | null }; + +// строки «БАЛАНС» по активу (1600) и пассиву (1700) выделяются цветом (просьба владельца 09.10.2026) +const GRAND_TOTALS = new Set(['1600', '1700']); + +function lineRow(row: GridRow, prefixes: string[], balance: boolean): LayoutRow { + const code = codeNum(row); + return { + kind: 'line', + key: row.key, + row, + total: code !== null && isTotal(code), + grand: balance && GRAND_TOTALS.has(row.code ?? ''), + ...stripSectionPrefixes(row.name, prefixes), + }; +} +``` + +Вызовы: в `balanceLayout` — `lineRow(r, BALANCE_PREFIXES, true)` (три места), в `incomeLayout` — `lineRow(r, [], false)`. + +- [ ] **Step 7: `StatementTable.tsx`:** + +```ts +const GRAND_BG = '#f9f0ff'; // строки «БАЛАНС»: фиолетовый не занят (серый — разделы, синий — исправлено, оранжевый — внимание) + +const lineWeight = (r: LayoutRow) => (r.kind === 'line' ? (r.grand ? 700 : r.total ? 600 : undefined) : undefined); +const lineBackground = (r: LayoutRow) => (r.kind === 'line' && r.grand ? GRAND_BG : undefined); +``` + +- колонка `name`: `onCell` для строки-линии возвращает `{ style: { background: lineBackground(r) } }` (для раздела — + как сейчас); в `render` `fontWeight: lineWeight(r)`; +- колонка `code`: `onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: lineBackground(r) } })`, + в `render` `fontWeight: lineWeight(r)`; +- колонки сумм: `style: { background: cellBackground(r.row.cells[col.id]) ?? lineBackground(r), fontWeight: lineWeight(r) }`; +- `const key = cellKey(row.key, col.id);` вместо `cellKey(row.lineId, col.id)`. + +- [ ] **Step 8: Проверки** + +Run: `cd .wt/stmt-template/arm-web && make check` +Expected: biome, `tsc --noEmit`, vitest, `rsbuild build` — зелёные. Ошибки tsc в файлах, которых задача не касалась и +которые были до неё, — не чинить, отметить в отчёте (O-маркер). + +- [ ] **Step 9: Коммит, push, PR в `test`** + +```bash +git -C .wt/stmt-template/arm-web add -A src openspec/changes/statement-template-lines +git -C .wt/stmt-template/arm-web commit -m "feat(review): шаблонные строки ББ/ОФР и выделение строк «БАЛАНС» (#)" +git -C .wt/stmt-template/arm-web push -u origin feat/-statement-template-lines +tea pulls create --login home --repo RSHB/arm-web --head feat/-statement-template-lines --base test \ + --title "Шаблонные строки ББ/ОФР и выделение «БАЛАНС» (#)" --description "<описание>" +``` +Описание: «Closes #», зависит от RSHB/arm-core PR (Task 5), объяснение решений простым языком (почему ключ строки по +коду, почему тело PATCH собирается по строкам, почему фиолетовый), вывод `make check`, скриншоты — после Task 8. + +--- + +### Task 7: Документация docs (arm-ka-docs) + +**Модель:** sonnet. + +**Подготовка:** +```bash +cd /Users/andrey.epifancev/projects/awp-ca +git -C docs fetch origin +tea issues create --login home --repo RSHB/arm-ka-docs --title "Полный состав строк ББ/ОФР: правило §7, OQ-150, гайды" \ + --description "Документация к RSHB/arm-core#80 и arm-web#." +# номер → +rm .wt/stmt-template/docs # это симлинк на main-checkout; вместо него — worktree +git -C docs worktree add -b feat/-statement-template-lines ../.wt/stmt-template/docs origin/main +``` + +**Files:** +- Modify: `docs/context/05-domain-rules.md` (§7 абзац «Ядро (Срез 3b, реализовано)»; §11 «Маппинг (Срез 3)» — пометка + про строки) +- Modify: `docs/context/07-open-questions.md` (OQ-150) +- Modify: `docs/dev/frontend-api-guide.md` (строка `PATCH …/statement` ~стр. 380, раздел формы, строка ошибок ~595) +- Modify: `docs/dev/api-contract-slice-3b.md` (~стр. 35 и 80) +- Modify: `docs/dev/qa-guide.md` (F8 ~стр. 542 + новая строка проверки; §8 UI-чек-лист — новые пункты) + +- [ ] **Step 1: §7.** После абзаца «Ядро (Срез 3b, реализовано)…» добавить абзац: + «**Полный состав формы (arm-core#80, решение владельца 09.10.2026).** Экран проверки показывает все строки формы + редакции документа (`edition`): справочник `arm-core/api/rules/statement_lines.yaml` (66н и ФСБУ 4/2023, ББ и ОФР, из + шаблонов банка `fixtures/statements-xlsx/templates/`). Строки и ячейки, которых нет в источнике, ядро достраивает в + ответе (`id = null`, `origin = template`) и в БД не пишет; первая непустая правка создаёт строку + (`line_code_original_value = NULL` — признак «не из источника») и ячейку как правку аналитика (аудит + `RECOGNIZED_DATA_EDIT`). Пустая добавленная строка не влияет на статус, проверки §7 и автоподтверждение; созданная + правкой строка участвует в проверках, пока у неё есть хоть одна сумма. Без редакции (нет даты периода) строки не + достраиваются. Строки «БАЛАНС» (1600, 1700) на экране выделяются цветом.» +- [ ] **Step 2: §11.** В пункт «`financial_statement_line` (`cells[1]` код, `cells[0]` имя; только строки с кодом)» + дописать: «; недостающие строки формы ядро достраивает на экране по справочнику редакции, §7». +- [ ] **Step 3: OQ-150** — в колонку статуса дописать: «**09.10.2026, решение владельца:** состав `fsbu4` (и `66n`) для + ядра принят из шаблонов банка — `arm-core/api/rules/statement_lines.yaml` (arm-core#80); словарь scan-gateway — follow-up, + копии расходятся (66н scan-gateway — словарь старого ядра).» +- [ ] **Step 4: Гайды.** frontend-api-guide и api-contract-slice-3b: `values?: [{line_id | line_code, column_id, + amount}]`; в описании `StatementOut` — `lines[].origin`, `id = null` у достроенных строк и ячеек; `UNKNOWN_CELL` — новые + случаи (код уже есть → передайте `line_id`; код вне справочника; нет редакции). qa-guide: в F8 добавить случай + `{"values":[{"line_code":"1105", …}]}` → 422 `UNKNOWN_CELL`; новая строка: «удалить строку 1700 нельзя через API — + проверить на документе без 1700 (РБ 1 кв. 2025 до OQ-156 или вручную): строка видна пустой, ввод суммы → «Сохранить» → + строка получила id, проверки пересчитаны; очистка → строка снова пустая, нарушений по ней нет»; §8 UI-чек-лист: + «строки 1600/1700 выделены фиолетовым и жирным», «пустые достроенные строки редактируются и не подсвечены». +- [ ] **Step 5: Коммит, push, PR** + +```bash +git -C .wt/stmt-template/docs add context dev +git -C .wt/stmt-template/docs commit -m "docs: полный состав строк ББ/ОФР — §7, OQ-150, гайды (#)" +git -C .wt/stmt-template/docs push -u origin feat/-statement-template-lines +tea pulls create --login home --repo RSHB/arm-ka-docs --head feat/-statement-template-lines --base main \ + --title "Полный состав строк ББ/ОФР: §7, OQ-150, гайды (#)" --description "Closes #. К RSHB/arm-core#80, arm-web#." +``` + +--- + +### Task 8: Проверка на стенде и сверка с макетом (выполняет сессия, не SDD-контроллер) + +**Модель:** сессия (Playwright, стенд владельца). + +- [ ] **Step 1:** Поднять arm-core из `.wt/stmt-template/arm-core` поверх БД стенда `.wt/web-proto` (порядок — `docs/dev/qa-guide.md` + §4; не пересоздавать стенд без согласия владельца), dev-сервер arm-web из `.wt/stmt-template/arm-web` (`make start`). +- [ ] **Step 2:** Открыть ББ «Русского бисквита» за 1 кв. 2025 и ББ 2024 Q3 (aep_6): все строки формы на месте, пустые + достроенные строки без подсветки, 1600/1700 — фиолетовые и жирные. Скриншоты 1600×1000 в `awp-ca/.playwright-mcp/`, + сравнить с `docs/mockup/screens/41-document-check.png` и `41-02-document-check-edit.png`. +- [ ] **Step 3:** Сценарий: «Редактировать» → ввести сумму в пустую строку (например, 1530 у 66н или 1215 у fsbu4) → + «Сохранить» → ячейка «исправлено», проверки пересчитаны; очистить → строка снова пустая; «Подтвердить данные» работает. +- [ ] **Step 4:** Расхождения с макетом и находки — в отчёт с маркерами `O-n`; скриншоты — в PR arm-web. + +## Порядок исполнения и PR + +Task 1 → 2 → 3 → 4 → 5 (PR arm-core) → 6 (PR arm-web в `test`) → 7 (PR docs) → 8 (сессия). Финальное ревью всей ветки +(opus) — после Task 7, по трём PR. Мерж — только по команде владельца. При «закрывай»: архив OpenSpec в arm-core (через PR, +как прошлые) и в arm-web (в `test`), follow-up «Follow-ups from #80» с пунктом «заполнить `fsbu4` в словаре scan-gateway и +свести копии справочника» (в RSHB/arm-scan-gateway) и прочими `deferred`. diff --git a/openspec/changes/statement-template-lines/design.md b/openspec/changes/statement-template-lines/design.md index 1fa5976..560ad7f 100644 --- a/openspec/changes/statement-template-lines/design.md +++ b/openspec/changes/statement-template-lines/design.md @@ -101,8 +101,8 @@ pydantic: коды — четыре цифры, без повторов внут Гонки двух правок одной формы исключены блокировкой документа: второй запрос ждёт и видит строку, созданную первым (получит 422 «передайте line_id» и перечитает форму). Уникальный индекс `(meta_id, line_code)` — страховка. -Аудит — одно `RECOGNIZED_DATA_EDIT` на запрос, как сейчас; в `before/after` ячейки созданной строки ключ — -`line_code` + `column_index`, `before.amount = null`. +Аудит — одно `RECOGNIZED_DATA_EDIT` на запрос, как сейчас; ключ созданной ячейки — тот же `value:` (id новой +ячейки), `before = null`. ### D-5. Пустые шаблонные строки вне проверок и статуса Достроенные строки в БД нет — `statement_amounts`, `value_flags`, `has_lines`, `_line_amount` (межформенная) их не @@ -118,10 +118,10 @@ pydantic: коды — четыре цифры, без повторов внут (`is_recognized = null`), введённое значение подсвечивается как правка. `formLayout` уже раскладывает строки по разделам — меняется только то, что разделы теперь всегда полные. **Строки «БАЛАНС» (1600 по активу, 1700 по пассиву) выделяются цветом** (просьба владельца 09.10.2026: сейчас итог баланса сливается с обычными строками и «Итого по разделу»): фон -всей строки — светло-синий (`#e6f4ff`, токен antd `colorPrimaryBg`; отличается от серого фона заголовков разделов), -шрифт 700 у наименования, кода и сумм. Подсветка ячейки «требует проверки» / «правлено» перекрывает фон строки — +всей строки — светло-фиолетовый (`#f9f0ff`, purple-1 палитры antd): серый `#f2f4f7` уже у заголовков разделов, +светло-синий `#e6f4ff` — «исправлено», оранжевый `#fff7e6` — «требует проверки»; шрифт 700 у наименования, кода и сумм. Подсветка ячейки «требует проверки» / «правлено» перекрывает фон строки — сигнал о данных важнее оформления. Признак — `formLayout` (`LayoutRow.grand: true` для 1600/1700 у `BALANCE`), цвет — -`StatementTable`. Сверка с макетом [41] (PNG `docs/mockup/screens/`) в Playwright и +`StatementTable`. В arm-web — свой OpenSpec change `statement-template-lines` (дельта `document-review-ui`). Сверка с макетом [41] (PNG `docs/mockup/screens/`) в Playwright и ручной прогон сценария: пустая строка → ввод → «Сохранить» → проверки пересчитаны. ## Risks / Trade-offs @@ -144,4 +144,5 @@ pydantic: коды — четыре цифры, без повторов внут | R-4 | 09.10.2026 | Без редакции (нет даты периода) строки не достраиваются, недостающие ячейки — достраиваются | Не из чего выбрать состав; дата периода нужна и для подтверждения (`PERIOD_REQUIRED`) — после её правки строки появятся | Аналитик не видит пустых строк, пока не введёт дату отчёта | | R-5 | 09.10.2026 | `line_code` для кода, уже существующего в форме, — 422 `UNKNOWN_CELL`, а не тихая правка существующей строки | Один способ адресации существующей ячейки (`line_id`), второй запрос в гонке не перезапишет чужую правку молча | Лишний круг фронта: перечитать форму и повторить | | R-6 | 09.10.2026 | Ячейка, которой нет у существующей строки, создаётся при правке по `line_id` | Та же проблема, что с отсутствующей строкой; отдельного API не требует | — | -| R-7 | 09.10.2026 | Цвет строк «БАЛАНС» — светло-синий `#e6f4ff` + жирный 700; подсветка ячейки перекрывает фон строки; ОФР не выделяется | Владелец просил выделить баланс по активу и пассиву, цвет не задал; в макете [41] строки «БАЛАНС» не видно; синий не спорит с серым фоном разделов и жёлтым «требует проверки» | Цвет не понравится — правка одной константы | +| R-7 | 09.10.2026 | Цвет строк «БАЛАНС» — светло-фиолетовый `#f9f0ff` + жирный 700; подсветка ячейки перекрывает фон строки; ОФР не выделяется | Владелец просил выделить баланс по активу и пассиву, цвет не задал; в макете [41] строки «БАЛАНС» не видно; фиолетовый не занят: серый — разделы, синий — «исправлено», оранжевый — «требует проверки» | Цвет не понравится — правка одной константы | +| R-8 | 09.10.2026 | `has_lines` (условие D-6 «в форме есть строки») учитывает и строки, созданные правкой | Форму, у которой источник не дал строк (не найдена колонка кодов), аналитик может заполнить вручную — это его данные, как любая правка; статус всё равно требует пройденных проверок и завершённого задания | Документ без строк источника станет `RECOGNIZED` после ручного заполнения — ожидаемо | diff --git a/openspec/changes/statement-template-lines/specs/documents-api/spec.md b/openspec/changes/statement-template-lines/specs/documents-api/spec.md index 98b9e80..44511f7 100644 --- a/openspec/changes/statement-template-lines/specs/documents-api/spec.md +++ b/openspec/changes/statement-template-lines/specs/documents-api/spec.md @@ -31,6 +31,7 @@ SHALL быть значение по каждой колонке формы — - **WHEN** у формы `BALANCE` за 2025 Q1 в БД нет строки 1700 (OCR склеил «1500\n1700» до OQ-156) - **THEN** в `lines` есть 1700: `id = null`, `origin = template`, `line_name = "БАЛАНС"`, значения по всем колонкам `id = null`, `amount = null`, `is_recognized = null`; статус документа и `checks` — те же, что без достройки + (`1600 = 1700` нарушено: 1700 отсутствует и считается 0) #### Scenario: Недостающая ячейка - **WHEN** у строки 1150 в БД есть значение только для колонки 0, а у формы две колонки diff --git a/openspec/changes/statement-template-lines/specs/statement-checks/spec.md b/openspec/changes/statement-template-lines/specs/statement-checks/spec.md index 6d38e0a..2bbf782 100644 --- a/openspec/changes/statement-template-lines/specs/statement-checks/spec.md +++ b/openspec/changes/statement-template-lines/specs/statement-checks/spec.md @@ -33,7 +33,8 @@ relation, expected, actual}`; MUST NOT храниться в БД. #### Scenario: Пустая шаблонная строка не проверяется - **WHEN** в форме нет строки 1700 (в ответе она достроена пустой), 1300 + 1400 + 1500 = 2 371 333 в колонке 0 -- **THEN** `1700 = 1300 + 1400 + 1500` и `1600 = 1700` не проверяются, `failed` — как без достройки +- **THEN** соотношения с целью 1700 (`1700 = 1300 + 1400 + 1500`) не проверяются; `1600 = 1700` (цель 1600 есть в + форме, 1700 = 0) нарушено — как и без достройки: потерянная строка «БАЛАНС» сама по себе даёт `NEEDS_CHECK` #### Scenario: Заполненная шаблонная строка проверяется - **WHEN** аналитик заполнил 1700 = 2 371 333 только в колонке 0, а в колонке 1 1300 + 1400 + 1500 = 2 000 000 -- 2.47.2 From 381c373467602705f3c57c3b38c3ffa0052ab2db Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 11:28:03 +0400 Subject: [PATCH 04/11] =?UTF-8?q?plan:=20=D0=B0=D0=B1=D1=81=D0=BE=D0=BB?= =?UTF-8?q?=D1=8E=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=83=D1=82=D0=B8=20wor?= =?UTF-8?q?ktree=20=D0=B2=20git-=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=B0?= =?UTF-8?q?=D1=85=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Относительный `git -C arm-core` из корня workspace указывал на main-checkout (O-1 первого запуска контроллера). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019qoydUVv8g1qHxc123u2RR --- .../2026-10-09-statement-template-lines.md | 78 ++++++++++--------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/docs/superpowers/plans/2026-10-09-statement-template-lines.md b/docs/superpowers/plans/2026-10-09-statement-template-lines.md index abfae31..7356688 100644 --- a/docs/superpowers/plans/2026-10-09-statement-template-lines.md +++ b/docs/superpowers/plans/2026-10-09-statement-template-lines.md @@ -33,6 +33,10 @@ - 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); хуки не обходить — при отказе хука остановиться и сообщить. @@ -141,7 +145,7 @@ def test_invalid_catalog_is_value_error(tmp_path: Path, body: str, message: str) - [ ] **Step 2: Запустить — падает** -Run: `cd arm-core/api && .venv/bin/pytest tests/test_domain_statement_lines.py -q` +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` (точно этот текст): @@ -354,19 +358,19 @@ def load_line_catalog(path: Path | None = None) -> LineCatalog: - [ ] **Step 5: Запустить — проходит** -Run: `cd arm-core/api && .venv/bin/pytest tests/test_domain_statement_lines.py -q` +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 arm-core && make lint` +Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make lint` Expected: ruff и mypy без ошибок (длинные строки yaml линтер не проверяет). - [ ] **Step 7: Коммит** ```bash -git -C arm-core add api/rules/statement_lines.yaml api/src/domain/statement_lines.py api/tests/test_domain_statement_lines.py -git -C arm-core commit -m "feat(statements): справочник строк форм ББ/ОФР по редакциям (#80)" +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 сессии.) @@ -564,8 +568,8 @@ async def test_get_without_edition_completes_cells_only( - [ ] **Step 2: Запустить — падают** -Run: `cd arm-core && make up` (если стенд PostgreSQL/NATS ещё не поднят), затем -`cd arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q -k "get_"` +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`.** @@ -691,19 +695,19 @@ load_line_catalog`; `build_out`: - [ ] **Step 5: Запустить — проходят** -Run: `cd arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q` +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 arm-core && 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 arm-core add api/src/schemas/statements.py api/src/services/statements.py api/tests/test_api_statements.py -git -C arm-core commit -m "feat(statements): GET формы достраивает строки справочника и ячейки (#80)" +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)" ``` --- @@ -952,7 +956,7 @@ async def test_patch_duplicate_line_code_rejected( - [ ] **Step 2: Запустить — падают** -Run: `cd arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q -k "patch"` +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`): @@ -1115,16 +1119,16 @@ FinancialStatementValue`. Константа `LINE_EXISTS = "строка уже - [ ] **Step 6: Запустить — проходят** -Run: `cd arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q` +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 arm-core && make check` — зелёный. +Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-core && make check` — зелёный. ```bash -git -C arm-core add api/src/schemas/statements.py api/src/repositories/statements.py api/src/services/statements.py api/tests/test_api_statements.py -git -C arm-core commit -m "feat(statements): правка по line_code создаёт строку и ячейку формы (#80)" +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)" ``` --- @@ -1180,7 +1184,7 @@ async def test_template_line_checks_follow_filled_amounts( - [ ] **Step 2: Запустить — падает** -Run: `cd arm-core/api && .venv/bin/pytest tests/test_api_statements.py -q -k template_line_checks` +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` после выборки колонок: @@ -1218,14 +1222,14 @@ Docstring дополнить: «Созданные правкой строки ( - [ ] **Step 4: Запустить — проходит; `make check`** -Run: `cd arm-core/api && .venv/bin/pytest tests/test_api_statements.py tests/test_form_confirmable.py tests/test_auto_confirm.py -q` -затем `cd arm-core && make check`. Expected: PASS. +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 arm-core add api/src/repositories/statements.py api/tests/test_api_statements.py -git -C arm-core commit -m "feat(statements): пустые созданные строки не участвуют в проверках §7 (#80)" +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)" ``` --- @@ -1252,16 +1256,16 @@ git -C arm-core commit -m "feat(statements): пустые созданные с созданные строки не участвуют в §7 (`statement_amounts`). Решения — `openspec/changes/statement-template-lines/design.md`.» - [ ] **Step 3: Полная проверка** -Run: `cd arm-core && make check-full` +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 arm-core add README.md CLAUDE.md openspec/changes/statement-template-lines/design.md -git -C arm-core commit -m "docs: полный состав строк ББ/ОФР в README и CLAUDE.md (#80)" -git -C arm-core push -u origin feat/80-statement-template-lines +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 "<описание>" ``` @@ -1277,13 +1281,13 @@ design.md + рулинги исполнения), таблица находок **Подготовка (до кода):** ```bash cd /Users/andrey.epifancev/projects/awp-ca -git -C arm-web fetch origin +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 arm-web worktree add -b feat/-statement-template-lines ../.wt/stmt-template/arm-web origin/test +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 .wt/stmt-template/arm-web && openspec new change statement-template-lines +cd /Users/andrey.epifancev/projects/awp-ca/.wt/stmt-template/arm-web && openspec new change statement-template-lines ``` **Files:** @@ -1545,16 +1549,16 @@ const lineBackground = (r: LayoutRow) => (r.kind === 'line' && r.grand ? GRAND_B - [ ] **Step 8: Проверки** -Run: `cd .wt/stmt-template/arm-web && make check` +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 .wt/stmt-template/arm-web add -A src openspec/changes/statement-template-lines -git -C .wt/stmt-template/arm-web commit -m "feat(review): шаблонные строки ББ/ОФР и выделение строк «БАЛАНС» (#)" -git -C .wt/stmt-template/arm-web push -u origin feat/-statement-template-lines +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 "<описание>" ``` @@ -1570,12 +1574,12 @@ tea pulls create --login home --repo RSHB/arm-web --head feat/-statement-temp **Подготовка:** ```bash cd /Users/andrey.epifancev/projects/awp-ca -git -C docs fetch origin +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 docs worktree add -b feat/-statement-template-lines ../.wt/stmt-template/docs origin/main +git -C /Users/andrey.epifancev/projects/awp-ca/docs worktree add -b feat/-statement-template-lines ../.wt/stmt-template/docs origin/main ``` **Files:** @@ -1610,9 +1614,9 @@ git -C docs worktree add -b feat/-statement-template-lines ../.wt/stmt-templa - [ ] **Step 5: Коммит, push, PR** ```bash -git -C .wt/stmt-template/docs add context dev -git -C .wt/stmt-template/docs commit -m "docs: полный состав строк ББ/ОФР — §7, OQ-150, гайды (#)" -git -C .wt/stmt-template/docs push -u origin feat/-statement-template-lines +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#." ``` -- 2.47.2 From 438752f035854434869d435ee70f5c0a32f9783a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 11:56:27 +0400 Subject: [PATCH 05/11] =?UTF-8?q?feat(statements):=20=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=20=D1=84=D0=BE=D1=80=D0=BC=20=D0=91=D0=91/=D0=9E?= =?UTF-8?q?=D0=A4=D0=A0=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=D0=BC=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/rules/statement_lines.yaml | 132 +++++++++++++++++++++++ api/src/domain/statement_lines.py | 66 ++++++++++++ api/tests/test_domain_statement_lines.py | 72 +++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 api/rules/statement_lines.yaml create mode 100644 api/src/domain/statement_lines.py create mode 100644 api/tests/test_domain_statement_lines.py diff --git a/api/rules/statement_lines.yaml b/api/rules/statement_lines.yaml new file mode 100644 index 0000000..ac0e1be --- /dev/null +++ b/api/rules/statement_lines.yaml @@ -0,0 +1,132 @@ +# Справочник строк форм ББ/ОФР по редакциям: полный состав формы для экрана проверки (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: "Разводненная прибыль (убыток) на акцию, руб. коп."} diff --git a/api/src/domain/statement_lines.py b/api/src/domain/statement_lines.py new file mode 100644 index 0000000..d1ef2a4 --- /dev/null +++ b/api/src/domain/statement_lines.py @@ -0,0 +1,66 @@ +"""Справочник строк форм ББ/ОФР по редакциям — полный состав формы для экрана проверки (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 diff --git a/api/tests/test_domain_statement_lines.py b/api/tests/test_domain_statement_lines.py new file mode 100644 index 0000000..32b4542 --- /dev/null +++ b/api/tests/test_domain_statement_lines.py @@ -0,0 +1,72 @@ +"""Справочник строк форм ББ/ОФР по редакциям (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) -- 2.47.2 From 3a543b5cd1991940d4ee772f02007fbc91ba7857 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 12:00:26 +0400 Subject: [PATCH 06/11] =?UTF-8?q?feat(statements):=20GET=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D1=83=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET …/statement добавляет строки справочника редакции, которых нет в форме (id = null, origin = template), и недостающие ячейки существующих строк (id = null, все значения пусты). В БД ничего не пишется. Добавлено поле LineOut.origin (source | template). Co-Authored-By: Claude Sonnet 5.5 --- api/src/schemas/statements.py | 93 +++++++++++++++---- api/src/services/statements.py | 10 ++- api/tests/test_api_statements.py | 150 ++++++++++++++++++++++++++++++- 3 files changed, 230 insertions(+), 23 deletions(-) diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index a359f71..a957b8c 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -9,11 +9,12 @@ import datetime import uuid from collections.abc import Sequence from decimal import Decimal -from typing import Annotated +from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, StringConstraints from src.domain.statement_checks import ChecksReport, FailedCheck +from src.domain.statement_lines import TemplateLine from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter from src.models.files import Document from src.models.statements import ( @@ -56,8 +57,11 @@ class ColumnOut(BaseModel): is_current: bool | None +LineOrigin = Literal["source", "template"] + + class ValueOut(BaseModel): - id: uuid.UUID + id: uuid.UUID | None # None — ячейка достроена в ответе, в БД её нет column_id: uuid.UUID column_index: int amount: Decimal | None @@ -71,7 +75,8 @@ class ValueOut(BaseModel): class LineOut(BaseModel): - id: uuid.UUID + id: uuid.UUID | None # None — строка достроена из справочника редакции, в БД её нет + origin: LineOrigin line_code: str | None line_code_confidence: Decimal | None line_name: str | None @@ -146,6 +151,71 @@ def _value_out(value: FinancialStatementValue, column_index: dict[uuid.UUID, int ) +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 + + def _accepted(meta: FinancialStatementMeta) -> ChecksAcceptedOut | None: if meta.checks_accepted_at is None or meta.checks_accepted_by is None: return None @@ -163,9 +233,10 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч cross: FailedCheck | None, *, edition: str | None, + template: Sequence[TemplateLine], ) -> StatementOut: - """Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки.""" - column_index = {column.id: column.column_index for column in columns} + """Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки; строки достроены по + справочнику редакции (D-3).""" return StatementOut( document=StatementDocumentOut( id=document.id, @@ -207,17 +278,7 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч address=meta.address, ), columns=[ColumnOut.model_validate(column) for column in columns], - lines=[ - LineOut( - id=line.id, - 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=[_value_out(value, column_index) for value in values], - ) - for line, values in lines - ], + lines=_lines_out(columns, lines, template), checks=ChecksOut( failed=[_failed(check) for check in report.failed], soft_failed=[_failed(check) for check in report.soft_failed], diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 774aeb8..4e5d1ae 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -16,6 +16,7 @@ from src.common.exceptions import ConflictError, DomainValidationError, NotFound from src.domain.osv_mapping import counterparty_check from src.domain.policy import VisibilityScope from src.domain.statement_checks import ChecksReport, FailedCheck, cross_form_check, load_check_rules, run_checks +from src.domain.statement_lines import load_line_catalog from src.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, values_ok from src.models.directories import LegalEntity from src.models.enums import ( @@ -123,12 +124,13 @@ class StatementService: return await self.build_out(document, meta) 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) - return schemas.statement_out( - document, meta, columns, lines, report, cross, edition=edition_for(document.reporting_date) - ) + 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) async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]: """Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы).""" diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 03fbddd..fe4d693 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -9,7 +9,7 @@ from http import HTTPStatus from pathlib import Path import pytest -from sqlalchemy import select, update +from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_checks import CROSS_FORM_RELATION @@ -42,6 +42,8 @@ ROSTOV = "Ростовский региональный филиал" MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" MEZH_BALANCE_LINES = 37 +MEZH_BALANCE_TEMPLATE_ONLY = {"1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н) +MEZH_INCOME_TEMPLATE_ONLY = {"2420"} EVENTS_AFTER_BREAK_AND_ACCEPT = 2 @@ -124,7 +126,11 @@ async def test_get_statement(seeded: AsyncSession, fixtures_dir: Path, auth_clie body = response.json() assert body["edition"] == "fsbu4" assert body["document"]["document_type"] == "BALANCE" and body["document"]["period_quarter"] == "Q3" - assert len(body["lines"]) == MEZH_BALANCE_LINES + 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"]) @@ -154,7 +160,15 @@ async def test_get_statement_shape_and_decimal_strings( assert {"organization_name", "okud", "okpo", "okopf", "okfs", "address"} <= set(body["header"]) assert set(body["columns"][0]) == {"id", "column_index", "label", "period_date", "is_current"} line = next(line for line in body["lines"] if any(v["amount"] is not None for v in line["values"])) - assert set(line) >= {"id", "line_code", "line_code_confidence", "line_name", "line_name_confidence", "values"} + assert set(line) == { + "id", + "origin", + "line_code", + "line_code_confidence", + "line_name", + "line_name_confidence", + "values", + } value = next(v for v in line["values"] if v["amount"] is not None) assert isinstance(value["amount"], str) assert set(value) == { @@ -183,6 +197,136 @@ async def test_get_income_statement(seeded: AsyncSession, fixtures_dir: Path, au assert body["lines"] +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) + ids = await session.execute(select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta.id)) + return len(ids.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"]) + + async def test_statement_of_osv_is_conflict( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: -- 2.47.2 From 37e7f40890412e09723966d289aee3ebf6a83f49 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 12:03:59 +0400 Subject: [PATCH 07/11] =?UTF-8?q?feat(statements):=20PATCH=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D1=91=D1=82=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=20=D0=B8=20=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D1=83=20=D0=BF?= =?UTF-8?q?=D0=BE=20line=5Fcode=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CellPatch адресует ячейку ровно одним из line_id / line_code. Строка справочника редакции, которой нет в форме, создаётся при первой непустой правке (line_code_original_value = NULL — признак «не из источника»); отсутствующая ячейка существующей строки тоже создаётся. Адресация (колонка, строка, код) проверяется целиком до первой вставки, две ячейки новой строки делят одну строку, повтор line_code после создания даёт 422 UNKNOWN_CELL. Аудит — одно RECOGNIZED_DATA_EDIT на запрос, у созданной ячейки before = null. Co-Authored-By: Claude Sonnet 5.5 --- api/src/repositories/statements.py | 53 +++++++ api/src/schemas/statements.py | 16 +- api/src/services/statements.py | 76 ++++++++-- api/tests/test_api_statements.py | 226 +++++++++++++++++++++++++++++ 4 files changed, 357 insertions(+), 14 deletions(-) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index dcdafb8..cb49e5d 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -22,7 +22,11 @@ from src.models.statements import ( __all__ = [ "BATCH", + "add_line", + "add_value", + "column_ids", "delete_file_statements", + "form_lines", "get_meta", "insert_statement", "load_form", @@ -189,6 +193,55 @@ async def lock_cells( } +async def form_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[FinancialStatementLine]: + """Строки формы (без значений) — адресация ячеек правки по `line_id` и `line_code`.""" + stmt = select(FinancialStatementLine).where(FinancialStatementLine.meta_id == meta_id) + return list((await session.execute(stmt)).scalars()) + + +async def column_ids(session: AsyncSession, meta_id: uuid.UUID) -> set[uuid.UUID]: + """Id колонок формы.""" + stmt = select(FinancialStatementColumn.id).where(FinancialStatementColumn.meta_id == meta_id) + return set((await session.execute(stmt)).scalars()) + + +async def add_line(session: AsyncSession, meta_id: uuid.UUID, code: str, name: str) -> FinancialStatementLine: + """Строка справочника, созданная правкой аналитика: `*_original_value = NULL` — признак «не из источника».""" + line = FinancialStatementLine( + id=uuid.uuid4(), + meta_id=meta_id, + line_code=code, + line_code_original_value=None, + line_code_confidence=None, + line_code_modified=False, + line_name=name, + line_name_original_value=None, + line_name_confidence=None, + line_name_modified=False, + ) + session.add(line) + await session.flush() + return line + + +async def add_value(session: AsyncSession, line_id: uuid.UUID, column_id: uuid.UUID) -> FinancialStatementValue: + """Пустая ячейка, созданная правкой: не распознана (`is_recognized = NULL`), исходной суммы нет.""" + value = FinancialStatementValue( + id=uuid.uuid4(), + line_id=line_id, + column_id=column_id, + amount=None, + string_value=None, + confidence=None, + is_recognized=None, + is_modified=False, + original_amount=None, + ) + session.add(value) + await session.flush() + return value + + async def get_meta( session: AsyncSession, document_id: uuid.UUID, *, refresh: bool = False ) -> FinancialStatementMeta | None: diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index a957b8c..6ca6546 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -11,7 +11,7 @@ from collections.abc import Sequence from decimal import Decimal from typing import Annotated, Literal -from pydantic import BaseModel, ConfigDict, Field, StringConstraints +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator from src.domain.statement_checks import ChecksReport, FailedCheck from src.domain.statement_lines import TemplateLine @@ -289,14 +289,24 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч class CellPatch(BaseModel): - """Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить.""" + """Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить. + + Ячейка адресуется ровно одним из: `line_id` — строка формы (если ячейки в колонке нет, она создаётся); `line_code` — + строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4).""" model_config = ConfigDict(extra="forbid") - line_id: uuid.UUID + 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 + class HeaderPatch(BaseModel): """Отсутствующее поле не меняется; `report_date: null` — очистить дату (`model_fields_set`); `inn: null` — 422 diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 4e5d1ae..916dd8f 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -16,7 +16,7 @@ from src.common.exceptions import ConflictError, DomainValidationError, NotFound from src.domain.osv_mapping import counterparty_check from src.domain.policy import VisibilityScope from src.domain.statement_checks import ChecksReport, FailedCheck, cross_form_check, load_check_rules, run_checks -from src.domain.statement_lines import load_line_catalog +from src.domain.statement_lines import TemplateLine, load_line_catalog from src.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, values_ok from src.models.directories import LegalEntity from src.models.enums import ( @@ -29,7 +29,7 @@ from src.models.enums import ( RecognitionStatus, ) from src.models.files import Document, RecognitionJob -from src.models.statements import FinancialStatementMeta +from src.models.statements import FinancialStatementLine, FinancialStatementMeta from src.repositories import files as files_repo from src.repositories import statements as repo from src.repositories.counterparties import CounterpartyIndex @@ -39,6 +39,7 @@ from src.services.documents import CONFIRMABLE_STATUSES, DOCUMENT_NOT_CONFIRMABL DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" DOCUMENT_READONLY = "DOCUMENT_READONLY" +LINE_EXISTS = "строка уже есть в форме — передайте line_id" UNKNOWN_CELL = "UNKNOWN_CELL" DUPLICATE_CELL = "DUPLICATE_CELL" NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" @@ -156,18 +157,40 @@ class StatementService: document.updated_at = utc_now() async def _apply_cells( - self, meta: FinancialStatementMeta, body: schemas.StatementPatch, before: dict[str, Any], after: dict[str, Any] + self, + document: Document, + meta: FinancialStatementMeta, + body: schemas.StatementPatch, + before: dict[str, Any], + after: dict[str, Any], ) -> None: - """Значения `body.values` (D-7): `original_amount` при первой правке, `is_modified`, `modified_by/at`.""" - keys = [(cell.line_id, cell.column_id) for cell in body.values] + """Значения `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, "ячейка указана в запросе дважды") - cells = await repo.lock_cells(self._session, meta.id, keys) - if len(cells) != len(set(keys)): - raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") + targets = await self._resolve_cells(document, meta, body) + 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 in body.values: - value = cells[(cell.line_id, cell.column_id)] + for cell, line, item in targets: + value = existing.get((line.id, cell.column_id)) if line is not None else None + if value is None: + if cell.amount is None: + continue # пустая сумма в несуществующую ячейку — не изменение + target = line + if target is None and item is not None: + target = created.get(item.code) + if target is None: + target = created[item.code] = await repo.add_line(self._session, meta.id, item.code, item.name) + if target is None: + raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") + value = await repo.add_value(self._session, target.id, cell.column_id) if value.amount == cell.amount: continue key = f"value:{value.id}" @@ -178,6 +201,37 @@ class StatementService: value.is_modified = value.amount != value.original_amount value.modified_by, value.modified_at = self._audit.user_id, now + async def _resolve_cells( + self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch + ) -> list[tuple[schemas.CellPatch, FinancialStatementLine | None, TemplateLine | None]]: + """Адресация ячеек правки: строка формы (`line_id`) либо строка справочника редакции (`line_code`). Ничего не + вставляет — любая ошибка адресации (422) происходит до первой записи.""" + 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)) + return targets + async def _apply_header( self, document: Document, @@ -218,7 +272,7 @@ class StatementService: raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр") before: dict[str, Any] = {} after: dict[str, Any] = {} - await self._apply_cells(meta, body, before, after) + await self._apply_cells(document, meta, body, before, after) if header is not None: await self._apply_header(document, meta, header, before, after) if not after: diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index fe4d693..cc452ee 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -1060,3 +1060,229 @@ async def test_status_with_lines_but_no_columns_is_stable_after_edit( response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) assert response.status_code == HTTPStatus.OK, response.text assert response.json()["lines"] and response.json()["document"]["status"] == "RECOGNIZED" + + +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" -- 2.47.2 From 2d21a416d63207076465abbabde096b95b66438b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 12:06:34 +0400 Subject: [PATCH 08/11] =?UTF-8?q?feat(statements):=20=D1=81=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=83=D1=81=D1=82?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=B2?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=20=C2=A77=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit statement_amounts не отдаёт проверкам строки с line_code_original_value IS NULL, у которых все суммы пустые; заполненная хотя бы в одной колонке строка проверяется по всем колонкам. Co-Authored-By: Claude Sonnet 5.5 --- api/src/repositories/statements.py | 23 +++++++++++++++---- api/tests/test_api_statements.py | 36 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index cb49e5d..3d8d8de 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -127,7 +127,9 @@ async def statement_amounts( session: AsyncSession, meta_id: uuid.UUID ) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]: """Индексы колонок и `(код строки, {колонка: сумма})` по возрастанию кода — вход `run_checks`. Порядок строк - формы в БД не хранится (DBML не хранит), поэтому порядок строк — лексикографический порядок кодов.""" + формы в БД не хранится (DBML не хранит), поэтому порядок строк — лексикографический порядок кодов. + Созданные правкой строки (`line_code_original_value IS NULL`) без единой суммы не участвуют + (spec statement-checks, design D-5).""" columns = ( await session.execute( select(FinancialStatementColumn.id, FinancialStatementColumn.column_index) @@ -138,16 +140,29 @@ async def statement_amounts( index_by_id = {column_id: index for column_id, index in columns} rows = ( await session.execute( - select(FinancialStatementLine.line_code, FinancialStatementValue.column_id, FinancialStatementValue.amount) + 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]] = {} - for code, column_id, amount in rows: + 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 - return [index for _, index in columns], list(by_code.items()) + 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 async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[bool, bool | None, Decimal | None]]: diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index cc452ee..03ece4a 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -1286,3 +1286,39 @@ async def test_patch_duplicate_line_code_rejected( ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "DUPLICATE_CELL" + + +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} -- 2.47.2 From 5c330eb400621b69b836027efe3f706dbe10079c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 12:14:53 +0400 Subject: [PATCH 09/11] =?UTF-8?q?docs:=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=81=D0=BE=D1=81=D1=82=D0=B0=D0=B2=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=20=D0=91=D0=91/=D0=9E=D0=A4=D0=A0=20=D0=B2?= =?UTF-8?q?=20README=20=D0=B8=20CLAUDE.md=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: достройка GET, адресация PATCH по line_code, строка UNKNOWN_CELL. CLAUDE.md: абзац про справочник строк. design.md: рулинги R-9…R-11. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 5 ++++- README.md | 5 +++-- openspec/changes/statement-template-lines/design.md | 7 +++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 644c08e..5fe14f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,10 @@ документа. **Порядок блокировок «файл → документ»**, шапка (`meta`) читается ПОД блокировкой с `refresh=True` — иначе в сессии остаётся устаревший акцепт. Подтверждение формы (`services/documents._check_statement`) требует период (`PERIOD_REQUIRED`) и пройденные/акцептованные жёсткие проверки (`CHECKS_NOT_PASSED`, `detail.failures`); мягкие (в т. ч. межформенная 1370↔2400) - не блокируют. Комментарии к документу — `services/documents.py` (`comments`/`add_comment`, `comment(object_type='document')`, + не блокируют. Полный состав формы — `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`. Комментарии к документу — `services/documents.py` (`comments`/`add_comment`, `comment(object_type='document')`, без аудита; `comments_count` в карточках). `GET /files/{id}/content` — `services/primary_files.content` + `ObjectStorage.stream` (поток из S3, первая порция до заголовков → `FILE_CONTENT_NOT_FOUND`; сессию тело потока не держит; имя — RFC 5987, `X-Content-Type-Options: nosniff`; освобождение потока — `common/streaming.py`: `ClosingStreamingResponse` + diff --git a/README.md b/README.md index d7cf77a..1bd018f 100644 --- a/README.md +++ b/README.md @@ -568,7 +568,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл (`organization_name`, `okud`, `okpo`, …); `lines[].values[]` — суммы строкой, `is_modified`, `original_amount`; `checks` — `{failed, soft_failed, cross_form, accepted}` (нарушение — `{column_index, relation, expected, actual}`, у межформенной проверки `column_index = -1`). Не форма (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) — 409 `DOCUMENT_NOT_STATEMENT`. - Подтверждённая форма читается. + Подтверждённая форма читается. Форма достраивается по справочнику строк редакции (`api/rules/statement_lines.yaml`): недостающие строки (`id = null`, `origin = template`) и ячейки (`id = null`); в БД не пишется. - **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id, column_id, amount}]}` (до 5000 ячеек; `amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов, пробельная строка — 422, `null` — «не менять»; `inn: null` — 422 `INVALID_INN`, ИНН не очищается). Правится только неподтверждённая форма @@ -577,6 +577,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус. + `values[]` адресует ячейку `line_id` или `line_code` (ровно одно): ячейка, которой нет, создаётся при первой непустой правке; строка по `line_code` — только код справочника редакции, которого нет в форме. Запрос без изменений — 200 без аудита. Ответ — та же форма, что у `GET`. - **`POST /documents/{id}/statement/checks/accept`** — `{comment}` (1–2000 символов): акцепт нарушенных жёстких проверок (`checks_accepted_by/at/comment`). Нарушений нет — 409 `NOTHING_TO_ACCEPT`. Акцепт сбрасывается любой правкой формы (в `before_value` события аудита сброса остаётся комментарий сброшенного акцепта). @@ -855,7 +856,7 @@ email без учёта регистра проверяет только сер | `FILE_CONTENT_NOT_FOUND` | 404 | `GET /files/{id}/content`: файл есть, объекта нет в S3 (истёк или пропал) | | `DOCUMENT_NOT_STATEMENT` | 409 | `…/statement`: документ — не форма ББ/ОФР (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) | | `DOCUMENT_READONLY` | 409 | правка/акцепт формы: форма уже `CONFIRMED` (исправление — новым файлом) | -| `UNKNOWN_CELL` | 422 | `PATCH …/statement`: `line_id`/`column_id` не принадлежат этой форме | +| UNKNOWN_CELL | 422 | PATCH …/statement: line_id/column_id не этой формы; line_code уже есть в форме (передайте line_id), нет в справочнике редакции или редакция не определена | | `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды | | `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) | | `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта | diff --git a/openspec/changes/statement-template-lines/design.md b/openspec/changes/statement-template-lines/design.md index 560ad7f..c90cfd2 100644 --- a/openspec/changes/statement-template-lines/design.md +++ b/openspec/changes/statement-template-lines/design.md @@ -71,7 +71,7 @@ pydantic: коды — четыре цифры, без повторов внут есть: справочник только добавляет, ничего не убирает и не переименовывает. ### D-3. Достройка ответа -`complete_form(form_type, edition, columns, lines) -> lines'`: +Достройка (по факту — `_lines_out` в `schemas/statements.py`, R-9; логика та же): - для каждой строки из БД — недостающие ячейки по всем `columns` (`id = null`, `amount = null`, `string_value = null`, `confidence = null`, `is_recognized = null`, `is_modified = false`, `original_amount = null`, `modified_* = null`); - при `edition ≠ None` — строки справочника, кода которых нет среди строк формы: `id = null`, `line_code`, `line_name` из @@ -113,7 +113,7 @@ pydantic: коды — четыре цифры, без повторов внут `is_recognized = NULL` и `confidence = NULL` — «без сомнений», правленные — `is_modified`. ### D-6. Фронт (arm-web, PR в `test`) -`statementGrid`: ключ строки — `line.id ?? "code:" + line_code`, ключ ячейки — `rowKey:column_id`; `draftToPatch` +`statementGrid`: ключ строки — `line.id ?? "code-" + line_code` (R-10), ключ ячейки — `rowKey:column_id`; `draftToPatch` отправляет `line_id` или `line_code` по ключу. Достроенная пустая ячейка (`id = null`) без подсветки «требует проверки» (`is_recognized = null`), введённое значение подсвечивается как правка. `formLayout` уже раскладывает строки по разделам — меняется только то, что разделы теперь всегда полные. **Строки «БАЛАНС» (1600 по активу, 1700 по пассиву) выделяются @@ -146,3 +146,6 @@ pydantic: коды — четыре цифры, без повторов внут | R-6 | 09.10.2026 | Ячейка, которой нет у существующей строки, создаётся при правке по `line_id` | Та же проблема, что с отсутствующей строкой; отдельного API не требует | — | | R-7 | 09.10.2026 | Цвет строк «БАЛАНС» — светло-фиолетовый `#f9f0ff` + жирный 700; подсветка ячейки перекрывает фон строки; ОФР не выделяется | Владелец просил выделить баланс по активу и пассиву, цвет не задал; в макете [41] строки «БАЛАНС» не видно; фиолетовый не занят: серый — разделы, синий — «исправлено», оранжевый — «требует проверки» | Цвет не понравится — правка одной константы | | R-8 | 09.10.2026 | `has_lines` (условие D-6 «в форме есть строки») учитывает и строки, созданные правкой | Форму, у которой источник не дал строк (не найдена колонка кодов), аналитик может заполнить вручную — это его данные, как любая правка; статус всё равно требует пройденных проверок и завершённого задания | Документ без строк источника станет `RECOGNIZED` после ручного заполнения — ожидаемо | +| R-9 | 09.10.2026 | Достройка формы реализована функцией `_lines_out` в `schemas/statements.py`, а не `domain/statement_lines.complete_form` из D-3 | План конкретнее, поведение то же | Рефакторинг в domain позже | +| R-10 | 09.10.2026 | Ключ достроенной строки на фронте `code-`, а не `code:` из D-6 | ':' разделяет rowKey и columnId в cellKey | Нет | +| R-11 | 09.10.2026 | Адресация ячеек PATCH вынесена из `_apply_cells` в `_resolve_cells` (отклонение от кода плана ради ruff PLR0912/PLW2901); поведение и порядок проверок сохранены | Линтер | Нет | -- 2.47.2 From d5118ba3a6ceb5b34f840196ab89eaba43a3ac23 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 12:33:00 +0400 Subject: [PATCH 10/11] =?UTF-8?q?fix(web):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=20=D0=B2=D0=BE=20=D0=B2=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=84=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правка arm-web 9e89b9f перенесена в arm-core/web: id и origin у строк и ячеек формы, ключ достроенной строки code-<код>, отправка line_code, раскладка formLayout. Без неё встроенный фронт отправлял бы line_id "null" (I-1, R-12). Co-Authored-By: Claude Sonnet 5.5 --- web/src/types/api.ts | 8 ++-- .../DocumentReview/StatementReview.tsx | 2 +- .../widgets/DocumentReview/StatementTable.tsx | 24 +++++++---- .../widgets/DocumentReview/formLayout.test.ts | 19 +++++++++ web/src/widgets/DocumentReview/formLayout.ts | 24 +++++++---- .../DocumentReview/statementGrid.test.ts | 42 ++++++++++++++++--- .../widgets/DocumentReview/statementGrid.ts | 32 ++++++++++---- 7 files changed, 120 insertions(+), 31 deletions(-) diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 91f6943..2a8e074 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -121,7 +121,7 @@ export interface ColumnOut { is_current: boolean | null; } export interface ValueOut { - id: string; + id: string | null; // null — ячейка достроена сервером, в БД её нет column_id: string; column_index: number; amount: string | null; @@ -132,7 +132,8 @@ export interface ValueOut { original_amount: string | null; } export interface LineOut { - id: string; + id: string | null; // null — строка достроена по справочнику редакции + origin: 'source' | 'template'; line_code: string | null; line_name: string | null; values: ValueOut[]; @@ -167,9 +168,10 @@ export interface StatementOut { lines: LineOut[]; checks: ChecksOut; } +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?: { line_id: string; column_id: string; amount: string | null }[]; + values?: CellPatch[]; } export type CounterpartyType = 'GSK' | 'GK' | 'EXTERNAL'; diff --git a/web/src/widgets/DocumentReview/StatementReview.tsx b/web/src/widgets/DocumentReview/StatementReview.tsx index bbdafec..081509b 100644 --- a/web/src/widgets/DocumentReview/StatementReview.tsx +++ b/web/src/widgets/DocumentReview/StatementReview.tsx @@ -57,7 +57,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut } const body: StatementPatch = {}; if (Object.keys(values).length > 0) { - body.values = draftToPatch(values); + body.values = draftToPatch(values, grid?.rows ?? []); } const headerPatch: HeaderDraft = {}; for (const field of ['inn', 'report_date', 'units'] as const) { diff --git a/web/src/widgets/DocumentReview/StatementTable.tsx b/web/src/widgets/DocumentReview/StatementTable.tsx index 1d40494..f32f0f2 100644 --- a/web/src/widgets/DocumentReview/StatementTable.tsx +++ b/web/src/widgets/DocumentReview/StatementTable.tsx @@ -34,6 +34,14 @@ function cellTip(value: ValueOut): string | undefined { } const SECTION_BG = '#f2f4f7'; +const GRAND_BG = '#f9f0ff'; // строки «БАЛАНС»: фиолетовый не занят (серый — разделы, синий — исправлено, оранжевый — внимание) + +function lineWeight(r: LayoutRow): number | undefined { + if (r.kind !== 'line') return undefined; + if (r.grand) return 700; + return r.total ? 600 : undefined; +} +const lineBackground = (r: LayoutRow) => (r.kind === 'line' && r.grand ? GRAND_BG : undefined); export function StatementTable({ documentType, columns, rows, editable, draft, onDraftChange }: Props) { const layout = formLayout(documentType, rows); @@ -46,11 +54,14 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o title: 'Наименование показателя', onCell: (r) => ({ ...sectionSpan(r, true), - style: r.kind === 'section' ? { background: SECTION_BG, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 } : undefined, + style: + r.kind === 'section' + ? { background: SECTION_BG, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 } + : { background: lineBackground(r) }, }), render: (_: unknown, r: LayoutRow) => { if (r.kind === 'section') return r.title; - const text = {r.displayName || '—'}; + const text = {r.displayName || '—'}; return r.fullName ? {text} : text; }, }, @@ -59,9 +70,8 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o title: 'Код', width: 60, align: 'center' as const, - onCell: (r) => sectionSpan(r, false), - render: (_: unknown, r: LayoutRow) => - r.kind === 'line' ? {r.row.code} : null, + onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: lineBackground(r) } }), + render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? {r.row.code} : null), }, ...columns.map((col) => ({ key: col.id, @@ -71,7 +81,7 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o onCell: (r: LayoutRow) => r.kind === 'section' ? sectionSpan(r, false) - : { style: { background: cellBackground(r.row.cells[col.id]), fontWeight: r.total ? 600 : undefined } }, + : { style: { background: cellBackground(r.row.cells[col.id]) ?? lineBackground(r), fontWeight: lineWeight(r) } }, render: (_: unknown, r: LayoutRow) => { if (r.kind === 'section') return null; const row = r.row; @@ -79,7 +89,7 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o if (!value) { return null; } - const key = cellKey(row.lineId, col.id); + const key = cellKey(row.key, col.id); const tip = cellTip(value); if (!editable) { return {formatAmount(value.amount, { fraction: 'auto', empty: '-' })}; diff --git a/web/src/widgets/DocumentReview/formLayout.test.ts b/web/src/widgets/DocumentReview/formLayout.test.ts index eb6127c..3b9f745 100644 --- a/web/src/widgets/DocumentReview/formLayout.test.ts +++ b/web/src/widgets/DocumentReview/formLayout.test.ts @@ -5,6 +5,7 @@ import type { GridRow } from './statementGrid'; const row = (code: string | null, name: string | null = `строка ${code}`): GridRow => ({ key: code ?? 'x', lineId: code ?? 'x', + origin: 'source', code, name, cells: {}, @@ -85,3 +86,21 @@ describe('formLayout — ОФР, справочные строки', () => { ).toEqual(['2410', '2400 (итог)', '2900', '2910']); }); }); + +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); + }); +}); diff --git a/web/src/widgets/DocumentReview/formLayout.ts b/web/src/widgets/DocumentReview/formLayout.ts index 4415df3..fe277e3 100644 --- a/web/src/widgets/DocumentReview/formLayout.ts +++ b/web/src/widgets/DocumentReview/formLayout.ts @@ -7,7 +7,7 @@ import type { GridRow } from './statementGrid'; export type LayoutRow = | { kind: 'section'; key: string; title: string; level: 1 | 2 } - | { kind: 'line'; key: string; row: GridRow; total: boolean; displayName: string; fullName: string | null }; + | { kind: 'line'; key: string; row: GridRow; total: boolean; grand: boolean; displayName: string; fullName: string | null }; type Section = { title: string; hundred: number }; @@ -50,9 +50,19 @@ function stripSectionPrefixes(name: string | null, prefixes: string[]): { displa return rest === name.trim() || rest === '' ? { displayName: name, fullName: null } : { displayName: rest, fullName: name }; } -function lineRow(row: GridRow, prefixes: string[]): LayoutRow { +// строки «БАЛАНС» по активу (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), ...stripSectionPrefixes(row.name, prefixes) }; + return { + kind: 'line', + key: row.key, + row, + total: code !== null && isTotal(code), + grand: balance && GRAND_TOTALS.has(row.code ?? ''), + ...stripSectionPrefixes(row.name, prefixes), + }; } function balanceLayout(rows: GridRow[]): LayoutRow[] { @@ -72,12 +82,12 @@ function balanceLayout(rows: GridRow[]): LayoutRow[] { if (lines.length === 0) continue; out.push({ kind: 'section', key: `s-${s.hundred}`, title: s.title, level: 2 }); for (const r of lines) { - out.push(lineRow(r, BALANCE_PREFIXES)); + out.push(lineRow(r, BALANCE_PREFIXES, true)); used.add(r.key); } } for (const r of total) { - out.push(lineRow(r, BALANCE_PREFIXES)); + out.push(lineRow(r, BALANCE_PREFIXES, true)); used.add(r.key); } }; @@ -86,7 +96,7 @@ function balanceLayout(rows: GridRow[]): LayoutRow[] { const rest = rows.filter((r) => !used.has(r.key)); if (rest.length > 0) { out.push({ kind: 'section', key: 's-rest', title: 'Прочие строки', level: 2 }); - for (const r of rest) out.push(lineRow(r, BALANCE_PREFIXES)); + for (const r of rest) out.push(lineRow(r, BALANCE_PREFIXES, true)); } return out; } @@ -98,7 +108,7 @@ function incomeLayout(rows: GridRow[]): LayoutRow[] { const hb = Math.floor((codeNum(b) as number) / 100); return ha - hb || byFormOrder(a, b); }); - return [...sorted, ...rows.filter((r) => codeNum(r) === null)].map((r) => lineRow(r, [])); + return [...sorted, ...rows.filter((r) => codeNum(r) === null)].map((r) => lineRow(r, [], false)); } export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] { diff --git a/web/src/widgets/DocumentReview/statementGrid.test.ts b/web/src/widgets/DocumentReview/statementGrid.test.ts index a0fb130..6a84abe 100644 --- a/web/src/widgets/DocumentReview/statementGrid.test.ts +++ b/web/src/widgets/DocumentReview/statementGrid.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { StatementOut, ValueOut } from '../../types/api'; import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch } from './statementGrid'; -const v = (id: string, column_id: string, column_index: number, extra: Partial = {}): ValueOut => ({ +const v = (id: string | null, column_id: string, column_index: number, extra: Partial = {}): ValueOut => ({ id, column_id, column_index, @@ -20,17 +20,34 @@ const statement = { { id: 'c2', column_index: 1, label: '31.12.2023', period_date: null, is_current: false }, { id: 'c1', column_index: 0, label: '30.09.2024', period_date: null, is_current: true }, ], - lines: [{ id: 'l1', line_code: '1600', line_name: 'Баланс', values: [v('v2', 'c2', 1), v('v1', 'c1', 0)] }], + lines: [{ id: 'l1', origin: 'source', line_code: '1600', line_name: 'Баланс', values: [v('v2', 'c2', 1), v('v1', 'c1', 0)] }], } as unknown as StatementOut; 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: 'Баланс' }); + 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' }); + }); }); describe('cellFlags', () => { @@ -40,6 +57,12 @@ describe('cellFlags', () => { expect(cellFlags(v('a', 'c', 0)).attention).toBe(false); expect(cellFlags(v('a', 'c', 0, { confidence: null })).attention).toBe(false); }); + it('пустая достроенная ячейка не требует внимания', () => { + expect(cellFlags(v(null, 'c', 0, { amount: null, confidence: null, is_recognized: null }))).toEqual({ + attention: false, + modified: false, + }); + }); it('исправлено', () => { expect(cellFlags(v('a', 'c', 0, { is_modified: true })).modified).toBe(true); expect(cellFlags(undefined)).toEqual({ attention: false, modified: false }); @@ -47,12 +70,19 @@ describe('cellFlags', () => { }); describe('draftToPatch', () => { - it('черновик → values', () => { - expect(draftToPatch({ [cellKey('l1', 'c1')]: '10.5', [cellKey('l2', 'c1')]: null })).toEqual([ + 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_id: 'l2', column_id: 'c1', amount: null }, + { line_code: '1700', column_id: 'c1', amount: null }, ]); }); + it('неизвестная строка — ошибка, а не тихий пропуск', () => { + expect(() => draftToPatch({ [cellKey('ghost', 'c1')]: '1' }, rows)).toThrow(); + }); }); describe('applyDraftInput', () => { diff --git a/web/src/widgets/DocumentReview/statementGrid.ts b/web/src/widgets/DocumentReview/statementGrid.ts index ac12d30..50c5905 100644 --- a/web/src/widgets/DocumentReview/statementGrid.ts +++ b/web/src/widgets/DocumentReview/statementGrid.ts @@ -1,13 +1,17 @@ -import type { ColumnOut, StatementOut, StatementPatch, ValueOut } from '../../types/api'; +import type { CellPatch, ColumnOut, StatementOut, ValueOut } from '../../types/api'; export type GridRow = { key: string; - lineId: 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) => { @@ -15,7 +19,14 @@ export function buildStatementGrid(statement: StatementOut): { columns: ColumnOu for (const value of line.values) { cells[value.column_id] = value; } - return { key: line.id, lineId: line.id, code: line.line_code, name: line.line_name, cells }; + 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 }; } @@ -29,12 +40,19 @@ export function cellFlags(value: ValueOut | undefined): { attention: boolean; mo return { attention: lowConfidence || value.is_recognized === false, modified: value.is_modified }; } -export const cellKey = (lineId: string, columnId: string) => `${lineId}:${columnId}`; +export const cellKey = (rowKeyValue: string, columnId: string) => `${rowKeyValue}:${columnId}`; -export function draftToPatch(draft: Record): NonNullable { +/** Черновик → тело 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 [line_id, column_id] = key.split(':'); - return { line_id, column_id, 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 }; }); } -- 2.47.2 From 753c10a5a459609b85f759d29ca8c83c45537ea8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 12:33:06 +0400 Subject: [PATCH 11/11] =?UTF-8?q?docs:=20=D0=B0=D0=BA=D1=82=D1=83=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?design/proposal=20=D0=B8=20README=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M-9: design.md и proposal.md приведены к коду (достройка - _lines_out в schemas/statements.py, справочник без valid_from/valid_until, ленивая загрузка с ручными проверками); добавлены R-12 и R-13. T5.1/M-8: сигнатура PATCH values и обратные кавычки в таблице ошибок README. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 ++-- openspec/changes/statement-template-lines/design.md | 12 ++++++------ .../changes/statement-template-lines/proposal.md | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1bd018f..3ecc8b1 100644 --- a/README.md +++ b/README.md @@ -569,7 +569,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл `{failed, soft_failed, cross_form, accepted}` (нарушение — `{column_index, relation, expected, actual}`, у межформенной проверки `column_index = -1`). Не форма (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) — 409 `DOCUMENT_NOT_STATEMENT`. Подтверждённая форма читается. Форма достраивается по справочнику строк редакции (`api/rules/statement_lines.yaml`): недостающие строки (`id = null`, `origin = template`) и ячейки (`id = null`); в БД не пишется. -- **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id, column_id, amount}]}` +- **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id | line_code, column_id, amount}]}` (до 5000 ячеек; `amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов, пробельная строка — 422, `null` — «не менять»; `inn: null` — 422 `INVALID_INN`, ИНН не очищается). Правится только неподтверждённая форма (`RECOGNIZED`/`NEEDS_CHECK`): `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление — новым файлом), заменённый файл — @@ -856,7 +856,7 @@ email без учёта регистра проверяет только сер | `FILE_CONTENT_NOT_FOUND` | 404 | `GET /files/{id}/content`: файл есть, объекта нет в S3 (истёк или пропал) | | `DOCUMENT_NOT_STATEMENT` | 409 | `…/statement`: документ — не форма ББ/ОФР (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) | | `DOCUMENT_READONLY` | 409 | правка/акцепт формы: форма уже `CONFIRMED` (исправление — новым файлом) | -| UNKNOWN_CELL | 422 | PATCH …/statement: line_id/column_id не этой формы; line_code уже есть в форме (передайте line_id), нет в справочнике редакции или редакция не определена | +| `UNKNOWN_CELL` | `422` | `PATCH …/statement`: `line_id`/`column_id` не этой формы; `line_code` уже есть в форме (передайте `line_id`), нет в справочнике редакции или редакция не определена | | `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды | | `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) | | `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта | diff --git a/openspec/changes/statement-template-lines/design.md b/openspec/changes/statement-template-lines/design.md index c90cfd2..746d14a 100644 --- a/openspec/changes/statement-template-lines/design.md +++ b/openspec/changes/statement-template-lines/design.md @@ -27,8 +27,8 @@ ## Decisions ### D-1. Достраивать в ответе, а не в БД (подход B, решение владельца 09.10.2026) -БД хранит только пришедшее из источника; достройка — чистая функция `domain/statement_lines.complete_form` поверх -прочитанной формы в `build_out`. Альтернатива «дописать строки при маппинге» отклонена: нужна data-миграция для уже +БД хранит только пришедшее из источника; достройка — чистая функция `_lines_out` в `schemas/statements.py` (R-9) поверх +прочитанной формы в `build_out`; справочник и выбор строк по редакции — `domain/statement_lines.py`. Альтернатива «дописать строки при маппинге» отклонена: нужна data-миграция для уже загруженных документов, в БД появляются строки, которых не было в первичке, и проверки §7 начали бы видеть пустые итоги (пустая 1700 при непустых 1300–1500 — жёсткое нарушение), то есть изменился бы статус документов. @@ -36,17 +36,15 @@ ```yaml editions: - id: 66n - valid_until: 2024-12-31 forms: BALANCE: [{code: "1110", name: "Нематериальные активы"}, ...] # порядок формы INCOME: [...] - id: fsbu4 - valid_from: 2025-01-01 forms: {BALANCE: [...], INCOME: [...]} ``` Список, а не словарь — порядок строк формы сохраняется (пригодится фронту и будущему порядку в ответе; сейчас ответ -сортируется по коду, как раньше). Загрузка — один раз при импорте модуля (как `statement_checks.yaml`), валидация -pydantic: коды — четыре цифры, без повторов внутри формы, обе формы в обеих редакциях. Редакция выбирается существующим +сортируется по коду, как раньше). Загрузка — лениво, один раз на процесс (`lru_cache` у `load_line_catalog`), проверки +вручную в `load_line_catalog`/`_form_lines` (ошибка формата — `ValueError`): коды — четыре цифры, без повторов внутри формы, обе формы в обеих редакциях. Границы редакций в yaml не хранятся: редакция выбирается существующим `edition_for` — новой логики выбора нет. Состав (решение владельца 09.10.2026 — из шаблонов `../fixtures/statements-xlsx/templates/`): @@ -149,3 +147,5 @@ pydantic: коды — четыре цифры, без повторов внут | R-9 | 09.10.2026 | Достройка формы реализована функцией `_lines_out` в `schemas/statements.py`, а не `domain/statement_lines.complete_form` из D-3 | План конкретнее, поведение то же | Рефакторинг в domain позже | | R-10 | 09.10.2026 | Ключ достроенной строки на фронте `code-`, а не `code:` из D-6 | ':' разделяет rowKey и columnId в cellKey | Нет | | R-11 | 09.10.2026 | Адресация ячеек PATCH вынесена из `_apply_cells` в `_resolve_cells` (отклонение от кода плана ради ruff PLR0912/PLW2901); поведение и порядок проверок сохранены | Линтер | Нет | +| R-12 | 09.10.2026 | Правка фронта шаблонных строк перенесена и во встроенный `arm-core/web` в этой же ветке (те же файлы, что в arm-web) | Иначе после слияния встроенный фронт отправляет `line_id` `"null"` и дублирует ключи строк | Дифф PR вырос; если `arm-core/web` не поддерживается — откатить | +| R-13 | 09.10.2026 | M-2, M-3, M-4, M-5, M-6 финального ревью отложены в follow-up | Не блокируют слияние | M-2: форма без сумм может стать `RECOGNIZED` после ручного создания и очистки строки | diff --git a/openspec/changes/statement-template-lines/proposal.md b/openspec/changes/statement-template-lines/proposal.md index 9ba7ce7..dbb0ea3 100644 --- a/openspec/changes/statement-template-lines/proposal.md +++ b/openspec/changes/statement-template-lines/proposal.md @@ -57,7 +57,7 @@ DBML и контракты обмена не меняются, миграций ## Impact -- Код: `domain/statement_lines.py` (новый: загрузка справочника, достройка формы — чистые функции), +- Код: `domain/statement_lines.py` (новый: загрузка справочника по редакциям), достройка формы — `_lines_out` в `schemas/statements.py`, `api/rules/statement_lines.yaml` (новый), `schemas/statements.py` (`LineOut.id`/`ValueOut.id` nullable, `LineOut.origin`, `CellPatch` с `line_id | line_code`), `services/statements.py` (`build_out`, `_apply_cells`), `repositories/statements.py` (создание строки/ячейки, исключение пустых шаблонных строк в `statement_amounts`). -- 2.47.2