Andrey Epifancev c5aa02c8ec openspec: решения №17–20 (проверка соотношений: цель, словарь, допуск подстрок, валидация кодов)
Refs #14

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 17:36:35 +04:00

35 KiB
Raw Blame History

Design: Срез 2b — парсер ББ и ОФР из Excel

Context

  • Точка подключения прежняя: get_parser(settings) → Parser.parse(data, file_type) -> ParseOutcome; воркер (core/worker.py) вызывает парсер в asyncio.to_thread, собирает конверт и пишет results/{file_id}/{job_id}/result.json. ParseOutcome требует непустые documents при done|needs_check.
  • Сейчас OsvParser.parse делает и оркестрацию книги (лимит, чтение, дедлайн, classify_sheet, цикл по листам), и конвейер листа ОСВ (parse_sheet). Листы SheetKind.STATEMENT пропускаются с предупреждением.
  • Контракт v1.1 уже содержит statementDocument{document_type BALANCE|INCOME|OTHER, locator, header{form_name, organization_name, inn, report_date_text, report_date, units, okud, okpo, okopf, okfs, address, *_confidence}, columns[{column_index, label, period_date}], lines[{line_code, line_name, *_confidence, values[{column_index, amount, string_value, confidence, is_recognized}]}]} — поля «как у OCR». Табличный путь заполняет confidence = 100, is_recognized = true (§12).
  • Реальные файлы (../fixtures/statements-xlsx/**, 15 файлов + 4 шаблона), три раскладки:
    1. 1С-xls постранично (Унагранде Компани, Унагранде Групп): один файл = одна страница формы; на странице 1 титульный блок (организация, ИНН, ОКПО, ОКВЭД, ОКОПФ/ОКФС, единица «в тыс. рублей», ОКЕИ 384, адрес, аудитор с своим ИНН), заголовок таблицы «Пояснения | Наименование показателя | Код | На 31 декабря 2024 г. | … 2023 | … 2022»; на странице 2 только «Форма 0710001 с.2» и та же строка заголовка таблицы. Под строками 1110/1150/1540 — пятизначные подстроки 11101, 11102 (1С «в том числе» без слов), есть строки без кода и без имени с нулями. Значения — рубли с копейками при единице «тыс. руб.».
    2. Многолистовая xlsx ФСБУ 4/2023 (НПЗ): ББ на 5 листах, ОФР на 3; на первом листе титул «Бухгалтерская отчетность за январь - сентябрь 2025 г.» и ниже заголовок «Бухгалтерский баланс | на 30 сентября 2025 г. | 0710001»; листы 2–5 начинаются сразу со строк («в том числе:», 11901, 1100, …) без заголовка; есть строка 1105 «Гудвил», подстроки 12301…12314, 23201…, в ОФР строка без кода с суммами («Реализация иного имущества…»).
    3. TDSheet (Межениновская): вся форма на одном листе, заголовок «БУХГАЛТЕРСКИЙ БАЛАНС / за 9 месяцев 2025 г.», дата тройкой «30 | 9 | 2025» (числа, не строки), колонки «Наименование показателя | Коды | За 9 месяцев\n2025 года | На 31 декабря\n2024 года | …», под шапкой строка нумерации «1 | 2 | 3 | 4 | 5», значения — тысячи с дробной частью.
    4. Шаблоны 66н и ФСБУ 4/2023: заголовок таблицы разнесён на две строки («На» / «20__ г.»), коды есть, сумм нет, лист содержит обе страницы формы с маркером «Форма 0710001 с. 2» посередине; в шаблоне ФСБУ строка «АКТИВ» и код 1110 стоят в одной строке из-за объединений (образец формы, не данные).
  • Параллельно в ветках issues #9/#10/#11 правятся модули ОСВ (header.py, classify.py, checks.py, hierarchy.py, словари ОСВ, golden ОСВ). Этот change их не трогает.

Goals / Non-Goals

Goals: все 15 реальных файлов разбираются без правил под файл, организацию, имя листа или номер строки; полные формы (НПЗ, Межениновская) дают done с сошедшимися жёсткими соотношениями §7; постраничные xls дают документы с данными и честный needs_check для страниц без шапки; формулы §7 и синонимы — данные; конвейер форм изолирован от ОСВ так, что параллельные правки ОСВ не конфликтуют; контракт растёт только MINOR-ом. Non-Goals: см. proposal.

Decisions

D-1. BookParser — оркестратор книги, OsvParser — конвейер листа

book.py: BookParser(osv: OsvParser, statements: StatementParser, rules: Rules, max_bytes, timeout_seconds, clock). parse() повторяет нынешний OsvParser.parse/_parse_book: лимит PARSE_MAX_BYTES → read_book → Deadline → для каждого листа classify_sheet → OSV в osv.parse_sheet, STATEMENT/STATEMENT_CONTINUATION накапливаются и передаются statements.parse_sheets(sheets_in_order, deadline) одним вызовом (склейка продолжений требует видеть соседние листы), OTHER → предупреждение. Документы книги — ОСВ в порядке листов, затем формы в порядке листов. Статус done, если все документы ok; ни одного документа ОСВ или формы → InvalidDocumentError («в книге нет листов ОСВ или форм»). OsvParser теряет parse/_parse_book, оставляет parse_sheet и свойства; get_parser собирает BookParser. NullParser без изменений. Альтернативы: ветка внутри osv.py (смешение доменов, конфликты с #9–#11); два Parser-а с композитом (книга читается дважды, дедлайн и лимиты делятся). Цена ошибки: BookParser тонкий, переносится.

D-2. Классификация листа — три вида для форм, признаки по области до таблицы

detect.classify_sheet(sheet, rules) -> SheetKind расширяется значением STATEMENT_CONTINUATION. Порядок проверки прежний: признак ОСВ → OSV; иначе признак формы (заголовок формы или ОКУД из statement_headers.yaml) в любой строке до первой строки, похожей на заголовок таблицы форм («Код»/«Коды» и справа ячейки-периоды) либо до первой строки с кодом строки формы → STATEMENT; иначе маркер продолжения («Форма 0710001 с.2» / «с. 2») или строка заголовка таблицы форм без заголовка формы или (без заголовка таблицы) колонка, где ≥ 60 % непустых значений — коды форм, → STATEMENT_CONTINUATION; иначе OTHER. Ограничение «первые N строк» для признаков формы снимается: у НПЗ заголовок формы стоит после титульного блока. Признак ОСВ по-прежнему ищется там же, где сейчас (модуль ОСВ не меняется).

D-3. Тип формы и разрез листа

statements/detect.py: find_forms(sheet, rules) -> list[FormBlock(kind: BALANCE|INCOME|DISCLOSURE, title_row, okud, table_header_row, first_data_row, last_row)]. Тип по заголовку/ОКУД из statement_headers.yaml: «Бухгалтерский баланс» / 0710001 → BALANCE; «Отчет о финансовых результатах», «Отчет о прибылях и убытках» / 0710002 → INCOME; «Пояснения к бухгалтерскому балансу», «Расшифровка», «Отчет об изменениях капитала» (0710003), «Отчет о движении денежных средств» (0710004) и прочие ОКУД 0710xxx → DISCLOSURE. Титул «Бухгалтерская отчетность» — не тип: тип берёт ближайший следующий заголовок. Два заголовка форм на листе → два блока, второй начинается со своего заголовка (§12 п. 1). Если ОКУД и текст заголовка противоречат — предупреждение, тип по ОКУД. DISCLOSURE → документ с locator, шапкой (если нашлась) и пустыми columns/lines; на статус книги не влияет.

D-4. Заголовок таблицы, колонка кодов, колонка наименований, колонки-периоды

statements/columns.py. Строка заголовка таблицы — первая после заголовка формы строка, где есть ячейка «Код»/«Коды» (синонимы в словаре) и правее неё хотя бы одна ячейка, матчащая период из statement_periods.yaml; при отсутствии слова «Код» — строка, под которой начинается колонка кодов. Заголовок, разнесённый на две строки (шаблоны: «На» + «20__ г.»; Межениновская: перенос строки внутри ячейки), склеивается: текст ячейки и текст ячейки той же колонки в следующей строке, если та строка не содержит кодов и сумм. Строка нумерации колонок («1 2 3 4 5» подряд) пропускается. Колонка кодов: среди колонок между наименованием и первой колонкой-периодом та, где ≥ 60 % непустых значений в блоке — коды форм (^\d{4}$ или ^\d{5}$ в диапазонах 1100–1799 / 2100–2999 и их пятизначных расширениях, плюс 5xxx для расшифровок); значения читаются как текст (числа 1110.0 → «1110»). Нет такой колонки → документ без строк, needs_check, предупреждение «колонка кодов не найдена» (решение владельца 06.10.2026, вопрос 6). Колонка наименований — ближайшая левее кодов колонка с преобладанием текста; колонка «Пояснения» исключается по заголовку и по тому, что её значения — пустые или короткие (≤ 3 символов). Колонки-периоды — все ячейки заголовка правее кодов с распознанным периодом, по порядку слева направо: column_index 0, 1, 2…; period_date — дата («На 31 декабря 2024 г.», «31.12.2024») или конец периода («За 9 месяцев 2025 г.» → 2025-09-30, «За январь - сентябрь 2025 г.» → 2025-09-30, «За 2024 г.» → 2024-12-31). Ячейки заголовка правее кодов без периода («Изменение», «%») не становятся колонками, их значения не читаются, одно предупреждение на документ. is_current в контракте нет — ядро берёт самую позднюю дату.

D-5. Шапка документа

statements/header.py: область — от начала блока формы до строки заголовка таблицы; для первого блока на листе — от начала листа (титульный блок НПЗ над заголовком формы входит в шапку баланса); шапка другого листа MUST NOT использоваться (у ОФР НПЗ нет метки «ИНН» → inn = null). Метки и регулярки — в statement_headers.yaml; значение метки — ближайшая непустая ячейка правее в той же строке, иначе первая непустая ячейка следующей строки (адрес, имя организации под меткой). Особые правила: inn — 10 или 12 цифр у метки «ИНН», строки после метки «аудитор» исключаются; organization_name — после «Организация», а если справа пусто и строкой выше есть текст с признаком ОПФ (ООО/АО/ПАО/ЗАО/кавычки) — он (НПЗ); okopf/okfs — у общей метки «по ОКОПФ / ОКФС» два числа по порядку; units — текст после «Единица измерения» как есть, ОКЕИ 384/385 рядом только подтверждает; report_date_text — подзаголовок формы («на 31 декабря 2024 г.», «за 9 месяцев 2025 г.», «за январь - сентябрь 2025 г.»), report_date — его дата или конец периода; при отсутствии — тройка «число, месяц, год» у метки «Дата»/«Отчетная дата» (строки или числа). Для ОФР дата отчёта — конец периода. *_confidence = 100 там, где поле найдено, null — где нет. Блок продолжения без шапки → все поля null, form_name из маркера «Форма 0710001 с.2» (тип по ОКУД в маркере).

D-6. Строки и значения

statements/lines.py: строка блока с кодом в колонке кодов → StatementLine(line_code=<текст без пробелов>, line_name=<trim, переносы → пробел>, line_code_confidence=100, line_name_confidence=100); строки без кода (разделы, «в том числе:», пустые с нулями, строка «Реализация иного имущества…» у НПЗ) не отдаются (ASSUMPTION(OQ-36)), их число — в предупреждение «строк без кода пропущено: N» только если среди них есть суммы. Пятизначные подстроки отдаются как обычные строки (решение владельца, вопрос 4). Повтор кода внутри документа → остаётся первая строка, код в checks.duplicate_line_codes, needs_check. Значения: для каждой колонки-периода StatementValue(column_index, amount, string_value, confidence=100, is_recognized): число → amount через numbers.parse_money (Decimal, 2 знака, скобки → минус, пробелы/NBSP убираются), is_recognized = true; пусто/«–»/«-» → amount = null, string_value = null, is_recognized = true; нечисловой текст → amount = null, string_value = <текст>, is_recognized = false. Единицы не нормализуются: если header.units содержит «тыс.» и хотя бы у 10 % значений дробная часть ≠ 0, в предупреждения пишется «N значений с дробной частью при единице тыс. руб.» (решение владельца, вопрос 3; новый OQ в docs).

D-7. Соотношения §7 — данные в statement_checks.yaml

Формат записи: {form: BALANCE|INCOME, relation: "1600 = 1100 + 1200", strict: true|false, abs_terms: [2120, 2210, …]}. Вычисление в statements/checks.py — чистая функция над Mapping[str, Decimal | None] по колонке: отсутствующий код — 0; правая часть считается дважды — как записано и с членами abs_terms по модулю (1С показывает расходы со знаком минус, 66н — в скобках, оба варианта допустимы); соотношение выполнено, если сходится хотя бы один вариант с допуском ±1 в единицах формы (Decimal("1")). Жёсткие (strict): ББ 1100 = 1110 + 1120 + 1130 + 1140 + 1150 + 1160 + 1170 + 1180 + 1190 (+1105 при наличии, ASSUMPTION(OQ-37)), 1200 = 1210 + 1220 + 1230 + 1240 + 1250 + 1260 (+1215), 1300 = 1310 + 1320 + 1340 + 1350 + 1360 + 1370, 1400 = 1410 + 1420 + 1430 + 1450, 1500 = 1510 + 1520 + 1530 + 1540 + 1550, 1600 = 1100 + 1200, 1700 = 1300 + 1400 + 1500, 1600 = 1700; ОФР 2100 = 2110 − 2120, 2200 = 2100 − 2210 − 2220, 2300 = 2200 + 2310 + 2320 − 2330 + 2340 − 2350. Мягкие (strict: false, только предупреждение): 2400 = 2300 − 2410 + 2420 и варианты ФСБУ 4/2023 с 2411/2412/2460 (ASSUMPTION(OQ-12)), 2500 = 2400 + 2510 + 2520 + 2530, и «сумма пятизначных подстрок = родитель» (генерируется по коду: 1110 = 11101 + 11102 + …, только когда подстроки есть). Проверка по каждой колонке отдельно; результат — StatementChecks(relations_failed[{column_index, relation, expected, actual}], soft_relations_failed[…], duplicate_line_codes[]) + строки в предупреждения. Для форм без строк (нет колонки кодов, DISCLOSURE) checks не заполняется.

D-8. Склейка листов-продолжений внутри книги, статус документа

statements/merge.py: parse_sheets идёт по листам книги по порядку; блок STATEMENT открывает документ; лист STATEMENT_CONTINUATION присоединяется к последнему открытому документу формы, если число колонок-сумм в нём совпадает (или заголовка таблицы на листе нет и число колонок с числами совпадает) — строки добавляются в конец, locator = sheet:<первый> + ,<следующие имена> через запятую. Продолжение без открытого документа (книга из одной страницы 2, лист ОСВ между страницами) → документ-одиночка: шапка пустая, тип по ОКУД маркера или OTHER, needs_check, предупреждение «страница-продолжение без шапки: загрузите форму одним файлом» (решение владельца, вопрос 2; переход к признаку is_continuation в контракте — две необязательных поля, без смены логики). Документ ok ⇔ все жёсткие соотношения сошлись по всем колонкам ∧ нет дубликатов кодов ∧ найдена колонка кодов ∧ есть хотя бы одна колонка-период ∧ в шапке есть организация или ИНН ∧ документ не одиночка-продолжение. Шаблоны (коды есть, сумм нет) → все значения null, соотношения «0 = 0» сходятся, шапка пустая → needs_check.

D-9. Контракт v1.2 — модели и копия

api/src/contract/result.py: StatementDocument.document_type: Literal["BALANCE","INCOME","DISCLOSURE","OTHER"]; StatementChecks и StatementDocument.checks: OptionalNotNull[StatementChecks] = None; FailedRelation(column_index, relation, expected: Money, actual: Money); OsvHeader.period_start: date | None, period_end: date | None, is_from_year_start: bool | None. CONTRACT_VERSION = "1.2"; копия схемы и примеры из ../docs/contracts v1.2 (тест идентичности). Заполнение новых полей шапки ОСВ: period_start/period_end — по разобранному периоду (квартал/год → первое и последнее число; диапазон месяцев из issue #11 — его границы), is_from_year_start — period_start = 1 января. Если ветка #11 сливается раньше и уже вычисляет границы диапазона, заполнение делается здесь одной задачей на его результате; если позже — поля в этом change заполняются для квартала/года, а для диапазона — в #11. Поля необязательные, оба порядка совместимы.

D-10. Словари форм — отдельные файлы

statement_headers.yaml (заголовки форм и ОКУД по типам, маркер продолжения, метки шапки, синонимы «Код», «Наименование показателя», «Пояснения», признаки ОПФ, метки-исключения «аудитор»), statement_periods.yaml (месяцы, формы «на <дата>», «за месяцев <год>», «за <месяц> - <месяц> <год>», «за <год> г.», «<дд.мм.гггг>», «<год>»), statement_checks.yaml (D-7). periods.yaml ОСВ не переиспользуется и не правится — ветка #11 его меняет. Загрузчик rules.py расширяется моделями форм; тест покрытия словарей — на объединении файлов statements-xlsx/** и синтетики.

D-11. Тесты, golden, синтетика

Unit (tests/unit/parser/statements/) на gridbuilder; property (tests/fixtures/test_statements_property.py, маркер fixtures) по манифесту tests/fixtures/statements_manifest.yaml (каталог → организация, ИНН, ожидаемые типы и статусы, полная форма или страница); golden ../fixtures/golden/expected_statement_parse/<org>/<period>/<file>.json через обобщённые golden_helpers и make golden-update; синтетика в tests/synthetic/generate.py: st01_both_forms_one_sheet, st02_csv_like, st03_no_codes, st04_mixed_book (ОСВ + ББ), st05_continuation_only; линтер «нет координат» включает statements/** и book.py; интеграционный тест воркера: книга НПЗ → done, result_schema_version = "1.2", два документа. CLI печатает формы так же, как ОСВ.

Risks / Trade-offs

  • [Колонка кодов не найдена из-за кодов-чисел или объединений] → коды читаются как текст из любого типа ячейки, merged берётся по origin; синтетика st02. Остаточный риск закрывается фикстурой + синонимом, не if.
  • [Соотношения §7 не сходятся из-за знаков 1С] → двойной расчёт с abs_terms; при несходимости обоих — needs_check, перечень в checks; знак не угадывается.
  • [Постраничные xls всегда needs_check] → осознанно (вопрос 2); ядро в Срезе 3 решит склейку; данные не теряются.
  • [Конфликт с ветками #9–#11 в osv.py/test_osv_parser.py] → правило при rebase: логика ОСВ из их ветки, оркестрация книги отсюда; PR создаётся после rebase на main.
  • [Контракт 1.2 ломает идентичность копии в arm-core и scan-gateway] → bounded-PR с копией схемы и моделями раньше мержа парсера; порядок merge docs → arm-core/scan-gateway → tabular-parser → fixtures.
  • [Большие книги с формами] → те же лимиты и дедлайн, что у ОСВ; формы маленькие (≤ 100 строк на лист).

Migration Plan

Не требуется: сервис без состояния. После merge — пересборка образа; потребители контракта обновляют копии раньше.

Open Questions

Не блокируют: OQ-12 (знаки 2410/2420/2430 — мягкие проверки), OQ-36 (строки без кода — не отдаются), OQ-23 (расшифровки — только классификация), OQ-37 (1105 как есть), OQ-131 (границы диапазона — D-9). Новые вопросы вносятся в 07-open-questions.md сквозным change'ем: единицы «тыс. руб.» против значений с копейками в 1С-выгрузках; форма без колонки кодов (сопоставление по именам); страница-продолжение в отдельном файле (склейка в ядре, признак в контракте); строка без кода с суммами в ОФР ФСБУ (НПЗ «Реализация иного имущества…»); шаблон ФСБУ с «АКТИВ» и 1110 в одной строке.

Decisions made without discussion

№ Дата Решение Обоснование Цена ошибки
1 2026-10-06 D-2: продолжение определяется маркером «с.2» либо заголовком таблицы/колонкой кодов без заголовка формы у НПЗ листы 2–5 без маркера, у 1С — с маркером добавить признак в словарь
2 2026-10-06 D-3: 0710003/0710004 и прочие ОКУД 0710xxx → DISCLOSURE, не OTHER DBML различает расшифровку и прочее; ОДДС/ОИК для консолидации не нужны переназначить тип
3 2026-10-06 D-4: порог 60 % кодов в колонке и диапазоны 1100–1799 / 2100–2999 (+5xxx) §12 п. 2; 1105 и 1215 внутри диапазона подвинуть границы в словаре
4 2026-10-06 D-4: колонка заголовка без периода не читается, одно предупреждение §12 п. 4 «в extra», но у statementDocument поля extra нет; MINOR ради диагностики не оправдан добавить extra в v1.x
5 2026-10-06 D-5: ИНН аудитора исключается по метке «аудитор» выше иначе у Унагранде второй ИНН в шапке синоним в словаре
6 2026-10-06 D-6: порог 10 % значений с дробной частью для предупреждения о единицах единичные копейки в тысячах бывают от округления число в словаре
7 2026-10-06 D-7: допуск ±1 единица формы, отсутствующий код = 0, двойной расчёт с abs_terms §7 «расхождение > 0», но формы в тыс. руб. округляются построчно; знаки 1С vs 66н допуск в словаре
8 2026-10-06 D-7: «сумма подстрок = родитель» — мягкая проверка, генерируется по коду подстроки бывают неполными («прочее» без кода) перевести в строгие
9 2026-10-06 D-8: критерий ok документа включает «в шапке есть организация или ИНН» без шапки ядро не сверит владельца; страница 2 должна быть needs_check ослабить критерий
10 2026-10-06 D-9: period_start/period_end/is_from_year_start в шапке ОСВ добавляются в контракт здесь, заполнение делится с #11 владелец: поля в v1.2 (вопрос 5) follow-up на заполнение
11 2026-10-06 D-1: документы книги — сначала ОСВ, потом формы детерминированный порядок для golden порядок листов
12 2026-10-06 Контракт v1.2 раскатан в arm-core (PR #27) и scan-gateway (PR #5) копиями; в scan-gateway вместе с v1.2 догнан v1.1 (OsvRow.extra, пример result-osv-needs-check.json), CONTRACT_VERSION обоих репозиториев не менялся копия схемы обязана быть байт в байт с docs; потребители версию не повышают, пока не пишут новых полей откат отдельным коммитом, поле необязательное (R-2)
13 2026-10-06 Минор-находки ревью задач 1–2 по $comment схемы («1.1»), статусу примера excel и checks в OCR-примере minimal отложены (правка в docs/contracts + пересинхронизация копий) план запрещал трогать $comment; примеры предписаны планом косметика примеров; ядро/парсер не затронуты (T1.1, T2.2, T2.3)
14 2026-10-06 Golden schema_version 1.1 → 1.2 (113 файлов) пересозданы сразу и закоммичены в ветку feat/14-statements-golden worktree fixtures-wt (шаг 1 задачи 13 выполнен раньше), симлинк fixtures переключён на worktree; основной checkout fixtures не тронут смена CONTRACT_VERSION ломала golden до задачи 14, make check был бы красным на задачах 4–13 коммит в fixtures-ветке раньше плана; откат — reset ветки (R-3)
15 2026-10-06 docs/contracts/** после задачи 1 заморожены: в задаче 16 не правятся; T1.1/T2.1/T2.2/T2.3 остаются deferred и уходят в follow-up копии схемы в arm-core#27 и scan-gateway#5 на ревью и должны остаться байт в байт равными docs/contracts на main три косметических замечания живут до следующего MINOR (R-4, указание координатора)
16 2026-10-06 Находка T6.I1 (osv.py не знает SheetKind.STATEMENT_CONTINUATION) не чинится в задаче 6, закрывается заменой osv.py::parse на BookParser в задаче 7 реальные фикстуры ОСВ листов-продолжений не содержат, промежуточное состояние невидимо если T7 не покроет — поймает финальное ревью (R-5)
17 2026-10-06 Task 11: соотношение проверяется в колонке, только если присутствует его цель (строка-итог); отсутствующие члены = 0; подстрочные правила — когда подстроки есть код и тесты плана противоречили друг другу (тесты 1/4 требуют пропуска цели); фрагменты форм (страница 1 ОФР без 2200) не должны давать ложных нарушений итоговая строка, пропущенная при наличии членов, этим соотношением не ловится (R-6)
18 2026-10-06 Task 11: словарь statement_checks.yaml не ослаблен — жёсткие соотношения ББ 1100…1500 и ОФР 2100…2300 остаются strict: true; данные теста плана исправлены спека D-7 и решение №7; ослабление словаря ради зелёного теста — не решение исполнителя меняется одна строка yaml (R-7)
19 2026-10-06 Task 11: для подстрок «сумма подстрок = родитель» действует общий допуск ±1, не нулевой спека: допуск ±1 без исключений; формы в тыс. руб. округляются построчно расхождение в 1 единицу у подстрок не ловится (R-8)
20 2026-10-06 Task 11: _RELATION/_TERM допускают пятизначные коды справа (предписано планом), но явные правила из yaml валидатор загрузки ограничивает четырёхзначными подстрочные правила генерируются кодом; спека: словарь — только четырёхзначные пятизначный код в yaml отвергается при загрузке (R-9)