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
56 KiB
Срез 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добавить overridedisallow_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
- Строка счёта без сумм в части колонок (все
null) — инвариант сальдо считаетnullкак 0 и не падает;extraне содержит ключей-дубликатов: Task 8 — тест. - Числа, записанные с процентным или денежным форматом (openpyxl даёт
float), иintв ячейках —Decimal(str(value))без хвостов вида.000000001: Task 7 — тест на0.1 + 0.2-подобных float иint. - Два контрагента с одинаковым именем под разными субсчетами —
parent_row_numberразличает их, инвариант «сумма детей» считается по дереву, а не по имени: Task 6 — тест. - Лист ОСВ, где заголовок таблицы повторяется на каждой «странице» печати (повтор строки «Счет | Сальдо на начало…» в середине данных) — повторы заголовка пропускаются как не-данные, а не классифицируются как OTHER: Task 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", у строки 3extra: {"КПП": "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): для всех 66account_code_rawсовпадает с кодом из имени файла (регулярка(\d{2,3}(?:\.\d{1,2})?)по имени; длясч 583_ожидать58.03, дляОСВ_01_ОС—01; таблица исключений в тесте запрещена — вместо неё нормализация имени каталога/файла одной регуляркой, 583 →58.03по правилу «три цифры = счёт+субсчёт»),period_quarterнеNone, для каталога2024-FYis_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; float0.1+0.2→0.30; int5→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; в docsdocs: §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 при закрытии.