diff --git a/context/05-domain-rules.md b/context/05-domain-rules.md index 1516928..ab2df4d 100644 --- a/context/05-domain-rules.md +++ b/context/05-domain-rules.md @@ -461,7 +461,7 @@ variance = |amount_a − amount_b|; variance_pct = variance / min(amount_a, amou словаря tabular-parser (те же жёсткие и мягкие соотношения, допуск ±1 в единицах формы, округление 0,001 %, `abs_terms`, мягкая «сумма пятизначных подстрок = родитель»); тест идентичности копий между репозиториями не ставится, расхождение — известный риск (`03-architecture.md`). Соотношение считается по каждой колонке и только если присутствует его цель -(левая часть); отсутствующий код в правой части = 0. Результат в ответе API: `failed` (жёсткие), `soft_failed` (мягкие, в +(левая часть — сумма в этой колонке непуста; пустая ячейка цели соотношение пропускает); отсутствующий код в правой части = 0. Результат в ответе API: `failed` (жёсткие), `soft_failed` (мягкие, в том числе «в пределах округления»), `cross_form`, `accepted`. Межформенная `1370(отчётная дата) − 1370(начало года) = 2400` — **мягкая** и не блокирует подтверждение: пара — другой документ формы того же ЮЛ с тем же годом и кварталом периода (файл не `SUPERSEDED`; подтверждённый предпочтительнее, далее @@ -601,7 +601,8 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; ` `financial_statement_line` (строки с кодом, `(meta_id, line_code)` уникален: повтор кода — остаётся первая строка, предупреждение) + `financial_statement_value` — **на каждую пару «строка × колонка» одна запись** (ячейки, которых нет в результате, — с пустыми суммой и уверенностью; значение с `column_index` вне колонок формы пропускается с - предупреждением). `checks` результата ядро игнорирует: §7 считает само (§7). **Период формы:** дата = `header.report_date`, + предупреждением; повтор `column_index` в колонках результата или повтор пары «строка × колонка» — остаётся первое, пишется + предупреждение, документ `NEEDS_CHECK`). `checks` результата ядро игнорирует: §7 считает само (§7). **Период формы:** дата = `header.report_date`, а если даты нет — **самая поздняя `period_date` колонок** (дата отчёта в OCR часто мусор; колонки надёжнее); `period_year` — год даты, `period_quarter` — только если дата — последний день марта, июня, сентября или декабря, иначе квартал пуст и предупреждение «дата … не конец квартала»; без даты год и квартал пусты. У колонки с самой поздней датой `is_current = true`. @@ -634,7 +635,7 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; ` как у ОСВ (§3 п. 1, §9.1): ББ/ОФР за квартал версии (`Q1` — только версии за `Q1`, R-14a Среза 4). 5. **Удаление** (`DELETE /files/{id}`, `DOCUMENT_EDIT`): запрещено при `CONFIRMED` документе (`FILE_HAS_CONFIRMED_DOCUMENTS`) и если файл заменил другие (`FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить - можно. Удаляются строки, документы, задания и `stored_file`; объектам S3 ставится `expires_at = now()` (физически не + можно. Удаляются строки, документы (вместе с данными форм `financial_statement_*` и комментариями документов), задания и `stored_file`; объектам S3 ставится `expires_at = now()` (физически не стираются — OQ-111); аудит `FILE_DELETE` с карточкой файла в `before_value`. **OQ-110 закрыт для Среза 4:** к версии привязываются только `CONFIRMED` документы, а файл с таким документом удалить нельзя — удалить файл с документом в версии невозможно; расширять удаление не требуется. @@ -653,7 +654,7 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; ` нужна. 9. **Правка формы ББ/ОФР** (`PATCH /documents/{id}/statement`, `DOCUMENT_EDIT`, Срез 3b; решения владельца 07.10.2026). Правятся **суммы** ячеек (`values[] {line_id, column_id, amount}`) и три поля шапки: `inn` (10 или 12 цифр, иначе 422 - `INVALID_INN`; пересчитывается `counterparty_check`), `report_date` (пересчитываются `period_year`, `period_quarter`, + `INVALID_INN`, в том числе `null` — очистить ИНН нельзя; пересчитывается `counterparty_check`), `report_date` (пересчитываются `period_year`, `period_quarter`, `reporting_date` по правилу периода п. 2) и `units` (непустая строка до 50 символов). Код и название строки, добавление и удаление строк и колонок не правятся. **Только до подтверждения:** документ `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление подтверждённой формы — новым файлом через автозамену, п. 3); файл `SUPERSEDED` — 409 `FILE_SUPERSEDED`; не @@ -666,10 +667,10 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; ` документ). 10. **Исходный файл и комментарии (Срез 3b).** `GET /files/{id}/content` (`DOCUMENT_VIEW`, policy по ЮЛ) отдаёт исходный объект из S3 потоком через ядро (в контуре MinIO для пользователя может быть недоступен, а через ядро действует - видимость); работает и для `SUPERSEDED`; объект истёк или пропал — 404 `FILE_CONTENT_NOT_FOUND`. Комментарии к документу — + видимость); работает и для `SUPERSEDED`; объекта нет в хранилище — 404 `FILE_CONTENT_NOT_FOUND`. Комментарии к документу — `POST/GET /documents/{id}/comments` (таблица `comment`, `object_type = 'document'`); добавлять можно к документу в любом - статусе; правки и удаления нет; при удалении файла (`DELETE /files/{id}`) комментарии его документов остаются в таблице - (связь полиморфная, наружу не отдаются). + статусе; правки и удаления нет; при удалении файла (`DELETE /files/{id}`, п. 5) комментарии его документов удаляются вместе с ними + (связь полиморфная, FK нет — удаляет сервис). --- diff --git a/context/09-delivery-plan.md b/context/09-delivery-plan.md index ef6d197..9716562 100644 --- a/context/09-delivery-plan.md +++ b/context/09-delivery-plan.md @@ -191,7 +191,7 @@ without discussion» №1–№35). Реализация — по `05-domain-rul **Статус: бэкенд и API реализованы в arm-core, октябрь 2026; UI экрана «Проверка распознавания» в срез не входит.** Объём по решению владельца 07.10.2026: весь API пути пользователя до сводной ОСВ включительно. Issue arm-core#36, docs#48; спецификация — -`arm-core/openspec/changes/slice-3b-statements-api/` (design D-1…D-14, «Decisions made without discussion» №1–№19); документы +`arm-core/openspec/changes/slice-3b-statements-api/` (design D-1…D-14, «Decisions made without discussion» №1–№22); документы workspace — bounded-PR в docs (без сквозного change: DBML и контракт не менялись). **Сделано (по факту):** diff --git a/dev/api-contract-slice-3b.md b/dev/api-contract-slice-3b.md index 67e61d9..b5999f0 100644 --- a/dev/api-contract-slice-3b.md +++ b/dev/api-contract-slice-3b.md @@ -2,7 +2,7 @@ Источник — реальный код arm-core (`api/src/schemas/statements.py`, `schemas/comments.py`, `api/endpoints/files/statements.py`, `comments.py`, `read.py`, `users.py`; Swagger — `/docs`) и спека `arm-core/openspec/changes/slice-3b-statements-api/` (design -D-1…D-14, «Decisions made without discussion» №1–№19). Правила предметной области — `context/05-domain-rules.md` §7, §9.3, §9.5 +D-1…D-14, «Decisions made without discussion» №1–№22). Правила предметной области — `context/05-domain-rules.md` §7, §9.3, §9.5 (п. 2, 4, 9, 10); экраны — `context/10-user-journey.md` (пути 1–2, шаг 3.1), номера в `[…]` — снимки макета (`mockup/INDEX.md`). Общие правила (префикс, токен, формат ошибок, права) — как в `dev/api-contract-slice-4.md` §1; здесь — то, что добавил Срез 3b. @@ -74,7 +74,7 @@ D-1…D-14, «Decisions made without discussion» №1–№19). Правила межформенной проверки колонки нет); `accepted` — `{by, at, comment}` (в коде схема `ChecksAcceptedOut`) или `null`. **`StatementPatch`** `{header?: {inn?, report_date?, units?}, values?: [{line_id, column_id, amount}]}` — частичное тело, лишние поля -запрещены (422). `header.inn` — строка из 10 или 12 цифр, `null` очищает; `header.report_date` — дата, `null` очищает; `header.units` — непустая +запрещены (422). `header.inn` — строка из 10 или 12 цифр (любое другое значение, включая `null`, — 422 `INVALID_INN`: ИНН очистить нельзя); `header.report_date` — дата, `null` очищает; `header.units` — непустая строка после обрезки пробелов, ≤ 50 (`null` — «не менять», единицы очистить нельзя; пробельная строка — 422 pydantic-массивом). Поле шапки, которого нет в теле, не меняется. `values` — до 5000 ячеек; одна ячейка дважды в запросе — 422 `DUPLICATE_CELL`; `line_id`/`column_id` не этого документа — 422 `UNKNOWN_CELL`. **`ChecksAcceptIn`** `{comment}`. @@ -124,11 +124,15 @@ expected, actual}` (суммы — строками) внутри `detail`: `{"d (`` и `` без заголовка не сработают). `Content-Type` — по типу файла (`application/pdf`, `image/png`, `image/jpeg`, для xls/xlsx/xlsb — свои MIME). `Content-Disposition`: `attachment` (по умолчанию) или `inline` (для просмотра в браузере) с ASCII-именем `file.` и `filename*=UTF-8''…` — исходным именем в percent-кодировке; берите имя из `filename*`. Ответ несёт - `X-Content-Type-Options: nosniff` (браузер не угадывает тип по содержимому; тип задаёт `Content-Type`). Работает и для файла `SUPERSEDED`. Объекта нет в хранилище (убран ретеншном) — 404 `FILE_CONTENT_NOT_FOUND` (не путать с `NOT_FOUND`: + `X-Content-Type-Options: nosniff` (браузер не угадывает тип по содержимому; тип задаёт `Content-Type`) и `Cache-Control: private, + no-store` (не кэшируйте байты); при `disposition=inline` — ещё `Content-Security-Policy: sandbox` (активное содержимое файла в + браузере не исполняется; проверьте, что выбранный просмотрщик PDF работает при таком заголовке). Работает и для файла `SUPERSEDED`. Объекта нет в хранилище (убран ретеншном) — 404 `FILE_CONTENT_NOT_FOUND` (не путать с `NOT_FOUND`: файл в БД есть, а байтов нет); хранилище не подключено — 503 `STORAGE_UNAVAILABLE`. Скачивание в журнал не пишется (OQ-153). 13. **Комментарии.** Только добавление и чтение; правки и удаления нет. `created_at` — порядок показа (по возрастанию). `text` обрезается по краям, пустой после обрезки или длиннее 4000 символов — 422 в формате pydantic. Автор — пользователь запроса. `comments_count` в карточках — для бейджа; список перечитывается отдельным запросом. + Удаление файла (`DELETE /files/{id}`) удаляет и комментарии его документов, и данные форм ББ/ОФР (`financial_statement_*`): + после этого `GET …/comments` и `GET …/statement` по документам файла — 404. 14. **Имена пользователей.** `GET /users/brief` нужен, чтобы показать «Загрузил» (`uploaded_by`) и авторов правок (`modified_by`, `checks.accepted.by`, `confirmed_by`) без права `USERS_ADMIN`. Отдаёт только найденные id (включая неактивных, `is_active = false`), упорядоченные по `id`, а не в порядке запроса; неизвестные пропускаются без ошибки; пустой `ids` — `[]`; больше 200 id — 422 `TOO_MANY_IDS`; не-UUID — diff --git a/dev/qa-guide.md b/dev/qa-guide.md index 83a7bef..ae6a514 100644 --- a/dev/qa-guide.md +++ b/dev/qa-guide.md @@ -492,12 +492,12 @@ curl -s "localhost:8000/api/v1/documents/$DOC/statement" -H "$H" | python3 -m js | F5 | ka_go | `POST …/statement/checks/accept` `{"comment":"округление в форме клиента"}` | 200; `checks.accepted = {by, at, comment}`, `checks.failed` по-прежнему непуст; статус документа пересчитан (`RECOGNIZED`, если других причин `NEEDS_CHECK` нет); `POST …/confirm` теперь проходит (но F11 делайте после возврата значений) | | F6 | ka_go | снова `PATCH` той же ячейки другой суммой | 200; `checks.accepted = null` (акцепт сброшен правкой) | | F7 | ka_go | `PATCH` той же ячейки обратно на исходную сумму (`original_amount` из F3) | 200; `is_modified = false`, `original_amount` остаётся, `checks.failed` пуст; `PATCH` с пустым телом `{}` — 200, новых записей `RECOGNIZED_DATA_EDIT` нет | -| F8 | ka_go | `PATCH` `{"header":{"inn":"123"}}`; `{"values":[{"line_id":"<случайный uuid>","column_id":"<случайный uuid>","amount":"1"}]}` | 422 `INVALID_INN`; 422 `UNKNOWN_CELL`; данные не изменились | +| F8 | ka_go | `PATCH` `{"header":{"inn":"123"}}` и `{"header":{"inn":null}}`; `{"values":[{"line_id":"<случайный uuid>","column_id":"<случайный uuid>","amount":"1"}]}` | 422 `INVALID_INN` (оба случая); 422 `UNKNOWN_CELL`; данные не изменились | | F9 | ka_go | `PATCH` `{"header":{"report_date":"2025-06-30"}}`, затем вернуть `"2025-09-30"` | период пересчитывается: `period_quarter = Q2` (`reporting_date = 2025-06-30`), `header.report_date.modified = true`, `original_value` не меняется (после возврата на `2025-09-30` — `Q3`, `modified` остаётся `true`); дата не на конец квартала (`2025-09-15`) — 200, `period_quarter = null`, статус `NEEDS_CHECK`, `POST …/confirm` — 409 `PERIOD_REQUIRED` (поправьте дату — подтверждение проходит) | | F10 | ka_go | `POST …/accept` без нарушений | 409 `NOTHING_TO_ACCEPT` | | F11 | ka_go | `POST …/confirm` для чистой формы (после F7/F9 вернуть исходные значения) | 200 `DocumentOut`, `status = CONFIRMED`, `confirmed_by`/`confirmed_at`; повтор — 409 `ALREADY_CONFIRMED`; `PATCH`/`accept` — 409 `DOCUMENT_READONLY`; в БД у шапки `reviewed_by/at` заполнены | | F12 | ka_go | загрузить `1_Форма2_бух_09.xlsx` тому же ЮЛ, дождаться `PROCESSED`; `GET …/statement` | `document_type = INCOME`, `period_quarter = Q3`, 2 колонки, `edition = fsbu4`; у ББ и ОФР поле `checks.cross_form` — либо `null`, либо нарушение `1370 − 1370(начало года) = 2400` (мягкое, не блокирует подтверждение); статус ОФР — `RECOGNIZED` | -| F13 | ka_go | `GET /files/{file_id}/content?disposition=inline` и без параметра | 200, тело байт в байт равно загруженному `.xlsx`, `Content-Type` xlsx; `inline` — `Content-Disposition: inline; filename="file.xlsx"; filename*=UTF-8''<имя>` (кириллица в percent-кодировке); `disposition=foo` — 422 | +| F13 | ka_go | `GET /files/{file_id}/content?disposition=inline` и без параметра | 200, тело байт в байт равно загруженному `.xlsx`, `Content-Type` xlsx, заголовки `X-Content-Type-Options: nosniff` и `Cache-Control: private, no-store`; `inline` — ещё `Content-Security-Policy: sandbox`, `Content-Disposition: inline; filename="file.xlsx"; filename*=UTF-8''<имя>` (кириллица в percent-кодировке); `disposition=foo` — 422 | | F14 | ka_go | `POST /documents/{id}/comments` `{"text":"нет подписи руководителя"}`; `{"text":" "}`; затем `GET …/comments` и `GET /files/{file_id}` | 201 с `author.full_name`; пустой после обрезки текст — 422; в списке один комментарий; в карточке файла и `GET /documents/{id}` у документа `comments_count = 1`; комментарий к `CONFIRMED` документу разрешён | | F15 | ka_go | `GET /users/brief?ids=,<случайный uuid>`; с 201 id; с `ids=abc` | 200, один элемент `{id, full_name, is_active}`; 422 `TOO_MANY_IDS`; 422 `INVALID_IDS`; то же под `expert` (без `USERS_ADMIN`) — 200 | | F16 | ka_go | `GET /directories/legal-entities?q=7016006635` после F1 и F12 | `documents_count = 2` (ББ и ОФР) |