Andrey Epifancev 70c5743c77 Архив OpenSpec retry-recognition-and-edit-osv-header (PR #161)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:20:08 +04:00

680 lines
70 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# documents-api Specification
## Purpose
Чтение файлов, документов и строк ОСВ для панели «Первичные документы» (подсрез 2d) и подтверждение документа.
## Requirements
### Requirement: Файлы организации и карточка файла
`GET /api/v1/legal-entities/{id}/files` (`DOCUMENT_VIEW`, policy; `include_superseded=false` по умолчанию) SHALL
возвращать `Page` файлов ЮЛ по убыванию `uploaded_at`: поля `stored_file` (в том числе `consolidation_id: uuid | null`,
v5.18 — консолидация, в контексте которой файл загружен), текущее задание (`status`, `error_code`,
`error_message`, `mapping_error` (`metrics.mapping_error`: причина `ERROR` файла после неудачного маппинга, когда у задания
`error_code` пуст), `metrics.warnings`, `metrics.mapping_warnings`, `finished_at`), документы файла (`id`, `document_type`,
`osv_account_code`, `period_year`, `period_quarter`, `is_cumulative`, `status`, `period_check`, `counterparty_check`,
`comments_count`). `GET /api/v1/files/{file_id}` SHALL отдавать ту же карточку; чужой или несуществующий файл — 404.
#### Scenario: Список после обработки
- **WHEN** у ЮЛ два файла: `PROCESSED` с документом и `ERROR`
- **THEN** оба в списке; у первого `documents` непустой, у второго `job.error_code` заполнен
#### Scenario: Счётчик комментариев
- **WHEN** к документу файла добавлены два комментария
- **THEN** в карточке файла у документа `comments_count = 2`
#### Scenario: Консолидация в карточке
- **WHEN** один файл ЮЛ загружен в консолидацию `C`, другой — без контекста
- **THEN** в списке ЮЛ и в `GET /files/{id}` у первого `consolidation_id = C`, у второго `consolidation_id = null`
### Requirement: Документ и строки
`GET /api/v1/documents/{document_id}` (`DOCUMENT_VIEW`, policy по ЮЛ владельца) SHALL отдавать документ, статус,
проверки, `locator`, схему группировок листа из `recognition_job.group_schema` и `file_id`.
`GET /api/v1/documents/{document_id}/entries` SHALL отдавать `Page` строк по возрастанию `row_number` (`limit` ≤ 2000,
по умолчанию 500) с полями `osv_entry`, включая `parent_entry_id`, `account_id`, `counterparty_id`.
#### Scenario: Строки деревом
- **WHEN** запрошены строки документа ОСВ 66
- **THEN** первая строка — счёт `66` уровня 0 без родителя, у строк уровня 2 `parent_entry_id` указывает на субсчёт
### Requirement: Подтверждение документа
`POST /api/v1/documents/{document_id}/confirm` (`DOCUMENT_EDIT`, policy) SHALL переводить документ из `RECOGNIZED`
или `NEEDS_CHECK` в `CONFIRMED` (`confirmed_by`, `confirmed_at`) с `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS)`;
из `CONFIRMED` повторно — 409 `ALREADY_CONFIRMED`; документ файла `SUPERSEDED` — 409 `FILE_SUPERSEDED`; документ в любом
другом статусе (например, `ERROR`) — 409 `DOCUMENT_NOT_CONFIRMABLE`. Для `BALANCE`/`INCOME` дополнительно: пустые
`period_year` или `period_quarter` — 409 `PERIOD_REQUIRED`; строки `unreadable` / `missing` с непустой суммой или текстом значения — 409 `LINES_UNRESOLVED` (`detail.lines[] {line_id, row_index, line_code_text}`); вторая подтверждённая форма того же типа и периода в файле — 409 `DUPLICATE_FORM` (`detail.document_id`); записи `document_issue` вида `relation` без акцепта — 409
`CHECKS_NOT_PASSED` со списком нарушений в `detail.failures`; при подтверждении SHALL записываться
`financial_statement_meta.reviewed_by/at`. Несовпадение контрагента и уверенность значений подтверждение MUST NOT
блокировать. Если совпали несколько условий, порядок: `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`,
`DOCUMENT_NOT_CONFIRMABLE`, `PERIOD_REQUIRED`, `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `CHECKS_NOT_PASSED`. Подтверждение берёт блокировки в порядке «файл →
документ → (advisory-блокировка привязок ГК) → версии консолидации (по возрастанию `id`)»: файл — `FOR UPDATE`,
документ — `FOR NO KEY UPDATE` (R-3; совместима со вставкой привязки `FOR KEY SHARE`), версии — `FOR UPDATE`; состояние
перечитывается под этими блокировками; документ или файл, исчезнувшие за время ожидания блокировки, — 404 `NOT_FOUND`.
В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`,
`DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии,
`period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал
версии; false → `period_quarter` ≤ квартала версии). ОСВ за `Q1` равнозначна нарастающей и квартальной (R-14): она
подходит любой версии года при любом `is_cumulative`; ББ/ОФР за `Q1` — отчёт на конец квартала и подходят только версии
за `Q1` (R-14a). Привязанный ранее документ того же ЮЛ с тем же ключом (`document_type`, `osv_account_code`,
`period_quarter`, признак нарастающего: `is_cumulative` true или NULL; для ОСВ за `Q1` признак не различается) в той же
версии SHALL отвязываться (R-1). Каждая версия, получившая
привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` =
подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться.
#### Scenario: Причина ошибки маппинга в карточке файла
- **WHEN** файл в `ERROR` после неудачного маппинга (задание `done`, `error_code` пуст)
- **THEN** `job.mapping_error` содержит причину, `job.error_code` пуст
#### Scenario: Документ не подтверждается
- **WHEN** подтверждается документ в статусе `ERROR`
- **THEN** 409 `DOCUMENT_NOT_CONFIRMABLE`, статус не изменился
#### Scenario: Подтверждение
- **WHEN** `ka_rf` подтверждает документ `NEEDS_CHECK` своего филиала
- **THEN** `status = CONFIRMED`, `confirmed_by` = пользователь, `audit_event(DOCUMENT_CONFIRM)` с `before_value.status = NEEDS_CHECK`
#### Scenario: Привязка к версии и переход статуса
- **WHEN** версия ГК 2024 Q3 в `INITIATED` с ЮЛ в периметре; подтверждается квартальная ОСВ 60 этого ЮЛ за 2024 Q2
- **THEN** `consolidation_document(version, document)` создан; версия в `DATA_CORRECTION`; запись истории
`INITIATED → DATA_CORRECTION` с `changed_by` = пользователь
#### Scenario: Не подходит по периоду
- **WHEN** подтверждается нарастающая ОСВ 60 за 2024 Q2 при версии за 2024 Q3
- **THEN** привязки нет, статус версии не изменился
#### Scenario: Версия в CANCELLED
- **WHEN** подходящий документ подтверждается при версии `CANCELLED`
- **THEN** привязки нет
#### Scenario: Форма с нарушением проверок
- **WHEN** подтверждается `BALANCE` с непройденным жёстким соотношением без акцепта
- **THEN** 409 `CHECKS_NOT_PASSED`, в `detail.failures` — нарушение; после акцепта подтверждение проходит
#### Scenario: Форма без периода
- **WHEN** подтверждается форма из OCR, у которой нет ни даты отчёта, ни дат колонок
- **THEN** 409 `PERIOD_REQUIRED`; после правки даты отчёта подтверждение проходит
#### Scenario: Форма в чек-листе
- **WHEN** версия ГК 2025 Q3 `INITIATED`, ЮЛ в периметре; подтверждается его `BALANCE` за 2025 Q3
- **THEN** привязка создана, ячейка «Бух. баланс» чек-листа ЮЛ — `CONFIRMED`, `reviewed_by` шапки = пользователь
#### Scenario: Неразобранная строка с суммой
- **WHEN** у формы строка `missing` с суммой и одновременно жёсткое нарушение
- **THEN** 409 `LINES_UNRESOLVED` с этой строкой в `detail.lines`
#### Scenario: Неразобранная строка только с текстом
- **WHEN** у формы строка `unreadable` без сумм, но с мусором `211(1 |` в ячейке значения (`string_value`)
- **THEN** 409 `LINES_UNRESOLVED` — потерянная сумма тоже требует разбора
#### Scenario: Вторая форма того же периода
- **WHEN** в файле два ББ на 30.09.2025, первый подтверждён, аналитик подтверждает второй
- **THEN** 409 `DUPLICATE_FORM` со ссылкой на первый
### Requirement: Подхват документов при создании версии и расширении периметра
При создании версии и при добавлении ЮЛ в периметр ядро SHALL привязать уже подтверждённые документы этого ЮЛ из
незаменённых файлов, подходящие версии (условия — как при подтверждении); на один ключ (`document_type`,
`osv_account_code`, `period_quarter`, признак «нарастающий»; NULL признака — нарастающий; для ОСВ за Q1 признак не
различается, R-1, R-14) — документ с самым поздним `confirmed_at`. Удаление ЮЛ из периметра SHALL
снимать привязку всех его документов в этой версии. Подхват и отвязка статус версии MUST NOT менять.
#### Scenario: Фикстуры, подтверждённые до консолидации
- **WHEN** ОСВ 60 РБ за Q1, Q2, Q3 2024 подтверждены, затем создана консолидация ГК за 2024 Q3
- **THEN** три `consolidation_document` на эти документы; в чек-листе ячейка ОСВ 60 РБ — `CONFIRMED`; версия в `INITIATED`
#### Scenario: Две подтверждённые версии одного документа
- **WHEN** у ЮЛ два незаменённых файла с `CONFIRMED` ОСВ 62 за 2024 Q3 (разные `locator`), создана версия 2024 Q3
- **THEN** привязан документ с более поздним `confirmed_at`
### 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[]` в порядке, описанном ниже (`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`). Элемент нарушения в `checks` — `{column_index,
relation, expected, actual, codes, line_ids}`; `expected` и `actual` SHALL быть `null`, когда сторона соотношения пуста
(пустая цель при непустых членах — `expected = null`, пакет `arm_statements`, P1-21), см. требование «Блок `checks`
ответа формы из `document_issue`». Документ другого типа — 409 `DOCUMENT_NOT_STATEMENT`; чужой или несуществующий — 404.
**Состав ответа v2.0.** `StatementOut` SHALL отдавать: у документа `form_variant`; у шапки `needs_review_fields`, `source` (`header_source`;
`confidence` — строкой Decimal, как прочие уверенности API), `organization_name_modified` и `okei`; у колонок `period_kind`, `label_confidence`; у строк `id`, `origin` (`template` ⇔
`row_index = null`), `row_index`, `source_ref`, `line_status`, `line_code`, `line_code_text`, `line_code_confidence`,
`line_name` (из файла), `reference_name` (справочное имя по коду — первое имя кода в словаре — или `null`),
`parent_row_index`, `parent_code`, `needs_review`, `is_confirmed`, `line_code_modified`; у значений `text`
(`source_text`), `needs_review`, `is_confirmed` и прежние поля; `issues[]` — записи `document_issue` с `line_ids`,
`column_id`, `column_index`, `field`, `codes`; `checks` — нарушения из `document_issue` с `codes` и `line_ids`;
`edition_lines[] {code, name}` — справочник строк для подсказки кода: коды словаря имён формы в редакции ответа по одному
(имя — первое), у формы без редакции — коды всех редакций по одному. `DocumentBrief` (`FileOut.documents[]`) и
`DocumentOut` SHALL отдавать `issue_counts {error, info}` — число записей `document_issue` документа по уровню. Порядок
`lines` — по возрастанию кода, как сейчас (`schemas/statements.py`), одинаковые коды — по `row_index`; строки
`unreadable` / `missing` — после ближайшей предыдущей по `row_index` строки, размещённой по коду, без такой — в начале;
`detail` — после родителя. Разделы и размещение итогов на экране («как на бумаге», Р-14) строит экран.
Форма 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 = "template"` ⇔ `row_index IS NULL` (создана правкой аналитика), иначе `"source"`. Строки источника, которых нет в справочнике, отдаются как есть.
Достроенные строки и значения MUST NOT записываться в БД при чтении и MUST NOT участвовать в проверках §7.
#### 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.failed` содержит соотношения с пустой целью 1700 (`1700 = 1300 + 1400 + 1500`, `expected = null`, `actual` —
сумма членов) и `1600 = 1700` (цель 1600 заполнена, 1700 пуста) — такие же, как без достройки; подтверждение формы
требует правки строки 1700 или акцепта проверок
#### 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`
#### Scenario: Документ, принятый по 1.x
- **WHEN** форма загружена до перехода сервиса (результат 1.2)
- **THEN** у строк `line_status = ok`, `row_index` по порядку, `source_ref = null`, `origin = source`, у значений `text =
string_value`, `issues` содержат нарушения §7, посчитанные ядром, и `MAPPING_WARNING`; экран работает
### 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`».
**Расширения v2.0.** `PATCH /documents/{id}/statement` SHALL принимать помимо `header {inn, report_date, units}` и `values` (сумма):
- `header.organization_name` — непустая строка до 1000 символов: `organization_name_modified = true`,
`organization_name_original_value` не меняется, пересчитывается `counterparty_check`, аудит `before/after`;
- `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты: поле уходит из
`needs_review_fields`, снимается `REPORT_DATE_UNCERTAIN`, аудит; иначе запрос без изменений — 200 без эффектов, как
прежде;
- `values[] {line_id, column_id, confirmed: true | false}` — подтвердить ячейку без изменения значения или снять
подтверждение;
- `columns[] {column_id, period_date}` — дата колонке (обязательная дата; `null` — 422 ошибка валидации; пересчитывает
`is_current` и период документа по правилу §9.5 п. 2); ручная дата ставит колонке `period_kind = absolute`, чтобы
следующая правка даты отчёта её не перезаписала; дата, равная текущей, у колонки с `COLUMN_DATE_UNCERTAIN` или
`COLUMN_WITHOUT_DATE` — подтверждение: предупреждение снимается, `period_kind = absolute`, аудит (иначе запрос без
изменений — 200 без эффектов); правка `header.report_date` (в том числе `null`) пересчитывает `period_date` колонок с
`period_kind = relative:*` общим кодом (`null` даёт `null`);
- `lines[] {line_id, line_code}` — назначить или сменить код; `lines[] {line_id, line_status: "detail"}` — отметить
расшифровкой; `lines[] {line_id, confirmed: true | false}` — подтвердить код или снять подтверждение.
Переходы: `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail`
отметкой; `ok` → `ok` сменой кода; `ok` с неуверенным кодом — подтверждением. Обратного перехода `detail` →
`unreadable` / `missing` нет: ошибочная отметка исправляется назначением кода, исходное состояние — перезагрузкой файла.
`line_status` принимает только `"detail"`; `"ok"` — 422 `LINE_CODE_REQUIRED` (в `ok` строку переводит только
`line_code`). Назначенный код: `line_code_confidence = 100`, `line_code_modified = true`, `needs_review` строки =
`false`. Подтверждённый код (`confirmed: true`): `is_confirmed = true`, `needs_review` строки = `false`,
`line_code_confidence` остаётся как пришло, `line_code_modified` не ставится; снятие подтверждения (`confirmed: false`)
возвращает `needs_review` по прежнему правилу (Р-34). `line_code_original_value` не меняется; после любого изменения статусов или кодов
`parent_row_index` **всех** строк `detail` документа пересчитывается как ближайшая строка `ok` выше по `row_index`
(требование «Пересчёт после правки»). Код — 4–5 цифр (иначе 422 `INVALID_LINE_CODE`), может совпадать с
существующим; незнакомый словарю — `info`. Строка с тем же кодом, **созданная правкой** (`row_index IS NULL`), с суммами —
409 `LINE_CODE_TAKEN` (сначала очистить её суммы или выбрать другой код); без сумм — удаляется (её `id` убирается из
`document_issue.line_ids`), её место занимает назначенная строка. Строки источника с тем же кодом не трогаются. В одном
элементе больше одного действия или ни одного — 422 `INVALID_LINE_PATCH`; строка не этого документа — 422
`UNKNOWN_LINE`; колонка не этого документа — 422 `UNKNOWN_COLUMN`. Ошибки элемента `lines[]` (`LINE_CODE_TAKEN`,
`LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`) SHALL нести в `detail` `line_id` и
`line_code` первого неверного элемента (`line_code` — код элемента или `null`). Правка суммы по `line_code` (для достроенной строки)
при наличии строки `ok` с этим кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления строк источника нет. Строки,
созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`, `line_code_original_value = null`. Аудит
`RECOGNIZED_DATA_EDIT` с `before/after` по коду, статусу, родителю, подтверждениям и наименованию организации.
Ограничения состояния — как прежде (`DOCUMENT_READONLY`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_STATEMENT`).
Части запроса применяются в порядке `lines` → `values` → `columns` → `header`; вся адресация (422) проверяется до первой записи. Один `line_id` дважды в `lines` — 422 `INVALID_LINE_PATCH`; `UNKNOWN_CELL` по `line_code` — по кодам строк `ok` с учётом назначений `lines` этого запроса.
Ячейка, которой нет в БД, SHALL создаваться при правке:
- по `line_id` строки формы и `column_id` колонки формы, если у строки нет значения в этой колонке;
- по `line_code`, если строки с этим кодом в форме нет, а код есть в справочнике редакции документа для типа формы:
создаётся `financial_statement_line` (`line_code`, `line_name` из справочника, `line_code_original_value = NULL`, `row_index = NULL`, `source_ref = NULL`, `line_status = ok`,
`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`, данные не изменились
#### Scenario: Назначение кода строке-мусору (AURORA-1462)
- **WHEN** аналитик назначает строке `unreadable` «ИЧ,250» (`row_index = 2`) код 1250
- **THEN** строка `ok`, `line_code = 1250`, `line_code_confidence = 100`, `line_code_modified = true`,
`line_code_original_value` остаётся `null`, `row_index` остаётся 2 (`origin = source`); §7 и статус пересчитаны
#### Scenario: Отметка расшифровкой
- **WHEN** аналитик отмечает строку `missing` с суммой как расшифровку
- **THEN** `line_status = detail`, строка выходит из сумм и не блокирует подтверждение
#### Scenario: Код занят шаблонной строкой с суммами
- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой аналитика (`row_index = null`) и
имеет сумму
- **THEN** 409 `LINE_CODE_TAKEN`, ничего не изменено
#### Scenario: Код занят пустой шаблонной строкой
- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой и сумм не имеет
- **THEN** шаблонная строка удалена, её `id` убран из `document_issue.line_ids`; строка источника стала `ok` с кодом 1250
#### Scenario: Правка наименования организации
- **WHEN** у формы `NO_ORGANIZATION_NO_INN`, аналитик вводит `header.organization_name`
- **THEN** наименование сохранено, `organization_name_modified = true`, исходное — в `organization_name_original_value`,
`counterparty_check` пересчитан, предупреждение снято, аудит
#### Scenario: Подтверждение неуверенной даты отчёта
- **WHEN** `report_date` в `needs_review_fields`, аналитик отправляет PATCH с тем же `header.report_date`
- **THEN** `needs_review_fields` пуст, `REPORT_DATE_UNCERTAIN` снят, статус пересчитан, аудит
#### Scenario: Ручная дата относительной колонки
- **WHEN** колонке `relative:prev_year_end` аналитик ставит `period_date` 31.12.2024, затем правит дату отчёта
- **THEN** после первой правки `period_kind = absolute`; правка даты отчёта её дату не меняет
#### Scenario: Подтверждение неуверенной даты колонки
- **WHEN** у колонки `COLUMN_DATE_UNCERTAIN`, аналитик отправляет `columns: [{column_id, period_date}]` с той же датой
- **THEN** предупреждение снято, `period_kind = absolute`, статус пересчитан, аудит
#### Scenario: Статус ok без кода
- **WHEN** приходит `lines: [{line_id, line_status: "ok"}]`
- **THEN** 422 `LINE_CODE_REQUIRED`
### Requirement: Акцепт проверок формы
`POST /api/v1/documents/{document_id}/statement/checks/accept` (`DOCUMENT_EDIT`, policy) с `{comment}` (1–2000 символов)
SHALL выполнять акцепт (`statement-checks`), пересчитывать статус и писать `audit_event(RECOGNIZED_DATA_EDIT,
RECOGNITION)` с `before/after` полей акцепта; без непройденных жёстких проверок — 409 `NOTHING_TO_ACCEPT`; те же 409,
что у правки формы.
#### Scenario: Акцепт
- **WHEN** у формы нарушено 1600 = 1100 + 1200, аналитик акцептует с комментарием «округление в форме клиента»
- **THEN** `checks.accepted` заполнен, документ `RECOGNIZED`, если других причин `NEEDS_CHECK` нет
### Requirement: Комментарии к документу
`POST /api/v1/documents/{document_id}/comments` (`DOCUMENT_EDIT`, policy) с `{text}` (1–4000 символов после обрезки
пробелов) SHALL создавать `comment(object_type = 'document')` с автором и временем; `GET …/comments` (`DOCUMENT_VIEW`)
SHALL отдавать комментарии по `created_at`: `{id, text, author: {id, full_name}, created_at}`. Правки и удаления нет;
комментировать можно документ в любом статусе, в том числе файла `SUPERSEDED`.
#### Scenario: Комментарий
- **WHEN** аналитик добавляет комментарий «нет подписи руководителя» к подтверждённому документу
- **THEN** 201, комментарий в списке с ФИО автора, `comments_count` документа = 1
### Requirement: Исходный файл
`GET /api/v1/files/{file_id}/content` (`DOCUMENT_VIEW`, policy) SHALL отдавать содержимое исходного объекта файла из S3
потоком, без чтения целиком в память: `Content-Type` по `file_type`, `Content-Disposition` (`attachment` по умолчанию,
`inline` при `?disposition=inline`) с ASCII-именем и `filename*` RFC 5987 исходного имени; работает и для файла
`SUPERSEDED`. Объект не найден в S3 — 404 `FILE_CONTENT_NOT_FOUND`; чужой файл — 404.
#### Scenario: PDF для просмотра
- **WHEN** запрошено содержимое загруженного `aep_6.pdf` с `disposition=inline`
- **THEN** 200, `Content-Type: application/pdf`, `Content-Disposition: inline; filename="file.pdf"; filename*=UTF-8''aep_6.pdf`, тело байт в байт равно загруженному
#### Scenario: Кириллица в имени
- **WHEN** запрошен файл `ОСВ_66 за 2024.xls`
- **THEN** `filename*` содержит percent-кодированное исходное имя
### Requirement: Рабочий стол консолидации для ЮЛ
`GET /api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (`DOCUMENT_VIEW`; `limit`,
`offset`, `include_superseded=false` по умолчанию; §9.5 п. 11 v5.18) SHALL отвечать 404 `NOT_FOUND` на консолидацию вне
видимости или несуществующую («консолидация не найдена») и на ЮЛ вне видимости, иначе возвращать `Page[FileOut]` —
объединение файлов ЮЛ, у которых `stored_file.consolidation_id = consolidation_id`, и файлов ЮЛ, у которых есть документ
с записью `consolidation_document` к любой версии этой консолидации; порядок и карточка — как у списка файлов ЮЛ;
`SUPERSEDED` — только при `include_superseded=true` (в обоих множествах). Принадлежность ЮЛ к ГК консолидации не
проверяется: чужому ЮЛ соответствует пустой список.
#### Scenario: Стол показывает только файлы консоли
- **WHEN** у ЮЛ три файла: загружен в консолидацию 2025 Q1, загружен в консолидацию 2025 Q2, без контекста
- **THEN** стол 2025 Q1 содержит только первый; стол 2025 Q2 — только второй; `GET /legal-entities/{id}/files` — все три
#### Scenario: Подхваченный файл виден через привязку
- **WHEN** файл с `CONFIRMED` ОСВ за 2025 Q1 загружен без контекста и его документ привязан к версии консолидации 2025 Q2
(подхват при создании версии)
- **THEN** файл есть в столе 2025 Q2, хотя `consolidation_id IS NULL`
#### Scenario: Заменённый файл скрыт по умолчанию
- **WHEN** файл консолидации заменён (`SUPERSEDED`) новым файлом той же консолидации
- **THEN** по умолчанию в столе только новый; с `include_superseded=true` — оба
#### Scenario: Чужая консолидация
- **WHEN** `ka_rf` запрашивает стол консолидации ГК другого филиала и несуществующий id
- **THEN** оба ответа 404 с одинаковым телом
### Requirement: Предупреждения и нарушения документа — `document_issue`
Ядро SHALL хранить предупреждения и нарушения документа в `document_issue`: вид (`warning` | `relation` | `soft`), код,
уровень (`error` | `info`), сообщение, список `line_id`, `column_id` (колонка формы) или `column_index` (столбец
предупреждения, которого нет среди колонок формы), поле шапки, источник (`service` | `core`); для нарушений — `codes`
(коды цели и членов), текст соотношения, `expected`, `actual`. Нарушение из `checks.relations_failed` — `kind =
relation`, `code = RELATION_FAILED`, `severity = error`; из `soft_relations_failed` — `kind = soft`, `code =
RELATION_SOFT`, `severity = info`; `line_ids` — все строки `ok` кодов `codes` в документе. При загрузке пишутся
предупреждения и `checks` результата (источник `service`) и проверки ядра уровня `info` (источник `core`):
`CODE_NOT_IN_DICTIONARY` (кода нет в словаре имён редакции документа) и `CODE_NOT_IN_EDITION` (код не из перечня
редакции документа и его варианта — по копии перечня `arm_statements`) — только для четырёхзначных кодов, одна запись на код документа (Р-40), `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`.
**Редакция документа** для этих проверок — `edition` точки входа пакета для пересчёта (`statement-normalization`):
редакция с лучшей подпоследовательностью кодов строк `ok` документа, при равенстве — по эффективной дате; так бланк 66н,
сданный в 2025 году, проверяется по `66n`, а не по дате. Поле `edition` ответа `GET …/statement` и выбор шаблонных
строк по-прежнему — по дате периода (§7 «Полный состав формы»). `document_issue` ведётся у **любого** документа файла: у
ОСВ — предупреждения `osvDocument.warnings` результата `2.0` (вид `warning`, источник `service`, `OSV_*`, `info`). При перезагрузке файла записи документа
удаляются вместе с ним. При пересчёте после правки записи `relation` и `soft` заменяются целиком независимо от источника;
записи `warning` источника `service` сохраняются, кроме снимаемых правкой: правка `header.report_date` (в том числе
подтверждение тем же значением) снимает `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`; правка
`header.inn` или `header.organization_name` — `NO_ORGANIZATION_NO_INN`; правка `columns[].period_date` —
`COLUMN_WITHOUT_DATE` и `COLUMN_DATE_UNCERTAIN` этой колонки; прочие `error` сервиса снимаются только перезагрузкой
файла. При удалении шаблонной строки её `id` SHALL убираться из `line_ids` всех записей документа. `GET …/statement` и
`POST …/confirm` SHALL читать нарушения §7 только из `document_issue`.
#### Scenario: Дата колонке
- **WHEN** у колонки предупреждение `COLUMN_WITHOUT_DATE`, аналитик ставит ей `period_date` через PATCH
- **THEN** предупреждение удаляется, статус пересчитывается
#### Scenario: Неснимаемое предупреждение
- **WHEN** у документа `SEGMENT_COLUMNS_MISMATCH`, аналитик правит значения
- **THEN** предупреждение остаётся; документ можно подтвердить вручную (оно подтверждение не блокирует)
#### Scenario: Бланк 66н в 2025 году без ложного info
- **WHEN** форма Межениновской за 3 кв. 2025 на бланке 66н с кодом 1120 (в перечне `fsbu4` его нет)
- **THEN** редакция документа для проверок ядра — `66n` (подпоследовательность длиннее), `CODE_NOT_IN_EDITION` у 1120 нет
#### Scenario: Пропущенная колонка
- **WHEN** в результате предупреждение `COLUMN_SKIPPED` с `column_index = 5`, колонки 5 среди `columns[]` нет
- **THEN** запись `document_issue` с `column_index = 5` и `column_id = null`; экран показывает её у документа
### Requirement: Пересчёт после правки
После каждого PATCH формы ядро SHALL пересчитывать `checks`, `needs_review`, `form_variant`, родителей строк `detail`
и статус **точкой входа пакета `arm_statements` для пересчёта** по сохранённым данным (`statement-normalization`;
конвенция знака — по `source_text`); временного пересчёта ядра MUST NOT быть. Правила: `is_modified` или `is_confirmed`
значения ⇒ `needs_review = false`; `line_code_modified` или `is_confirmed` строки ⇒ `needs_review` строки = `false`;
значения строк `detail` — `needs_review = false` (Р-23), у строки, выведенной из `detail` назначением кода,
`needs_review` её значений считается по общему правилу; акцепт ⇒ жёсткие нарушения не учитываются; правленые ячейки в
голосование знака не входят. Результат пишется: `needs_review` строк и значений, `form_variant`, `parent_row_index` всех
строк `detail` (ближайшая строка `ok` выше по `row_index`; нет такой — `null`, строка остаётся `detail`), записи
`document_issue` видов `relation` / `soft` (заменяются целиком независимо от источника), статус. Относительные даты
колонок (`period_kind`, `statement-normalization`) пересчитываются от даты отчёта после правки `header.report_date` и
`columns[].period_date`. Отличие `statements_version` задания от версии копии ядра — `document_issue`
`STATEMENTS_VERSION_MISMATCH` (`info`); у задания без версии (`1.x`) запись не пишется. Записи `core`
`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION` (только четырёхзначные коды), `NAME_MISMATCH` и
`STATEMENTS_VERSION_MISMATCH` пересчитываются заменой; `NAME_MISMATCH` не ставится строке с назначенным аналитиком кодом;
`MAPPING_WARNING`, `RESULT_STATUS_INCONSISTENT`, `LEGACY_WARNING` сохраняются. Один PATCH — один пересчёт и одна запись
аудита `RECOGNIZED_DATA_EDIT`. Акцепт SHALL сбрасываться правкой данных — суммы, кода или статуса строки, даты
колонки, поля шапки; подтверждение или снятие подтверждения (`confirmed`) и подтверждение даты отчёта или даты колонки
тем же значением акцепт MUST NOT сбрасывать (Р-25, S-47).
#### Scenario: Подтверждение ячейки без правки
- **WHEN** аналитик подтверждает ячейку с `needs_review = true` без изменения суммы
- **THEN** `is_confirmed = true`, `confirmed_by/at`, `is_modified` не ставится, `needs_review = false`, аудит; акцепт
проверок, если был, сохраняется
#### Scenario: Снятие подтверждения
- **WHEN** аналитик снимает подтверждение с ячейки (`confirmed: false`), которая не покрыта соотношением
- **THEN** `is_confirmed = false`, `needs_review = true` после пересчёта, аудит
#### Scenario: Родитель уходит в расшифровки
- **WHEN** у строки 1150 две расшифровки (`detail`, `parent_row_index` = 1150), аналитик отмечает саму 1150 расшифровкой
- **THEN** после пересчёта родитель всех трёх строк `detail` — ближайшая строка `ok` выше 1150; строк над ними нет — `null`
(на экране — в начале раздела)
#### Scenario: Правка исправляет нарушение
- **WHEN** форма принята с `RELATION_FAILED` по `1200 = 1210 + … + 1260`, аналитик правит сумму 1230 так, что
соотношение сходится
- **THEN** запись `relation` по 1200 заменена (её больше нет), статус пересчитан точкой входа пакета
#### Scenario: После PATCH остаётся RELATION_FAILED
- **WHEN** аналитик правит сумму, но соотношение всё ещё не сходится
- **THEN** запись `RELATION_FAILED` пересоздана с новыми `expected` / `actual`; `POST …/confirm` без акцепта — 409
`CHECKS_NOT_PASSED`
### Requirement: Словари ядра и достройка шаблонных строк
Копия словаря §7 ядра (`api/rules/statement_checks.yaml`) SHALL быть удалена: проверки ядро считает копией пакета
`arm_statements`. `api/rules/statement_lines.yaml` SHALL оставаться только словарём имён (код → наименования по
редакциям): у кода может быть несколько имён (синонимы и законные повторы кода в бланке, Р-20), загрузчик повтор кода в
редакции MUST NOT запрещать; сверка наименования — с любым именем кода; шаблонная строка берёт первое имя. Порядок и
состав редакций — из копии перечня кодов пакета (`code_orders.editions`); код словаря имён, которого нет в перечне своей
редакции, — ошибка проверки (тест согласованности, `sync-statements --check`). В редакции `fsbu4` словарь имён SHALL быть
дополнен вторыми именами законных повторов 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде», 2410
«Налог при упрощенной системе налогообложения» (Р-20) и кодами печатной формы 1С 1105 «Гудвил», 1130 «Нематериальные
поисковые активы», 1140 «Материальные поисковые активы», 1530 «Доходы будущих периодов», 2460 «Прочее» (имена — по
`fixtures/ocr` и шаблонам банка); без этого законные повторы РБ, Счастливочной и aep_6 и любой неуверенный 1105 дают
`NAME_MISMATCH`. Шаблонные строки достраиваются по словарю имён (одна на код, первое имя); код, у которого есть хотя бы
одна строка `ok` из файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной строкой
молча.
#### Scenario: Код словаря вне перечня
- **WHEN** в `statement_lines.yaml` у редакции есть код, которого нет в перечне этой редакции копии пакета
- **THEN** проверка согласованности словаря с перечнем падает
#### Scenario: Код есть только у строки unreadable
- **WHEN** у формы нет строки `ok` с кодом 1250, но есть строка `unreadable` «ИЧ,250»
- **THEN** шаблонная строка 1250 достраивается пустой, строка `unreadable` показывается отдельно со статусом
#### Scenario: Два имени у кода
- **WHEN** в редакции `fsbu4` словаря имён код 1520 записан дважды — «Кредиторская задолженность» и «Обязательство по
аренде»
- **THEN** словарь загружается; шаблонная строка 1520 одна, с именем «Кредиторская задолженность»
#### Scenario: Гудвил с заголовком раздела в имени
- **WHEN** строка 1105 (уверенность 67) с наименованием «АКТИВ I. Внеоборотные активы Гудвил»
- **THEN** справочное «Гудвил» (≥ 5 символов) входит в него — совпало, `NAME_MISMATCH` нет
### Requirement: Предупреждения файла и ОСВ в API, статус ОСВ при приёме 2.0
`recognition_job.metrics.warnings` SHALL хранить предупреждения уровня файла структурами `{code, severity, message,
locator?}` (`fileWarning` контракта): у `2.0` — как пришли, у `1.x` — строки, обёрнутые в `LEGACY_WARNING` `info`.
`JobBrief.warnings` карточки файла (`FileOut.job`) SHALL отдавать список этих структур, а не строк; строки заданий,
сохранённых до выкладки ядра v5.19, при чтении конвертируются в `{code: "LEGACY_WARNING", severity: "info", message:
<строка>, locator: null}` (данные в БД не переписываются). `JobBrief.mapping_warnings` (предупреждения маппинга ядра)
остаётся списком строк. Это несовместимое изменение типа поля для фронта — фиксируется в гайде фронта.
Предупреждения листа ОСВ результата `2.0` (`osvDocument.warnings`, коды `OSV_*`, `info`) SHALL храниться в
`document_issue` документа ОСВ (вид `warning`, источник `service`) и отдаваться в `DocumentOut.issues[]` (те же поля,
что `issues` формы) вместе с предупреждениями маппинга ядра этого листа; `DocumentOut.warnings` (строки маппинга файла)
сохраняется на окно перехода. `osvDocument.status` SHALL использоваться: при приёме `2.0` документ ОСВ `RECOGNIZED` ⇔
`osvDocument.status = done` ∧ `checks` листа без нарушений ∧ в `metrics.warnings` файла нет предупреждений уровня
`error` ∧ `counterparty_check ≠ MISMATCH` ∧ нет предупреждений маппинга ядра по этому листу (повтор счёта в файле, строки
без родителя, нарастающая по данным Q1); статус конверта в условие не входит (как Р-19 для форм). При приёме `1.x` — как
прежде (§9.3).
#### Scenario: Карточка файла задания до выкладки
- **WHEN** у задания, сохранённого до v5.19, `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]`
- **THEN** `JobBrief.warnings = [{code: "LEGACY_WARNING", severity: "info", message: "sheet=ОСВ 60 строки без родителя",
locator: null}]`
#### Scenario: Потерянный json в карточке файла
- **WHEN** результат `2.0` scan-gateway несёт `metrics.warnings` с `JSON_FAILED`
- **THEN** `JobBrief.warnings` содержит `{code: "JSON_FAILED", severity: "error", message, locator}`; предупреждение не
теряется
#### Scenario: ОСВ в файле с ОФР, требующим проверки
- **WHEN** книга из ОСВ (`status = done`, контрольные суммы сошлись) и ОФР (`needs_check`), конверт `needs_check`,
предупреждений файла `error` нет
- **THEN** ОСВ `RECOGNIZED` и автоподтверждается; её предупреждения `OSV_*` — в `DocumentOut.issues`
### Requirement: `CHECKS_NOT_PASSED` из `document_issue`
Нарушения для 409 `CHECKS_NOT_PASSED` при `POST /documents/{id}/confirm` формы ББ/ОФР (порядок проверок и условия —
«Подтверждение документа») SHALL читаться **только из записей `document_issue` вида `relation`** без акцепта
(акцептованная форма нарушенной не считается); пересчёта §7 в запросе подтверждения нет. `detail.failures[]` —
`{column_index, relation, expected, actual}`: `expected` и `actual` — строки Decimal или `null` (пустая цель при
непустых членах — `expected = null`); `column_index` — индекс колонки записи, а если колонку определить нельзя
(у записи нет ни `column_id`, ни `column_index`) — `null`.
#### Scenario: Жёсткое нарушение из document_issue
- **WHEN** у формы есть запись `relation` без акцепта и нет строк `unreadable` / `missing` со значениями
- **THEN** 409 `CHECKS_NOT_PASSED`, `detail.failures[]` — по записи на нарушение
#### Scenario: Нарушение с пустой целью
- **WHEN** у формы нет строки 1700 (потеряна источником), 1300 + 1400 + 1500 заполнены
- **THEN** 409 `CHECKS_NOT_PASSED`; в `detail.failures[]` нарушение `1700 = 1300 + 1400 + 1500` с `expected = null`
### Requirement: Блок `checks` ответа формы из `document_issue`
Блок `checks` ответа `GET …/statement` SHALL строиться из записей `document_issue` видов `relation` (`failed`) и `soft`
(`soft_failed`) с `codes`, `expected`, `actual`, `line_ids`; собственного расчёта §7 при чтении нет. Элемент нарушения —
`{column_index, relation, expected, actual, codes, line_ids}`: `expected` и `actual` — `Decimal` или `null`
(`expected = null` — цель соотношения пуста при непустых членах); `column_index` — индекс колонки формы по `column_id`
записи, иначе `column_index` записи, иначе `-1` (колонки нет: межформенная проверка `cross_form` всегда `-1`).
Фактическое различие с `detail.failures[]` 409 (там отсутствующий индекс — `null`, а не `-1`) сохраняется как есть
(P3-60). Межформенная проверка `cross_form` считается по агрегату формы по коду.
#### Scenario: checks из document_issue
- **WHEN** у формы две записи `relation` и одна `soft`
- **THEN** `checks.failed` содержит две записи, `checks.soft_failed` — одну; расчёт §7 при запросе не выполняется
#### Scenario: Нарушение с пустой целью в ответе
- **WHEN** у формы запись `relation` по `1700 = 1300 + 1400 + 1500` с `expected = NULL`
- **THEN** элемент `checks.failed` имеет `expected = null`, `actual` — сумма членов
### Requirement: Уточнение шапки ОСВ
`PATCH /api/v1/documents/{id}/osv-header` SHALL требовать `DOCUMENT_EDIT` и видимость ЮЛ, принимать частичный набор
`period_year`, `period_quarter`, `is_cumulative`, `osv_account_code` и возвращать карточку документа с
`is_modified`, `original_values`, `modified_by`, `modified_at`. Отсутствующие поля SHALL сохраняться.
Правила — §9.5 п.12. Чужой и несуществующий документ SHALL возвращать одинаковый 404.
#### Scenario: Изменение только счёта
- **WHEN** аналитик меняет счёт ОСВ РБ с 60 на 62, не передавая период
- **THEN** меняется только счёт; даже неопределённый квартал остаётся прежним
#### Scenario: Нет доступа
- **WHEN** пользователь без DOCUMENT_EDIT правит шапку либо документ находится вне видимости ЮЛ
- **THEN** без права возвращается 403, при наличии права и отсутствии видимости — 404; данные не меняются
### Requirement: Проверки правки шапки ОСВ
После проверки типов тела запроса Pydantic PATCH SHALL проверять последовательно: тип OSV (иначе 409 DOCUMENT_NOT_OSV), файл не SUPERSEDED (иначе 409
FILE_SUPERSEDED), корректность периода (422 INVALID_PERIOD), счёта (422 INVALID_ACCOUNT_CODE), отсутствие
другой ОСВ с тем же нормализованным счётом в файле (409 DUPLICATE_ACCOUNT с detail.document_id),
редактируемость привязанных версий (409 VERSION_NOT_EDITABLE с detail.version_ids). Все проверки SHALL
предшествовать записи.
#### Scenario: Частичная правка периода
- **WHEN** передано хотя бы одно поле периода
- **THEN** итоговые год, квартал и is_cumulative обязательны: год 1900–2099, квартал Q1–Q4, признак boolean;
для Q1 признак true; иначе 422 INVALID_PERIOD без изменений
#### Scenario: Нормализация и коллизия
- **WHEN** передано 60.1, а у другой ОСВ этого файла уже счёт 60.01
- **THEN** нормализация даёт 60.01, возвращается 409 DUPLICATE_ACCOUNT с id другого документа
#### Scenario: Пустой счёт
- **WHEN** передан пустой счёт
- **THEN** 422 INVALID_ACCOUNT_CODE, прежний счёт сохранён
#### Scenario: Порядок конфликтов
- **WHEN** у заменённого файла документ BALANCE и некорректный период в теле
- **THEN** возвращается DOCUMENT_NOT_OSV; у OSV того же файла — FILE_SUPERSEDED
### Requirement: Исходная шапка и признак исправления
При первой фактической правке ядро SHALL сохранить прежние значения всех четырёх полей в document.original_values;
при последующих правках снимок MUST NOT меняться. Новые значения SHALL записываться в ту же строку document,
is_modified SHALL отражать отличие от снимка, modified_by/at — последнюю правку. document.id, file_ordinal,
строки osv_entry, файл и JSON результата в S3 MUST NOT изменяться.
#### Scenario: Две правки
- **WHEN** счёт ОСВ РБ меняется 60 → 62 → 76
- **THEN** текущий счёт 76, original_values.osv_account_code = 60, в original_values также исходный период;
is_modified = true, id и количество документов и строк ОСВ прежние
#### Scenario: Возврат к исходному
- **WHEN** все четыре поля возвращены к значениям original_values
- **THEN** is_modified = false, original_values остаётся заполненным, последняя правка атрибутирована
#### Scenario: Запрос без изменений
- **WHEN** нормализованная итоговая шапка совпадает с текущей
- **THEN** 200 с карточкой без аудита, создания original_values, сброса подтверждения и изменения modified_by/at
### Requirement: Снятие подтверждения ОСВ после уточнения
Фактическая правка CONFIRMED ОСВ SHALL установить NEEDS_CHECK, очистить confirmed_by/at и снять привязки
consolidation_document из всех версий «в работе», сохранив привязки CANCELLED и статусы версий.
Привязка к иной версии SHALL запретить правку. Неподтверждённый документ SHALL сохранить статус.
consolidation_lock MUST NOT требоваться; автоподтверждение после PATCH MUST NOT запускаться.
#### Scenario: Исправление автоподтверждённой ОСВ
- **WHEN** ОСВ РБ привязана к версии DATA_CORRECTION, аналитик исправляет период
- **THEN** NEEDS_CHECK, confirmed_by/at = null, привязка снята, версия остаётся DATA_CORRECTION;
повторное ручное подтверждение привязывает документ по новому периоду и счёту
#### Scenario: Завершённая версия
- **WHEN** документ привязан к VERIFIED, даже если одновременно привязан к версии «в работе»
- **THEN** 409 VERSION_NOT_EDITABLE со списком запрещающих version_ids, ни одна привязка и шапка не меняются
#### Scenario: Отменённая версия
- **WHEN** подтверждённый документ связан только с CANCELLED и версиями «в работе»
- **THEN** правка разрешена без consolidation_lock, привязки CANCELLED остаются, остальные снимаются
### Requirement: Производные данные после уточнения ОСВ
Подходящесть версиям, чек-лист и колонка «ОСВ» SHALL отражать новую шапку при следующем чтении;
period_check, counterparty_check и строки osv_entry SHALL сохраняться. Правка MUST NOT запускать автозамену
другого файла; замещение привязки с тем же ключом происходит при повторном подтверждении по §3 п.1.
Перезагрузка файла MUST NOT переносить уточнения старых документов. Флаг CONSOLIDATION_PERIOD_FROM_VERSION сохраняется.
#### Scenario: Смена счёта шапки
- **WHEN** счёт ОСВ РБ исправлен с 60 на 62
- **THEN** колонка «ОСВ» и подходящие слоты чек-листа учитывают 62; account_code/account_id/account_root
строк ОСВ, period_check и counterparty_check не изменились, другой файл ОСВ 62 не заменён
#### Scenario: Перезагрузка
- **WHEN** новый загруженный файл распознан с исходным счётом 60
- **THEN** новые документы созданы из нового результата, исправление 62 предыдущего документа не переносится
### Requirement: Строгие типы шапки ОСВ
PATCH SHALL принимать period_year как integer, period_quarter и osv_account_code как string,
is_cumulative как boolean, без приведения типов. Отсутствующие поля не меняются; null SHALL передаваться
в доменную проверку явно. Неверные типы SHALL отклоняться стандартным 422 валидации тела до доменных
проверок. Границы года, допустимые кварталы и совместимость полей SHALL проверяться доменом.
#### Scenario: Неверный тип года
- **WHEN** передан period_year как строка, дробное число, boolean, массив или объект
- **THEN** стандартный 422 с detail-массивом ошибок валидации, доменная обработка не запускается
#### Scenario: Год вне диапазона
- **WHEN** у доступного незаменённого документа OSV передан period_year = 2100
- **THEN** тип integer принят, домен возвращает 422 INVALID_PERIOD
#### Scenario: Неизвестный квартал
- **WHEN** у доступного незаменённого документа OSV передан period_quarter = "Q0"
- **THEN** тип string принят, домен возвращает 422 INVALID_PERIOD