45 KiB
Колонка «Показатели» и валютные строки ОСВ: план реализации
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), синтетику s37–s44. Этот план в 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— только для новых s33–s36, отдельным коммитом в 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
- Документ «Выводимые данные: НУ» (s12) — все строки NU, проверки работают как раньше (признак показателя — не
data_type). - Строки «Вал.»/«Кол.»/код валюты в колонке текста (s04, s30, реальные файлы) — родитель и уровень те же, golden без diff.
- Строка «НУ» под объединённой ячейкой названия —
row_value = "НУ", а не повтор названия; не контрагент, не счёт. - Вторая «Итого» с меткой «НУ» — TOTAL, без предупреждения «ниже таблицы», сверка «Итого = корень» по строке БУ.
- Умолчание 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модуля):
@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:
# Колонка вида суммы строки 1С («Показатели», в шапке часто с переносом «Показа-тели»): метки БУ/НУ/Кол./Вал./ПР/ВР
# (§1.3, AURORA-1613). Метки читает `data_types.yaml` — `indicator_column`
indicator:
- '^показа\s?тели$'
В data_types.yaml: в row добавить - ['^в\s+валюте$', CURRENCY] с комментарием «строка суммы в валюте под «Валюта <код>» (AURORA-1661); сама «Валюта USD» — обычная строка в рублях»; после row — новые разделы:
# Метка колонки «Показатели» (§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:
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: Синтетика s33–s36 и ожидания (модель: sonnet)
Files:
-
Modify:
api/tests/synthetic/generate.py -
Modify:
api/tests/synthetic/test_synthetic_files.py -
Create:
$WT/fixtures/osv/synthetic/s33_indicator_column_bu_nu.xlsx,s34_indicator_column_qty_val_diff.xlsx,s35_currency_slices.xlsx,s36_no_data_type_header.xlsx -
Step 1: Построитель листа «по сетке». В
generate.pyпослеform_sheetдобавить (константыORG,GROUP_TITLES,DEBIT,CREDIT,D— уже есть):
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: Четыре файла (суммы согласованы: родитель БУ = Σ детей БУ, «Итого» = корень, тождество строк выполняется у всех строк):
def _s33() -> 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 _s34() -> 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 _s35() -> 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 _s36() -> 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:
"s33_indicator_column_bu_nu.xlsx": (_s33, to_xlsx),
"s34_indicator_column_qty_val_diff.xlsx": (_s34, to_xlsx),
"s35_currency_slices.xlsx": (_s35, to_xlsx),
"s36_no_data_type_header.xlsx": (_s36, to_xlsx),
и поправить описание CLI в main («s01…s36»). Номера s37–s44 заняты треком B (#59); рулинг владельца R-1.
-
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—"s33_indicator_column_bu_nu.xlsx": Expect("outline", 2025, "Q3", False, data_type=None),"s34_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False),"s35_currency_slices.xlsx": Expect("outline", 2025, "Q3", False),"s36_no_data_type_header.xlsx": Expect("outline", 2025, "Q3", False, data_type=None);EXPECTED_NAMES— четыре имени; докстрингtest_synthetic_set_is_complete— «s01…s36»;- сценарии (по образцу
test_s30_…, хелперыone_document,parse_named,warnings_of):
def test_s33_indicator_column_bu_nu(fixtures_dir: Path) -> None:
"""AURORA-1613: строки «НУ» колонки «Показатели» — показатели строки БУ; две «Итого»; проверки сходятся."""
outcome = parse_named(fixtures_dir, "s33_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_s34_indicator_column_quantity_currency_differences(fixtures_dir: Path) -> None:
"""«Кол.» — QUANTITY, «Вал.» — CURRENCY, «ПР»/«ВР»/неизвестная метка — null; одно предупреждение о метке."""
outcome = parse_named(fixtures_dir, "s34_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_s35_v_valyute_is_currency_of_previous_row(fixtures_dir: Path) -> None:
"""AURORA-1661: «В валюте» — CURRENCY, родитель — «Валюта …» над ней; «Валюта …» — обычные строки в рублях."""
outcome = parse_named(fixtures_dir, "s35_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_s36_no_data_type_in_header_rows_are_bu(fixtures_dir: Path) -> None:
"""Шапка без «Выводимые данные»: строки — BU, предупреждение одно, `header.data_type` — null."""
outcome = parse_named(fixtures_dir, "s36_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: Запустить — сценарии s33–s36 красные (это и есть воспроизведение багов):
ENV_FILE=.env.test .venv/bin/python -m pytest tests/synthetic -q -k "s33 or s34 or s35 or s36". Записать в ledger, как именно падает каждый (до исправления). - Step 6: Commit в tabular-parser (
generate.py, тесты;make checkкрасный только на s33–s36 и покрытии словарей — допустимо с пометкой в ledger) и отдельный commit в fixtures (git -C $WT/fixtures add osv/synthetic/s3[3-6]*.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:
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, с умолчаниями — позиционные конструкторы в тестах не ломаются):
indicator: bool = False # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
(DataType — из src.contract.common.)
- Step 2: Модуль
indicators.py— чтение метки:
"""Строки-показатели ОСВ (§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(сценарии s33–s36 ещё могут быть красными — отметить в 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):- счёт (outline 0), строка «НУ»-показатель (outline 0), объект (outline 1), «НУ» (outline 1), «Итого», «Итого»-показатель → показатель счёта: родитель — счёт, уровень 1; показатель объекта: родитель — объект, уровень 2; объект — ребёнок счёта (каскад не видел показателей); обе итоговые — уровень 0 без родителя; стратегия
outline; - показатель первой строкой таблицы — уровень 0 без родителя;
- порядок ключей
by_rowиparent— порядок строк листа.
- счёт (outline 0), строка «НУ»-показатель (outline 0), объект (outline 1), «НУ» (outline 1), «Итого», «Итого»-показатель → показатель счёта: родитель — счёт, уровень 1; показатель объекта: родитель — объект, уровень 2; объект — ребёнок счёта (каскад не видел показателей); обе итоговые — уровень 0 без родителя; стратегия
-
Step 2: Реализация в
indicators.py:
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_sheetlevels = 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; итоговая строка-показатель →TOTALcdata_typeметки иindicator=True.
-
Step 2: Реализация:
ClassifiedRow: полеindicator: bool = False(последним, с умолчанием);_Kind: полеindicator: bool = False;_classify_row: вместоrow_data_type(...):
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): перед классификацией
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: Синтетика s33–s36 зелёная:
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/s33…s36.json(новые,make golden-update) -
Step 1:
make golden-checkиз$WT/tabular-parser. Ожидание: diff только «нет снимка» для s33–s36. Любой diff по реальным файлам или s01–s32 — стоп: записать в ledger (файл, строки, что изменилось) и вернуть контроллеру; не перегенерировать. -
Step 2:
make golden-update, проверитьgit -C $WT/fixtures status— только четыре новых снимка; просмотреть их глазами (строки НУ/«В валюте» — показатели с верными родителями, две TOTAL в s33). Commit в fixtures «Golden s33–s36: колонка «Показатели», «В валюте» (#58)». -
Step 3: Покрытие словарей —
ENV_FILE=.env.test .venv/bin/python -m pytest tests/fixtures/test_rules_coverage.py -qзелёный (каждая новая запись срабатывает на s33–s35). -
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 синтетики: s33–s36 одной строкой каждый) -
Modify:
CLAUDE.mdtabular-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 — не трогать: статусы ведёт главная сессия.