arm-tabular-parser/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md
Andrey Epifancev b5a3b480b2 feat(parser): модель листа Sheet и читатели xlsx/xls/xlsb
grid.py: Cell, MergedRange, Sheet (1-based, только непустые ячейки, ленивые
индексы строк и merged; диапазоны больше 10 000 ячеек проверяются перебором),
column_letter для диагностики. errors.py: ParserError и коды контракта.
readers: read_book определяет формат по сигнатуре (zip → XLSX/XLSB по
xl/workbook.bin, OLE2 → XLS), предупреждает о несовпадении с file_type;
ошибки библиотек → UnreadableError одной строкой. Числа — Decimal(str(value)),
bool отдельно, даты как datetime; xlsb без outline/merged с предупреждением.
Тесты: синтетическая книга openpyxl, подмена file_type, мусор/обрезанные
файлы, xlsb через подменённый pyxlsb; 66 реальных ОСВ читаются < 5 с с
outline и merged; xls «62 за 3 квартал 2024»: строка 15 — outline 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 12:11:16 +04:00

56 KiB
Raw Blame History

Срез 2a — парсер ОСВ (tabular-parser) — план исполнения

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Branch: feat/5-slice-2a-osv-parser (worktree .wt/slice-2/tabular-parser). Issue: RSHB/arm-tabular-parser#5. PR: #6 (открыт, спека). Соседние worktree той же фичи: docs .wt/slice-2/docs (ветка feat/9-slice-2a-osv-parser, issue #9, PR #10), fixtures .wt/slice-2/fixtures (ветка feat/4-slice-2a-osv-parser, issue #4, PR создаёт человек-сессия). ../arm-core — симлинк на main, только чтение. Spec: openspec/changes/slice-2a-osv-parser/ — proposal.md, design.md (D-1…D-10, таблица решений №1–10), specs/osv-parsing/spec.md (9 требований), specs/tabular-recognition-service/spec.md. Правила: ../docs/context/05-domain-rules.md §1.0–§1.5 (строки 8–137), наблюдения по реальным файлам — ../docs/context/06-test-data.md §3 (строки 72–103). Спека — единственный источник истины при рулингах; план сгенерирован из tasks.md change'а (номера в скобках), по завершении задачи отметить [x] и там.

Goal: get_parser() возвращает парсер, который разбирает все 66 реальных ОСВ и всю синтетику в osvDocument контракта v1.1 с выполненными инвариантами §1.4, без единого правила под файл или координату.

Architecture: читатели xlsx/xls/xlsb → единая Sheet → детекторы по содержимому (листы, заголовок, колонки) → шапка и group_schema → каскад иерархии → классификация → Decimal → контрольные суммы → ParseOutcome. Словари YAML, лимиты и кооперативный таймаут, golden и property-тесты на fixtures, AST-линтер координат.

Tech Stack: Python 3.11, pydantic v2 (модели контракта src/contract), openpyxl 3.1.5, xlrd 2.0.2, pyxlsb 1.0.10, PyYAML, pytest; без БД.

Global Constraints

  • Без координат. В src/parser/** (кроме grid.py и readers/) запрещены литералы адресов/букв колонок ("E9", "A"), имён листов, сравнения с номерами строк. Буквы колонок появляются только в диагностике columns через grid.column_letter(index). Проверяет тест Task 12; нарушение — не «готово».
  • Правила — данные. Все синонимы и регулярки распознавания — в api/rules/*.yaml; код содержит только структуру алгоритма. Новый формат = фикстура + синоним.
  • Контракт v1.1 — ../docs/contracts/recognition-result.schema.json (после Task 0), копия в api/contracts/ байт в байт (тест идентичности). Деньги — Decimal → строка ^-?\d+(\.\d{1,2})?$; float в домене запрещён. Поля результата — только из контракта; rows[].extra — dict[str, str | None].
  • Статусы и коды — ровно по D-6 спеки: INVALID_DOCUMENT (нет листов ОСВ), INVALID_FORMAT (нет заголовка/колонок), UNREADABLE, UNSUPPORTED_FORMAT (формат или лимит размера), TIMEOUT, RECOGNITION_FAILED; нарушение §1.4 → needs_check, причины в metrics.warnings, номера строк в checks. Парсер не подгоняет структуру под сходимость.
  • Воркер, NATS, S3 не меняются (кроме test_worker под schema_version 1.1). ParseOutcome требует непустые documents при done|needs_check.
  • Фикстуры — ../fixtures/osv/** (66 файлов: 48 xlsx, 18 xls), доступ через ARM_KA_FIXTURES или соседний каталог; без каталога — пропуск с причиной. Фикстуры — регрессия, не оракул: правило под конкретный файл запрещено.
  • Производительность: любой файл fixtures < 5 с; PARSE_MAX_BYTES = 50 MiB, PARSE_TIMEOUT_SECONDS = 120.
  • Линтеры: ruff (line-length 120, E,F,I,UP,B,PL — PLR2004 решается константами, PLR0913 — dataclass параметров или # noqa: PLR0913 - причина) + mypy (check_untyped_defs; для src/parser/** в pyproject.toml добавить override disallow_untyped_defs = true). Все команды — из api/ (make — из корня). Тесты: маркеры integration (NATS/MinIO) и fixtures (реальные данные), pytest.ini уже объявляет оба.
  • Коммиты: русский язык, по задаче; последние строки — Co-Authored-By: Claude <модель исполнителя> <noreply@anthropic.com> и Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2. Не пушить, не создавать PR, не openspec archive. Коммиты в ../docs и ../fixtures — в их worktree, те же правила.
  • Допущения в коде: # ASSUMPTION(OQ-02) (is_cumulative по тексту), # ASSUMPTION(OQ-45) (NU как есть), # ASSUMPTION(OQ-01) (TOTAL в rows).

Review Focus

  1. Строка счёта без сумм в части колонок (все null) — инвариант сальдо считает null как 0 и не падает; extra не содержит ключей-дубликатов: Task 8 — тест.
  2. Числа, записанные с процентным или денежным форматом (openpyxl даёт float), и int в ячейках — Decimal(str(value)) без хвостов вида .000000001: Task 7 — тест на 0.1 + 0.2-подобных float и int.
  3. Два контрагента с одинаковым именем под разными субсчетами — parent_row_number различает их, инвариант «сумма детей» считается по дереву, а не по имени: Task 6 — тест.
  4. Лист ОСВ, где заголовок таблицы повторяется на каждой «странице» печати (повтор строки «Счет | Сальдо на начало…» в середине данных) — повторы заголовка пропускаются как не-данные, а не классифицируются как OTHER: Task 5 — тест на синтетической сетке.
  5. Пустые строки между блоками данных и пробелы вместо пустоты (" ") — считаются пустыми, не рвут дерево: Task 8 — тест.

Задачи

Task 0: [docs 1.1] Контракт v1.1 в ../docs и копия в api/contracts/

Files (worktree .wt/slice-2/docs, ветка feat/9-slice-2a-osv-parser):

  • Modify: contracts/recognition-result.schema.json, contracts/examples/result-osv-minimal.json
  • Create: contracts/examples/result-osv-needs-check.json
  • Modify: openspec/changes/slice-2a-osv-parser/tasks.md (1.1 → [x])

Files (этот репозиторий):

  • Modify: api/contracts/recognition-result.schema.json (копия), api/src/contract/result.py (OsvRow.extra), api/src/contract/common.py (CONTRACT_VERSION = "1.1"), tests/unit/test_worker.py и tests/unit/test_contract.py (ожидания 1.1)

Interfaces:

  • Схема: в osvDocument.rows.items.properties добавить

    "extra": { "type": ["object", "null"], "description": "нераспознанные колонки внутри таблицы: заголовок колонки → текст ячейки (§1.2)", "additionalProperties": { "type": ["string", "null"] } }
    

    title → «… контракт v1.1», schema_version.description → «производитель контракта v1.1 пишет "1.1"». Pattern ^1\.\d+$ не меняется.

  • OsvRow.extra: dict[str, str | None] | None = None.

  • CONTRACT_VERSION = "1.1" — воркер пишет его в schema_version файла и result_schema_version конверта (уже так).

  • Пример result-osv-needs-check.json: статус needs_check, один документ, checks.balance_identity_violations = [3], metrics.warnings = ["hierarchy=outline", "balance identity violated at rows [3]"], schema_version "1.1", у строки 3 extra: {"КПП": "352801001"}.

  • Step 1 (docs): внести правки схемы и примеров; проверить python -c "import json,jsonschema; ..." — оба примера result-osv-*.json валидны по новой схеме, старый result-osv-minimal.json без extra тоже валиден; openspec validate slice-2a-osv-parser --strict в docs → valid. Commit в docs: contracts: результат v1.1 — rows[].extra (§1.2), пример needs_check.

  • Step 2 (здесь): скопировать схему, добавить поле модели и версию; pytest tests/unit/test_contract.py tests/unit/test_worker.py -v → сначала падает на версии/идентичности, затем PASS после правок ожиданий ("1.1").

  • Step 3: make check → PASS. Commit: contract: v1.1 — OsvRow.extra, CONTRACT_VERSION 1.1, копия схемы.


Task 1: [1.1] Зависимости и доступ к фикстурам

Files:

  • Modify: api/requirements.txt (openpyxl==3.1.5, pyxlsb==1.0.10, xlrd==2.0.2 по алфавиту; et_xmlfile подтянется транзитивно — закрепить et_xmlfile==2.0.0, если uv pip install его ставит)
  • Create: api/tests/fixtures_helpers.py, модифицировать api/tests/conftest.py
  • Modify: api/tests/unit/test_layout.py (страж не должен запрещать новые пакеты — проверить), api/pyproject.toml (mypy override для openpyxl.*, xlrd.*, pyxlsb.* — ignore_missing_imports = true; strict-флаги для src.parser.*)

Interfaces:

# tests/fixtures_helpers.py
FIXTURES_ENV = "ARM_KA_FIXTURES"
def resolve_fixtures_dir() -> Path          # env или Path(__file__).resolve().parents[3] / "fixtures"
def fixtures_skip_reason() -> str | None
def osv_fixture_files(root: Path) -> list[Path]   # все *.xls/*.xlsx/*.xlsb под root/"osv", кроме каталога synthetic, отсортированы
def synthetic_files(root: Path) -> list[Path]     # root/"osv"/"synthetic"/*
# conftest.py
@pytest.fixture(scope="session") def fixtures_dir() -> Path   # skip с причиной
def pytest_generate_tests(metafunc): параметризует аргумент `osv_file` всеми osv_fixture_files (ids — относительный путь), `synthetic_file` — synthetic_files; при недоступном каталоге — один параметр с pytest.param(marks=skip)
def pytest_collection_modifyitems: тестам с маркером `fixtures` — skip при недоступном каталоге
  • Step 1: тест tests/unit/test_fixtures_helpers.py: при ARM_KA_FIXTURES=<tmp> без osv/ — osv_fixture_files пуст; с двумя файлами и synthetic/ — синтетика исключена; fixtures_skip_reason() даёт текст с путём.
  • Step 2: реализовать; make venv; pytest -m fixtures --collect-only -q | tail -3 показывает 66 параметров (через временный тест-заглушку test_smoke_fixtures(osv_file) с assert osv_file.exists(), который остаётся как дымовой).
  • Step 3: make check → PASS. Commit: test: доступ к ../fixtures (ARM_KA_FIXTURES), параметризация по 66 ОСВ; зависимости openpyxl/xlrd/pyxlsb.

Task 2: [1.3] Модель листа grid.py и читатели

Files:

  • Create: api/src/parser/grid.py, api/src/parser/readers/__init__.py, api/src/parser/readers/xlsx.py, api/src/parser/readers/xls.py, api/src/parser/readers/xlsb.py, api/src/parser/errors.py
  • Test: api/tests/unit/parser/test_grid.py, api/tests/unit/parser/test_readers.py, api/tests/fixtures/test_readers_real.py

Interfaces:

# errors.py
class ParserError(Exception): code: ErrorCode; message: str      # базовый, превращается в ParseOutcome(error)
class UnreadableError(ParserError)          # code UNREADABLE
class UnsupportedFormatError(ParserError)   # code UNSUPPORTED_FORMAT
class InvalidFormatError(ParserError)       # code INVALID_FORMAT
class InvalidDocumentError(ParserError)     # code INVALID_DOCUMENT
class ParseTimeoutError(ParserError)        # code TIMEOUT

# grid.py
CellKind = Literal["empty", "text", "number", "date", "bool"]
@dataclass(frozen=True, slots=True)
class Cell:
    value: str | Decimal | datetime.date | datetime.datetime | bool | None
    kind: CellKind
    bold: bool = False
    indent: int = 0          # alignment.indent или число ведущих пробелов текста
    @property
    def text(self) -> str   # строка для сопоставления: str(value).strip() или "" ("пробелы" = пусто)
@dataclass(frozen=True, slots=True)
class MergedRange: top: int; left: int; bottom: int; right: int   # 1-based включительно
@dataclass(frozen=True)
class Sheet:
    name: str
    cells: Mapping[tuple[int, int], Cell]      # только непустые; (row, col) 1-based
    merged: tuple[MergedRange, ...]
    row_outline: Mapping[int, int]             # row → outline level (0 по умолчанию)
    n_rows: int; n_cols: int
    def cell(self, row: int, col: int) -> Cell                # EMPTY_CELL если нет
    def text_at(self, row: int, col: int) -> str              # с учётом merged: значение origin диапазона
    def merged_origin(self, row: int, col: int) -> tuple[int, int]
    def merged_span(self, row: int, col: int) -> MergedRange | None
    def row_cells(self, row: int) -> list[tuple[int, Cell]]   # непустые (col, cell) по возрастанию col
    def is_row_empty(self, row: int) -> bool
    def outline(self, row: int) -> int
def column_letter(index: int) -> str          # 1 → "A", 27 → "AA" (только для диагностики)
EMPTY_CELL = Cell(None, "empty")

# readers/__init__.py
def read_book(data: bytes, file_type: FileType) -> tuple[tuple[Sheet, ...], list[str]]
# по сигнатуре: PK\x03\x04 → xlsx/xlsb (различать по наличию xl/workbook.bin в zip), D0 CF 11 E0 → xls;
# несовпадение с file_type → предупреждение "file_type=XLSX, но содержимое BIFF8: прочитано как XLS";
# иная сигнатура → UnsupportedFormatError; исключения библиотек → UnreadableError(причина одной строкой)

Числа: читатели кладут Decimal(str(value)) для float/int (без квантования — это делает numbers.py), bool отдельно (kind="bool"), даты — как есть. xlsx: load_workbook(BytesIO(data), data_only=True, read_only=False); outline — ws.row_dimensions[r].outline_level; bold — cell.font.bold; indent — cell.alignment.indent; merged — ws.merged_cells.ranges. xls: xlrd.open_workbook(file_contents=data, formatting_info=True); outline — sheet.rowinfo_map[r].outline_level; bold/indent — через book.xf_list[cell.xf_index] (font.bold, alignment.indent_level); merged — sheet.merged_cells (полуоткрытые → включительные). xlsb: pyxlsb.open_workbook(BytesIO); outline/merged недоступны → пустые, предупреждение "xlsb: outline level недоступен".

  • Step 1: Тесты. test_grid.py: text_at в merged-диапазоне берёт origin; row_cells упорядочен; column_letter(1/26/27/52/703); Cell(" ").text == "". test_readers.py: синтетическая книга, собранная openpyxl в памяти (2 листа, merged заголовок, outline 0/1/2, bold, indent, число, текст, дата, пустая строка) → read_book даёт ожидаемую Sheet; та же книга, сохранённая как xls невозможна без Excel — для xls используется реальный файл в test_readers_real.py; подмена file_type (XLSX при BIFF8) → предупреждение; мусорные байты → UnsupportedFormatError; обрезанный zip → UnreadableError. test_readers_real.py (fixtures): все 66 файлов читаются < 5 с, у каждого ≥ 1 лист, есть строки с outline ≥ 1, merged > 0; xls …62 за 3 квартал 2024 г..xls — outline строки 15 (1-based) равен 3.
  • Step 2: падает → Step 3: реализация → Step 4: pytest tests/unit/parser tests/fixtures/test_readers_real.py -v, make check → PASS.
  • Step 5: Commit feat(parser): модель листа Sheet и читатели xlsx/xls/xlsb.

Task 3: [1.4] Словари rules/*.yaml и загрузчик

Files:

  • Create: api/rules/osv_headers.yaml, api/rules/osv_groups.yaml, api/rules/periods.yaml, api/rules/data_types.yaml, api/src/parser/rules.py
  • Modify: api/rules/README.md
  • Test: api/tests/unit/parser/test_rules.py

Interfaces:

# rules.py
def normalize_text(s: str) -> str   # lower, ё→е, NBSP/переносы/дефисы-переносы → пробел, схлопнуть пробелы, strip
class Pattern:  # обёртка над re.Pattern, матчит normalize_text(text)
    def search(self, text: str) -> re.Match | None
class OsvHeaderRules(BaseModel):
    sheet_markers: list[Pattern]                      # «оборотно сальдовая ведомость»
    statement_markers: list[Pattern]                  # «бухгалтерский баланс», «0710001», «отчет о финансовых результатах», «0710002» — для пропуска листов форм
    groups: dict[Literal["opening","turnover","closing"], list[Pattern]]   # «сальдо на начало», «нач. сальдо», …
    debit: list[Pattern]; credit: list[Pattern]       # «дебет», «дт», «д-т»
    service_columns: dict[str, list[Pattern]]         # inn: «инн»; kpp, code, quantity, currency — для имён extra
    total_row: list[Pattern]                          # «итого»
    header_markers: dict[Literal["account","period","data_type","currency","org_params","subkonto"], list[Pattern]]
        # account: «по счету», period: «за », data_type: «выводимые данные», currency: «валюта», subkonto: «субконто», «детализация»
class OsvGroupRules(BaseModel): mapping: list[tuple[Pattern, Literal["ACCOUNT","DEPARTMENT","COUNTERPARTY","CONTRACT","OTHER"]]]
class PeriodRule(BaseModel): pattern: Pattern; quarter: Literal["Q1","Q2","Q3","Q4"]; is_cumulative: bool
class PeriodRules(BaseModel): rules: list[PeriodRule]; year: Pattern   # год — четыре цифры
class DataTypeRules(BaseModel): header: list[tuple[Pattern, DataType]]; row: list[tuple[Pattern, Literal["CURRENCY","QUANTITY"]]]; currency_codes: list[str]
@dataclass(frozen=True)
class Rules: headers: OsvHeaderRules; groups: OsvGroupRules; periods: PeriodRules; data_types: DataTypeRules
def load_rules(path: Path = DEFAULT_RULES_DIR) -> Rules   # pydantic-валидация; ошибка → RuntimeError с файлом и ключом

Содержимое словарей — по §1.0 п. 1–3, §1.1 (таблица group_type), §1.5 (таблица периодов, включая «I кв.», «полугодие», «9 месяцев», «январь-сентябрь», «год», «12 месяцев», диапазоны дат 01.01–31.03), §1.3 (Вал./Кол., коды валют ISO 4217 буквенные — минимум RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED). Порядок правил периода — от более специфичных к общим («9 месяцев» раньше «месяц», «12 месяцев» раньше «2 месяца»-подобных), задаётся порядком списка, первый матч побеждает; тест §1.5 проверяет каждую строку таблицы.

  • Step 1: Тесты test_rules.py: normalize_text("Оборотно-сальдовая\nведомость") == "оборотно сальдовая ведомость", normalize_text("ОБОРОТНО–САЛЬДОВАЯ") ; каждая строка таблицы §1.5 → ожидаемые quarter/is_cumulative; «Договоры контрагентов» → CONTRACT, «Номенклатура» → OTHER; битый YAML (нет ключа groups) → RuntimeError с именем файла.
  • Step 2–4: падает → словари и загрузчик → pytest tests/unit/parser/test_rules.py -v, make check.
  • Step 5: Commit feat(rules): словари ОСВ (заголовки, группировки, периоды, типы данных) и загрузчик.

Task 4: [2.1] Детекторы: листы ОСВ, заголовок таблицы, колонки

Files:

  • Create: api/src/parser/detect.py
  • Test: api/tests/unit/parser/test_detect.py, api/tests/fixtures/test_detect_real.py, api/tests/unit/parser/gridbuilder.py (хелпер сборки Sheet из текстовой «картинки» для тестов)

Interfaces:

@dataclass(frozen=True)
class ColumnMap:
    opening_debit: int | None; opening_credit: int | None
    turnover_debit: int | None; turnover_credit: int | None
    closing_debit: int | None; closing_credit: int | None
    collapsed: frozenset[Literal["opening","closing"]]   # группы со свёрнутым сальдо (одна колонка; индекс лежит в *_debit)
    def as_diagnostics(self) -> dict[str, str]           # {"opening_debit": "E", ...} через column_letter
@dataclass(frozen=True)
class TableHeader:
    first_row: int; last_row: int                        # блок заголовка (включительно)
    columns: ColumnMap
    text_col: int
    inn_col: int | None
    right_edge: int                                      # последняя колонка таблицы
    extra_cols: dict[int, str]                           # col → заголовок (или буква, если заголовка нет)
    group_label_rows: list[int]                          # строки блока, где в text_col стоит текст группировки («Счет», «Контрагенты», …)
class SheetKind(StrEnum): OSV = "osv"; STATEMENT = "statement"; OTHER = "other"
def classify_sheet(sheet: Sheet, rules: Rules) -> SheetKind
def find_table_header(sheet: Sheet, rules: Rules) -> TableHeader      # InvalidFormatError с названием ненайденного
def first_data_row(sheet: Sheet, header: TableHeader) -> int           # первая непустая строка после last_row
def is_header_repeat(sheet: Sheet, row: int, header: TableHeader, rules: Rules) -> bool   # повтор заголовка внутри данных (Review Focus 4)

Алгоритм find_table_header: для каждой непустой строки r (сверху) собрать тексты ячеек с учётом merged; строка-кандидат — где найдены ≥ 2 из трёх групп (groups), при этом текст группы лежит в merged-диапазоне или одиночной ячейке → ColumnSpan(left, right). Для каждой группы искать «дебет»/«кредит» в строках r+1..r+3 внутри span (origin merged); найдены оба → индексы; найдена одна колонка без подзаголовков (span ширины 1 или пустые подзаголовки) → свёрнутое сальдо (collapsed); группа «turnover» свёрнутой быть не может → InvalidFormatError. last_row = максимум строк, где встретились подзаголовки или метки группировок (ячейки text_col до первой строки данных: строка считается частью блока, если её текст матчит osv_groups или service_columns.inn или header_markers.subkonto). text_col = минимальный col среди непустых ячеек первых 20 строк данных, лежащий левее min(всех индексов сумм). inn_col: колонка между text_col и первой суммой с заголовком inn в блоке, иначе колонка, где ≥ 80 % непустых значений первых 200 строк данных — 10/12 цифр. extra_cols: прочие колонки между text_col и right_edge, не входящие в суммы/ИНН, у которых есть хоть одно непустое значение в данных; имя — текст заголовка в блоке над колонкой или буква. classify_sheet: OSV если любая ячейка (первые 30 строк) матчит sheet_markers; иначе STATEMENT если матчит statement_markers; иначе OTHER.

  • Step 1: Тесты. gridbuilder.sheet_from_rows([...]) строит Sheet из списка строк (значения, M(text, width) для merged, L(level) для outline). Юнит-сценарии: заголовок 2 строки (вариант 1 РБ), 3 строки с ИНН (Счастливочная), 4 строки, вариант 2 («Субконто»), без «начала», свёрнутое «конец», «Обороты» свёрнутые → InvalidFormatError, КПП между ИНН и суммами → extra_cols, мусор правее right_edge не в extra_cols, повтор заголовка в данных → is_header_repeat, classify_sheet для ОСВ / ББ / прочего. test_detect_real.py (fixtures): для всех 66 — find_table_header без ошибок, все шесть индексов заданы, collapsed пуст; для rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx диагностика E/H/J/L/O/Q, inn_col is None; для schastl/2024-Q3/…62 за 3 квартал 2024 г..xls — G/J/K/N/Q/S, inn_col = E (5), group_label_rows длиной 3.
  • Step 2–4: падает → реализация → тесты, make check.
  • Step 5: Commit feat(parser): детекторы листов ОСВ, заголовка таблицы и колонок по содержимому.

Task 5: [2.2] Шапка документа и group_schema

Files:

  • Create: api/src/parser/header.py
  • Test: api/tests/unit/parser/test_header.py, api/tests/fixtures/test_header_real.py

Interfaces:

@dataclass(frozen=True)
class DocHeader:
    organization_name: str; account_code_raw: str; period_text: str
    period_year: int | None; period_quarter: Literal["Q1","Q2","Q3","Q4"] | None; is_cumulative: bool | None
    data_type: DataType | None; currency: str | None; header_variant: Literal[1, 2]
    warnings: tuple[str, ...]
def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHeader
def build_group_schema(sheet: Sheet, header: TableHeader, rules: Rules) -> tuple[list[OsvGroup], Literal[1, 2]]

parse_doc_header: область — строки 1..header.first_row−1; организация — первая непустая текстовая ячейка, не матчащая ни один header_markers и sheet_markers; счёт — текст после header_markers.account до пробела/«за»; период — после header_markers.period до «г.»/конца строки (для варианта 2 — строка «Период: …»); отсутствие → "" + предупреждение; data_type по data_types.header; валюта по header_markers.currency → код (RUB для «RUB», «руб», «рубл»); год — periods.year; квартал/нарастание — первый матч periods.rules (# ASSUMPTION(OQ-02)). build_group_schema: вариант 1 — тексты text_col в group_label_rows сверху вниз → первая всегда ACCOUNT (даже если текст «Счет»), остальные по osv_groups; has_inn — в той же строке есть ячейка service_columns.inn; вариант 2 — если в области шапки есть строка с header_markers.subkonto: ACCOUNT + элементы через запятую после двоеточия.

  • Step 1: Тесты. Юнит: вариант 1 с «Счет/Контрагенты/Договоры», с «Подразделение/Контрагенты ИНН» → has_inn у COUNTERPARTY; вариант 2; шапка без организации → "" + warning; счёт «58.03»; все строки таблицы §1.5 через parse_doc_header на мини-сетке; валюта «руб.» и «RUB» → RUB. test_header_real.py (fixtures): для всех 66 account_code_raw совпадает с кодом из имени файла (регулярка (\d{2,3}(?:\.\d{1,2})?) по имени; для сч 583_ ожидать 58.03, для ОСВ_01_ОС — 01; таблица исключений в тесте запрещена — вместо неё нормализация имени каталога/файла одной регуляркой, 583 → 58.03 по правилу «три цифры = счёт+субсчёт»), period_quarter не None, для каталога 2024-FY is_cumulative is True, для *-Q3 — False; organization_name непустой; group_schema[0].type == "ACCOUNT".
  • Step 2–4: падает → реализация → тесты, make check.
  • Step 5: Commit feat(parser): шапка ОСВ и схема группировок (§1.1, §1.5).

Task 6: [2.3] Иерархия — каскад стратегий

Files:

  • Create: api/src/parser/hierarchy.py
  • Test: api/tests/unit/parser/test_hierarchy.py, api/tests/fixtures/test_hierarchy_real.py

Interfaces:

@dataclass(frozen=True)
class BodyRow:                 # строка данных до классификации
    row: int; text: str; is_account: bool; account_rank: int | None   # ранг = число сегментов кода (60 → 1, 60.01 → 2, 60.01.1 → 3)
    bold: bool; indent: int; outline: int
    sums: tuple[Decimal | None, ...]   # шесть значений по ColumnMap (уже Decimal, см. Task 7 — здесь через numbers.to_money)
@dataclass(frozen=True)
class Levels: by_row: dict[int, int]; parent: dict[int, int | None]; strategy: str
Strategy = Callable[[Sequence[BodyRow]], dict[int, int] | None]
def strategy_outline(rows) -> dict|None     # None, если < 2 различных уровней
def strategy_indent(rows) -> dict|None      # уровни = ранги различных отступов (0,1,2…); None, если < 2 различных
def strategy_account_pattern(rows) -> dict|None   # счёт: level = rank−1 относительно минимального ранга; не-счёт: level родителя+1, bold на уровне не-счёта → не глубже, последующие небольшие (не bold) → +1; None, если нет счетов
def strategy_sums(rows) -> dict|None        # жадно: строка — родитель последующих, пока Σ по шести колонкам не совпадёт; None, если не сошлось
STRATEGIES: tuple[tuple[str, Strategy], ...] = (("outline", …), ("indent", …), ("account_pattern", …), ("sums", …))
def is_consistent(rows, levels, total_row: int | None) -> bool   # у каждой строки level>0 есть предыдущая с меньшим level; total (если есть) level 0; первая строка level 0
def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels   # первый консистентный; иначе InvalidFormatError("иерархия строк не определена")
def parents(rows, by_row) -> dict[int, int | None]   # ближайшая предыдущая строка с меньшим уровнем
  • Step 1: Тесты. Юнит на BodyRow-списках: outline 0/1/2 → outline; все outline 0, отступы 0/2/4 → indent; без того и другого, коды 60/60.01/текст → account_pattern с уровнями 0/1/2; sums на трёх строках (родитель = сумма двух детей); неконсистентные уровни (ребёнок без родителя) отбрасываются, берётся следующая стратегия; «Итого» level 0 обязательно; parents для двух одноимённых контрагентов под разными субсчетами (Review Focus 3) различает родителей; дедлайн истёк → ParseTimeoutError. test_hierarchy_real.py (fixtures): все 66 — strategy == "outline", is_consistent, у «Итого» level 0; …66 за 2024.xls — «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» (первое вхождение) имеет родителя-строку с текстом 66.03.
  • Step 2–4: падает → реализация → тесты, make check.
  • Step 5: Commit feat(parser): каскад стратегий иерархии строк (outline → indent → account_pattern → sums).

Task 7: [2.4a] Числа

Files:

  • Create: api/src/parser/numbers.py
  • Test: api/tests/unit/parser/test_numbers.py

Interfaces:

MONEY_Q = Decimal("0.01")
class MoneyParse(NamedTuple): value: Decimal | None; warning: str | None
def to_money(cell: Cell) -> MoneyParse
# number → Decimal(str(float)).quantize(MONEY_Q, ROUND_HALF_UP); int → Decimal(int); bool → None + warning
# text: убрать все пробелы (\s,  ,  ,  ), заменить ',' → '.', '(x)' → '-x', '−'(U+2212) → '-';
#       прочерки {'-', '—', '–', ''} → None без warning; остаток не матчит ^-?\d+(\.\d+)?([eE][-+]?\d+)?$ → None + warning
def inn_digits(cell: Cell) -> str | None     # только цифры; длина 10 или 12 → строка, иначе None (число 3528052718.0 → "3528052718")
  • Step 1: Тесты: 1 234 567,89 → 1234567.89; (1 234,00) → -1234.00; — → None; 8.577e+00 (текст) → 8.58; float 0.1+0.2 → 0.30; int 5 → 5.00; 1234567.891 → 1234567.89; "abc" → None + warning; inn_digits для "3528052718", 3528052718.0, "ИНН 352819549855", "123" → None.
  • Step 2–4: падает → реализация → тесты.
  • Step 5: Commit feat(parser): нормализация сумм и ИНН (Decimal, текстовые форматы).

Task 8: [2.4b] Классификация строк

Files:

  • Create: api/src/parser/classify.py
  • Test: api/tests/unit/parser/test_classify.py, api/tests/fixtures/test_classify_real.py

Interfaces:

ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,2}){0,2})(?:,\s*(.*))?$")   # единственная регулярка в коде: формат кода счёта — структура, не синоним
def collect_body_rows(sheet: Sheet, header: TableHeader, rules: Rules, deadline: Deadline) -> tuple[list[BodyRow], int | None, list[str]]
# строки от first_data_row до последней непустой; пропуск пустых и строк из пробелов (Review Focus 5) и повторов заголовка (is_header_repeat); последняя строка, матчащая rules.headers.total_row и имеющая суммы → total_row; суммы через numbers.to_money (warnings с row_number)
@dataclass(frozen=True)
class ClassifiedRow: body: BodyRow; level: int; parent: int | None; group_type: Literal[...]; group_name: str | None; account_code_raw: str | None; counterparty_name: str | None; counterparty_inn: str | None; data_type: DataType | None; extra: dict[str, str | None] | None
def classify_rows(rows: Sequence[BodyRow], levels: Levels, schema: list[OsvGroup], header: TableHeader, sheet: Sheet, doc_data_type: DataType | None, total_row: int | None, rules: Rules) -> list[ClassifiedRow]

Правила: ACCOUNT по ACCOUNT_RE (account_code_raw = группа 1, как в файле); текущая ветка счёта наследуется детям (account_code_raw у не-счётных строк = код ближайшего предка-счёта); не-счёт — позиция = число не-счётных предков после последнего ACCOUNT → schema[позиция] (индекс 0 — ACCOUNT, поэтому первая не-счётная группа = schema[1]), выход за схему → OTHER; group_name = OsvGroup.name; COUNTERPARTY → counterparty_name = text, counterparty_inn = inn_digits(cell(inn_col)); data_type: текст матчит data_types.row или ∈ currency_codes (регистронезависимо) → CURRENCY/QUANTITY, иначе doc_data_type (# ASSUMPTION(OQ-45)); total_row → TOTAL с level 0, parent None; extra = {имя: sheet.text_at(row, col) or None for col, имя in header.extra_cols} или None, если все значения пусты.

  • Step 1: Тесты. Юнит: 60 / 60.01 / контрагент / договор → ACCOUNT, ACCOUNT, COUNTERPARTY (schema[1]), CONTRACT; выход за схему → OTHER; «60.01, Расчеты с поставщиками» → account_code_raw "60.01"; «USD» и «Кол.» → CURRENCY/QUANTITY; ИНН-колонка; extra с КПП и None при пустых; строка счёта со всеми null суммами не падает и не попадает в extra дубликатами (Review Focus 1); пустые строки и " " пропущены (Review Focus 5); повтор заголовка пропущен. test_classify_real.py (fixtures): schastl/2024-Q3/…62…xls — «БП Аренда» DEPARTMENT, «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» COUNTERPARTY с 10-значным ИНН, «Ведомость в счет з/платы» COUNTERPARTY с None; rb/2024-FY/ОСВ_66 за 2024.xls — «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» account_code_raw "66.03"; у всех 66 ровно одна строка TOTAL.
  • Step 2–4: падает → реализация → тесты, make check.
  • Step 5: Commit feat(parser): классификация строк по group_schema (§1.3).

Task 9: [3.1] Контрольные суммы, статусы, оркестратор, настройки

Files:

  • Create: api/src/parser/checks.py, api/src/parser/osv.py, api/src/parser/deadline.py
  • Modify: api/src/parser/__init__.py (get_parser() → OsvParser(load_rules(), settings); NullParser остаётся для тестов), api/src/core/config.py (PARSE_MAX_BYTES: PositiveInt = 50 * 1024 * 1024, PARSE_TIMEOUT_SECONDS: PositiveInt = 120), api/.env.example, api/.env.test, api/src/app.py (передать настройки в get_parser(settings))
  • Test: api/tests/unit/parser/test_checks.py, api/tests/unit/parser/test_osv_parser.py, api/tests/fixtures/test_osv_property.py; tests/unit/test_null_parser.py → переименовать в test_parser_factory.py

Interfaces:

# deadline.py
class Deadline:
    def __init__(self, seconds: float, clock: Callable[[], float] = time.monotonic)
    def check(self, where: str) -> None     # ParseTimeoutError(f"превышен лимит {seconds} с на шаге {where}")
# checks.py
TOLERANCE = Decimal("0.01")
def balance_identity(rows: Sequence[ClassifiedRow]) -> list[int]         # row_number нарушителей (null = 0)
def parent_sums(rows: Sequence[ClassifiedRow]) -> list[int]              # родители, у которых Σ детей того же data_type (кроме CURRENCY/QUANTITY-детей) ≠ своих сумм хотя бы в одной колонке
def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool            # TOTAL == первая строка ACCOUNT level 0 по шести колонкам; нет корня → True, если все суммы TOTAL null/0
def run_checks(rows) -> tuple[OsvChecks, list[str]]                      # + тексты предупреждений с номерами строк
# osv.py
class OsvParser:
    def __init__(self, rules: Rules, *, max_bytes: int, timeout_seconds: float, clock=time.monotonic) -> None
    def parse(self, data: bytes, file_type: FileType) -> ParseOutcome
    def parse_sheet(self, sheet: Sheet, deadline: Deadline) -> tuple[OsvDocument, list[str], bool]   # документ, предупреждения, ok(инварианты)
def get_parser(settings: Settings | None = None) -> Parser

parse: размер > max_bytes → UNSUPPORTED_FORMAT; read_book (ошибки → коды); листы: classify_sheet → OSV → parse_sheet, STATEMENT → warning sheet=<name> skipped: statement (slice 2b), OTHER → warning; ни одного OSV → INVALID_DOCUMENT «листов просмотрено N»; InvalidFormatError на листе → error INVALID_FORMAT всего файла с именем листа и шага; ParseTimeoutError → TIMEOUT; любое другое исключение → RECOGNITION_FAILED (одна строка type: message). Статус: все листы ok → done, иначе needs_check. metrics = Metrics(rows=Σ строк данных без TOTAL, warnings=[...]), первой записью по каждому листу — sheet=<name> hierarchy=<strategy>. columns документа = ColumnMap.as_diagnostics() + {"inn": letter} при наличии.

  • Step 1: Тесты. test_checks.py: тождество с null; родитель ≠ Σ детей; CURRENCY-дети исключены; TOTAL vs корень; пустая ОСВ без корня → True. test_osv_parser.py (юнит, книги собираются openpyxl в памяти): мини-ОСВ → done с полными полями; испорченная сумма → needs_check, checks и warnings с номерами; лист без заголовка → INVALID_FORMAT; книга без ОСВ → INVALID_DOCUMENT; data на 1 байт больше лимита (через max_bytes=10) → UNSUPPORTED_FORMAT; дедлайн (clock-заглушка) → TIMEOUT; исключение в стратегии (monkeypatch) → RECOGNITION_FAILED; ББ-лист + ОСВ-лист → один документ + warning; результат проходит RecognitionResult.model_validate и JSON-схему v1.1. test_parser_factory.py: get_parser() — OsvParser, NullParser по-прежнему NOT_IMPLEMENTED. test_osv_property.py (fixtures, параметр osv_file): status is DONE; один документ; checks.total_row_matches_root; списки нарушений пусты; header.account_code_raw == rows[0].account_code_raw (корень); period_quarter задан; metrics.rows > 0 кроме файла …66 за 2 квартал 2025 г..xlsx (0); разбор < 5 с; результат валиден по схеме v1.1.
  • Step 2–4: падает → реализация → pytest tests/unit/parser tests/fixtures -v, make check.
  • Step 5: Commit feat(parser): контрольные суммы §1.4, статусы, OsvParser как парсер по умолчанию, лимиты.

Task 10: [3.2] Интеграция с воркером и CLI

Files:

  • Create: api/src/parser/cli.py (python -m src.parser.cli <файл> [--file-type XLSX] → JSON результата в stdout, код возврата 0/1/2 по статусу)

  • Modify: api/tests/unit/test_worker.py (новый тест с реальным OsvParser и байтами rb/2024-FY/ОСВ_66 за 2024.xls, маркер fixtures), api/tests/integration/test_roundtrip.py (сценарий «Реальная ОСВ через воркер»: файл в MinIO, запрос XLS, конверт done, result_schema_version "1.1", файл результата по схеме, account_code_raw "66"), README.md (раздел «Запуск на файле»)

  • Step 1: тесты (юнит с fixtures, интеграция с make up) → падают (нет CLI / воркер с NullParser).

  • Step 2: реализация CLI, app.py уже на get_parser(settings); make check, make test-integration → PASS.

  • Step 3: Commit feat: реальный парсер в воркере, CLI разбора файла, интеграционный сценарий.


Task 11: [4.1] Синтетические фикстуры

Files:

  • Create: api/tests/synthetic/generate.py (детерминированный генератор openpyxl; python -m tests.synthetic.generate <dir>), api/tests/synthetic/__init__.py, api/tests/synthetic/test_synthetic_files.py
  • Create (worktree .wt/slice-2/fixtures, ветка feat/4-slice-2a-osv-parser): osv/synthetic/*.xlsx, osv/synthetic/*.xlsb (один), osv/synthetic/README.md

Набор (имена файлов фиксированы, каждый — отдельный сценарий спеки):

Файл Что проверяет
s01_variant2_header.xlsx шапка вариант 2, «Субконто», группировки через запятую
s02_no_outline_indent.xlsx уровни только отступами → hierarchy=indent
s03_no_outline_no_indent.xlsx уровни по кодам счетов и жирности → account_pattern
s04_currency_quantity_rows.xlsx строки «USD», «Кол.» под контрагентом
s05_text_numbers.xlsx суммы текстом «1 234 567,89», «(1 234,00)», «—»
s06_two_osv_and_other_sheet.xlsx листы «ОСВ 60», «Справка», «ОСВ 62»
s07_no_opening_collapsed_closing.xlsx без «начала», свёрнутое сальдо на конец
s08_extra_kpp_column.xlsx колонка КПП между ИНН и суммами → extra
s09_subaccount_names.xlsx «60.01, Расчеты с поставщиками»
s10_broken_sum.xlsx испорченная сумма → needs_check
s11_header_repeat.xlsx повтор заголовка внутри данных
s12_statement_and_osv.xlsx лист ББ + лист ОСВ → один документ + warning
s13_no_outline_indent.xlsb xlsb, сконвертирован вручную из s02 (LibreOffice/Excel), закоммичен; hierarchy=indent
  • Step 1: тесты test_synthetic_files.py (параметр synthetic_file, маркер fixtures): таблица «файл → ожидания» (стратегия, статус, число документов, конкретные поля из сценариев спеки); для s13 — результат равен s02 кроме locator/warnings.
  • Step 2: генератор; python -m tests.synthetic.generate ../../fixtures/osv/synthetic (из api/); проверить детерминированность (повторный запуск — файлы байт в байт). xlsb: конвертировать s02 вручную; если инструмента на машине нет — записать в README fixtures и в ledger как deferred с инструкцией, тест s13 помечается skip с причиной «нет файла» (без провала плана).
  • Step 3: коммит в fixtures: osv/synthetic: 12 синтетических ОСВ и xlsb (Срез 2a), README; коммит здесь: test: генератор синтетики и сценарии спеки на синтетических файлах.

Task 12: [4.2 + 4.3] Golden-снимки, линтер координат, покрытие словарей

Files:

  • Create: api/tests/golden_helpers.py, api/tests/fixtures/test_golden.py, api/scripts/golden_update.py, api/tests/unit/parser/test_no_coordinates.py, api/tests/fixtures/test_rules_coverage.py
  • Modify: Makefile (golden-update, test-fixtures), README.md
  • Create (worktree fixtures): golden/expected_osv_parse/**.json (зеркало osv/, включая synthetic/)

Interfaces:

# golden_helpers.py
VOLATILE = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms"))
def normalize_result(result: dict) -> dict      # удалить volatile; порядок warnings и ключей не менять (он значим для diff)
def golden_path(fixtures_root: Path, file: Path) -> Path   # golden/expected_osv_parse/<относительный путь без расширения>.json
def render(parser, file: Path) -> dict          # как CLI: RecognitionResult с фиксированными job_id/file_id → to_wire → normalize
# scripts/golden_update.py: для всех файлов пишет снимки, печатает unified diff изменившихся; флаг --check только сравнивает
# test_no_coordinates.py: AST по src/parser/**/*.py кроме grid.py и readers/*; падает на: строковых литералах, матчащих ^[A-Z]{1,3}\d{0,7}$ длиной ≤ 4, литералах, содержащих «лист» (без регистра), сравнениях `ast.Compare` где одна сторона — Attribute/Name с именем row|row_number|r и другая — ast.Constant int; подписка Sheet.cells[(int, int)] константами
# test_rules_coverage.py: прогоняет парсер по всем фикстурам+синтетике с Rules, обёрнутыми в счётчик срабатываний Pattern; каждая запись каждого словаря ≥ 1; сообщение — список несработавших
  • Step 1: тесты: test_golden.py (параметры osv_file и synthetic_file): снимок существует (иначе падение «нет снимка: make golden-update») и равен нормализованному результату (diff в сообщении); линтер — проверить, что намеренно подложенный файл с "E9" ловится (тест линтера на временном дереве); покрытие словарей.
  • Step 2: реализация; первичная запись снимков make golden-update; ручная сверка трёх файлов в отчёте задачи: rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx (Итого 371 372 027.47 / 2 034 858 970.41 / 2 148 560 264.30 / 485 073 321.36 — opening_credit, turnover_debit, turnover_credit, closing_credit), schastl/2024-Q3/…62 за 3 квартал 2024 г..xls (Итого 67 604 324.76 / 139 931 692.60 / 113 996 722.90 / 93 539 294.46 — opening_debit, turnover_debit, turnover_credit, closing_debit), rb/2024-Q3/сч 583_3 кв 2024.xlsx (корень 58.03); несработавшие записи словарей — удалить или добавить синтетику (новый файл s14_* допустим, с обновлением Task 11 таблицы).
  • Step 3: коммит в fixtures: golden/expected_osv_parse: снимки парсера для 66 ОСВ и синтетики; коммит здесь: test: golden-снимки, линтер координат, покрытие словарей; make golden-update.

Task 13: [5.1] Документы и make check-full

Files:

  • Modify: README.md (архитектура парсера по модулям, статусы и коды, словари и как добавить синоним, golden-процесс, лимиты, CLI), CLAUDE.md (раздел «Добавлено в Срезе 2a»: модули, правила расширения, линтер), api/rules/README.md, openspec/changes/slice-2a-osv-parser/tasks.md (все [x] кроме 5.2), openspec/changes/slice-2a-osv-parser/design.md (рулинги контроллера в таблицу решений)

  • Modify (worktree docs): context/05-domain-rules.md §1 (уточнения из спеки D-6: карта статусов, hierarchy=<имя>, TOTAL в rows, лимиты, имена словарей; §1.5 — ASSUMPTION(OQ-02)), context/06-test-data.md (синтетика s01–s13 и назначение, golden, xlsb), context/09-delivery-plan.md (Срез 2 → 2a–2d, 2a по факту), context/07-open-questions.md (OQ-81 закрыт; новые OQ: коды валют CURRENCY-строк; несколько ОСВ одного счёта в одной книге), openspec/changes/slice-2a-osv-parser/tasks.md (1.2, 2.1 → [x])

  • Step 1: таблица «требование спеки osv-parsing → сценарий → тест» в отчёте задачи; каждый сценарий имеет тест (иначе добавить).

  • Step 2: документы по списку; openspec validate --strict в обоих репозиториях.

  • Step 3: make check-full (compose поднят: make up; MinIO бакет arm-ka есть — проверить test_s3_integration, OQ-80) → всё зелёное, вывод в ledger; pytest -m fixtures → 66 + синтетика + golden зелёные.

  • Step 4: коммиты: здесь docs: README/CLAUDE.md Среза 2a, tasks.md; в docs docs: §1 по факту парсера, 06-test-data, план срезов 2a–2d, OQ.


Завершение

Финальное ревью всей ветки (opus): спека ↔ код по 9 требованиям osv-parsing, инварианты (нет координат — помимо линтера глазами, нет float в домене, словари — данные), качество детекторов на граничных случаях (Review Focus 1–5), тесты. Находки C-n/I-n/M-n со статусами; deferred — в описание PR #6 и follow-up issue при закрытии.