Compare commits
No commits in common. "5c330eb400621b69b836027efe3f706dbe10079c" and "8be43512afaf95c4804065a2590c3a9b849a24a2" have entirely different histories.
5c330eb400
...
8be43512af
@ -98,10 +98,7 @@
|
||||
документа. **Порядок блокировок «файл → документ»**, шапка (`meta`) читается ПОД блокировкой с `refresh=True` — иначе в сессии
|
||||
остаётся устаревший акцепт. Подтверждение формы (`services/documents._check_statement`) требует период (`PERIOD_REQUIRED`) и
|
||||
пройденные/акцептованные жёсткие проверки (`CHECKS_NOT_PASSED`, `detail.failures`); мягкие (в т. ч. межформенная 1370↔2400)
|
||||
не блокируют. Полный состав формы — `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')`,
|
||||
не блокируют. Комментарии к документу — `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` +
|
||||
|
||||
@ -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,7 +577,6 @@ 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` события аудита сброса остаётся комментарий сброшенного акцепта).
|
||||
@ -856,7 +855,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` не принадлежат этой форме |
|
||||
| `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды |
|
||||
| `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) |
|
||||
| `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта |
|
||||
|
||||
@ -1,132 +0,0 @@
|
||||
# Справочник строк форм ББ/ОФР по редакциям: полный состав формы для экрана проверки (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: "Разводненная прибыль (убыток) на акцию, руб. коп."}
|
||||
@ -1,66 +0,0 @@
|
||||
"""Справочник строк форм ББ/ОФР по редакциям — полный состав формы для экрана проверки (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
|
||||
@ -22,11 +22,7 @@ from src.models.statements import (
|
||||
|
||||
__all__ = [
|
||||
"BATCH",
|
||||
"add_line",
|
||||
"add_value",
|
||||
"column_ids",
|
||||
"delete_file_statements",
|
||||
"form_lines",
|
||||
"get_meta",
|
||||
"insert_statement",
|
||||
"load_form",
|
||||
@ -127,9 +123,7 @@ async def statement_amounts(
|
||||
session: AsyncSession, meta_id: uuid.UUID
|
||||
) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]:
|
||||
"""Индексы колонок и `(код строки, {колонка: сумма})` по возрастанию кода — вход `run_checks`. Порядок строк
|
||||
формы в БД не хранится (DBML не хранит), поэтому порядок строк — лексикографический порядок кодов.
|
||||
Созданные правкой строки (`line_code_original_value IS NULL`) без единой суммы не участвуют
|
||||
(spec statement-checks, design D-5)."""
|
||||
формы в БД не хранится (DBML не хранит), поэтому порядок строк — лексикографический порядок кодов."""
|
||||
columns = (
|
||||
await session.execute(
|
||||
select(FinancialStatementColumn.id, FinancialStatementColumn.column_index)
|
||||
@ -140,29 +134,16 @@ async def statement_amounts(
|
||||
index_by_id = {column_id: index for column_id, index in columns}
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
FinancialStatementLine.line_code,
|
||||
FinancialStatementLine.line_code_original_value,
|
||||
FinancialStatementValue.column_id,
|
||||
FinancialStatementValue.amount,
|
||||
)
|
||||
select(FinancialStatementLine.line_code, 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:
|
||||
for code, 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
|
||||
return [index for _, index in columns], list(by_code.items())
|
||||
|
||||
|
||||
async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[bool, bool | None, Decimal | None]]:
|
||||
@ -208,55 +189,6 @@ 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:
|
||||
|
||||
@ -9,12 +9,11 @@ import datetime
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from decimal import Decimal
|
||||
from typing import Annotated, Literal
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
|
||||
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 (
|
||||
@ -57,11 +56,8 @@ class ColumnOut(BaseModel):
|
||||
is_current: bool | None
|
||||
|
||||
|
||||
LineOrigin = Literal["source", "template"]
|
||||
|
||||
|
||||
class ValueOut(BaseModel):
|
||||
id: uuid.UUID | None # None — ячейка достроена в ответе, в БД её нет
|
||||
id: uuid.UUID
|
||||
column_id: uuid.UUID
|
||||
column_index: int
|
||||
amount: Decimal | None
|
||||
@ -75,8 +71,7 @@ class ValueOut(BaseModel):
|
||||
|
||||
|
||||
class LineOut(BaseModel):
|
||||
id: uuid.UUID | None # None — строка достроена из справочника редакции, в БД её нет
|
||||
origin: LineOrigin
|
||||
id: uuid.UUID
|
||||
line_code: str | None
|
||||
line_code_confidence: Decimal | None
|
||||
line_name: str | None
|
||||
@ -151,71 +146,6 @@ 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
|
||||
@ -233,10 +163,9 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
|
||||
cross: FailedCheck | None,
|
||||
*,
|
||||
edition: str | None,
|
||||
template: Sequence[TemplateLine],
|
||||
) -> StatementOut:
|
||||
"""Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки; строки достроены по
|
||||
справочнику редакции (D-3)."""
|
||||
"""Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки."""
|
||||
column_index = {column.id: column.column_index for column in columns}
|
||||
return StatementOut(
|
||||
document=StatementDocumentOut(
|
||||
id=document.id,
|
||||
@ -278,7 +207,17 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
|
||||
address=meta.address,
|
||||
),
|
||||
columns=[ColumnOut.model_validate(column) for column in columns],
|
||||
lines=_lines_out(columns, lines, template),
|
||||
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
|
||||
],
|
||||
checks=ChecksOut(
|
||||
failed=[_failed(check) for check in report.failed],
|
||||
soft_failed=[_failed(check) for check in report.soft_failed],
|
||||
@ -289,24 +228,14 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
|
||||
|
||||
|
||||
class CellPatch(BaseModel):
|
||||
"""Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить.
|
||||
|
||||
Ячейка адресуется ровно одним из: `line_id` — строка формы (если ячейки в колонке нет, она создаётся); `line_code` —
|
||||
строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4)."""
|
||||
"""Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
line_id: uuid.UUID | None = None
|
||||
line_code: str | None = Field(None, max_length=10)
|
||||
line_id: uuid.UUID
|
||||
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
|
||||
|
||||
@ -16,7 +16,6 @@ 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 (
|
||||
@ -29,7 +28,7 @@ from src.models.enums import (
|
||||
RecognitionStatus,
|
||||
)
|
||||
from src.models.files import Document, RecognitionJob
|
||||
from src.models.statements import FinancialStatementLine, FinancialStatementMeta
|
||||
from src.models.statements import FinancialStatementMeta
|
||||
from src.repositories import files as files_repo
|
||||
from src.repositories import statements as repo
|
||||
from src.repositories.counterparties import CounterpartyIndex
|
||||
@ -39,7 +38,6 @@ 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"
|
||||
@ -125,13 +123,12 @@ 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)
|
||||
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)
|
||||
return schemas.statement_out(
|
||||
document, meta, columns, lines, report, cross, edition=edition_for(document.reporting_date)
|
||||
)
|
||||
|
||||
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
|
||||
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
|
||||
@ -157,40 +154,18 @@ class StatementService:
|
||||
document.updated_at = utc_now()
|
||||
|
||||
async def _apply_cells(
|
||||
self,
|
||||
document: Document,
|
||||
meta: FinancialStatementMeta,
|
||||
body: schemas.StatementPatch,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
self, 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]
|
||||
"""Значения `body.values` (D-7): `original_amount` при первой правке, `is_modified`, `modified_by/at`."""
|
||||
keys = [(cell.line_id, cell.column_id) for cell in body.values]
|
||||
if len(keys) != len(set(keys)):
|
||||
raise DomainValidationError(DUPLICATE_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] = {}
|
||||
cells = await repo.lock_cells(self._session, meta.id, keys)
|
||||
if len(cells) != len(set(keys)):
|
||||
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
|
||||
now = utc_now()
|
||||
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)
|
||||
for cell in body.values:
|
||||
value = cells[(cell.line_id, cell.column_id)]
|
||||
if value.amount == cell.amount:
|
||||
continue
|
||||
key = f"value:{value.id}"
|
||||
@ -201,37 +176,6 @@ 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,
|
||||
@ -272,7 +216,7 @@ class StatementService:
|
||||
raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр")
|
||||
before: dict[str, Any] = {}
|
||||
after: dict[str, Any] = {}
|
||||
await self._apply_cells(document, meta, body, before, after)
|
||||
await self._apply_cells(meta, body, before, after)
|
||||
if header is not None:
|
||||
await self._apply_header(document, meta, header, before, after)
|
||||
if not after:
|
||||
|
||||
@ -9,7 +9,7 @@ from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domain.statement_checks import CROSS_FORM_RELATION
|
||||
@ -42,8 +42,6 @@ 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
|
||||
|
||||
|
||||
@ -126,11 +124,7 @@ 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"
|
||||
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)
|
||||
assert len(body["lines"]) == MEZH_BALANCE_LINES
|
||||
codes = [line["line_code"] for line in body["lines"]]
|
||||
assert codes == sorted(codes)
|
||||
columns = len(body["columns"])
|
||||
@ -160,15 +154,7 @@ 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",
|
||||
"origin",
|
||||
"line_code",
|
||||
"line_code_confidence",
|
||||
"line_name",
|
||||
"line_name_confidence",
|
||||
"values",
|
||||
}
|
||||
assert set(line) >= {"id", "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) == {
|
||||
@ -197,136 +183,6 @@ 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:
|
||||
@ -1060,265 +916,3 @@ 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}
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
"""Справочник строк форм ББ/ОФР по редакциям (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
@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-09
|
||||
@ -1,151 +0,0 @@
|
||||
# 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); поведение и порядок проверок сохранены | Линтер | Нет |
|
||||
@ -1,67 +0,0 @@
|
||||
# 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.
|
||||
@ -1,104 +0,0 @@
|
||||
## 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`, данные не изменились
|
||||
@ -1,46 +0,0 @@
|
||||
## 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 снова не участвует в проверках
|
||||
Loading…
x
Reference in New Issue
Block a user