Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
172 lines
31 KiB
Markdown
172 lines
31 KiB
Markdown
# Design: Срез 3a — горячие папки как у armka_file_worker и нормализатор OCR
|
||
|
||
## Context
|
||
|
||
Срез 0 построил конвейер scan-gateway (ack → S3 → горячая папка → опрос → `raw/` → нормализатор → конверт) с
|
||
`NullNormalizer`. Контракт v1.2 (Срез 2b) определил `statementDocument`: `header`, `columns[{column_index, label,
|
||
period_date}]`, `lines[{line_code, line_code_confidence, line_name, line_name_confidence, values[{column_index, amount,
|
||
string_value, confidence, is_recognized}]}]`, необязательный `checks`. Реальные ответы ИС (`../fixtures/ocr/aep_3.json`,
|
||
`aep_6.json`) — `data_ocr {label, organization, inn, units, date, table_structure[{label, rows[{row_index, cells[{column_index,
|
||
value, confidence}]}]}]}`; текст в ячейках зашумлён (двойники букв и цифр, `|` как границы клеток, переносы строк, скобки),
|
||
в `aep_3.json` у таблицы нет строки-шапки. Старое ядро (`DKB_ARMKA`, `services/ofr_result_conversion/`) решает эти
|
||
задачи кодом, проверенным на стенде ИС Сканирование; владелец 06.10.2026 разрешил его перенос (§11.1).
|
||
|
||
## Goals / Non-Goals
|
||
|
||
Goals: PDF-путь отдаёт ядру тот же `statementDocument` v1.2, что Excel-путь; раскладка горячих папок совпадает с
|
||
проверенным в контуре FileWorker; перенос проверенных функций очистки без их DB-обвязки; dev-стенд с эмулятором
|
||
работает на реальных парах PDF+JSON.
|
||
Non-goals: см. `proposal.md`.
|
||
|
||
## Decisions
|
||
|
||
### D-1. Имена в горячих папках как у FileWorker, временный файл остаётся в `Tmp` (решение владельца)
|
||
`put(job_id, ext, data)` пишет `<Tmp>/.<job_id>.<ext>.part`, затем rename без замены в `<Input>/<job_id>.<ext>`.
|
||
Расширение — суффикс `original_name` запроса в нижнем регистре; без суффикса — `RECOGNITION_FAILED` (ИС различает
|
||
форматы по расширению). Назначение уже существует (повторная публикация задания после потери ack) → файл считается
|
||
доставленным, `.part` остаётся до очистки. Владелец отклонил `.tmp` рядом с назначением (как у FileWorker): ИС может
|
||
подхватить недописанный файл; FileWorker полагается на игнор `*.tmp`, у нас такой гарантии нет. `poll` без изменений.
|
||
|
||
### D-2. Очистка — отдельный метод под флагом `SCAN_CLEANUP` (решение владельца)
|
||
`cleanup(job_id, ext)` удаляет `Output/<job_id>/*.json`, каталог `Output/<job_id>`, `<Input>/<job_id>.<ext>`,
|
||
`<Input>/<job_id>.<ext>.done` (след эмулятора) и `<Tmp>/.<job_id>.<ext>.part`. Вызывается воркером только после того,
|
||
как набор ответа прочитан и склеен в `raw/` (любой исход разбора), и только при `SCAN_CLEANUP=true`. При `TIMEOUT`,
|
||
ошибке чтения S3, ошибке записи в горячую папку — не вызывается. Любое удаление, включая `.part`, подчиняется флагу: на
|
||
SMB-шаре контура права на удаление не подтверждены. Ошибки удаления — одна строка лога на задание, исход задания и
|
||
`/healthcheck` не меняются. По умолчанию `false`; dev-compose — `true`.
|
||
|
||
### D-3. Эмулятор ищет пару по sha256 содержимого, при промахе — набор по умолчанию (решение владельца, вариант 3)
|
||
Имя входящего файла больше не несёт имени фикстуры. Эмулятор при старте индексирует `SCAN_FIXTURES_DIR`: для каждого
|
||
файла, не являющегося `*.json`, считается sha256 → основа имени `<name>` (без расширения); индекс пересчитывается,
|
||
если изменился список файлов каталога. Для входящего `<uuid>.<ext>` считается sha256; совпадение → набор `<name>.json` +
|
||
`<name>_*.json` по прежним правилам (`<uuid>.json`, `<uuid>_<suffix>.json`, `file_uid = <uuid>`); промах →
|
||
`SCAN_EMULATOR_DEFAULT=<name>` (если задан и его набор существует) → иначе ответ-ошибка `INVALID_DOCUMENT` с текстом «нет
|
||
пары по содержимому». Владелец дополнит пары PDF+JSON в fixtures (сейчас пары есть для `aep_3`, `aep_5`, `aep_6`, `aep_8`).
|
||
|
||
### D-4. Нормализатор — конвейер чистых функций, проверенные куски переносятся дословно (решение владельца, вариант B)
|
||
`src/normalizer/ocr/`:
|
||
- `text.py` — `fix_lookalikes` (замена двойников только вплотную к цифре, несколько проходов; `|` как разделитель, если
|
||
первая строка состоит из цифр, пробелов, `|`, `•`, `·`, точек, двоеточий, запятых), `clean_value` (буквы, цифры, пробел,
|
||
точка; `\n`/`\t` → пробел), `digits_only`, `normalize_header` (нижний регистр, не-буквенно-цифровое → пробел).
|
||
- `dates.py` — `parse_date(text) -> date | None`: текстовые формы по приоритету, затем числовые; дословный перенос
|
||
`date_utils._parse_date` и паттернов. Паттерны остаются в коде (регулярки с лямбдами).
|
||
- `amounts.py` — `parse_amount(text) -> ParsedAmount(amount: Decimal | None, verified: bool)`; `is_blank` (прочерки).
|
||
- `layout.py` — `ColumnLayout(label_idx, code_idx, value_idxs)`, `build_layout(cells)` по строке с ячейкой «код…»,
|
||
`infer_layout(rows_cells, known_codes)` по данным; `cells` — `dict[int, Cell(value, confidence)]`.
|
||
- `rows.py` — `parse_table_rows(rows, known_codes) -> list[ParsedRow] | None` (первая строка — шапка настоящая или
|
||
синтетическая, повторная шапка перезаписывает раскладку), `classify_row` (`HEADER`/`TOTAL`/`BALANCE`/`ROW`),
|
||
`table_type_by_codes`.
|
||
- `header.py` — `parse_file_header(data_ocr) -> FileHeader` (организация, ИНН, единицы, дата отчёта, их `confidence` и
|
||
признаки «распознано» по §11.1 п. 8).
|
||
- `document.py` — `build_document(table, index, file_name, file_header, rules) -> BuiltDocument` (документ контракта +
|
||
предупреждения + флаг «всё распознано»).
|
||
- `__init__.py` — `OcrNormalizer(rules)`: обход `files` склейки по возрастанию имени, сбор документов, статус (D-6),
|
||
`metrics`. Исключение при разборе одной таблицы → предупреждение `BACKEND_PARSING_ERROR`, остальные таблицы
|
||
разбираются; исключение вне таблицы → как в Срезе 0 (воркер отвечает `UNKNOWN_ERROR`, `raw/` уже сохранён).
|
||
Все функции без I/O и настроек; правила (D-7) передаются объектом. Перенос с именами без подчёркиваний, типами и
|
||
docstring'ами на русском; алгоритмы и регулярки — как в источнике, расхождения фиксируются в тестах.
|
||
|
||
### D-5. `checks` в документе scan-gateway отсутствуют (решение владельца)
|
||
Отсутствие `checks` по контракту означает «не проверялось». Соотношения §7 для обоих источников считает ядро в 3b;
|
||
дублировать `statement_checks.yaml` из 2b в scan-gateway не нужно. Чтобы не конфликтовать: ядро не должно полагаться на
|
||
`checks` как на единственный источник, это требование уйдёт в change 3b.
|
||
|
||
### D-6. Статусы и предупреждения
|
||
Файлы ответа: правила Среза 0 без изменений (ошибочные файлы, `error` при всех ошибочных, строки `metrics.warnings`).
|
||
Из успешных файлов собираются документы по одному на таблицу:
|
||
- ни одной таблицы не разобрано (нигде нет столбца кода, `table_structure` пусты) → `status = error`,
|
||
`error.code = RECOGNITION_FAILED`, `error.message = "ни одной таблицы не разобрано"`, причины по таблицам — в warnings;
|
||
- есть документы → `done`, если у всех документов `inn_confidence` и `organization_name_confidence` равны 100,
|
||
`report_date` и все `period_date` заполнены, все `line_code_confidence`/`line_name_confidence` равны 100, все значения
|
||
`is_recognized`, тип совпал с `label`, редакция определена по дате и заполнена, и нет предупреждений уровня таблицы;
|
||
иначе `needs_check`. Единицы (`units`) в критерий не входят: у поля нет `confidence` в контракте.
|
||
Предупреждения уровня таблицы: `<имя json>: table:<i>: <текст>` — таблица пропущена (нет шапки и столбца кода), шапка
|
||
синтезирована (колонки без дат), тип по кодам разошёлся с `label`, колонка без даты, редакция определена по кодам,
|
||
редакция `fsbu4` не заполнена, код из другой редакции, `BACKEND_PARSING_ERROR: <класс>: <сообщение>`. Уровня файла:
|
||
`<имя json>: ИНН не распознан`, `<имя json>: дата отчёта не разобрана`. Длина и число строк — как в Срезе 0 (`clean_text`).
|
||
`metrics.rows` — сумма строк документов. Документ `OTHER` (тип не определён) включается с разобранным содержимым и даёт
|
||
`needs_check`.
|
||
|
||
### D-7. Правила — данные в `api/rules/`, справочник кодов с редакциями (решение владельца)
|
||
- `statement_lines.yaml`: `editions: [{id: 66n, valid_until: 2024-12-31, lines: {...}}, {id: fsbu4, valid_from:
|
||
2025-01-01, lines: {}}]`. `66n` — из `codes_mapping` старого ядра с исправленными опечатками; `fsbu4` — пустая, данные
|
||
положит владелец, код не меняется. Выбор редакции для документа: по `report_date` шапки → по самой поздней
|
||
`period_date` колонок → по совпадениям кодов (с предупреждением). Пустая выбранная редакция → предупреждение
|
||
«редакция fsbu4 не заполнена», имена из OCR, `needs_check`. Код не в выбранной редакции, но есть в другой →
|
||
предупреждение, `needs_check`; код нигде → имя из ячейки OCR, `line_code_confidence = min(confidence ячейки кода, 99)` (решение №19).
|
||
- `ocr_lookalikes.yaml`: пары «символ → цифра» и список символов замены.
|
||
- `statement_labels.yaml`: нормализованные подстроки `label` таблицы → `BALANCE` / `INCOME`.
|
||
Загрузка один раз при старте (`SCAN_RULES_DIR`, по умолчанию `rules/` сервиса), pydantic-валидация; битый yaml — ошибка
|
||
старта. Тест покрытия: коды 4–5 цифр, имена непустые, дубли отсутствуют, обе редакции валидируются одинаково.
|
||
|
||
### D-8. Тесты
|
||
Юнит по каждому модулю `normalizer/ocr/`; golden на реальных фикстурах `../fixtures/ocr/*.json` с эталонами
|
||
`../fixtures/ocr/expected_normalize/<name>.json` (формат — файл результата: `status`, `documents`, `warnings`;
|
||
структурное сравнение, обновление — `make golden-update`); протокольные тесты нормализатора переводятся на
|
||
`OcrNormalizer`; тесты горячих папок, эмулятора, воркера обновляются; `test_roundtrip.py` на `aep_6.pdf` до конверта с
|
||
документами. Интеграция не запускается одновременно с интеграцией arm-core (OQ-83).
|
||
|
||
### D-9. Настройки и соседние репозитории
|
||
`SCAN_CLEANUP=false`, `SCAN_RULES_DIR`, `SCAN_EMULATOR_DEFAULT=""`; `SCAN_TMP_DIR` сохраняется. Ветка — от main после
|
||
мержа scan-gateway #5 (копия схемы 1.2). fixtures: пары PDF+JSON от владельца и `expected_normalize/` — отдельный PR.
|
||
docs: bounded-PR (`03-architecture.md`, OQ-137, §11.1 п. 6 и 9, новые OQ).
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- Реальные сканы почти всегда дадут `needs_check` — это ожидаемо; `done` означает лишь отсутствие подсветок в 3b.
|
||
- Пустая редакция `fsbu4` до заполнения: документы 2025 года идут с именами из OCR. Цена — больше ручной проверки, не
|
||
потеря данных.
|
||
- `|`-эвристика и двойники могут исказить редкие корректные значения; `string_value` хранит очищенный, а `raw/` — сырой
|
||
текст, аналитик видит оба.
|
||
- Rename без замены на SMB: `smbclient.rename` поднимает `FileExistsError`; при гонке с ИС, уже забравшей файл,
|
||
назначения может не быть — тогда `.part` переименовывается повторно, это идемпотентно.
|
||
|
||
## Migration Plan
|
||
|
||
Не требуется: сервис без БД; изменение имён файлов видят только ИС и эмулятор; контракт не меняется. Dev-стенд: после
|
||
мержа обновить `docs/dev/.env.example` и compose (`SCAN_CLEANUP=true`, `SCAN_EMULATOR_DEFAULT` по желанию).
|
||
|
||
## Open Questions
|
||
|
||
1. Права на удаление на SMB-шаре контура (новый OQ) — пока `SCAN_CLEANUP=false`.
|
||
2. Нужна ли ядру редакция формы в конверте (кандидат в MINOR 1.3) — новый OQ.
|
||
3. Словари §7 и §12 по редакциям (2b, 3b) — новый OQ, расширение OQ-12.
|
||
4. Состав редакции `fsbu4` — данные владельца.
|
||
5. Скобки, распознанные как `}` / `]` (`(7744536}` в `aep_3.json`): дословный `parse_amount` минус не ставит (решение №12).
|
||
Владелец 06.10.2026: «пока устроит как есть», вынести в follow-up — новый OQ в `07-open-questions.md` (расширить
|
||
множество закрывающих скобок в §11.1 п. 7 после проверки на стенде).
|
||
|
||
## Decisions made without discussion
|
||
|
||
| № | Дата | Решение | Обоснование | Цена ошибки |
|
||
|---|---|---|---|---|
|
||
| 1 | 2026-10-06 | Один `statementDocument` на каждую таблицу `table_structure[i]`; `locator = "file:<имя json>;table:<i>"` | §11: тип документа задаёт `label` таблицы; `locator` нужен ядру для привязки к `raw/` | смена формата `locator` |
|
||
| 2 | 2026-10-06 | Расширение входящего файла — суффикс `original_name` в нижнем регистре; без суффикса `RECOGNITION_FAILED` | ИС различает форматы по расширению; FileWorker делает так же | потеря заданий без расширения — их не бывает (ядро проверяет сигнатуру) |
|
||
| 3 | 2026-10-06 | Очистка не вызывается при `TIMEOUT` и транспортных ошибках | ИС может ещё писать ответ; FileWorker чистит только после результата | мусор в папке при таймаутах |
|
||
| 4 | 2026-10-06 | Исключение при разборе одной таблицы — предупреждение, остальные таблицы разбираются; исключение вне таблиц — `UNKNOWN_ERROR` воркера | §11.1 п. 9, поведение старого ядра по файлам | скрытая ошибка в предупреждении вместо конверта `error` |
|
||
| 5 | 2026-10-06 | Документ без строки-шапки отдаётся с синтетической шапкой и колонками без `period_date`, `needs_check` | рулинг §11.1 п. 3 | аналитик расставляет даты руками |
|
||
| 6 | 2026-10-06 | Выбор редакции: `report_date` → поздняя `period_date` → совпадения кодов | дата отчёта надёжнее кодов; коды — последний резерв | неверная редакция при мусорной дате; тогда предупреждение по кодам |
|
||
| 7 | 2026-10-06 | Паттерны дат остаются в коде, двойники и `label` — в yaml | регулярки с лямбдами в yaml нечитаемы; таблицы символов — данные | править даты — через код |
|
||
| 8 | 2026-10-06 | `OTHER` включается в `documents`, а не отбрасывается | как `OTHER` у Excel-пути 2b; ядро решает | лишний документ на экране проверки |
|
||
| 9 | 2026-10-06 | Эмулятор пересчитывает индекс хешей при изменении списка файлов каталога, а не на каждый файл | дёшево и достаточно для dev | изменённый, но не переименованный PDF не переиндексируется |
|
||
| 10 | 2026-10-06 | `label` колонки — сырой текст шапки с `\n` → пробел; `period_date` из `parse_date` | ядро показывает текст как есть | — |
|
||
| 12 | 2026-10-06 | `parse_amount` переносится дословно: скобка, распознанная как `}` (`(7744536}`), минус не даёт — сумма положительная с `is_recognized = false` | проверенный код; расширение множества скобок — отдельное правило §11.1 после проверки на стенде | знак потерян, аналитик видит `raw/` и подсветку |
|
||
| 13 | 2026-10-06 | В переносе `parse_amount` признак `verified` считается до постановки знака минус (в источнике — после, и любая сумма в скобках получала `verified = False`) | иначе каждая отрицательная сумма формы давала `is_recognized = false` и `needs_check` без причины; единственное отличие от источника, зафиксировано тестом `(6032409) → -6032409, verified` | расхождение с поведением старого ядра на стенде: там такие суммы подсвечивались, у нас — нет |
|
||
| 14 | 2026-10-06 | `CONTRACT_VERSION` scan-gateway остаётся `1.0`, хотя копия схемы — v1.2 | документы 3a не используют полей 1.1–1.2 (`DISCLOSURE`, `checks`, границы периода ОСВ); потребитель принимает любой MINOR; меньше изменений в тестах конверта | если ядро начнёт различать MINOR производителя — одна константа и проверка в `worker_helpers` |
|
||
| 15 | 2026-10-06 | Повтор кода строки в таблице OCR: остаётся первая строка, предупреждение, `needs_check` | DBML: уникальный индекс `financial_statement_line (meta_id, line_code)` — без этого маппинг 3b падает; Excel-путь 2b делает так же (§12 п. 5); старое ядро дубли не убирало | вторая строка с тем же кодом теряется в документе (остаётся в `raw/`) |
|
||
| 16 | 2026-10-06 | `string_value` заполняется только при `amount = null` и непустом не-прочерке, текстом как распознан (`\n`/`\t` → пробел); у разобранных сумм — `null` | DBML `financial_statement_value.string_value`: «как распознано, если не число»; так же в 2b; первичная редакция спеки и старое ядро писали очищенный текст всегда | аналитик не видит сырой текст суммы на экране (он в `raw/`) |
|
||
| 11 | 2026-10-06 | Имя строки: из словаря редакции по коду; из OCR — только если кода нет в словаре | старое ядро, §11.1 п. 6 | расхождение имени словаря и формы клиента не видно аналитику (видно в `raw/`) |
|
||
| 17 | 2026-10-07 | Запись во входящий каталог «без замены» реализуется как `os.link(.part → назначение)` + `unlink(.part)` (локальная ФС), с запасным путём `os.replace` при `EPERM/EXDEV/ENOTSUP`, если назначения нет; на SMB — `smbclient.rename` | POSIX `rename` перезаписывает; жёсткая ссылка атомарно даёт `FileExistsError`; в D-1 сказано «rename без замены» — это его реализация | на ФС без жёстких ссылок окно гонки (проверка существования и `replace`) — повтор задания с тем же `job_id` редок |
|
||
| 18 | 2026-10-07 | R-1. Тест плана `test_aep3_like_rows_without_header` требовал `5 in layout.value_idxs`; ожидание заменено на `layout.value_idxs == (2, 4)`, алгоритм `infer_layout` не менялся | столбец 5 содержит только мусор («:», «+ :») и пустые ячейки: по дословному `_infer_layout` суммы (0) < мусор (2) — столбец не значение; ошибка плана, а не переноса | если реальный `aep_3.json` даст иной набор колонок — выяснится в golden (Task 10) |
|
||
| 19 | 2026-10-07 | R-2. `line_code_confidence = confidence ячейки кода` только если очищенный текст равен сырому И код есть в выбранной редакции; иначе `min(confidence, 99)`; `line_name_confidence` словарного имени равна `line_code_confidence`, имени из OCR — `confidence` ячейки наименования | требование «Нормализация ответа ИС» спеки; код плана капил уверенность только при мусоре в тексте кода, тест плана ожидал 99 для кода вне справочника | код вне справочника чаще даёт `needs_check` — это то, что нужно аналитику |
|
||
| 20 | 2026-10-07 | О-3 (наблюдение, не меняет поведение): прочерк «—»/«–» при `confidence = 100` даёт `is_recognized = false` (дословный `parse_amount` вырезает тире → `verified = false`), дефис «-» — `true` | дословный перенос и формулировка спеки «разбор не отбрасывал символов» | лишний `needs_check` на формах с длинным тире; правило уточняется в §11.1 после проверки на стенде |
|
||
| 21 | 2026-10-07 | R-3. `values` строки строятся по позиции колонки документа; при ином числе колонок у раскладки строки после повторной шапки — недостающие как «ячейки нет», лишние отбрасываются, одно предупреждение на таблицу, `needs_check` | спека: «values — по одному на колонку»; код плана давал значения по индексам раскладки строки и мог породить `column_index`-сироту, на которой упадёт уникальность `(meta_id, column_index)` в 3b | значения «лишних» колонок повторной шапки теряются в документе (остаются в `raw/`) |
|
||
| 22 | 2026-10-07 | R-4. `fix_lookalikes` — только для дат (дата отчёта, подписи колонок); коды — `clean_value`, суммы — `parse_amount`, ИНН — `digits_only`. Формулировка спеки про коды и суммы исправлена | §11.1 п. 1 и проверенный на стенде старый код; замена `S→5` и т. п. в суммах не проверена на стенде | код с латинской `O` вместо нуля отбрасывается как не-целое (остаётся в `raw/`) |
|
||
| 23 | 2026-10-07 | R-5. Предупреждения «код N из редакции X» не выдаются, если выбранная редакция пуста | до заполнения `fsbu4` каждая строка 2025 года давала бы шумное предупреждение (на `aep_6` 39+25), информативно одно «редакция <id> не заполнена» | при пустой редакции аналитик не видит, что код из другой редакции |
|
||
| 24 | 2026-10-07 | `header.form_name` не передаётся, если у таблицы нет `label` | контракт запрещает `null` в `form_name` (`OptionalNotNull`) | поле отсутствует вместо `null` |
|
||
| 25 | 2026-10-07 | R-6. Таблица без колонок значений (`columns = []`) → предупреждение «нет колонок значений», `needs_check` | иначе документ без колонок мог оказаться `done` | — |
|
||
| 26 | 2026-10-07 | R-8. `.part`, ставший целью успешной публикации (`os.link` → назначение), снимается самой записью независимо от `SCAN_CLEANUP`; ошибка его снятия подавляется. Флаг управляет остатками: осиротевшие `.part`, `.part` при уже существующем назначении, ответы, входящий файл, `.done` | иначе при `SCAN_CLEANUP=false` на каждое задание оставалась бы жёсткая ссылка; ошибка удаления на шаре с неподтверждёнными правами не должна превращать доставленный файл в `RECOGNITION_FAILED` | на ФС без права удаления в `Tmp` у каждого задания останется `.part`-ссылка (место не занимает) |
|
||
| 27 | 2026-10-07 | R-9. При `SCAN_CLEANUP=false` сервис ничего не удаляет и не логирует «осиротевший `.part`»; сценарий «Очистка выключена» приведён к «все файлы остаются на месте» | в слое горячих папок нет логгера, а поиск сирот потребовал бы обхода `Tmp` на каждое задание; ценность — только диагностическая | сироты в `Tmp` при выключенной очистке накапливаются без заметки в логе; чистятся вручную или включением флага |
|
||
| 28 | 2026-10-07 | R-10. Ошибочный файл набора при хотя бы одном успешном понижает статус до `needs_check` (`done` невозможен) | спека «Ошибка одного из документов»: потерянный документ (например, ОФР) не должен остаться незамеченным под `done`; строка об ошибке уже в `metrics.warnings` | чаще `needs_check`; в D-6 («только документы») — уточнение |
|
||
| 29 | 2026-10-07 | R-11. На таблицу — одно предупреждение `пропущено строк с нечисловым кодом: N` для строк с непустым нечисловым кодом, отброшенных `classify_row`; строки с пустым кодом — без предупреждения | иначе строки данных с мусорным кодом (aep_3: «Выручка» `211(1 |`, `БАЛАНС`) терялись молча; поведение источника (отбрасывание) не меняется | таблица с отброшенными строками всегда `needs_check`; восстановление таких строк — OQ-149 |
|