Словари api/rules/{osv_headers,osv_groups,periods,data_types}.yaml и src/parser/rules.py
(normalize_text, Pattern, load_rules, pydantic-валидация с RuntimeError по файлу и ключу,
currency_aliases по R-6). AST-линтер координат tests/unit/parser/test_no_coordinates.py
перенесён из Task 12 (R-7, правила R-4/R-5). Тест формулировок реальных ОСВ.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
447 lines
56 KiB
Markdown
447 lines
56 KiB
Markdown
# Срез 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.
|
||
|
||
- [ ] **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:**
|
||
```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` + элементы через запятую после двоеточия.
|
||
|
||
- [ ] **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:**
|
||
```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] # ближайшая предыдущая строка с меньшим уровнем
|
||
```
|
||
|
||
- [ ] **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:**
|
||
```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")
|
||
```
|
||
|
||
- [ ] **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:**
|
||
```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`, если все значения пусты.
|
||
|
||
- [ ] **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:**
|
||
```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}` при наличии.
|
||
|
||
- [ ] **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:**
|
||
```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; сообщение — список несработавших
|
||
```
|
||
|
||
- [ ] **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 при закрытии.
|