Compare commits

...

9 Commits

Author SHA1 Message Date
Andrey Epifancev
5c330eb400 docs: полный состав строк ББ/ОФР в README и CLAUDE.md (#80)
README: достройка GET, адресация PATCH по line_code, строка UNKNOWN_CELL.
CLAUDE.md: абзац про справочник строк. design.md: рулинги R-9…R-11.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 12:14:53 +04:00
Andrey Epifancev
2d21a416d6 feat(statements): созданные пустые строки вне проверок §7 (#80)
statement_amounts не отдаёт проверкам строки с line_code_original_value IS NULL,
у которых все суммы пустые; заполненная хотя бы в одной колонке строка
проверяется по всем колонкам.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 12:06:34 +04:00
Andrey Epifancev
37e7f40890 feat(statements): PATCH создаёт строку и ячейку по line_code (#80)
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 <noreply@anthropic.com>
2026-10-09 12:03:59 +04:00
Andrey Epifancev
3a543b5cd1 feat(statements): GET достраивает форму строками справочника (#80)
GET …/statement добавляет строки справочника редакции, которых нет в форме
(id = null, origin = template), и недостающие ячейки существующих строк
(id = null, все значения пусты). В БД ничего не пишется. Добавлено поле
LineOut.origin (source | template).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 12:00:26 +04:00
Andrey Epifancev
438752f035 feat(statements): справочник строк форм ББ/ОФР по редакциям (#80)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:56:27 +04:00
Andrey Epifancev
381c373467 plan: абсолютные пути worktree в git-командах (#80)
Относительный `git -C arm-core` из корня workspace указывал на main-checkout
(O-1 первого запуска контроллера).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qoydUVv8g1qHxc123u2RR
2026-10-09 11:28:03 +04:00
Andrey Epifancev
dba7273a2d plan: statement-template-lines — план реализации; уточнения спеки (#80)
Спека: «1600 = 1700» проверяется при потерянной 1700 (цель 1600 есть);
цвет строк «БАЛАНС» — #f9f0ff (синий занят «исправлено»); R-8 has_lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qoydUVv8g1qHxc123u2RR
2026-10-09 10:39:23 +04:00
Andrey Epifancev
f540c7f661 openspec: statement-template-lines — выделение строк «БАЛАНС» цветом (#80)
Просьба владельца: 1600/1700 сливаются с обычными строками. R-7 в design.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qoydUVv8g1qHxc123u2RR
2026-10-09 10:29:23 +04:00
Andrey Epifancev
5c7d5393c5 openspec: change statement-template-lines — полный состав строк ББ/ОФР (#80)
Спека: достройка формы по справочнику строк редакции в GET …/statement,
создание строки и ячейки при первой правке по line_code, пустые шаблонные
строки вне проверок §7 и статуса.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qoydUVv8g1qHxc123u2RR
2026-10-09 10:17:23 +04:00
15 changed files with 2932 additions and 43 deletions

View File

@ -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` +

View File

@ -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 | подтверждение формы: период не определён — поправьте дату отчёта |

View File

@ -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: "Разводненная прибыль (убыток) на акцию, руб. коп."}

View File

@ -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

View File

@ -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",
@ -123,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)
@ -134,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]]:
@ -189,6 +208,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:

View File

@ -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 pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
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],
@ -228,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

View File

@ -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 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 (
@ -28,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
@ -38,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"
@ -123,12 +125,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; при акцепте — пустой кортеж (используется и подтверждением формы)."""
@ -154,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}"
@ -176,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,
@ -216,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:

View File

@ -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:
@ -916,3 +1060,265 @@ 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"
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}

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-09

View File

@ -0,0 +1,151 @@
# 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. Достройка ответа
Достройка (по факту — `_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` из
справочника, `*_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` на запрос, как сейчас; ключ созданной ячейки — тот же `value:<id>` (id новой
ячейки), `before = 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` (R-10), ключ ячейки — `rowKey:column_id`; `draftToPatch`
отправляет `line_id` или `line_code` по ключу. Достроенная пустая ячейка (`id = null`) без подсветки «требует проверки»
(`is_recognized = null`), введённое значение подсвечивается как правка. `formLayout` уже раскладывает строки по разделам —
меняется только то, что разделы теперь всегда полные. **Строки «БАЛАНС» (1600 по активу, 1700 по пассиву) выделяются
цветом** (просьба владельца 09.10.2026: сейчас итог баланса сливается с обычными строками и «Итого по разделу»): фон
всей строки — светло-фиолетовый (`#f9f0ff`, purple-1 палитры antd): серый `#f2f4f7` уже у заголовков разделов,
светло-синий `#e6f4ff` — «исправлено», оранжевый `#fff7e6` — «требует проверки»; шрифт 700 у наименования, кода и сумм. Подсветка ячейки «требует проверки» / «правлено» перекрывает фон строки —
сигнал о данных важнее оформления. Признак — `formLayout` (`LayoutRow.grand: true` для 1600/1700 у `BALANCE`), цвет —
`StatementTable`. В arm-web — свой OpenSpec change `statement-template-lines` (дельта `document-review-ui`). Сверка с макетом [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 не требует | — |
| 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>`, а не `code:<code>` из D-6 | ':' разделяет rowKey и columnId в cellKey | Нет |
| R-11 | 09.10.2026 | Адресация ячеек PATCH вынесена из `_apply_cells` в `_resolve_cells` (отклонение от кода плана ради ruff PLR0912/PLW2901); поведение и порядок проверок сохранены | Линтер | Нет |

View File

@ -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`, отсутствие подсветки «требует проверки» у пустых
достроенных ячеек; выделение цветом строк «БАЛАНС» (1600 актив, 1700 пассив) — просьба владельца 09.10.2026.

View File

@ -0,0 +1,104 @@
## 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` — те же, что без достройки
(`1600 = 1700` нарушено: 1700 отсутствует и считается 0)
#### 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`, данные не изменились

View File

@ -0,0 +1,46 @@
## 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 (`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
- **THEN** в колонке 0 соотношение выполнено; в колонке 1 `failed` содержит `1700 = 1300 + 1400 + 1500` с `expected 0`,
`actual 2000000`
#### Scenario: Очищенная шаблонная строка
- **WHEN** затем аналитик очищает 1700 в колонке 0 (`amount = null`)
- **THEN** строка 1700 снова не участвует в проверках