arm-tabular-parser/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md
Andrey Epifancev 7738221b5a docs: README/CLAUDE.md Среза 2a, tasks.md, спека-дельты
- README: парсер по модулям, статусы и коды, словари и добавление синонима, лимиты, golden-процесс, настройки PARSE_*
- CLAUDE.md: раздел «Добавлено в Срезе 2a» (модули, правила расширения, линтер, R-21); api/rules/README.md: ключи словарей
- openspec: tasks.md (5.1), design.md (D-1…D-7, O-7/O-8), спека-дельты osv-parsing (R-12…R-22) и tabular-recognition-service («Результат по контракту»)
- tests: check_snapshot в golden_helpers + тесты «нет снимка»/«регрессия»; locator в property-тесте; пример needs_check синхронизирован с docs

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

447 lines
56 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Срез 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` добавить
```json
"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"}`.
- [x] **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`.
- [x] **Step 2 (здесь):** скопировать схему, добавить поле модели и версию; `pytest tests/unit/test_contract.py tests/unit/test_worker.py -v` → сначала падает на версии/идентичности, затем PASS после правок ожиданий (`"1.1"`).
- [x] **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:**
```python
# 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 при недоступном каталоге
```
- [x] **Step 1:** тест `tests/unit/test_fixtures_helpers.py`: при `ARM_KA_FIXTURES=<tmp>` без `osv/` — `osv_fixture_files` пуст; с двумя файлами и `synthetic/` — синтетика исключена; `fixtures_skip_reason()` даёт текст с путём.
- [x] **Step 2:** реализовать; `make venv`; `pytest -m fixtures --collect-only -q | tail -3` показывает 66 параметров (через временный тест-заглушку `test_smoke_fixtures(osv_file)` с `assert osv_file.exists()`, который остаётся как дымовой).
- [x] **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:**
```python
# 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 недоступен"`.
- [x] **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.
- [x] **Step 2:** падает → **Step 3:** реализация → **Step 4:** `pytest tests/unit/parser tests/fixtures/test_readers_real.py -v`, `make check` → PASS.
- [x] **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:**
```python
# 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 проверяет каждую строку таблицы.
- [x] **Step 1: Тесты** `test_rules.py`: `normalize_text("Оборотно-сальдовая\nведомость") == "оборотно сальдовая ведомость"`, `normalize_text("ОБОРОТНО–САЛЬДОВАЯ") `; каждая строка таблицы §1.5 → ожидаемые `quarter`/`is_cumulative`; «Договоры контрагентов» → CONTRACT, «Номенклатура» → OTHER; битый YAML (нет ключа `groups`) → `RuntimeError` с именем файла.
- [x] **Step 2–4:** падает → словари и загрузчик → `pytest tests/unit/parser/test_rules.py -v`, `make check`.
- [x] **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:**
```python
@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.
- [x] **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.
- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
- [x] **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:**
```python
@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` + элементы через запятую после двоеточия.
- [x] **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"`.
- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
- [x] **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:**
```python
@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] # ближайшая предыдущая строка с меньшим уровнем
```
- [x] **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`.
- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
- [x] **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:**
```python
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")
```
- [x] **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.
- [x] **Step 2–4:** падает → реализация → тесты.
- [x] **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:**
```python
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`, если все значения пусты.
- [x] **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.
- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
- [x] **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:**
```python
# 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}` при наличии.
- [x] **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.
- [x] **Step 2–4:** падает → реализация → `pytest tests/unit/parser tests/fixtures -v`, `make check`.
- [x] **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` (раздел «Запуск на файле»)
- [x] **Step 1:** тесты (юнит с `fixtures`, интеграция с `make up`) → падают (нет CLI / воркер с NullParser).
- [x] **Step 2:** реализация CLI, `app.py` уже на `get_parser(settings)`; `make check`, `make test-integration` → PASS.
- [x] **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` |
- [x] **Step 1:** тесты `test_synthetic_files.py` (параметр `synthetic_file`, маркер `fixtures`): таблица «файл → ожидания» (стратегия, статус, число документов, конкретные поля из сценариев спеки); для `s13` — результат равен `s02` кроме `locator`/warnings.
- [x] **Step 2:** генератор; `python -m tests.synthetic.generate ../../fixtures/osv/synthetic` (из `api/`); проверить детерминированность (повторный запуск — файлы байт в байт). xlsb: конвертировать `s02` вручную; если инструмента на машине нет — записать в README fixtures и в ledger как `deferred` с инструкцией, тест `s13` помечается `skip` с причиной «нет файла» (без провала плана).
- [x] **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:**
```python
# 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; сообщение — список несработавших
```
- [x] **Step 1:** тесты: `test_golden.py` (параметры `osv_file` и `synthetic_file`): снимок существует (иначе падение «нет снимка: make golden-update») и равен нормализованному результату (diff в сообщении); линтер — проверить, что намеренно подложенный файл с `"E9"` ловится (тест линтера на временном дереве); покрытие словарей.
- [x] **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 таблицы).
- [x] **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]`)
- [x] **Step 1:** таблица «требование спеки `osv-parsing` → сценарий → тест» в отчёте задачи; каждый сценарий имеет тест (иначе добавить).
- [x] **Step 2:** документы по списку; `openspec validate --strict` в обоих репозиториях.
- [x] **Step 3:** `make check-full` (compose поднят: `make up`; MinIO бакет `arm-ka` есть — проверить `test_s3_integration`, OQ-80) → всё зелёное, вывод в ledger; `pytest -m fixtures` → 66 + синтетика + golden зелёные.
- [x] **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 при закрытии.