Refs #14 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
32 KiB
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С-xls постранично (Унагранде Компани, Унагранде Групп): один файл = одна страница формы; на странице 1 титульный блок (организация, ИНН, ОКПО, ОКВЭД, ОКОПФ/ОКФС, единица «в тыс. рублей», ОКЕИ 384, адрес, аудитор с своим ИНН), заголовок таблицы «Пояснения | Наименование показателя | Код | На 31 декабря 2024 г. | … 2023 | … 2022»; на странице 2 только «Форма 0710001 с.2» и та же строка заголовка таблицы. Под строками 1110/1150/1540 — пятизначные подстроки 11101, 11102 (1С «в том числе» без слов), есть строки без кода и без имени с нулями. Значения — рубли с копейками при единице «тыс. руб.».
- Многолистовая xlsx ФСБУ 4/2023 (НПЗ): ББ на 5 листах, ОФР на 3; на первом листе титул «Бухгалтерская отчетность за январь - сентябрь 2025 г.» и ниже заголовок «Бухгалтерский баланс | на 30 сентября 2025 г. | 0710001»; листы 2–5 начинаются сразу со строк («в том числе:», 11901, 1100, …) без заголовка; есть строка 1105 «Гудвил», подстроки 12301…12314, 23201…, в ОФР строка без кода с суммами («Реализация иного имущества…»).
TDSheet(Межениновская): вся форма на одном листе, заголовок «БУХГАЛТЕРСКИЙ БАЛАНС / за 9 месяцев 2025 г.», дата тройкой «30 | 9 | 2025» (числа, не строки), колонки «Наименование показателя | Коды | За 9 месяцев\n2025 года | На 31 декабря\n2024 года | …», под шапкой строка нумерации «1 | 2 | 3 | 4 | 5», значения — тысячи с дробной частью.- Шаблоны 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) |