OpenSpec change osv-indicator-column и план (#58)
Колонка «Показатели», строки-показатели, «В валюте», BU по умолчанию (AURORA-1613, AURORA-1661 — валютная часть). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
aef2d3557b
commit
656486a7d2
551
docs/superpowers/plans/2026-10-19-osv-indicator-column.md
Normal file
551
docs/superpowers/plans/2026-10-19-osv-indicator-column.md
Normal file
@ -0,0 +1,551 @@
|
||||
# Колонка «Показатели» и валютные строки ОСВ: план реализации
|
||||
|
||||
> **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.
|
||||
|
||||
**Goal:** Строки ОСВ, которые не являются данными БУ (метки колонки «Показатели» — НУ, Кол., Вал., ПР, ВР; строка «В валюте»), не ломают дерево и проверки строк БУ и не попадают в свод; строки без вида данных при молчащей шапке получают BU (AURORA-1613, AURORA-1661 — валютная часть).
|
||||
|
||||
**Architecture:** Признак строки-показателя `indicator` вычисляется при сборе строк (`collect_body_rows` → новый модуль `indicators.py`: метка колонки «Показатели» по словарю, иначе текст строки по `data_types.row`) и живёт в `BodyRow` и `ClassifiedRow`. Каскад иерархии получает строки без показателей; показатель привязывается к предыдущей строке данных (`indicators.levels_with_indicators`). Проверки читают признак вместо набора `data_type`. Вид данных строки: метка колонки → иначе шапка → иначе BU с предупреждением.
|
||||
|
||||
**Tech Stack:** Python 3.11, pytest, ruff, mypy; openpyxl (генератор синтетики); OpenSpec.
|
||||
|
||||
**Spec:** `tabular-parser/openspec/changes/osv-indicator-column/` (proposal.md, design.md, specs/osv-parsing/spec.md). Правило — `docs/context/05-domain-rules.md` §1.3, §1.4 (ветка docs `feat/58-osv-indicators`, уже закоммичено). Issue RSHB/arm-tabular-parser#58. Jira AURORA-1613, AURORA-1661 (валютная часть).
|
||||
|
||||
**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/.wt/osv-indicators/` — `tabular-parser/`, `docs/`, `fixtures/` на ветке `feat/58-osv-indicators`; `CLAUDE.md`, `.claude/`, `reference-old-arm/` — симлинки. Ниже `$WT` = этот путь. Git — только `git -C $WT/<repo>`. Команды Python — из `$WT/tabular-parser/api` с `ENV_FILE=.env.test .venv/bin/python -m pytest …`; `make` — из `$WT/tabular-parser` (сначала один раз `make venv`). codegraph: `codegraph_explore` с `projectPath` = `$WT/tabular-parser`.
|
||||
|
||||
**Параллельный трек B** (`/Users/andrey.epifancev/projects/awp-ca/.wt/osv-flat/`, плоская форма AURORA-1661) правит `header.py` (`_account_code`, `_organization`), `hierarchy.py` (стратегии, `resolve_levels`), `classify.py` (`_find_total`, `_trailing_totals`), `checks.py` (`_total`), синтетику s33–s39. Этот план в `hierarchy.py` добавляет только поля `BodyRow`, в `_trailing_totals` и `_total` — по одному условию. Его файлы не трогать сверх этого; при конфликте слияния второй вливающийся трек добавляет своё условие к версии первого.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Язык комментариев, докстрингов, предупреждений, коммитов — русский; идентификаторы — английские.
|
||||
- Никаких координат в `src/parser/**` (кроме `grid.py`, `readers/`): AST-линтер `tests/unit/parser/test_no_coordinates.py`.
|
||||
- Словари — данные (`api/rules/*.yaml`); каждая новая запись срабатывает хотя бы на одном файле (`tests/fixtures/test_rules_coverage.py`). Паттерны без дефисов, «ё» и переносов (текст нормализуется: дефис и перенос → пробел).
|
||||
- Контракт результата и DBML не меняются. `data_type` строки — только значения контракта (`BU`, `NU`, `SUM`, `CURRENCY`, `QUANTITY`, `null`).
|
||||
- Деньги — `Decimal`.
|
||||
- Golden-снимки реальных файлов и s01–s32 MUST NOT измениться. Любой diff по ним — стоп, отчёт контроллеру, вопрос владельцу. `make golden-update` — только для новых s40–s43, отдельным коммитом в fixtures.
|
||||
- Файлы контура (`/Users/andrey.epifancev/projects/awp-ca/result_dir.zip`) в git MUST NOT попадать; распаковка — только во временный каталог вне репозиториев (`mktemp -d`).
|
||||
- Каждое решение без обсуждения — строкой в `openspec/changes/osv-indicator-column/design.md`, раздел `## Decisions made without discussion`. Маркеры находок T/C/I/M/R/O — по `~/.claude/CLAUDE.md`.
|
||||
- Перед каждым коммитом в tabular-parser — `make check` из `$WT/tabular-parser` зелёный. Коммиты заканчиваются строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Python-файлы правятся инструментами Edit/Write, не `sed`/heredoc через Bash.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Документ «Выводимые данные: НУ» (s12)** — все строки NU, проверки работают как раньше (признак показателя — не `data_type`).
|
||||
2. **Строки «Вал.»/«Кол.»/код валюты в колонке текста (s04, s30, реальные файлы)** — родитель и уровень те же, golden без diff.
|
||||
3. **Строка «НУ» под объединённой ячейкой названия** — `row_value = "НУ"`, а не повтор названия; не контрагент, не счёт.
|
||||
4. **Вторая «Итого» с меткой «НУ»** — TOTAL, без предупреждения «ниже таблицы», сверка «Итого = корень» по строке БУ.
|
||||
5. **Умолчание BU** — только строкам без своего вида; `header.data_type` остаётся `null`; предупреждение одно на лист и только если умолчание применилось.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Словари (модель: haiku)
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/rules/osv_headers.yaml` (`service_columns`)
|
||||
- Modify: `api/rules/data_types.yaml`
|
||||
- Modify: `api/src/parser/rules.py` (`DataTypeRules`)
|
||||
- Test: `api/tests/unit/parser/test_rules.py`
|
||||
|
||||
- [ ] **Step 1: Тест загрузки и совпадений** — дописать в `test_rules.py` (по образцу соседних тестов словарей, фикстура `rules` модуля):
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize(
|
||||
("label", "expected"),
|
||||
[("БУ", "BU"), ("НУ", "NU"), ("Кол.", "QUANTITY"), ("Кол", "QUANTITY"), ("Вал.", "CURRENCY")],
|
||||
)
|
||||
def test_indicator_column_labels(rules: Rules, label: str, expected: str) -> None:
|
||||
assert first_match(rules.data_types.indicator_column, label) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label", ["ПР", "ВР"])
|
||||
def test_indicator_column_untyped_labels(rules: Rules, label: str) -> None:
|
||||
assert first_match(rules.data_types.indicator_column, label) is None
|
||||
assert any(pattern.search(label) for pattern in rules.data_types.indicator_column_untyped)
|
||||
|
||||
|
||||
def test_v_valyute_row_is_currency(rules: Rules) -> None:
|
||||
assert first_match(rules.data_types.row, "В валюте") == "CURRENCY"
|
||||
assert first_match(rules.data_types.row, "Валюта USD") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title", ["Показа-\nтели", "Показатели", "ПОКАЗАТЕЛИ"])
|
||||
def test_indicator_service_column(rules: Rules, title: str) -> None:
|
||||
assert any(pattern.search(title) for pattern in rules.headers.service_columns["indicator"])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — падает** (`AttributeError: indicator_column`, `KeyError: 'indicator'`).
|
||||
|
||||
Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_rules.py -q`
|
||||
|
||||
- [ ] **Step 3: Словари.** В `osv_headers.yaml`, `service_columns`, после `inn`:
|
||||
|
||||
```yaml
|
||||
# Колонка вида суммы строки 1С («Показатели», в шапке часто с переносом «Показа-тели»): метки БУ/НУ/Кол./Вал./ПР/ВР
|
||||
# (§1.3, AURORA-1613). Метки читает `data_types.yaml` — `indicator_column`
|
||||
indicator:
|
||||
- '^показа\s?тели$'
|
||||
```
|
||||
|
||||
В `data_types.yaml`: в `row` добавить `- ['^в\s+валюте$', CURRENCY]` с комментарием «строка суммы в валюте под «Валюта <код>» (AURORA-1661); сама «Валюта USD» — обычная строка в рублях»; после `row` — новые разделы:
|
||||
|
||||
```yaml
|
||||
# Метка колонки «Показатели» (§1.3, AURORA-1613): БУ — обычная строка с data_type BU; остальные — строки-показатели
|
||||
# (вид сумм предыдущей строки данных, вне проверок «дети = родитель» и «Итого»)
|
||||
indicator_column:
|
||||
- ['^бу$', BU]
|
||||
- ['^ну$', NU]
|
||||
- ['^кол\.?$', QUANTITY]
|
||||
- ['^вал\.?$', CURRENCY]
|
||||
|
||||
# Метки-показатели без значения data_type в контракте (постоянные и временные разницы): data_type = null.
|
||||
# Прикладной сценарий НУ и разниц — аналитика AURORA-1669
|
||||
indicator_column_untyped:
|
||||
- '^пр$'
|
||||
- '^вр$'
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `rules.py`.** В `DataTypeRules` после `row`:
|
||||
|
||||
```python
|
||||
indicator_column: list[tuple[Pattern, DataType]]
|
||||
indicator_column_untyped: list[Pattern]
|
||||
```
|
||||
|
||||
Проверить, что `OsvHeaderRules` не требует обязательных ключей кроме `inn` (новый ключ `indicator` читается кодом через `service_columns.get("indicator", [])` — Task 3).
|
||||
|
||||
- [ ] **Step 5: Тест зелёный**, затем `make check` (тест покрытия словарей упадёт на новых записях — это ожидаемо до Task 2; если `make check` красный только из-за `test_rules_coverage`, коммит допустим с пометкой в ledger, покрытие закрывает Task 7).
|
||||
- [ ] **Step 6: Commit** `git -C $WT/tabular-parser commit -am "Словари: колонка «Показатели», «В валюте» (#58)"`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Синтетика s40–s43 и ожидания (модель: sonnet)
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/tests/synthetic/generate.py`
|
||||
- Modify: `api/tests/synthetic/test_synthetic_files.py`
|
||||
- Create: `$WT/fixtures/osv/synthetic/s40_indicator_column_bu_nu.xlsx`, `s41_indicator_column_qty_val_diff.xlsx`, `s42_currency_slices.xlsx`, `s43_no_data_type_header.xlsx`
|
||||
|
||||
- [ ] **Step 1: Построитель листа «по сетке».** В `generate.py` после `form_sheet` добавить (константы `ORG`, `GROUP_TITLES`, `DEBIT`, `CREDIT`, `D` — уже есть):
|
||||
|
||||
```python
|
||||
MERGED = "<объединено>" # ячейка текста — продолжение объединения строки выше
|
||||
INDICATOR_TITLE = "Показа-\nтели"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GridRow:
|
||||
"""Строка листа «по сетке»: текст (None — пусто, MERGED — объединение с ячейкой выше), метка колонки «Показатели»,
|
||||
шесть сумм (начало Д/К, обороты Д/К, конец Д/К; строки — `Decimal` в файле) и outline level."""
|
||||
|
||||
text: str | None
|
||||
sums: tuple[str | None, str | None, str | None, str | None, str | None, str | None]
|
||||
label: str | None = None
|
||||
outline: int = 0
|
||||
|
||||
|
||||
def _grid_osv( # noqa: PLR0913 - лист описывается полностью одним вызовом
|
||||
*,
|
||||
account: str,
|
||||
group_label: str,
|
||||
rows: Sequence[GridRow],
|
||||
data_type_line: str | None,
|
||||
indicator_column: bool,
|
||||
name: str = "Лист_1",
|
||||
) -> SheetSpec:
|
||||
"""ОСВ, заданная строками напрямую (не деревом `Node`): колонка «Показатели», объединённые названия, строки-соседи."""
|
||||
sheet = SheetSpec(name)
|
||||
sheet.put(1, 1, ORG)
|
||||
sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {account}")
|
||||
sheet.put(3, 1, "Период: 3 квартал 2025 г.")
|
||||
if data_type_line is not None:
|
||||
sheet.put(4, 1, data_type_line)
|
||||
sheet.put(5, 1, "Параметры:")
|
||||
sheet.put(5, 3, "Валюта БУ, НУ: Руб")
|
||||
label_col, first_sum = 4, 5
|
||||
top = 7
|
||||
sheet.put(top, 1, "Счет")
|
||||
if indicator_column:
|
||||
sheet.put(top, label_col, INDICATOR_TITLE)
|
||||
sheet.merge(top, label_col, top + 1, label_col)
|
||||
for index, key in enumerate(("opening", "turnover", "closing")):
|
||||
col = first_sum + 2 * index
|
||||
sheet.put(top, col, GROUP_TITLES[key])
|
||||
sheet.merge(top, col, top, col + 1)
|
||||
sheet.put(top + 1, col, DEBIT)
|
||||
sheet.put(top + 1, col + 1, CREDIT)
|
||||
sheet.put(top + 1, 1, group_label)
|
||||
for row, item in enumerate(rows, start=top + 2):
|
||||
if item.text == MERGED:
|
||||
sheet.merge(row - 1, 1, row, 1)
|
||||
elif item.text:
|
||||
sheet.put(row, 1, item.text)
|
||||
if item.label is not None:
|
||||
sheet.put(row, label_col, item.label)
|
||||
if item.outline:
|
||||
sheet.outline[row] = item.outline
|
||||
for offset, value in enumerate(item.sums):
|
||||
if value is not None:
|
||||
sheet.put(row, first_sum + offset, D(value))
|
||||
return sheet
|
||||
```
|
||||
|
||||
(Если `Sequence` не импортирован в `generate.py` — добавить в импорт `collections.abc`.)
|
||||
|
||||
- [ ] **Step 2: Четыре файла** (суммы согласованы: родитель БУ = Σ детей БУ, «Итого» = корень, тождество строк выполняется у всех строк):
|
||||
|
||||
```python
|
||||
def _s40() -> list[SheetSpec]:
|
||||
"""Колонка «Показатели» БУ/НУ (AURORA-1613): счёт 02, шапка без «Выводимые данные», название объединено на строки
|
||||
БУ и НУ, «Итого» по БУ и по НУ. Строки НУ — показатели строки БУ над ними."""
|
||||
rows = [
|
||||
GridRow("02", (None, "300000.00", "20000.00", "30000.00", None, "310000.00"), "БУ"),
|
||||
GridRow(None, (None, "300000.02", "20000.00", "30000.03", None, "310000.05"), "НУ"),
|
||||
GridRow("Печь подовая", (None, "100000.00", "20000.00", "10000.00", None, "90000.00"), "БУ", 1),
|
||||
GridRow(MERGED, (None, "100000.02", "20000.00", "10000.01", None, "90000.03"), "НУ", 1),
|
||||
GridRow("Плита индукционная", (None, "200000.00", None, "20000.00", None, "220000.00"), "БУ", 1),
|
||||
GridRow(None, (None, "200000.00", None, "20000.02", None, "220000.02"), "НУ", 1),
|
||||
GridRow("Итого", (None, "300000.00", "20000.00", "30000.00", None, "310000.00"), "БУ"),
|
||||
GridRow(MERGED, (None, "300000.02", "20000.00", "30000.03", None, "310000.05"), "НУ"),
|
||||
]
|
||||
return [_grid_osv(account="02", group_label="Основные средства", rows=rows, data_type_line=None, indicator_column=True)]
|
||||
|
||||
|
||||
def _s41() -> list[SheetSpec]:
|
||||
"""Колонка «Показатели»: «Вал.», «Кол.», «ПР», «ВР» и неизвестная метка «Ед.» — показатели вне сверки родителя."""
|
||||
rows = [
|
||||
GridRow("60", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00"), "БУ"),
|
||||
GridRow("ООО Альфа", ("1000.00", None, "500.00", "300.00", "1200.00", None), "БУ", 1),
|
||||
GridRow(None, ("12.50", None, "6.00", "3.00", "15.50", None), "Вал.", 1),
|
||||
GridRow(None, ("3", None, None, None, "3", None), "Кол.", 1),
|
||||
GridRow("ООО Бета", (None, "400.00", "100.00", None, None, "300.00"), "БУ", 1),
|
||||
GridRow(None, (None, None, "10.00", None, "10.00", None), "ПР", 1),
|
||||
GridRow(None, (None, "5.00", None, None, None, "5.00"), "ВР", 1),
|
||||
GridRow(None, ("1", None, None, None, "1", None), "Ед.", 1),
|
||||
GridRow("Итого", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00"), "БУ"),
|
||||
]
|
||||
line = "Выводимые данные: БУ (данные бухгалтерского учета)"
|
||||
return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=line, indicator_column=True)]
|
||||
|
||||
|
||||
def _s42() -> list[SheetSpec]:
|
||||
"""«Валюта <код>» — строка в рублях, «В валюте» — показатель (AURORA-1661): пары на одном outline-уровне."""
|
||||
rows = [
|
||||
GridRow("60", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00")),
|
||||
GridRow("60.21", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00"), outline=1),
|
||||
GridRow("ООО Альфа", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00"), outline=2),
|
||||
GridRow("Валюта USD", ("9000.00", None, "1000.00", None, "10000.00", None), outline=3),
|
||||
GridRow("В валюте", ("100.00", None, "11.00", None, "111.00", None), outline=3),
|
||||
GridRow("Валюта EUR", (None, "2000.00", None, "500.00", None, "2500.00"), outline=3),
|
||||
GridRow("В валюте", (None, "20.00", None, "5.00", None, "25.00"), outline=3),
|
||||
GridRow("Итого", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00")),
|
||||
]
|
||||
line = "Выводимые данные: БУ (данные бухгалтерского учета)"
|
||||
return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=line, indicator_column=False)]
|
||||
|
||||
|
||||
def _s43() -> list[SheetSpec]:
|
||||
"""Шапка без «Выводимые данные», колонки «Показатели» нет: строки — BU по умолчанию с предупреждением."""
|
||||
rows = [
|
||||
GridRow("60", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00")),
|
||||
GridRow("ООО Альфа", ("1000.00", None, "500.00", "300.00", "1200.00", None), outline=1),
|
||||
GridRow("ООО Бета", (None, "400.00", "100.00", None, None, "300.00"), outline=1),
|
||||
GridRow("Итого", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00")),
|
||||
]
|
||||
return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=None, indicator_column=False)]
|
||||
```
|
||||
|
||||
Добавить в `FILES`:
|
||||
|
||||
```python
|
||||
"s40_indicator_column_bu_nu.xlsx": (_s40, to_xlsx),
|
||||
"s41_indicator_column_qty_val_diff.xlsx": (_s41, to_xlsx),
|
||||
"s42_currency_slices.xlsx": (_s42, to_xlsx),
|
||||
"s43_no_data_type_header.xlsx": (_s43, to_xlsx),
|
||||
```
|
||||
|
||||
и поправить описание CLI в `main` («s01…s43»). Номера s33–s39 оставлены треку B.
|
||||
|
||||
- [ ] **Step 3: Сгенерировать в fixtures:** `cd $WT/tabular-parser/api && .venv/bin/python -m tests.synthetic.generate $WT/fixtures/osv/synthetic` — проверить `git -C $WT/fixtures status`: изменены только четыре новых файла (s01–s32 побайтно те же; если нет — стоп, отчёт).
|
||||
|
||||
- [ ] **Step 4: Ожидания и сценарии** в `test_synthetic_files.py`:
|
||||
- `Expect.data_type: str | None = "BU"`;
|
||||
- `EXPECTED` — `"s40_indicator_column_bu_nu.xlsx": Expect("outline", 2025, "Q3", False, data_type=None)`, `"s41_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False)`, `"s42_currency_slices.xlsx": Expect("outline", 2025, "Q3", False)`, `"s43_no_data_type_header.xlsx": Expect("outline", 2025, "Q3", False, data_type=None)`; `EXPECTED_NAMES` — четыре имени; докстринг `test_synthetic_set_is_complete` — «s01…s43»;
|
||||
- сценарии (по образцу `test_s30_…`, хелперы `one_document`, `parse_named`, `warnings_of`):
|
||||
|
||||
```python
|
||||
def test_s40_indicator_column_bu_nu(fixtures_dir: Path) -> None:
|
||||
"""AURORA-1613: строки «НУ» колонки «Показатели» — показатели строки БУ; две «Итого»; проверки сходятся."""
|
||||
outcome = parse_named(fixtures_dir, "s40_indicator_column_bu_nu.xlsx")
|
||||
doc = one_document(outcome)
|
||||
by_row = {row.row_number: row for row in doc.rows}
|
||||
nu = [row for row in doc.rows if row.data_type == "NU"]
|
||||
assert [row.row_value for row in nu] == ["НУ"] * 4
|
||||
assert all(row.group_type in {"OTHER", "TOTAL"} for row in nu)
|
||||
for row in nu:
|
||||
if row.group_type != "TOTAL":
|
||||
parent = by_row[row.parent_row_number]
|
||||
assert parent.data_type == "BU" and parent.row_number == row.row_number - 1
|
||||
assert row.level == parent.level + 1
|
||||
assert [row.data_type for row in doc.rows if row.group_type == "TOTAL"] == ["BU", "NU"]
|
||||
assert {row.data_type for row in doc.rows if row.row_value != "НУ"} == {"BU"}
|
||||
assert doc.header.data_type is None
|
||||
texts = warnings_of(outcome)
|
||||
assert not any("ниже таблицы" in text for text in texts), texts
|
||||
assert not any("приняты как БУ" in text for text in texts), texts
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
|
||||
|
||||
def test_s41_indicator_column_quantity_currency_differences(fixtures_dir: Path) -> None:
|
||||
"""«Кол.» — QUANTITY, «Вал.» — CURRENCY, «ПР»/«ВР»/неизвестная метка — null; одно предупреждение о метке."""
|
||||
outcome = parse_named(fixtures_dir, "s41_indicator_column_qty_val_diff.xlsx")
|
||||
doc = one_document(outcome)
|
||||
types = {row.row_value: row.data_type for row in doc.rows if row.row_value in {"Вал.", "Кол.", "ПР", "ВР", "Ед."}}
|
||||
assert types == {"Вал.": "CURRENCY", "Кол.": "QUANTITY", "ПР": None, "ВР": None, "Ед.": None}
|
||||
unknown = [text for text in warnings_of(outcome) if "«Ед.»" in text]
|
||||
assert len(unknown) == 1, warnings_of(outcome)
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
|
||||
|
||||
def test_s42_v_valyute_is_currency_of_previous_row(fixtures_dir: Path) -> None:
|
||||
"""AURORA-1661: «В валюте» — CURRENCY, родитель — «Валюта …» над ней; «Валюта …» — обычные строки в рублях."""
|
||||
outcome = parse_named(fixtures_dir, "s42_currency_slices.xlsx")
|
||||
doc = one_document(outcome)
|
||||
by_row = {row.row_number: row for row in doc.rows}
|
||||
in_currency = [row for row in doc.rows if row.row_value == "В валюте"]
|
||||
assert [row.data_type for row in in_currency] == ["CURRENCY", "CURRENCY"]
|
||||
assert [by_row[row.parent_row_number].row_value for row in in_currency] == ["Валюта USD", "Валюта EUR"]
|
||||
slices = [row for row in doc.rows if row.row_value.startswith("Валюта ")]
|
||||
assert {(row.group_type, row.data_type) for row in slices} == {("OTHER", "BU")}
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
|
||||
|
||||
def test_s43_no_data_type_in_header_rows_are_bu(fixtures_dir: Path) -> None:
|
||||
"""Шапка без «Выводимые данные»: строки — BU, предупреждение одно, `header.data_type` — null."""
|
||||
outcome = parse_named(fixtures_dir, "s43_no_data_type_header.xlsx")
|
||||
doc = one_document(outcome)
|
||||
assert doc.header.data_type is None
|
||||
assert {row.data_type for row in doc.rows} == {"BU"}
|
||||
assert len([text for text in warnings_of(outcome) if "приняты как БУ" in text]) == 1
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
```
|
||||
|
||||
Если имена хелперов в модуле другие — взять существующие (`one_document`, `parse_named`, `warnings_of`, `ResultStatus` уже используются в s30/s31).
|
||||
|
||||
- [ ] **Step 5: Запустить — сценарии s40–s43 красные** (это и есть воспроизведение багов): `ENV_FILE=.env.test .venv/bin/python -m pytest tests/synthetic -q -k "s40 or s41 or s42 or s43"`. Записать в ledger, как именно падает каждый (до исправления).
|
||||
- [ ] **Step 6: Commit** в tabular-parser (`generate.py`, тесты; `make check` красный только на s40–s43 и покрытии словарей — допустимо с пометкой в ledger) и отдельный commit в fixtures (`git -C $WT/fixtures add osv/synthetic/s4*.xlsx`).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Колонка «Показатели» в заголовке (модель: haiku)
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/src/parser/detect.py` (`TableHeader`, `find_table_header`)
|
||||
- Test: `api/tests/unit/parser/test_detect.py`
|
||||
|
||||
- [ ] **Step 1: Тест** — сетка `gridbuilder` по образцу тестов `inn_col` в `test_detect.py`: заголовок «Счет | Показа-\nтели | Сальдо на начало …» → `header.indicator_col` = колонка «Показа-тели», `header.text_col` не меняется, колонка «Показатели» есть в `header.extra_cols`; без такой колонки — `indicator_col is None`.
|
||||
- [ ] **Step 2: Запустить — падает.**
|
||||
- [ ] **Step 3: Реализация.** `TableHeader` — последнее поле `indicator_col: int | None = None` (комментарий: «колонка «Показатели» — метка вида суммы строки, §1.3»). В `find_table_header` после `inn_col`:
|
||||
|
||||
```python
|
||||
indicator_col = _service_col(sheet, range(group_row, last_row + 1), text_col, first_sum, rules, "indicator")
|
||||
```
|
||||
|
||||
где `_service_col` — первая колонка между колонкой текста и суммами, чей текст в блоке заголовка матчит `rules.headers.service_columns.get(key, [])` (вынести общий цикл из `_inn_col` по заголовку, поведение `_inn_col` не менять). В `occupied` колонку **не** добавлять (остаётся в `extra_cols`). Передать `indicator_col=indicator_col` в итоговый `TableHeader`.
|
||||
- [ ] **Step 4: Тест зелёный, `make check`, commit** «Заголовок ОСВ: колонка «Показатели» (#58)».
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Строки-показатели при сборе строк (модель: sonnet)
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/src/parser/hierarchy.py` (`BodyRow` — только поля)
|
||||
- Create: `api/src/parser/indicators.py`
|
||||
- Modify: `api/src/parser/classify.py` (`_label_cols`, `_body_row`, `_trailing_totals`, `collect_body_rows`)
|
||||
- Test: `api/tests/unit/parser/test_indicators.py`, `api/tests/unit/parser/test_classify.py`
|
||||
|
||||
- [ ] **Step 1: Поля `BodyRow`** (после `label_col`, с умолчаниями — позиционные конструкторы в тестах не ломаются):
|
||||
|
||||
```python
|
||||
indicator: bool = False # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики
|
||||
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
|
||||
```
|
||||
|
||||
(`DataType` — из `src.contract.common`.)
|
||||
|
||||
- [ ] **Step 2: Модуль `indicators.py` — чтение метки:**
|
||||
|
||||
```python
|
||||
"""Строки-показатели ОСВ (§1.3, AURORA-1613, AURORA-1661): метка вида суммы строки и привязка к строке данных."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.contract.common import DataType
|
||||
from src.parser.detect import TableHeader
|
||||
from src.parser.grid import Sheet
|
||||
from src.parser.rules import Rules, first_match
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RowLabel:
|
||||
"""Вид строки по метке: показатель ли она, `data_type` метки, текст строки взамен исходного, предупреждение."""
|
||||
|
||||
indicator: bool
|
||||
data_type: DataType | None
|
||||
text: str | None = None # для метки колонки «Показатели» — сама метка (`row_value`)
|
||||
warning: str | None = None
|
||||
|
||||
|
||||
def column_label(sheet: Sheet, row: int, header: TableHeader) -> str:
|
||||
"""Метка строки в колонке «Показатели» (пусто — колонки нет или ячейка пуста)."""
|
||||
return "" if header.indicator_col is None else sheet.text_at(row, header.indicator_col)
|
||||
|
||||
|
||||
def read_label(sheet: Sheet, row: int, text: str, header: TableHeader, rules: Rules) -> RowLabel:
|
||||
"""Метка колонки «Показатели» важнее текста строки: «БУ» — строка данных BU, прочие — показатели; без метки —
|
||||
показатель по тексту («Вал.», «Кол.», «Кол-во», «В валюте», код валюты), иначе обычная строка без своего вида."""
|
||||
label = column_label(sheet, row, header)
|
||||
if label:
|
||||
data_type = first_match(rules.data_types.indicator_column, label)
|
||||
if data_type == "BU":
|
||||
return RowLabel(indicator=False, data_type="BU")
|
||||
if data_type is not None or any(p.search(label) for p in rules.data_types.indicator_column_untyped):
|
||||
return RowLabel(indicator=True, data_type=data_type, text=label)
|
||||
warning = f"строка {row}: метка «{label}» в колонке «Показатели» не распознана — строка не участвует в проверках"
|
||||
return RowLabel(indicator=True, data_type=None, text=label, warning=warning)
|
||||
data_type = row_data_type(text, rules, frozenset(rules.data_types.currency_codes))
|
||||
return RowLabel(indicator=data_type is not None, data_type=data_type)
|
||||
|
||||
|
||||
def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules) -> bool:
|
||||
"""Итоговая строка-показатель за «Итого»: метка колонки «Показатели» не «БУ»."""
|
||||
label = column_label(sheet, row, header)
|
||||
return bool(label) and first_match(rules.data_types.indicator_column, label) != "BU"
|
||||
```
|
||||
|
||||
`row_data_type` сейчас живёт в `classify.py`, а `classify.py` будет импортировать `indicators.py` — перенести `row_data_type` в `indicators.py` и в `classify.py` оставить реэкспорт (`from src.parser.indicators import row_data_type`), чтобы не ломать импорт в `inn_rows.py` и тестах; цикла импортов быть не должно (`indicators.py` не импортирует `classify`).
|
||||
|
||||
- [ ] **Step 3: Тесты `test_indicators.py`** (сетки `gridbuilder`; `TableHeader` — через `find_table_header`): «БУ» → `indicator False, data_type BU`; «НУ» → `True, NU, text "НУ"`; «ПР» → `True, None`, без предупреждения; «Ед.» → `True, None`, предупреждение с номером строки; без колонки, текст «В валюте» → `True, CURRENCY`; текст «Валюта USD» → `False, None`; `is_indicator_total` для «НУ» — `True`, для «БУ» — `False`.
|
||||
- [ ] **Step 4: `classify.py`:**
|
||||
- `_label_cols`: условие `col != header.inn_col` дополнить `and col != header.indicator_col`;
|
||||
- `_body_row(sheet, row, header, label_cols, warnings, rules)`: после `_label_of` — `label = read_label(sheet, row, text, header, rules)`; если `label.text is not None` — `text = label.text` (у строки с меткой колонки текст — метка; `account` считать по новому тексту → не счёт); `label.warning` — в `warnings`; в `BodyRow` — `indicator=label.indicator, label_data_type=label.data_type`;
|
||||
- `_trailing_totals`: строка продолжает серию, если `_is_secondary_total(...)` **или** `is_indicator_total(sheet, row, header, rules)`; докстринг — «и итоговые строки-показатели («Итого» с меткой «НУ», AURORA-1613)»;
|
||||
- `collect_body_rows` передаёт `rules` в `_body_row`.
|
||||
- [ ] **Step 5: Тест в `test_classify.py`** — сетка с колонкой «Показатели» (строки БУ/НУ, объединённое название, «Итого» БУ+НУ): `collect_body_rows` — строки «НУ» с `indicator True`, `text == "НУ"`, `label_data_type == "NU"`; вторая «Итого» в `body`, предупреждения «ниже таблицы» нет.
|
||||
- [ ] **Step 6: `make check`** (сценарии s40–s43 ещё могут быть красными — отметить в ledger), **commit** «Строки-показатели: метка колонки «Показатели» при сборе строк (#58)».
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Иерархия — показатели вне каскада (модель: sonnet)
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/src/parser/indicators.py` (`levels_with_indicators`)
|
||||
- Modify: `api/src/parser/osv.py` (`parse_sheet`)
|
||||
- Modify: `api/src/parser/inn_rows.py` (`_kind`)
|
||||
- Test: `api/tests/unit/parser/test_indicators.py`
|
||||
|
||||
- [ ] **Step 1: Тесты** на `levels_with_indicators` (строки `BodyRow` вручную, `Deadline` с большим запасом, по образцу `test_hierarchy.py`):
|
||||
1. счёт (outline 0), строка «НУ»-показатель (outline 0), объект (outline 1), «НУ» (outline 1), «Итого», «Итого»-показатель → показатель счёта: родитель — счёт, уровень 1; показатель объекта: родитель — объект, уровень 2; объект — ребёнок счёта (каскад не видел показателей); обе итоговые — уровень 0 без родителя; стратегия `outline`;
|
||||
2. показатель первой строкой таблицы — уровень 0 без родителя;
|
||||
3. порядок ключей `by_row` и `parent` — порядок строк листа.
|
||||
- [ ] **Step 2: Реализация** в `indicators.py`:
|
||||
|
||||
```python
|
||||
def levels_with_indicators(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels:
|
||||
"""Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
|
||||
(§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.
|
||||
|
||||
Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
|
||||
"""
|
||||
levels = resolve_levels([item for item in rows if not item.indicator], total_row, deadline)
|
||||
by_row: dict[int, int] = {}
|
||||
parent: dict[int, int | None] = {}
|
||||
previous: int | None = None
|
||||
for item in rows:
|
||||
if not item.indicator:
|
||||
by_row[item.row], parent[item.row] = levels.by_row[item.row], levels.parent[item.row]
|
||||
if total_row is None or item.row < total_row:
|
||||
previous = item.row
|
||||
continue
|
||||
if previous is None or (total_row is not None and item.row >= total_row):
|
||||
by_row[item.row], parent[item.row] = ROOT_LEVEL, None
|
||||
else:
|
||||
by_row[item.row], parent[item.row] = by_row[previous] + 1, previous
|
||||
return Levels(by_row=by_row, parent=parent, strategy=levels.strategy)
|
||||
```
|
||||
|
||||
(`ROOT_LEVEL`, `Levels`, `resolve_levels`, `BodyRow` — из `hierarchy.py`; `Deadline` — из `deadline.py`.)
|
||||
|
||||
- [ ] **Step 3: `osv.py`** — в `parse_sheet` `levels = resolve_levels(body, total_row, deadline)` заменить на `levels = levels_with_indicators(body, total_row, deadline)` (импорт `resolve_levels` убрать, если больше не нужен).
|
||||
- [ ] **Step 4: `inn_rows._kind`** — `if row_data_type(item.text, rules, currency) is not None:` заменить на `if item.indicator:` (параметр `currency` убрать, если больше не используется, вместе с вызывающим кодом).
|
||||
- [ ] **Step 5: `make check`** — все прежние тесты (в том числе s04, s30, `test_inn_rows*.py`) зелёные; **commit** «Строки-показатели вне каскада иерархии (#58)».
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Классификация, проверки, вид данных по умолчанию (модель: sonnet)
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/src/parser/classify.py` (`ClassifiedRow`, `_Kind`, `_classify_row`, `classify_rows`)
|
||||
- Modify: `api/src/parser/checks.py` (`_INDICATOR_DATA_TYPES`, `_is_indicator`, `_total`)
|
||||
- Modify: `api/src/parser/osv.py` (предупреждение умолчания)
|
||||
- Test: `api/tests/unit/parser/test_classify.py`, `api/tests/unit/parser/test_checks.py`
|
||||
|
||||
- [ ] **Step 1: Тесты:**
|
||||
- `test_checks.py`: строка `ClassifiedRow` с `data_type="NU", indicator=True` под родителем BU не входит в сумму детей; документ из одних строк NU без признака (как «Выводимые данные: НУ») проверяется как обычно; `_total`/`total_matches_root` берёт первую итоговую строку без признака, даже если перед ней стоит итоговая строка-показатель;
|
||||
- `test_classify.py`: строка с `label_data_type="BU"` при `doc_data_type="NU"` → BU; строка без метки при `doc_data_type=None` → BU; показатель → `OTHER`, `data_type` метки, `indicator=True`, `account_code_raw` ветки родителя, `counterparty_*` — `None`; итоговая строка-показатель → `TOTAL` c `data_type` метки и `indicator=True`.
|
||||
- [ ] **Step 2: Реализация:**
|
||||
- `ClassifiedRow`: поле `indicator: bool = False` (последним, с умолчанием);
|
||||
- `_Kind`: поле `indicator: bool = False`;
|
||||
- `_classify_row`: вместо `row_data_type(...)`:
|
||||
|
||||
```python
|
||||
if item.indicator:
|
||||
return _Kind("OTHER", None, code, item.label_data_type, indicator=True), inherited
|
||||
own = item.label_data_type or doc_data_type
|
||||
```
|
||||
|
||||
и далее везде, где строка получала `doc_data_type`, — `own` (ACCOUNT, строка ИНН без пары, схема, COUNTERPARTY);
|
||||
- `classify_rows`: параметр `doc_data_type` — уже с умолчанием (см. `osv.py`); для итоговых строк `_Kind("TOTAL", None, None, item.label_data_type or doc_data_type, indicator=item.indicator)`; в `ClassifiedRow(...)` передать `indicator=kind.indicator`; докстринг — пункт про строки-показатели переписать по §1.3;
|
||||
- `checks.py`: удалить `_INDICATOR_DATA_TYPES`; `_is_indicator(item)` → `return item.indicator`; `_total` — первая строка `TOTAL` с `not item.indicator`; докстринги `parent_sums`/`_level_zero` — «строки-показатели»;
|
||||
- `osv.py` (`parse_sheet`): перед классификацией
|
||||
|
||||
```python
|
||||
data_type = doc.data_type or DEFAULT_DATA_TYPE
|
||||
defaulted = doc.data_type is None and any(
|
||||
not item.indicator and item.label_data_type is None for item in body
|
||||
)
|
||||
```
|
||||
|
||||
`classify_rows(..., data_type, ...)`; после классификации, если `defaulted`, — `notes.append(OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING))` сразу после заметок шапки; константы в `osv.py`: `DEFAULT_DATA_TYPE: DataType = "BU"`, `DEFAULT_DATA_TYPE_WARNING = "вид данных в шапке не указан — строки без метки приняты как БУ"`. `header.data_type` (`_header(doc)`) не трогать.
|
||||
- [ ] **Step 3: Синтетика s40–s43 зелёная:** `ENV_FILE=.env.test .venv/bin/python -m pytest tests/synthetic -q`.
|
||||
- [ ] **Step 4: `make check`, commit** «Классификация и проверки по признаку строки-показателя, BU по умолчанию (#58)».
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Регрессия, golden, файлы контура (модель: sonnet)
|
||||
|
||||
**Files:**
|
||||
- Modify: `$WT/fixtures/golden/expected_osv_parse/synthetic/s40…s43.json` (новые, `make golden-update`)
|
||||
|
||||
- [ ] **Step 1: `make golden-check`** из `$WT/tabular-parser`. Ожидание: diff только «нет снимка» для s40–s43. **Любой diff по реальным файлам или s01–s32 — стоп**: записать в ledger (файл, строки, что изменилось) и вернуть контроллеру; не перегенерировать.
|
||||
- [ ] **Step 2: `make golden-update`**, проверить `git -C $WT/fixtures status` — только четыре новых снимка; просмотреть их глазами (строки НУ/«В валюте» — показатели с верными родителями, две TOTAL в s40). Commit в fixtures «Golden s40–s43: колонка «Показатели», «В валюте» (#58)».
|
||||
- [ ] **Step 3: Покрытие словарей** — `ENV_FILE=.env.test .venv/bin/python -m pytest tests/fixtures/test_rules_coverage.py -q` зелёный (каждая новая запись срабатывает на s40–s42).
|
||||
- [ ] **Step 4: Файлы контура (не в git).** `TMP=$(mktemp -d)`; извлечь из `/Users/andrey.epifancev/projects/awp-ca/result_dir.zip` файлы `files/3da66219-*.xlsx`, `files/86353387-*.xls` и все прочие файлы, где в заголовке таблицы есть «Показа» (найти перебором через `python -I`, без записи в репозитории), а также `files/270b41da-*.xlsx`, `files/738bfcf5-*.xlsx`. Для каждого: `cd $WT/tabular-parser/api && .venv/bin/python -m src.parser.cli "$TMP/<файл>"` на ветке и на `origin/main` (второй worktree не создавать: сравнить с выдачей `admin/1639-contour-run/parser-out/<id>*.json`, если она есть). Свести в ledger таблицу: файл, статус до/после, `parent_sum_violations` до/после, число строк NU/QUANTITY/CURRENCY, оборот Дт корня по BU. Ожидание по AURORA-1613: оборот по дебету БУ без удвоения. Для 270b41da/738bfcf5 (плоская форма, трек B) — только что строки «В валюте» стали CURRENCY; структура плоской формы — не цель этого плана. `rm -rf "$TMP"` в конце.
|
||||
- [ ] **Step 5:** если на файлах контура найден случай, который план не покрывает (новая метка, иная раскладка), — не чинить, записать находкой `O-n` в ledger и вернуть контроллеру.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Документация и итоговые проверки (модель: sonnet)
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` (раздел «Парсер ОСВ»: строки-показатели, колонка «Показатели», BU по умолчанию)
|
||||
- Modify: `openspec/changes/osv-indicator-column/tasks.md` (отметить выполненное)
|
||||
- Modify: `$WT/docs/context/06-test-data.md` (§7 синтетики: s40–s43 одной строкой каждый)
|
||||
- Modify: `CLAUDE.md` tabular-parser (одна строка в «Модули»: `indicators.py` — строки-показатели)
|
||||
|
||||
- [ ] **Step 1:** README и CLAUDE.md — коротко, по образцу соседних абзацев; `06-test-data.md` — commit в docs (ветка `feat/58-osv-indicators`, там уже правка §1.3/§1.4).
|
||||
- [ ] **Step 2:** `openspec validate osv-indicator-column --strict` — valid.
|
||||
- [ ] **Step 3:** `make check-full` из `$WT/tabular-parser` — зелёный; вывод — в ledger.
|
||||
- [ ] **Step 4:** commit «Документация: строки-показатели ОСВ (#58)».
|
||||
|
||||
## После плана (контроллер)
|
||||
|
||||
Финальное ревью ветки — opus; затем finishing-a-development-branch: PR в tabular-parser (Closes #58), docs, fixtures (fixtures сливается первым, затем tabular-parser, затем docs). Список «Rulings I made» и таблица находок — в описание PR tp. Jira AURORA-1613/1661 — не трогать: статусы ведёт главная сессия.
|
||||
2
openspec/changes/osv-indicator-column/.openspec.yaml
Normal file
2
openspec/changes/osv-indicator-column/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-19
|
||||
107
openspec/changes/osv-indicator-column/design.md
Normal file
107
openspec/changes/osv-indicator-column/design.md
Normal file
@ -0,0 +1,107 @@
|
||||
# Design: колонка «Показатели» и валютные строки ОСВ
|
||||
|
||||
Правило — `05-domain-rules.md` §1.3 (абзацы «строки-показатели», «Колонка «Показатели»»), §1.4 (сверка родителя),
|
||||
ветка docs `feat/58-osv-indicators`.
|
||||
|
||||
## Как выглядит файл (реальный образец контура, в git не кладётся)
|
||||
|
||||
`result_dir.zip`, `files/3da66219-….xlsx` — ОСВ по счёту 02, шапка без «Выводимые данные» («Параметры: Валюта БУ, НУ:
|
||||
Руб»):
|
||||
|
||||
```
|
||||
r7 Счет | Показа-\nтели | Сальдо на начало | Обороты за период | Сальдо на конец
|
||||
r8 Основные средства | | Дебет | Кредит | Дебет | Кредит | Дебет | Кредит
|
||||
r10 02 | БУ | | 480896,82 | 45754,41 | 46336,70 | | 481479,11 outline 0
|
||||
r11 | НУ | | 480896,82 | 45754,41 | 46336,71 | | 481479,12 outline 0
|
||||
r12 Интерактивная … | БУ | … outline 1
|
||||
r13 | НУ | … outline 1
|
||||
r16 Машина … (A16:D17 объединены) | БУ | … outline 1
|
||||
r17 (продолжение объединения) | НУ | … outline 1
|
||||
r30 Итого (A30:D31 объединены) | БУ | … outline 0
|
||||
r31 (продолжение) | НУ | … outline 0
|
||||
```
|
||||
|
||||
`files/270b41da-….xlsx` (плоская форма, трек B) — пары строк «Валюта CNY» (рубли) / «В валюте» (сумма в валюте) под
|
||||
субсчётом 76.55.
|
||||
|
||||
Сейчас: колонка «Показа-тели» попадает в `extra_cols` и в `_label_cols` (r11, r13 — пустой текст → текст «НУ» из
|
||||
колонки E, отступ +1000 → ребёнок строки БУ); r17 — текст объединённой ячейки (повтор названия), `data_type` всех строк
|
||||
`null` (шапка молчит); r31 — не «Итого развернутое», `_trailing_totals` обрывается, строка уходит в подвал с
|
||||
предупреждением «ниже таблицы есть числа».
|
||||
|
||||
## Решения
|
||||
|
||||
### Признак строки-показателя — поле строки, не значение `data_type`
|
||||
|
||||
Сейчас «показатель» = `data_type ∈ {CURRENCY, QUANTITY}` (`checks._INDICATOR_DATA_TYPES`). NU в этот набор добавить
|
||||
нельзя: документ с «Выводимые данные: НУ» (s12) целиком состоит из строк NU, и все его проверки пропали бы. Поэтому
|
||||
вводится признак `indicator: bool` у `BodyRow` (вычисляется при сборе строк) и у `ClassifiedRow`; `checks._is_indicator`
|
||||
читает признак. Строка-показатель — если:
|
||||
|
||||
1. в листе есть колонка «Показатели», ячейка строки в ней непуста и метка ≠ «БУ» (словарь
|
||||
`data_types.indicator_column`); `data_type` — из словаря; «ПР», «ВР» — `null` (словарь `indicator_column_untyped`);
|
||||
метка не из обоих словарей — `null` + предупреждение `OSV_ROWS` «строка N: метка «X» в колонке «Показатели» не
|
||||
распознана — строка не участвует в проверках»;
|
||||
2. иначе — текст строки матчит `data_types.row` («Вал.», «Кол.», «Кол-во», **«В валюте»**) или равен коду валюты
|
||||
(как сейчас, `row_data_type`).
|
||||
|
||||
Метка «БУ» в колонке даёт строке `data_type = BU` явно (даже если шапка молчит или называет НУ — колонка точнее шапки).
|
||||
|
||||
### Текст строки с меткой колонки — метка
|
||||
|
||||
У строки-показателя из колонки текст в колонке текста либо пуст, либо повторяет объединённую ячейку строки БУ (r17).
|
||||
`BodyRow.text` = метка («НУ»), `is_account = False`. Так `row_value` не дублирует название объекта, а ИНН-логика и
|
||||
классификатор не принимают строку за счёт или контрагента. Колонка «Показатели» исключается из `_label_cols` (как колонка
|
||||
ИНН) и остаётся в `extra` под своим заголовком.
|
||||
|
||||
### Иерархия: показатели вне каскада, родитель — предыдущая строка данных
|
||||
|
||||
Новый модуль `src/parser/indicators.py` (чтобы не пересекаться с треком B в `hierarchy.py`):
|
||||
`attach_indicators(rows, levels, total_row) -> Levels`. В `OsvParser.parse_sheet` каскад `resolve_levels` получает
|
||||
строки без показателей; затем каждому показателю (до «Итого») назначается родитель — ближайшая строка выше, которая не
|
||||
показатель, — и уровень `level(родителя) + 1`; показатель в начале таблицы без строки данных выше — уровень 0 без
|
||||
родителя. Итоговые строки-показатели — уровень 0 без родителя (как «Итого»). Стратегия иерархии (`Levels.strategy`) —
|
||||
стратегия каскада.
|
||||
|
||||
Для строк «Вал.»/«Кол.» под контрагентом (s04, s30, реальные файлы) результат совпадает с прежним: 1С печатает
|
||||
показатель сразу под строкой, к которой он относится. Golden-проверка это подтверждает; любое расхождение golden на
|
||||
реальных файлах — стоп и вопрос владельцу (не перегенерировать молча).
|
||||
|
||||
### Итоговые строки-показатели
|
||||
|
||||
`_trailing_totals` продолжает серию итоговых строк и через строки с меткой показателя в колонке «Показатели»
|
||||
(r31 после r30): они остаются в таблице, классифицируются TOTAL с `data_type` метки и `indicator = True`;
|
||||
`checks._total` берёт первую итоговую строку **не показатель**. Трек B меняет `_trailing_totals`/`_total` под
|
||||
«Итого развернутое» перед «Итого» — при слиянии второй трек добавляет своё условие к версии первого.
|
||||
|
||||
### Вид данных по умолчанию
|
||||
|
||||
Если шапка не называет вид данных (`doc.data_type is None`), строки без собственного вида (не показатели и без метки
|
||||
«БУ») получают `BU`; один раз на лист — предупреждение `OSV_HEADER` «вид данных в шапке не указан — строки без метки
|
||||
приняты как БУ». Если все строки данных размечены колонкой «Показатели», предупреждения нет (умолчание не
|
||||
применялось). `header.data_type` остаётся `null`; существующее предупреждение шапки «тип выводимых данных … не найден»
|
||||
сохраняется.
|
||||
|
||||
### Что не меняется
|
||||
|
||||
- `balance_identity` проверяет все строки, включая показатели (как сейчас у «Вал.»/«Кол.»): у НУ/количества тождество
|
||||
«начало + обороты = конец» тоже выполняется.
|
||||
- «Валюта <код>» — обычная строка OTHER (рубли): `row_data_type("Валюта USD")` = `None` уже сейчас; закрепляется тестом.
|
||||
- Контракт, DBML, arm-core.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| Дата | Решение | Обоснование | Что стоит ошибка |
|
||||
|---|---|---|---|
|
||||
| 19.10.2026 | «ПР», «ВР» и метка не из словаря — `data_type = null`, строка-показатель | В контракте нет значения для разниц; `null` ядро не берёт в свод, проверки строку не видят | Разницы не видны как отдельный вид данных до AURORA-1669; переразметка — правка словаря |
|
||||
| 19.10.2026 | Признак показателя — поле `indicator`, а не набор значений `data_type` | Документ «Выводимые данные: НУ» не должен терять проверки | Лишнее поле в двух dataclass; контракт не меняется |
|
||||
| 19.10.2026 | `row_value` строки с меткой колонки — метка, а не текст колонки текста | Текст там пуст или повтор объединённой ячейки строки БУ | Если 1С когда-то печатает в строке НУ своё название — оно теряется (не встречалось в 31 файле контура) |
|
||||
| 19.10.2026 | Колонка «Показатели» остаётся в `extra` | Первичка не теряется, ядро `extra` хранит как есть | Дублирование метки в `extra` и `data_type` |
|
||||
| 19.10.2026 | Родитель показателя — предыдущая строка данных для всех показателей, включая «Вал.»/«Кол.» в колонке текста | Один механизм для колонки, «В валюте» и старых меток; каскад не видит строк, которые ломают дерево | Если показатель стоит не сразу под своей строкой, родитель будет неверным; ловит golden |
|
||||
| 19.10.2026 | Умолчание BU — только строкам без собственного вида; `header.data_type` остаётся `null` | Шапка действительно молчит; ядру нужен вид строки, не шапки | Предупреждение на листах без «Выводимые данные» (были без него) |
|
||||
| 19.10.2026 | Синтетика s40–s43 (s33–s39 оставлены треку B) | Параллельные треки не должны конфликтовать по именам файлов набора | Пропуск номеров в наборе |
|
||||
| 19.10.2026 | Новый модуль `indicators.py` вместо правки `hierarchy.resolve_levels` | Трек B меняет каскад иерархии параллельно | Ещё один модуль конвейера |
|
||||
|
||||
Решения владельца 19.10.2026 (чат): п. 1 колонка «Показатели» как метки §1.3, НУ без своих проверок; п. 2 «В валюте» →
|
||||
CURRENCY, «Валюта <код>» — строка OTHER в рублях, код валюты не хранится; п. 3 BU по умолчанию с предупреждением — по
|
||||
рекомендации агента, владелец принял без возражений («ошибки давай исправлять сейчас»).
|
||||
51
openspec/changes/osv-indicator-column/proposal.md
Normal file
51
openspec/changes/osv-indicator-column/proposal.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Колонка «Показатели» и валютные строки ОСВ (AURORA-1613, AURORA-1661 — валютная часть)
|
||||
|
||||
Issue: RSHB/arm-tabular-parser#58. Ветки `feat/58-osv-indicators` в tabular-parser, docs, fixtures.
|
||||
|
||||
## Why
|
||||
|
||||
Прогон выгрузки тестового контура (AURORA-1639, свод `admin/1639-contour-run/`, находки O-7, O-8) и тестирование
|
||||
AURORA-1534 показали два дефекта одного механизма — вида данных строки ОСВ:
|
||||
|
||||
- **AURORA-1613.** 1С печатает вид суммы строки отдельной колонкой «Показатели» (БУ / НУ / Кол. / Вал. / ПР / ВР;
|
||||
31 файл контура). Парсер о колонке не знает: метка уходит в `extra`, `data_type` строк НУ/Кол. — `null` (шапка без
|
||||
«Выводимые данные»); строка НУ с пустым текстом получает текст из колонки «Показатели» и отступ «колонка глубже»,
|
||||
становится ребёнком строки БУ — «дети = родитель» и «Итого» не сходятся, документ `needs_check`, на экране оборот
|
||||
удваивается. Вторая строка «Итого» (по НУ) обрезается как подвал с предупреждением «ниже таблицы есть числа».
|
||||
- **AURORA-1661, валютная часть.** Строки «В валюте» не распознаются как валютные: суммы в валюте (69,6 млн USD)
|
||||
записываются как рублёвые и попадают в свод.
|
||||
- Строки без вида данных (шапка без «Выводимые данные», нет метки) получают `data_type = null`, а ядро берёт в свод
|
||||
только BU/SUM — строки молча пропадают из сводной ОСВ.
|
||||
|
||||
Решения владельца 19.10.2026 — минимальное исправление: строки, которые не являются БУ, изолируются от данных БУ.
|
||||
Глубокая аналитика НУ и валют вынесена в AURORA-1669 и AURORA-1670 (бэклог аналитики).
|
||||
|
||||
## What Changes
|
||||
|
||||
- Колонка «Показатели» определяется по заголовку (словарь `service_columns.indicator`); метка строки читается по
|
||||
новому словарю `data_types.indicator_column`: «БУ» → BU (обычная строка), «НУ» → NU, «Кол.» → QUANTITY, «Вал.» →
|
||||
CURRENCY, «ПР»/«ВР»/метка не из словаря → `data_type = null` (метка не из словаря — с предупреждением).
|
||||
- Понятие **строки-показателя** (`indicator`) — признак строки, а не значение `data_type`: строки-показатели в колонке
|
||||
текста («Вал.», «Кол.», «Кол-во», код валюты, **новое** «В валюте») и строки с меткой колонки «Показатели», кроме
|
||||
«БУ». Строки-показатели не передаются в каскад иерархии; родитель — предыдущая строка данных, уровень на один
|
||||
глубже. В проверки «дети = родитель» и «Итого» не входят (как сейчас CURRENCY/QUANTITY); тождество строки — как у всех.
|
||||
- `row_value` строки с меткой колонки — метка (а не повтор текста объединённой ячейки строки БУ).
|
||||
- Итоговая строка с меткой показателя сразу за «Итого» — TOTAL, в подвал не уходит, в сверку не входит.
|
||||
- Строки без вида данных при шапке без «Выводимые данные» — `data_type = BU` с предупреждением; `header.data_type`
|
||||
остаётся `null` (шапка его не называет).
|
||||
- «Валюта <код>» — обычная строка (OTHER, рубли): поведение не меняется, закрепляется тестом.
|
||||
- §1.3/§1.4 `05-domain-rules.md` — отдельным PR в arm-ka-docs (ветка `feat/58-osv-indicators`).
|
||||
- Синтетика s40–s43 и golden — PR в arm-ka-fixtures.
|
||||
|
||||
Затронутые репозитории: arm-tabular-parser (код, словари, тесты, спека `osv-parsing`), arm-ka-docs (§1.3, §1.4),
|
||||
arm-ka-fixtures (синтетика, golden). Таблицы DBML v5.19 не меняются (`osv_entry.data_type` уже включает NU, CURRENCY,
|
||||
QUANTITY, NULL); контракт `recognition-result` не меняется. Срез: поддержка после Среза 2a (ошибки контура).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Собственные проверки по НУ, итоги по видам данных, хранение «ПР»/«ВР» отдельными значениями — AURORA-1669.
|
||||
- Код валюты строки, мультивалютные контрагенты, пересчёт; пересмотр «USD» как CURRENCY (в ТЗ — разрез с рублями) —
|
||||
AURORA-1670.
|
||||
- Плоская форма 1С (шапка «по счету: X», плоский список без строки счёта, «Итого развернутое» перед «Итого»,
|
||||
контрагенты на расчётных счетах) — отдельный трек AURORA-1661 (`.wt/osv-flat/`).
|
||||
- Изменения arm-core: ядро уже берёт в свод только BU/SUM и пишет `data_type` как пришёл.
|
||||
192
openspec/changes/osv-indicator-column/specs/osv-parsing/spec.md
Normal file
192
openspec/changes/osv-indicator-column/specs/osv-parsing/spec.md
Normal file
@ -0,0 +1,192 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Строки-показатели и колонка «Показатели»
|
||||
Строка-показатель — строка, которая несёт вид сумм предыдущей строки данных, а не уровень аналитики. Парсер SHALL считать
|
||||
строку показателем, если:
|
||||
1. в таблице есть колонка «Показатели» (заголовок блока по словарю `osv_headers.yaml` — `service_columns.indicator`,
|
||||
«Показатели», «Показа-тели»), ячейка строки в ней непуста и метка не «БУ»: «НУ» → `data_type = NU`, «Кол.» →
|
||||
QUANTITY, «Вал.» → CURRENCY (словарь `data_types.yaml` — `indicator_column`); «ПР», «ВР» (`indicator_column_untyped`)
|
||||
→ `data_type = null`; метка не из словарей → `data_type = null` и предупреждение `OSV_ROWS` «строка N: метка «X» в
|
||||
колонке «Показатели» не распознана — строка не участвует в проверках»;
|
||||
2. иначе — текст строки матчит `data_types.yaml` — `row` («Вал.», «Кол.», «Кол-во», «В валюте» → CURRENCY/QUANTITY) или
|
||||
равен коду валюты из `currency_codes`.
|
||||
|
||||
Строка «Валюта <код>» («Валюта USD») MUST NOT считаться показателем: это строка аналитики с рублёвыми суммами; код валюты
|
||||
не хранится. Колонка «Показатели» MUST NOT быть колонкой текста строки (как колонка ИНН) и SHALL оставаться в `extra`
|
||||
строки под своим заголовком. `row_value` строки с меткой колонки — сама метка.
|
||||
|
||||
Строки-показатели MUST NOT передаваться в каскад иерархии. Родитель строки-показателя — ближайшая строка выше, которая
|
||||
сама не показатель; уровень — на один глубже родителя; показатель без строки данных выше — уровень 0 без родителя.
|
||||
Строки-показатели MUST NOT входить в сверку «сумма детей = родитель» (ни как дети, ни как родители) и в сверку «Итого»;
|
||||
тождество строки (начало + обороты = конец) проверяется у них, как у всех строк. Строки NU хранятся как пришли
|
||||
(`ASSUMPTION(OQ-45)`); собственных проверок по NU нет (AURORA-1669).
|
||||
|
||||
Итоговые строки, следующие сразу за основной «Итого» и помеченные в колонке «Показатели» меткой показателя, SHALL
|
||||
оставаться в таблице как TOTAL (`data_type` по метке) и MUST NOT уходить в подвал или давать предупреждение «ниже
|
||||
таблицы есть числа»; сверка «Итого = корень» берёт первую итоговую строку, которая не показатель.
|
||||
|
||||
#### Scenario: Колонка «Показатели» БУ/НУ
|
||||
- **WHEN** разбирается синтетическая ОСВ `s40` (счёт 02, шапка без «Выводимые данные», колонка «Показа-тели», на каждую
|
||||
строку — «БУ» и «НУ», название объединено на две строки, «Итого» БУ и НУ)
|
||||
- **THEN** строки «БУ» — `data_type = BU`, строки «НУ» — `data_type = NU`, `row_value = "НУ"`, `group_type = OTHER`,
|
||||
родитель — строка «БУ» над ней; `parent_sum_violations = []`, `total_row_matches_root = true`, две строки TOTAL
|
||||
(BU и NU), предупреждения «ниже таблицы» и «вид данных … приняты как БУ» нет, статус `done`
|
||||
|
||||
#### Scenario: Количество, валюта и разницы в колонке «Показатели»
|
||||
- **WHEN** разбирается синтетическая ОСВ `s41` (колонка «Показатели» с метками «Кол.», «Вал.», «ПР», «ВР» и одной
|
||||
неизвестной меткой)
|
||||
- **THEN** «Кол.» — QUANTITY, «Вал.» — CURRENCY, «ПР»/«ВР»/неизвестная — `data_type = null`, все — показатели вне
|
||||
сверки родителя; одно предупреждение `OSV_ROWS` о нераспознанной метке; статус `done`
|
||||
|
||||
#### Scenario: «В валюте» под «Валюта <код>»
|
||||
- **WHEN** разбирается синтетическая ОСВ `s42` (под контрагентом пары «Валюта USD» / «В валюте», «Валюта EUR» /
|
||||
«В валюте»)
|
||||
- **THEN** «В валюте» — CURRENCY, родитель — «Валюта …» над ней; контрагент = Σ строк «Валюта …» по рублям;
|
||||
статус `done`
|
||||
|
||||
#### Scenario: Строки «Вал.»/«Кол.» в колонке текста не меняются
|
||||
- **WHEN** разбираются s04, s30 и все реальные фикстуры `../fixtures/osv/**`
|
||||
- **THEN** golden-снимки совпадают с прежними (родитель показателя — строка над ним, как и было)
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Классификация строк
|
||||
Классификация SHALL выполняться над деревом, из которого уже убраны склеенные строки ИНН (требование «Строки ИНН рядом с
|
||||
контрагентом»). Каждая строка данных SHALL получить `group_type`: ACCOUNT — текст матчит `ACCOUNT_RE` (регистр
|
||||
учитывается, IGNORECASE нет): две-три цифры и до двух сегментов субсчёта через точку, где сегмент — `\d{1,2}` **или** 1–3
|
||||
заглавные буквы кириллицы/латиницы («76.АВ», «76.ВА», «76.ОТ», R-16), далее опционально запятая и название:
|
||||
`^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$` с флагами `re.ASCII | re.DOTALL` (после запятой допустим любой
|
||||
пробел или его отсутствие, R-18; цифры — только 0–9; название после запятой может занимать несколько строк ячейки — M-1);
|
||||
`account_code_raw` — код как в файле, без названия после запятой. Не-счёт — группировка из `group_schema` по позиции
|
||||
(число не-счётных предков после последнего ACCOUNT + 1), при выходе за схему — OTHER; строка ИНН без пары — OTHER;
|
||||
последняя строка уровня 0 с текстом из словаря «Итого» — TOTAL.
|
||||
|
||||
Для COUNTERPARTY `counterparty_name` SHALL быть текстом строки без ИНН в конце и разделителя перед ним (`row_value` —
|
||||
исходный текст; OQ-100), а `counterparty_inn` — первым найденным из:
|
||||
1. колонки ИНН: цифры текстовой ячейки («ИНН 352819549855» → «352819549855», R-15), склеенная «ИНН/КПП» (`inn_kpp_cell`)
|
||||
→ ИНН; результат только при 10 или 12 цифрах, иначе `null`; число из 9/11 цифр (ИНН регионов 01–09 с потерянным
|
||||
ведущим нулём) дополняется нулём слева (R-24);
|
||||
2. строки ИНН, склеенной с этой строкой (требование «Строки ИНН рядом с контрагентом»);
|
||||
3. конца текста названия: 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»
|
||||
(`counterparty_inn_suffix`; «Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)»).
|
||||
Контрольная сумма ИНН из колонки и из названия MUST NOT проверяться. Строки-показатели (требование «Строки-показатели
|
||||
и колонка «Показатели»») SHALL получать `group_type = OTHER`, `data_type` по метке, MUST NOT занимать позицию в
|
||||
`group_schema` и MUST NOT наследовать `counterparty_name`/`counterparty_inn` (`account_code_raw` наследуется от ветки
|
||||
родителя, R-19); их суммы остаются в шести полях. Строка с меткой «БУ» в колонке «Показатели» получает `data_type = BU`.
|
||||
Остальные строки получают `data_type` шапки, а если шапка вид данных не называет — `BU` и одно предупреждение `OSV_HEADER`
|
||||
на лист «вид данных в шапке не указан — строки без метки приняты как БУ» (`header.data_type` остаётся `null`). Все строки, включая TOTAL, SHALL присутствовать в `rows`
|
||||
(`ASSUMPTION(OQ-01)`: хранить ли «Итого», решает ядро); склеенные строки ИНН в `rows` MUST NOT попадать.
|
||||
|
||||
#### Scenario: Подразделение, контрагент с ИНН, физлицо
|
||||
- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
|
||||
- **THEN** «БП Аренда» → DEPARTMENT; «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» → COUNTERPARTY с `counterparty_inn` из 10 цифр;
|
||||
«Ведомость в счет з/платы» → COUNTERPARTY с `counterparty_inn = null`
|
||||
|
||||
#### Scenario: ИНН в конце названия
|
||||
- **WHEN** разбирается синтетическая ОСВ `s17` (ИНН в тексте названия с разными разделителями)
|
||||
- **THEN** у контрагентов `counterparty_name` без ИНН, `counterparty_inn` — ИНН из текста, `row_value` — исходный текст
|
||||
|
||||
#### Scenario: «ИНН/КПП» в одной ячейке колонки ИНН
|
||||
- **WHEN** разбирается синтетическая ОСВ `s19`
|
||||
- **THEN** `counterparty_inn` — 10 цифр до «/»
|
||||
|
||||
#### Scenario: Название субсчёта в строке счёта
|
||||
- **WHEN** в синтетической ОСВ строка «60.01, Расчеты с поставщиками»
|
||||
- **THEN** `group_type = ACCOUNT`, `account_code_raw = "60.01"`, `row_value` — исходный текст
|
||||
|
||||
#### Scenario: Буквенный субсчёт
|
||||
- **WHEN** в ОСВ 76 строка «76.АВ» (реальные файлы РБ и Счастливочной) либо «60.АВ, …» (синтетика)
|
||||
- **THEN** `group_type = ACCOUNT`, строка наследуется детьми как счёт ветки; «76.ав» (строчные) счётом не считается
|
||||
|
||||
#### Scenario: Строки Вал./Кол.
|
||||
- **WHEN** в синтетической ОСВ под контрагентом строки «USD» и «Кол.»
|
||||
- **THEN** `group_type = OTHER`, `data_type = CURRENCY` и `QUANTITY`, `counterparty_name`/`counterparty_inn` равны `null`,
|
||||
`account_code_raw` совпадает с родителем, строки исключены из проверки «сумма детей = родитель», статус `done`
|
||||
|
||||
#### Scenario: Шапка без вида данных
|
||||
- **WHEN** разбирается синтетическая ОСВ `s43` (шапка без «Выводимые данные», колонки «Показатели» нет)
|
||||
- **THEN** `header.data_type = null`, у всех строк данных и «Итого» `data_type = BU`, в `warnings` одно `OSV_HEADER` «вид
|
||||
данных в шапке не указан — строки без метки приняты как БУ», статус `done`
|
||||
|
||||
#### Scenario: «Валюта <код>» — обычная строка
|
||||
- **WHEN** в синтетической ОСВ `s42` под контрагентом строка «Валюта USD» с рублёвыми суммами
|
||||
- **THEN** `group_type = OTHER`, `data_type` шапки, строка участвует в сверке «сумма детей = родитель»
|
||||
|
||||
### Requirement: Контрольные суммы определяют статус
|
||||
Для каждого документа парсер SHALL проверить: по каждой строке `opening_debit − opening_credit + turnover_debit −
|
||||
turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по каждому родителю суммы = Σ детей с тем же
|
||||
`data_type` (±0.01), причём **обороты** сверяются по колонкам, а **сальдо** (начало, конец) — по колонкам либо, если у
|
||||
родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто «дебет − кредит» (R-21: 1С показывает счёт нетто,
|
||||
а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки-показатели (требование «Строки-показатели и колонка
|
||||
«Показатели»») в суммирование не входят и сами родителями не проверяются; строка TOTAL = корневая строка ACCOUNT
|
||||
(первая строка уровня 0); итоговая строка-показатель («Итого» с меткой «НУ», «Кол.») в эту сверку не входит. Если корня нет (пустая ОСВ,
|
||||
R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, `total_row_matches_root = false` и в
|
||||
`warnings` — «строка «Итого» не найдена» (R-20). Результат — `checks{total_row_matches_root,
|
||||
balance_identity_violations, parent_sum_violations}`. Результат проверки SHALL записываться в документ листа (контракт v2.0): `status` листа — `done`, когда контрольные суммы
|
||||
§1.4 сошлись (и строки ИНН без нарушений), иначе `needs_check` (прежние условия §1.6, применённые к листу); любое
|
||||
нарушение → `status = needs_check` документа и запись причины в `warnings[]` документа. Предупреждения листа SHALL быть
|
||||
структурами `{code, severity, message}` с `severity = info` и кодом с префиксом `OSV_` (`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`,
|
||||
`OSV_HEADER`, `OSV_ROWS`, `OSV_INN_ROWS`, `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL`,
|
||||
`OSV_XLSB_NO_OUTLINE`); строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Парсер MUST NOT подгонять
|
||||
структуру под сходимость.
|
||||
|
||||
Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 91 и 99 строки аналитики несут
|
||||
только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml`
|
||||
(`accounts_without_analytic_balance`, начальный состав `91`, `99`; 90 не входит). Строка относится к такому счёту, если
|
||||
код ближайшего счёта её ветки (`account_code_raw`) совпадает с записью списка либо является её субсчётом
|
||||
(`91`, `91.01`, но не `910`). Для таких строк тождество строки SHALL NOT проверяться; сверка «сумма детей = родитель»
|
||||
сохраняется: обороты — всегда по колонкам, сальдо — только у родителя с дочерними субсчетами (корень → субсчета), а у
|
||||
родителя с одной лишь аналитикой сальдо не сверяется (сальдо аналитики пусто по определению); «Итого» сверяется как обычно.
|
||||
|
||||
Остаточные риски (известные слепые зоны, не ловятся проверками §1.4):
|
||||
- RR-1 — у свёрнутого родителя сверяется только нетто сальдо: распределение сальдо детей между дебетом и кредитом
|
||||
(перестановка сторон при совпавшем нетто) не проверяется.
|
||||
- RR-2 — «свёрнутость» определяется по самому родителю: развёрнутое сальдо родителя, у которого в файле потеряна одна
|
||||
сторона, неотличимо от нетто-показа 1С и проходит, если нетто совпало.
|
||||
- RR-3 — нетто допускается на любом уровне дерева (счёт, субсчёт, контрагент, договор), а не только на уровне «счёт →
|
||||
субсчета», где его показывает 1С: ошибка распределения сальдо между договорами под свёрнутым контрагентом не ловится.
|
||||
- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто
|
||||
строки, ловится лишь сверкой с родителем по колонкам, а под свёрнутым родителем — ничем (см. слепую зону ниже).
|
||||
- Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а его
|
||||
непосредственный родитель свёрнут — не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто,
|
||||
а нетто ребёнка не изменилось; значения родителя и выше не затронуты; тождество строки и «Итого = корень» сохраняются).
|
||||
- Родитель с сальдо (0, 0) при детях (X Дт, X Кт) принимается: нулевое сальдо — свёрнутое, нетто детей X − X = 0
|
||||
(закреплено тестом как осознанное поведение R-21).
|
||||
- Вне R-21: текст вида «12.ООО» (2–3 цифры, точка, до трёх заглавных букв) принимается за счёт (R-16) и меняет
|
||||
`group_type` строк ветки; суммы и проверки это не затрагивает.
|
||||
|
||||
#### Scenario: Все реальные файлы сходятся
|
||||
- **WHEN** разбирается каждый файл `fixtures/osv/**`
|
||||
- **THEN** `status = done`, `checks.total_row_matches_root = true`, оба списка нарушений пусты
|
||||
|
||||
#### Scenario: Испорченная сумма
|
||||
- **WHEN** в синтетической ОСВ у одного контрагента `closing_debit` увеличен на 1
|
||||
- **THEN** `status = needs_check` документа, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` —
|
||||
`row_number` родителя, в `warnings[]` документа — `OSV_BALANCE_IDENTITY` / `OSV_PARENT_SUM` (`info`) с номерами строк
|
||||
|
||||
#### Scenario: Сальдо родителя свёрнуто нетто
|
||||
- **WHEN** у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя
|
||||
- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто
|
||||
или расхождение оборотов — нарушение
|
||||
|
||||
#### Scenario: Счёт 91 — аналитика только с оборотами
|
||||
- **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на
|
||||
субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`)
|
||||
- **THEN** `status = done`, оба списка нарушений пусты; обороты аналитики при этом сходятся с оборотами субсчёта, а
|
||||
расхождение оборотов, сальдо субсчетов с корнем или «Итого» осталось бы нарушением
|
||||
|
||||
#### Scenario: «Итого» не найдена
|
||||
- **WHEN** в таблице нет строки с текстом из словаря «Итого»
|
||||
- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings[]` — `OSV_TOTAL` (`info`) «строка «Итого» не найдена»
|
||||
|
||||
#### Scenario: Пустая ОСВ
|
||||
- **WHEN** разбираются реальные пустые ОСВ (в фикстурах две: `schastl/2025-Q2/…58 за 2 квартал 2025 г..xlsx` и `…66 за 2
|
||||
квартал 2025 г..xlsx`: заголовок и сразу «Итого») либо любой файл, где кроме «Итого» строк данных нет
|
||||
- **THEN** `status = done` документа, `rows` содержит только строку TOTAL, `metrics.rows = 0` (строки данных без TOTAL),
|
||||
`hierarchy=trivial`, `checks.total_row_matches_root = true` (корня нет, суммы TOTAL пусты), предупреждение
|
||||
`OSV_NO_DATA_ROWS` «нет строк данных»
|
||||
|
||||
#### Scenario: Предупреждение ОСВ
|
||||
- **WHEN** на листе ОСВ есть строка без родителя
|
||||
- **THEN** у документа ОСВ предупреждение `{code: "OSV_…", severity: "info", message}`, статус документа по §1.6;
|
||||
в `metrics.warnings` конверта строки `sheet=…` нет
|
||||
32
openspec/changes/osv-indicator-column/tasks.md
Normal file
32
openspec/changes/osv-indicator-column/tasks.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Tasks: колонка «Показатели» и валютные строки ОСВ
|
||||
|
||||
План исполнения — `docs/superpowers/plans/2026-10-19-osv-indicator-column.md` (задачи 1–8, модели исполнителей там).
|
||||
|
||||
## 1. Словари
|
||||
- [ ] 1.1 `api/rules/osv_headers.yaml` — `service_columns.indicator`; `api/rules/data_types.yaml` — `indicator_column`,
|
||||
`indicator_column_untyped`, `row: «В валюте»`; `src/parser/rules.py` — поля `DataTypeRules`; тесты `test_rules.py`
|
||||
|
||||
## 2. Синтетика
|
||||
- [ ] 2.1 `tests/synthetic/generate.py` — s40–s43 (`_grid_osv`); `tests/synthetic/test_synthetic_files.py` — ожидания
|
||||
и сценарии (сначала красные); файлы в `../fixtures/osv/synthetic/`
|
||||
|
||||
## 3. Колонка «Показатели» в заголовке
|
||||
- [ ] 3.1 `src/parser/detect.py` — `TableHeader.indicator_col`; тест `test_detect.py`
|
||||
|
||||
## 4. Строки-показатели при сборе строк
|
||||
- [ ] 4.1 `src/parser/hierarchy.py` (`BodyRow`), новый `src/parser/indicators.py` (`read_label`), `src/parser/classify.py`
|
||||
(`collect_body_rows`, `_label_cols`, `_trailing_totals`); тесты `test_classify.py`, `test_indicators.py`
|
||||
|
||||
## 5. Иерархия
|
||||
- [ ] 5.1 `indicators.levels_with_indicators`, `osv.py` (вызов), `inn_rows._kind`; тест `test_indicators.py`
|
||||
|
||||
## 6. Классификация и проверки
|
||||
- [ ] 6.1 `classify.py` (`_Kind`, `ClassifiedRow.indicator`, вид данных строки), `checks.py` (`_is_indicator`, `_total`),
|
||||
`osv.py` (предупреждение «приняты как БУ»); тесты `test_classify.py`, `test_checks.py`
|
||||
|
||||
## 7. Регрессия и реальные файлы
|
||||
- [ ] 7.1 Синтетика зелёная; `make golden-check` — снимки реальных файлов и s01–s32 без изменений; `make golden-update`
|
||||
только для s40–s43; локальная сверка файлов контура (не в git)
|
||||
|
||||
## 8. Документация и проверки
|
||||
- [ ] 8.1 README (раздел «Парсер ОСВ»), `../docs/context/06-test-data.md` §7 (s40–s43), `make check-full`
|
||||
Loading…
x
Reference in New Issue
Block a user