Compare commits
No commits in common. "main" and "v0.1.1" have entirely different histories.
35
CHANGELOG.md
35
CHANGELOG.md
@ -1,35 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.5 — 2026-11-07
|
||||
|
||||
- `split_by_form(candidate, rules)` — разрез кандидата по смене формы (AURORA-1734): сегмент со своей шапкой, первые
|
||||
`min_codes` кодов которого — другой формы, начинает новую часть со своими колонками и периодами (`locator` с хвостом
|
||||
`;form:<k>`, `type_hint = None`). Хост вызывает разрез перед `normalize_candidate`; без вызова поведение прежнее.
|
||||
- Редкий столбец с неразобранной подписью между колонками периодов (число не более чем в одной строке с кодом при трёх и
|
||||
более у каждой колонки периода и числах всех колонок периода в строке его числа) — не колонка сумм; его числа — в `NUMBERS_OUTSIDE_TABLE` с текстом «числа вне колонок
|
||||
формы не учитываются» (AURORA-1735).
|
||||
|
||||
## 0.1.4 — 2026-11-06
|
||||
|
||||
- `statement_checks.yaml`: необязательный ключ `group` — группа альтернатив мягких соотношений одной цели. В колонке
|
||||
сошлась хоть одна — нарушений группы нет; не сошлась ни одна — одно нарушение, ближайшая к цели. `group` у жёсткого
|
||||
соотношения или разные цели в одной группе — ошибка загрузки словаря. Подсветка, `needs_check` и подтверждение 2400 (Р-21) не менялись (AURORA-1864).
|
||||
- Соотношения 2400 ОФР — группа `net_profit`: полная форма «2400 = 2300 − 2410 + 2420» и «2400 = 2300 + 2410 + 2460»;
|
||||
в упрощённую добавлено «2400 = 2110 − 2120 − 2330 + 2340 − 2350 + 2410». Положительный 2410 (доход по налогу,
|
||||
ФСБУ 4/2023) больше не даёт ложного предупреждения.
|
||||
|
||||
## 0.1.3 — 2026-11-02
|
||||
|
||||
- `statement_code_orders.yaml`: шапка приведена к байтовой копии текущего `docs/contracts/statement-code-orders.yaml`
|
||||
(комментарии описывают библиотечную поставку без `sync-statements`); сами перечни кодов не менялись.
|
||||
|
||||
## 0.1.2 — 2026-10-30
|
||||
|
||||
- Добавлен маркер PEP 561 `arm_statements/py.typed` в дистрибутив: аннотации доступны потребителям библиотеки.
|
||||
- `statements_version()` получает версию только из `importlib.metadata` дистрибутива `pylib-ai-arm-statements`; при отсутствии метаданных возвращает `None`. Параметр пути и чтение `arm_statements/VERSION` удалены.
|
||||
- README приведён к установке библиотеки; changelog и маркер типов включены в исходный дистрибутив.
|
||||
|
||||
## 0.1.1
|
||||
|
||||
- Общий код нормализации ББ/ОФР оформлен как устанавливаемый пакет `pylib-ai-arm-statements` с импортом `arm_statements` и YAML-словарями.
|
||||
- Добавлены проверки упаковки, тесты и настройки Ruff/mypy.
|
||||
@ -3,6 +3,3 @@ include VERSION
|
||||
include requirements.txt
|
||||
recursive-include arm_statements *.py
|
||||
recursive-include arm_statements/rules *.yaml
|
||||
|
||||
include CHANGELOG.md
|
||||
include arm_statements/py.typed
|
||||
|
||||
11
README.md
11
README.md
@ -1,11 +1,10 @@
|
||||
# arm-statements
|
||||
|
||||
Общий код нормализации форм ББ/ОФР (пакет `arm_statements`) для tabular-parser, scan-gateway и arm-core.
|
||||
Устанавливается как библиотека `pylib_ai_arm_statements` из локального checkout или собранного wheel; в PyPI не публикуется.
|
||||
Раскладывается копией в `api/arm_statements/` хостов скриптом `sync-statements` из arm-ka-docs.
|
||||
Спека: arm-ka-docs, `openspec/changes/unified-statement-normalization/`.
|
||||
|
||||
В сервисах библиотека устанавливается через `make venv` / `make install`; исходники и словари изменяются в этом репозитории.
|
||||
История выпусков — [CHANGELOG.md](CHANGELOG.md).
|
||||
Копия раскладывается `make -C docs sync-statements`; прямые правки копии в хостах запрещены.
|
||||
|
||||
## Как проверить
|
||||
|
||||
@ -141,7 +140,7 @@ make_warning(code: str, message: str, rules: Rules, *, line_refs: Sequence[str]
|
||||
# SHEET_NOT_JOINED, SHEET_OTHER только через неё
|
||||
to_jsonable(obj: object) -> Any # dataclass пакета → json-совместимое значение (golden)
|
||||
candidate_from_dict(data: Mapping[str, Any]) -> FormCandidate # обратная для кандидата
|
||||
statements_version() -> str | None # importlib.metadata: версия установленного дистрибутива
|
||||
statements_version(path: Path | None = None) -> str | None # содержимое api/arm_statements/VERSION копии
|
||||
load_rules(directory: Path | None = None) -> Rules # RulesError при ошибке словаря
|
||||
default_rules() -> Rules # кэш словарей пакета
|
||||
```
|
||||
@ -239,8 +238,8 @@ Rules.warnings -> Mapping[str, WarningRule] # WarningRule(code, severit
|
||||
|
||||
## Как менять словари
|
||||
|
||||
Словари пакета — данные в `arm_statements/rules/*.yaml`, правка идёт PR сюда, затем выпускается новая версия библиотеки
|
||||
и обновляется зависимость в сервисах («сначала пакет»).
|
||||
Словари пакета — данные в `arm_statements/rules/*.yaml`, правка идёт PR сюда, затем `make -C docs sync-statements` в
|
||||
хостах («сначала пакет»).
|
||||
|
||||
- `statement_code_orders.yaml` — **байтовая копия** `docs/contracts/statement-code-orders.yaml` (arm-ka-docs); руками не
|
||||
правится. Перечень кодов меняется только PR в docs, затем копия переносится сюда без изменений.
|
||||
|
||||
@ -53,7 +53,6 @@ from arm_statements.recompute import (
|
||||
recompute,
|
||||
)
|
||||
from arm_statements.serialize import candidate_from_dict, to_jsonable
|
||||
from arm_statements.split import split_by_form
|
||||
from arm_statements.status import (
|
||||
StatusInput,
|
||||
StatusLine,
|
||||
@ -130,7 +129,6 @@ __all__ = [
|
||||
"recompute",
|
||||
"resolve_relative",
|
||||
"sheet_joins",
|
||||
"split_by_form",
|
||||
"statements_version",
|
||||
"status_input",
|
||||
"status_input_from_contract",
|
||||
|
||||
@ -166,41 +166,12 @@ def evaluate_relations(
|
||||
return outcomes
|
||||
|
||||
|
||||
def _miss(item: RelationOutcome) -> Decimal | None:
|
||||
"""Расхождение цели и правой части; `None` — сравнить не с чем (пуста цель или все члены)."""
|
||||
if item.expected is None or item.actual is None:
|
||||
return None
|
||||
return abs(item.expected - item.actual)
|
||||
|
||||
|
||||
def _group_reported(outcomes: Sequence[RelationOutcome]) -> set[int]:
|
||||
"""Позиции нарушений групп альтернатив, которые выдаются (AURORA-1864).
|
||||
|
||||
Группа — мягкие соотношения с одним `group` в одной колонке. Сошлась хоть одна — не выдаётся ни одна;
|
||||
не сошлась ни одна — одна, ближайшая к цели (при равенстве — первая по словарю)."""
|
||||
members: dict[tuple[str, str], list[int]] = {}
|
||||
for position, item in enumerate(outcomes):
|
||||
if item.rule.group is not None and not item.rule.strict:
|
||||
members.setdefault((item.column, item.rule.group), []).append(position)
|
||||
reported: set[int] = set()
|
||||
for positions in members.values():
|
||||
failed = [p for p in positions if outcomes[p].computed and not outcomes[p].within_tolerance]
|
||||
if not failed or any(outcomes[p].computed and outcomes[p].within_tolerance for p in positions):
|
||||
continue
|
||||
comparable = [p for p in failed if _miss(outcomes[p]) is not None]
|
||||
reported.add(min(comparable, key=lambda p: _miss(outcomes[p]) or _ZERO) if comparable else failed[0])
|
||||
return reported
|
||||
|
||||
|
||||
def checks_from(outcomes: Sequence[RelationOutcome], column_index_of: Mapping[str, int | None]) -> StatementChecks:
|
||||
hard: list[FailedRelation] = []
|
||||
soft: list[FailedRelation] = []
|
||||
reported = _group_reported(outcomes)
|
||||
for position, item in enumerate(outcomes):
|
||||
for item in outcomes:
|
||||
if not item.computed or item.within_tolerance:
|
||||
continue
|
||||
if item.rule.group is not None and not item.rule.strict and position not in reported:
|
||||
continue
|
||||
failure = FailedRelation(
|
||||
column_index=column_index_of.get(item.column),
|
||||
column_key=item.column,
|
||||
|
||||
@ -15,10 +15,6 @@ from arm_statements.model import Cell, PeriodKind, Source, StatementColumn, Stat
|
||||
|
||||
_FULL_CONFIDENCE = 100.0
|
||||
_MAX_REFS = 10
|
||||
_SPARSE_MAX_NUMBERS = 1 # редкий столбец: число не более чем в одной строке с кодом (AURORA-1735)
|
||||
_PERIOD_MIN_NUMBERS = 3 # … при числах не менее чем в трёх строках у каждой колонки периода
|
||||
_RIGHT_TEXT = "числа правее таблицы формы не учитываются"
|
||||
_BESIDE_TEXT = "числа вне колонок формы не учитываются"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@ -63,15 +59,6 @@ def has_numbers( # noqa: PLR0913, PLR0917 - сигнатура задана п
|
||||
return any(_is_number(cell_at(rows, r, col), source, rules) for r in _code_rows(rows, data_rows, code_col, rules))
|
||||
|
||||
|
||||
def _number_count( # noqa: PLR0913, PLR0917 - как has_numbers
|
||||
rows: Grid, data_rows: Sequence[int], col: int, code_col: int, source: Source, rules: Rules
|
||||
) -> int:
|
||||
"""Сколько строк с кодом содержат в столбце число."""
|
||||
return sum(
|
||||
1 for r in _code_rows(rows, data_rows, code_col, rules) if _is_number(cell_at(rows, r, col), source, rules)
|
||||
)
|
||||
|
||||
|
||||
def _width(rows: Grid, row_indexes: Sequence[int]) -> int:
|
||||
return max((len(rows[r]) for r in row_indexes if 0 <= r < len(rows)), default=0)
|
||||
|
||||
@ -110,9 +97,7 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана
|
||||
warnings: list[StatementWarning] = []
|
||||
columns = range(code_col + 1, _width(rows, [*header_rows, *data_rows]))
|
||||
last_dated = _last_dated_column(rows, header_rows, data_rows, columns, code_col, source, rules)
|
||||
dated = _dated_counts(rows, header_rows, data_rows, columns, code_col, source, rules)
|
||||
outside: list[int] = []
|
||||
beside: list[int] = []
|
||||
for col in columns:
|
||||
numbers = has_numbers(rows, data_rows, col, code_col, source, rules)
|
||||
if not header_rows:
|
||||
@ -128,67 +113,16 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана
|
||||
drafts.append(ColumnDraft(col, cell.text, cell.confidence, parsed.kind, parsed.date))
|
||||
elif numbers and cell is None and last_dated is not None and col > last_dated:
|
||||
outside.append(col) # числа правее последней колонки с подписью периода — не колонка сумм
|
||||
elif numbers and cell is not None and _is_sparse(col, dated, rows, data_rows, code_col, source, rules):
|
||||
beside.append(col) # обрывок подписи периода с посторонним числом — не колонка сумм (AURORA-1735)
|
||||
elif numbers:
|
||||
label, conf = (cell.text, cell.confidence) if cell is not None else ("", None)
|
||||
message = f"у столбца {col} с числами нет разобранной подписи периода"
|
||||
warnings.append(make_warning(WarningCode.COLUMN_WITHOUT_DATE, message, rules, column_index=col))
|
||||
drafts.append(ColumnDraft(col, label, conf, PeriodKind.NONE, None))
|
||||
if outside or beside:
|
||||
warnings.append(
|
||||
_outside_table_warning(rows, data_rows, [*beside, *outside], code_col, source, rules, beside=bool(beside))
|
||||
)
|
||||
if outside:
|
||||
warnings.append(_outside_table_warning(rows, data_rows, outside, code_col, source, rules))
|
||||
return drafts, warnings
|
||||
|
||||
|
||||
def _dated_counts( # noqa: PLR0913, PLR0917 - внутренняя сборка
|
||||
rows: Grid,
|
||||
header_rows: tuple[int, ...],
|
||||
data_rows: Sequence[int],
|
||||
columns: range,
|
||||
code_col: int,
|
||||
source: Source,
|
||||
rules: Rules,
|
||||
) -> dict[int, int]:
|
||||
"""Столбцы сегмента с подписью периода и числами → число строк с кодом, где есть число."""
|
||||
counts: dict[int, int] = {}
|
||||
if not header_rows:
|
||||
return counts
|
||||
for col in columns:
|
||||
cell = _label(rows, header_rows, col)
|
||||
if cell is None:
|
||||
continue
|
||||
parsed = parse_period_label(cell, rules)
|
||||
if parsed.excluded or parsed.kind is PeriodKind.NONE:
|
||||
continue
|
||||
count = _number_count(rows, data_rows, col, code_col, source, rules)
|
||||
if count:
|
||||
counts[col] = count
|
||||
return counts
|
||||
|
||||
|
||||
def _is_sparse( # noqa: PLR0913, PLR0917 - внутренняя проверка
|
||||
col: int,
|
||||
dated: dict[int, int],
|
||||
rows: Grid,
|
||||
data_rows: Sequence[int],
|
||||
code_col: int,
|
||||
source: Source,
|
||||
rules: Rules,
|
||||
) -> bool:
|
||||
"""Редкий столбец: колонки периода левее и правее, у него ≤ 1 числа, у каждой колонки периода ≥ 3, и в строке его
|
||||
числа у всех колонок периода тоже есть числа — число лишнее, а не сдвинутое OCR из пустой ячейки периода."""
|
||||
if not any(c < col for c in dated) or not any(c > col for c in dated):
|
||||
return False
|
||||
if any(count < _PERIOD_MIN_NUMBERS for count in dated.values()):
|
||||
return False
|
||||
hits = [r for r in _code_rows(rows, data_rows, code_col, rules) if _is_number(cell_at(rows, r, col), source, rules)]
|
||||
if len(hits) > _SPARSE_MAX_NUMBERS:
|
||||
return False
|
||||
return all(_is_number(cell_at(rows, r, period), source, rules) for r in hits for period in dated)
|
||||
|
||||
|
||||
def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сборка границы таблицы
|
||||
rows: Grid,
|
||||
header_rows: tuple[int, ...],
|
||||
@ -215,14 +149,7 @@ def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сбо
|
||||
|
||||
|
||||
def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя сборка предупреждения
|
||||
rows: Grid,
|
||||
data_rows: Sequence[int],
|
||||
outside: Sequence[int],
|
||||
code_col: int,
|
||||
source: Source,
|
||||
rules: Rules,
|
||||
*,
|
||||
beside: bool = False,
|
||||
rows: Grid, data_rows: Sequence[int], outside: Sequence[int], code_col: int, source: Source, rules: Rules
|
||||
) -> StatementWarning:
|
||||
refs = [
|
||||
cell.ref
|
||||
@ -230,15 +157,13 @@ def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя с
|
||||
for col in outside
|
||||
if (cell := cell_at(rows, r, col)) is not None and _is_number(cell, source, rules)
|
||||
]
|
||||
return outside_table_warning(refs, rules, beside=beside)
|
||||
return outside_table_warning(refs, rules)
|
||||
|
||||
|
||||
def outside_table_warning(refs: Sequence[str], rules: Rules, *, beside: bool = False) -> StatementWarning:
|
||||
"""`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек вне колонок формы (`locator` — все, через перевод строки).
|
||||
|
||||
`beside` — среди адресов есть редкий столбец между колонками периодов (AURORA-1735): текст «вне колонок»."""
|
||||
def outside_table_warning(refs: Sequence[str], rules: Rules) -> StatementWarning:
|
||||
"""`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек правее таблицы (`locator` — все, через перевод строки)."""
|
||||
shown = ", ".join(refs[:_MAX_REFS]) + (f" и ещё {len(refs) - _MAX_REFS}" if len(refs) > _MAX_REFS else "")
|
||||
message = f"{_BESIDE_TEXT if beside else _RIGHT_TEXT}: {shown}"
|
||||
message = f"числа правее таблицы формы не учитываются: {shown}"
|
||||
return make_warning(WarningCode.NUMBERS_OUTSIDE_TABLE, message, rules, locator="\n".join(refs))
|
||||
|
||||
|
||||
@ -246,14 +171,12 @@ def merge_outside_table(warnings: Sequence[StatementWarning], rules: Rules) -> l
|
||||
"""Предупреждения `NUMBERS_OUTSIDE_TABLE` сегментов — в одно (адреса без повторов)."""
|
||||
refs: dict[str, None] = {}
|
||||
rest: list[StatementWarning] = []
|
||||
beside = False
|
||||
for warning in warnings:
|
||||
if warning.code == WarningCode.NUMBERS_OUTSIDE_TABLE:
|
||||
refs.update(dict.fromkeys((warning.locator or "").split("\n")))
|
||||
beside = beside or warning.message.startswith(_BESIDE_TEXT)
|
||||
else:
|
||||
rest.append(warning)
|
||||
return [*rest, outside_table_warning(list(refs), rules, beside=beside)] if refs else rest
|
||||
return [*rest, outside_table_warning(list(refs), rules)] if refs else rest
|
||||
|
||||
|
||||
def trailing_outside_refs( # noqa: PLR0913, PLR0917 - внутренняя сборка
|
||||
|
||||
@ -83,8 +83,6 @@ class RelationRule:
|
||||
terms: tuple[tuple[int, str], ...]
|
||||
strict: bool
|
||||
abs_terms: frozenset[str]
|
||||
# группа альтернатив (только мягкие, одна форма и вариант): сошлась одна — сошлась группа (AURORA-1864)
|
||||
group: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@ -284,11 +282,6 @@ def _load_checks(directory: Path) -> CheckRules:
|
||||
abs_terms = frozenset(_str(file, f"{key}.abs_terms", code) for code in raw_abs)
|
||||
if not abs_terms <= {code for _, code in terms}:
|
||||
raise _fail(file, f"{key}.abs_terms", "коды должны быть членами правой части соотношения")
|
||||
strict = _bool(file, f"{key}.strict", _get(file, entry, "strict"))
|
||||
raw_group = entry.get("group")
|
||||
group = None if raw_group is None else _str(file, f"{key}.group", raw_group)
|
||||
if group is not None and strict:
|
||||
raise _fail(file, f"{key}.group", "группа альтернатив — только у мягкого соотношения (strict: false)")
|
||||
relations.append(
|
||||
RelationRule(
|
||||
form=form,
|
||||
@ -296,18 +289,10 @@ def _load_checks(directory: Path) -> CheckRules:
|
||||
relation=text,
|
||||
target=target,
|
||||
terms=terms,
|
||||
strict=strict,
|
||||
strict=_bool(file, f"{key}.strict", _get(file, entry, "strict")),
|
||||
abs_terms=abs_terms,
|
||||
group=group,
|
||||
)
|
||||
)
|
||||
group_targets: dict[tuple[DocumentType, FormVariant, str], str] = {}
|
||||
for i, rule in enumerate(relations):
|
||||
if rule.group is None:
|
||||
continue
|
||||
known = group_targets.setdefault((rule.form, rule.variant, rule.group), rule.target)
|
||||
if known != rule.target:
|
||||
raise _fail(file, f"relations[{i}].group", f"у группы {rule.group!r} разные цели: {known} и {rule.target}")
|
||||
return CheckRules(
|
||||
tolerance=_number(file, "tolerance", _get(file, data, "tolerance")),
|
||||
rounding_tolerance_percent=_number(
|
||||
|
||||
@ -3,9 +3,6 @@
|
||||
# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check
|
||||
# (false — только мягкое предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н —
|
||||
# в скобках: правая часть считается и как записано, и с этими членами по модулю.
|
||||
# group: только у мягких — альтернативы одной цели (одна форма и вариант): сошлась хоть одна в колонке — нарушений нет;
|
||||
# не сошлась ни одна — одно нарушение, ближайшая к цели. Знак 2410 (налог: расход или доход, ФСБУ 4/2023) не
|
||||
# определяем — принимаем любой вариант группы (AURORA-1864, OQ-12).
|
||||
tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно)
|
||||
# OQ-138: единицы шапки верны, расхождение до 0,001 % итоговой строки — округление (мягкое нарушение, статус не портит).
|
||||
rounding_tolerance_percent: 0.001
|
||||
@ -22,12 +19,11 @@ relations:
|
||||
- {form: INCOME, variant: full, relation: "2100 = 2110 - 2120", strict: true, abs_terms: ["2120"]}
|
||||
- {form: INCOME, variant: full, relation: "2200 = 2100 - 2210 - 2220", strict: true, abs_terms: ["2210", "2220"]}
|
||||
- {form: INCOME, variant: full, relation: "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350", strict: true, abs_terms: ["2330", "2350"]}
|
||||
- {form: INCOME, variant: full, relation: "2400 = 2300 - 2410 + 2420", strict: false, abs_terms: ["2410"], group: net_profit}
|
||||
- {form: INCOME, variant: full, relation: "2400 = 2300 + 2410 + 2460", strict: false, group: net_profit}
|
||||
- {form: INCOME, variant: full, relation: "2400 = 2300 - 2410 + 2420", strict: false, abs_terms: ["2410"]}
|
||||
- {form: INCOME, variant: full, relation: "2400 = 2300 + 2410 + 2460", strict: false}
|
||||
- {form: INCOME, variant: full, relation: "2410 = 2411 + 2412", strict: false}
|
||||
- {form: INCOME, variant: full, relation: "2500 = 2400 + 2510 + 2520 + 2530", strict: false}
|
||||
- {form: BALANCE, variant: simplified, relation: "1600 = 1150 + 1170 + 1210 + 1230 + 1250", strict: true}
|
||||
- {form: BALANCE, variant: simplified, relation: "1700 = 1300 + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550", strict: true}
|
||||
- {form: BALANCE, variant: simplified, relation: "1600 = 1700", strict: true}
|
||||
- {form: INCOME, variant: simplified, relation: "2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410", strict: false, abs_terms: ["2120", "2330", "2350", "2410"], group: net_profit}
|
||||
- {form: INCOME, variant: simplified, relation: "2400 = 2110 - 2120 - 2330 + 2340 - 2350 + 2410", strict: false, abs_terms: ["2120", "2330", "2350"], group: net_profit}
|
||||
- {form: INCOME, variant: simplified, relation: "2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410", strict: false, abs_terms: ["2120", "2330", "2350", "2410"]}
|
||||
|
||||
@ -7,10 +7,10 @@
|
||||
# fixtures/statements-xlsx/templates/) и кодов печатных форм 1С и сканов, найденных в fixtures (источник — комментарий
|
||||
# у кода). Код без образца в fixtures сюда не добавляется: кандидаты — OQ-168.
|
||||
#
|
||||
# Где живёт: этот файл — источник; в библиотеке arm_statements лежит его байтовая копия (rules/statement_code_orders.yaml),
|
||||
# идентичность проверяет make -C docs statements-orders-check. Ядро берёт порядок и состав редакций из библиотеки; что все
|
||||
# коды словаря имён ядра есть в перечне своей редакции, сверяет тест ядра test_name_dictionary_codes_in_code_orders.
|
||||
# Правка перечня — PR сюда, затем в библиотеку (новая версия), затем подъём версии в потребителях («сначала ядро»).
|
||||
# Где живёт: этот файл — источник; в пакете arm_statements лежит его байтовая копия (rules/statement_code_orders.yaml),
|
||||
# идентичность проверяет sync-statements --check (как копии схем контракта в сервисах). Ядро берёт порядок и состав
|
||||
# редакций из копии пакета; sync-statements --check сверяет, что все коды словаря имён ядра есть в перечне своей
|
||||
# редакции. Правка перечня — PR сюда, затем в arm-statements, затем sync-statements в потребителях («сначала ядро»).
|
||||
#
|
||||
# Как читается (§11.9): четырёхзначные коды строк ok документа — последовательность; для каждой редакции берётся
|
||||
# наибольшая подпоследовательность, согласованная с порядком перечня варианта формы документа (form_variant); выбирается
|
||||
|
||||
@ -180,29 +180,6 @@ def _split_and_analyse(candidate: FormCandidate, rules: Rules) -> tuple[list[_An
|
||||
return result, section
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SegmentForm:
|
||||
"""Сегмент для разреза по смене формы: первая строка, своя шапка, коды столбца кодов."""
|
||||
|
||||
first: int
|
||||
has_header: bool
|
||||
codes: tuple[str, ...]
|
||||
|
||||
|
||||
def split_segments(candidate: FormCandidate, rules: Rules) -> list[SegmentForm]:
|
||||
"""Сегменты кандидата по правилу шага 2 (тот же разбор, что у раскладки); сегменты без столбца кодов пропущены."""
|
||||
segments, _ = _split_and_analyse(candidate, rules)
|
||||
return [
|
||||
SegmentForm(
|
||||
seg.first,
|
||||
bool(seg.header_rows),
|
||||
tuple(_segment_codes(candidate.rows, seg.first, seg.last, seg.code_col, rules)),
|
||||
)
|
||||
for seg in segments
|
||||
if seg.code_col is not None
|
||||
]
|
||||
|
||||
|
||||
def _map_by_date(
|
||||
drafts: Sequence[ColumnDraft], columns: Sequence[StatementColumn]
|
||||
) -> tuple[tuple[int, int], ...] | None:
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
"""Разрез кандидата по смене формы до шагов 2–8 (спека «Шаг 2 — разрез кандидата по смене формы», AURORA-1734).
|
||||
|
||||
ИС Сканирование может вернуть ББ и ОФР одной таблицей: шапка ОФР внутри таблицы — повторная шапка, и без разреза строки
|
||||
ОФР попадают в ББ под его колонками. Часть начинается с сегмента со своей шапкой, первые `min_codes` кодов которого —
|
||||
одной формы, другой, чем у текущей части (тип части — по большинству кодов её первого сегмента с кодами).
|
||||
"""
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from arm_statements.codes import document_type_of, majority_section, section_of
|
||||
from arm_statements.issues import WarningCode
|
||||
from arm_statements.loader import Rules, default_rules
|
||||
from arm_statements.model import DocumentType, Evidence, FormCandidate
|
||||
from arm_statements.segments import split_segments
|
||||
|
||||
# предупреждения источника, нужные каждой части: без числовых форматов xlsb шаг 4 иначе проверяет масштаб (ревью I-2)
|
||||
_SOURCE_WARNINGS = frozenset({WarningCode.XLSB_NO_FORMATS})
|
||||
|
||||
|
||||
def _opening_type(codes: tuple[str, ...], rules: Rules) -> DocumentType | None:
|
||||
"""Тип формы, которой сегмент открывается: первые `min_codes` кодов одной формы; иначе `None`."""
|
||||
head = codes[: rules.code_ranges.min_codes]
|
||||
if len(head) < rules.code_ranges.min_codes:
|
||||
return None
|
||||
kinds = {None if (s := section_of(code, rules)) is None else document_type_of(s) for code in head}
|
||||
return kinds.pop() if len(kinds) == 1 else None
|
||||
|
||||
|
||||
def _cuts(candidate: FormCandidate, rules: Rules) -> list[int]:
|
||||
"""Строки сетки, с которых начинаются части после первой."""
|
||||
cuts: list[int] = []
|
||||
current: DocumentType | None = None
|
||||
for segment in split_segments(candidate, rules):
|
||||
if current is None:
|
||||
section = majority_section(list(segment.codes), rules)
|
||||
current = None if section is None else document_type_of(section)
|
||||
continue
|
||||
opening = _opening_type(segment.codes, rules)
|
||||
if segment.has_header and opening is not None and opening is not current and segment.first > 0:
|
||||
cuts.append(segment.first)
|
||||
current = opening
|
||||
return cuts
|
||||
|
||||
|
||||
def split_by_form(candidate: FormCandidate, rules: Rules | None = None) -> tuple[FormCandidate, ...]:
|
||||
"""Части кандидата по смене формы; без смены — `(candidate,)` тем же объектом.
|
||||
|
||||
Первая часть сохраняет поля исходного кандидата (кроме строк и границ листов); часть `k` (с 1) — `locator`
|
||||
с хвостом `;form:<k>`, `type_hint = None`, пустые `evidence`, из `warnings` — только предупреждения источника
|
||||
(`XLSB_NO_FORMATS`): тип — только по кодам на шаге 6.
|
||||
"""
|
||||
rules = rules if rules is not None else default_rules()
|
||||
cuts = _cuts(candidate, rules)
|
||||
if not cuts:
|
||||
return (candidate,)
|
||||
bounds = [0, *cuts, len(candidate.rows)]
|
||||
parts: list[FormCandidate] = []
|
||||
for k, (start, end) in enumerate(zip(bounds, bounds[1:], strict=False)):
|
||||
breaks = tuple(b - start for b in candidate.segment_breaks if start <= b < end)
|
||||
rows = candidate.rows[start:end]
|
||||
if k == 0:
|
||||
parts.append(replace(candidate, rows=rows, segment_breaks=breaks))
|
||||
else:
|
||||
parts.append(
|
||||
replace(
|
||||
candidate,
|
||||
locator=f"{candidate.locator};form:{k}",
|
||||
type_hint=None,
|
||||
evidence=Evidence(okud=None, title=None),
|
||||
rows=rows,
|
||||
segment_breaks=breaks,
|
||||
warnings=tuple(w for w in candidate.warnings if w.code in _SOURCE_WARNINGS),
|
||||
)
|
||||
)
|
||||
return tuple(parts)
|
||||
@ -1,10 +1,20 @@
|
||||
"""Версия установленного дистрибутива библиотеки."""
|
||||
"""Версия копии из VERSION или метаданных установленного пакета."""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
_DEFAULT_MARKER = Path(__file__).parent / "VERSION"
|
||||
|
||||
|
||||
def statements_version() -> str | None:
|
||||
"""Вернуть версию установленного пакета или None, если метаданные недоступны."""
|
||||
def statements_version(path: Path | None = None) -> str | None:
|
||||
"""Прочитать VERSION; для установленного пакета использовать версию дистрибутива."""
|
||||
marker = _DEFAULT_MARKER if path is None else path
|
||||
if marker.is_file():
|
||||
text = marker.read_text(encoding="utf-8").strip()
|
||||
if text or path is not None:
|
||||
return text or None
|
||||
if path is not None:
|
||||
return None
|
||||
try:
|
||||
return version("pylib-ai-arm-statements")
|
||||
except PackageNotFoundError:
|
||||
|
||||
@ -1,160 +0,0 @@
|
||||
# Разрез по смене формы и редкая колонка — план реализации
|
||||
|
||||
> **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:** AURORA-1734 — таблица ИС с ББ и ОФР режется на два кандидата; AURORA-1735 — редкий столбец с неразобранной подписью между периодами не становится колонкой сумм.
|
||||
|
||||
**Architecture:** Новая публичная функция `split_by_form` (модуль `arm_statements/split.py`) ищет сегменты тем же кодом шага 2 (`segments._split_and_analyse`) и режет `FormCandidate` по смене типа формы; шаги 2–8 не меняются. Правило редкого столбца — в `columns.segment_columns`, адреса идут в общее `NUMBERS_OUTSIDE_TABLE`.
|
||||
|
||||
**Tech Stack:** Python 3.11, pytest, ruff, mypy (`make check`).
|
||||
|
||||
**Spec:** `/Users/andrey.epifancev/projects/awp-ca/.wt/form-split/docs/openspec/changes/statement-form-split/` (proposal.md, design.md, specs/statement-normalization/spec.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Версия библиотеки — 0.1.5 (`VERSION`, `CHANGELOG.md`, дата 2026-11-07).
|
||||
- Порог разреза — `rules.ranges.min_codes` (сейчас 3), новых ключей словаря нет.
|
||||
- `locator` части k ≥ 1 — `f"{candidate.locator};form:{k}"`; первая часть — исходный кандидат без изменений полей.
|
||||
- Часть k ≥ 1: `type_hint=None`, `evidence=Evidence(okud=None, title=None)`, `warnings=()`, `header` и `source` исходного.
|
||||
- Редкий столбец: ≤ 1 число в строках с кодом; у каждой колонки периода сегмента (с числами) ≥ 3; колонка периода есть левее и правее.
|
||||
- Текст: «числа вне колонок формы не учитываются: …», если среди адресов есть адрес редкого столбца; иначе прежний «числа правее таблицы формы не учитываются: …».
|
||||
- Комментарии и сообщения — по-русски; идентификаторы — английские.
|
||||
- Зелёным считается только весь `make check`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- Кандидат без смены формы (обычный ББ с повторной шапкой второй страницы) должен вернуться **тем же объектом** — `split_by_form(c) == (c,)`; тест в Task 2.
|
||||
- Сегмент без шапки с кодами другой формы (лист-продолжение Excel) не режет — тест в Task 2.
|
||||
- Excel-кандидат с `segment_breaks` после точки разреза: границы части сдвинуты на начало части — тест в Task 2.
|
||||
- Столбец с подписью-мусором и числами во многих строках (`"| | г"`) остаётся `COLUMN_WITHOUT_DATE` — существующий сценарий не ломается; тест в Task 1.
|
||||
- Обрывок подписи правее последнего периода (нет периода справа) — правило не срабатывает, `COLUMN_WITHOUT_DATE`; тест в Task 1.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Редкий столбец с неразобранной подписью (AURORA-1735)
|
||||
|
||||
**Files:**
|
||||
- Modify: `arm_statements/columns.py` (`segment_columns`, `_outside_table_warning`, `outside_table_warning`, `merge_outside_table`)
|
||||
- Test: `tests/test_pipeline.py` (рядом с тестом `NUMBERS_OUTSIDE_TABLE`, строка ~211)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `outside_table_warning(refs: Sequence[str], rules: Rules, *, beside: bool = False) -> StatementWarning` — `beside=True` даёт текст «числа вне колонок формы не учитываются: …»; `merge_outside_table` выбирает текст «вне колонок», если хоть одно входное предупреждение имеет этот текст.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** (через `normalize_candidate(ocr(...))`, раскладка Калтана):
|
||||
|
||||
```python
|
||||
def test_sparse_column_between_periods_is_outside(rules: Rules) -> None:
|
||||
rows = [
|
||||
["Наименование показателя", "Коды", "За 1 полугодие 2026 года", "31", "За I полугодие 2025 года"],
|
||||
["Выручка", "2110", "9671", None, "15405"],
|
||||
["Себестоимость продаж", "2120", "-7084", None, "-10446"],
|
||||
["Валовая прибыль (убыток)", "2100", "2587", None, "4959"],
|
||||
["Прочие расходы", "2350", "-486", "——-", "-348"],
|
||||
["Чистая прибыль (убыток)", "2400", "1935", "$301 .", "4182"],
|
||||
]
|
||||
doc = normalize_candidate(ocr(rows), rules).document
|
||||
assert [c.column_index for c in doc.columns] == [2, 4]
|
||||
assert all(v.column_index != 3 for line in doc.lines for v in line.values)
|
||||
(w,) = [w for w in doc.warnings if w.code == "NUMBERS_OUTSIDE_TABLE"]
|
||||
assert w.severity is Severity.INFO
|
||||
assert w.message == "числа вне колонок формы не учитываются: row:5"
|
||||
assert "COLUMN_WITHOUT_DATE" not in [w.code for w in doc.warnings]
|
||||
```
|
||||
|
||||
Ещё два теста той же раскладки:
|
||||
- `test_unparsed_caption_with_two_numbers_stays_column`: подпись столбца 3 — «Прим.», числа в столбце 3 в двух строках с кодом → колонки `[2, 3, 4]`, есть `COLUMN_WITHOUT_DATE` с `column_index == 3`, нет `NUMBERS_OUTSIDE_TABLE`.
|
||||
- `test_sparse_column_without_period_to_the_right_stays`: подпись столбца 4 — «юлугодие 25 года» (не период), столбец 3 «За I п 20:» с одним числом → столбец 3 остаётся колонкой с `COLUMN_WITHOUT_DATE` (правее нет колонки периода).
|
||||
|
||||
Существующий тест `NUMBERS_OUTSIDE_TABLE` (строка ~211) должен остаться с прежним текстом «числа правее таблицы формы…».
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_pipeline.py -k "sparse or two_numbers" -v`
|
||||
Expected: `test_sparse_column_between_periods_is_outside` FAIL (колонки `[2, 3, 4]`); два других могут пройти уже сейчас — это охранные тесты.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
В `segment_columns`: до цикла посчитать для каждого столбца число строк с кодом, где есть число (`_number_count(rows, data_rows, col, code_col, source, rules) -> int`, по образцу `has_numbers`), и множество колонок периода сегмента с числами (подпись разобрана как период, не исключение). В ветке `elif numbers:` (будущий `COLUMN_WITHOUT_DATE`) перед добавлением проверить: `cell is not None`, есть колонка периода левее и правее `col`, `_number_count(col) <= 1`, у каждой колонки периода счёт ≥ 3 → столбец в отдельный список `beside`, не в drafts и без `COLUMN_WITHOUT_DATE`. После цикла адреса `outside` и `beside` — в одно предупреждение; `beside=True`, если список `beside` не пуст. Константы: `_SPARSE_MAX_NUMBERS = 1`, `_PERIOD_MIN_NUMBERS = 3`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `make check`
|
||||
Expected: все тесты зелёные, ruff и mypy чистые.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add arm_statements/columns.py tests/test_pipeline.py
|
||||
git commit -m "AURORA-1735: редкий столбец с неразобранной подписью между периодами — числа вне колонок (#5)"
|
||||
```
|
||||
|
||||
### Task 2: `split_by_form` — разрез кандидата по смене формы (AURORA-1734)
|
||||
|
||||
**Files:**
|
||||
- Create: `arm_statements/split.py`
|
||||
- Modify: `arm_statements/__init__.py` (импорт и `__all__`)
|
||||
- Test: `tests/test_split.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `segments._split_and_analyse(candidate, rules) -> tuple[list[_Analysed], CodeSection | None]` (поля `_Analysed`: `first`, `last`, `header_rows`, `code_col`); `segments._segment_codes(rows, first, last, code_col, rules) -> list[str]`; `codes.majority_section`, `codes.document_type_of`; `rules.ranges.min_codes`.
|
||||
- Produces: `split_by_form(candidate: FormCandidate, rules: Rules | None = None) -> tuple[FormCandidate, ...]` — экспорт в `arm_statements.__all__`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** в `tests/test_split.py` (кандидаты через `tests.builders.ocr` / `excel`):
|
||||
|
||||
- `test_balance_and_income_in_one_table_split`: сетка — шапка «Наименование показателя | Коды | На 30 июня 2026 года | На 31 декабря 2025 года», строки 1110, 1150, 1100, 1210, 1200, 1600; повторная шапка той же формы + 1310, 1300, 1700; затем шапка «Наименование показателя | Коды | За I полугодие 2026 года | За I полугодие 2025 года» и строки 2110, 2120, 2100, 2400. `type_hint=DocumentType.BALANCE`, `title="Бухгалтерский баланс"`, `locator="file:x.json;table:0"`. Ожидание: две части; `parts[0].locator == "file:x.json;table:0"`, `parts[0].type_hint is DocumentType.BALANCE`, `len(parts[0].rows)` — до строки шапки ОФР; `parts[1].locator == "file:x.json;table:0;form:1"`, `parts[1].type_hint is None`, `parts[1].evidence == Evidence(okud=None, title=None)`, `parts[1].rows[0]` — строка шапки ОФР, `parts[1].header == candidate.header`. Затем `normalize_candidate` по частям: документы `BALANCE` и `INCOME`, у ОФР колонки с датами 2026-06-30 и 2025-06-30, нет `TYPE_CONFLICT`, у ББ нет кодов 2xxx.
|
||||
- `test_same_form_repeat_header_not_split`: ББ с повторной шапкой → `split_by_form(c) == (c,)`, `result[0] is c`.
|
||||
- `test_income_tail_25xx_not_split`: ОФР + повторная шапка со строками 2510, 2520, 2500 → одна часть.
|
||||
- `test_two_foreign_codes_not_split`: после ББ повторная шапка с двумя кодами 2110, 2400 → одна часть.
|
||||
- `test_foreign_segment_without_header_not_split`: строки 2110, 2120, 2100, 2400 идут после ББ без шапки → одна часть.
|
||||
- `test_excel_breaks_shifted`: `excel` с листами «Б» (ББ, шапка) и «Б2» (шапка ОФР + 4 кода ОФР) в одном кандидате… если `excel` builder даёт по листу кандидата — собрать `FormCandidate` вручную через `dataclasses.replace(c, rows=..., segment_breaks=(k, k+3))`; ожидание: `parts[1].segment_breaks == (0, 3)` при разрезе на строке `k` (границы вне `0 < b < len(rows)` допустимы — `_chunks` их отбрасывает; храним сдвинутые `b - start` для `b >= start`).
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_split.py -v`
|
||||
Expected: FAIL — `ImportError: cannot import name 'split_by_form'`.
|
||||
|
||||
- [ ] **Step 3: Implement `split_by_form` в `arm_statements/split.py`**
|
||||
|
||||
Алгоритм: сегменты `_split_and_analyse`; идём по сегментам с `code_col is not None`; тип сегмента — `document_type_of(majority_section(_segment_codes(...)))` (секции нет — `None`); тип текущей части — первый определённый; сегмент режет, если `header_rows` непусты, `len(codes) >= rules.ranges.min_codes`, тип определён и `!=` типу части → точка разреза `seg.first`, новая часть получает тип сегмента. Нет точек — вернуть `(candidate,)`. Иначе части: `dataclasses.replace` — первая только с `rows=rows[:cut1]` и `segment_breaks` `< cut1`; следующие — по Global Constraints, `rows=rows[cut_k:cut_{k+1}]`, `segment_breaks=tuple(b - cut_k for b in breaks if cut_k <= b < cut_{k+1})`. Docstring — ссылка на требование «Шаг 2 — разрез кандидата по смене формы».
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `make check`
|
||||
Expected: всё зелёное.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add arm_statements/split.py arm_statements/__init__.py tests/test_split.py
|
||||
git commit -m "AURORA-1734: split_by_form — разрез кандидата по смене формы (#5)"
|
||||
```
|
||||
|
||||
### Task 3: Версия 0.1.5 и прогон реальных ответов ИС
|
||||
|
||||
**Files:**
|
||||
- Modify: `VERSION`, `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1:** `VERSION` → `0.1.5`; в `CHANGELOG.md` раздел `## 0.1.5 — 2026-11-07`: `split_by_form` (AURORA-1734; хост вызывает перед `normalize_candidate`; без вызова поведение прежнее), редкий столбец (AURORA-1735).
|
||||
- [ ] **Step 2: Прогон реальных JSON** (вне репозитория, не коммитится): python из venv scan-gateway (`/Users/andrey.epifancev/projects/awp-ca/.wt/review-1677/scan-gateway/api/.venv`, venv не менять) с `PYTHONPATH` = корень этой ветки библиотеки (идёт раньше установленного 0.1.3) и обёрткой над адаптером scan-gateway, которая вызывает `split_by_form` перед `normalize_candidate`; JSON — `~/.local/share/sdlc-hub/downloads/2375066/…json` (АК-ЮЛ) и `…/2375226/…json` (Калтан). Ожидание: АК-ЮЛ — два документа (`BALANCE` без кодов 2xxx, `INCOME` со своими колонками, `locator` с `;form:1`); Калтан ОФР — колонки 4 и 6, `NUMBERS_OUTSIDE_TABLE` с `row:21`, без `COLUMN_WITHOUT_DATE`.
|
||||
- [ ] **Step 3:** `make check` зелёный.
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add VERSION CHANGELOG.md
|
||||
git commit -m "AURORA-1734/1735: 0.1.5 (#5)"
|
||||
```
|
||||
|
||||
### Task 4: Документы контекста (arm-ka-docs, ветка `feat/5-form-split`)
|
||||
|
||||
**Files (в `/Users/andrey.epifancev/projects/awp-ca/.wt/form-split/docs`):**
|
||||
- Modify: `context/05-domain-rules.md` — §11 п. 2 «Сегменты» (абзац о разрезе по смене формы, ссылка AURORA-1734), п. 6 «Подпись без периода» (редкий столбец, AURORA-1735; в перечне «Не меняется» — уточнение «кроме редкого столбца»); таблица предупреждений, строка `NUMBERS_OUTSIDE_TABLE` — «правее последней подписанной колонки или в редком столбце между периодами»; §11 «Повтор формы»/OQ-77 не трогать.
|
||||
- Modify: `openspec/changes/statement-form-split/design.md` — дописать в «Decisions made without discussion» решения, принятые при исполнении (если были).
|
||||
|
||||
- [ ] **Step 1:** правки текста по спеке change.
|
||||
- [ ] **Step 2:** `make check` в docs и `openspec validate statement-form-split --strict` — зелёные.
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add context/05-domain-rules.md openspec/changes/statement-form-split/design.md
|
||||
git commit -m "AURORA-1734/1735: §11 — разрез по смене формы и редкий столбец (RSHB/pylib_ai_arm_statements#5)"
|
||||
```
|
||||
@ -64,63 +64,7 @@ def test_simplified_income_soft(rules: Rules) -> None:
|
||||
lines = [line("2110", "1000"), line("2120", "-600"), line("2410", "50"), line("2400", "500")]
|
||||
checks = failed(lines, INC, FormVariant.SIMPLIFIED, rules)
|
||||
assert checks.relations_failed == ()
|
||||
# группа альтернатив 2400 (AURORA-1864): одно нарушение — ближайшая к цели (450 против 350)
|
||||
assert [f.relation for f in checks.soft_relations_failed] == ["2400 = 2110 - 2120 - 2330 + 2340 - 2350 + 2410"]
|
||||
|
||||
|
||||
def _net_profit_soft(checks: StatementChecks) -> list[str]:
|
||||
return [f.relation for f in checks.soft_relations_failed if f.relation.startswith("2400 =")]
|
||||
|
||||
|
||||
def test_net_profit_group_full_any_sign_of_2410(rules: Rules) -> None:
|
||||
"""AURORA-1864: 2410 доходом (плюс), расходом с минусом и расходом без знака — 2400 без мягких нарушений."""
|
||||
for tax, net in (("20", "120"), ("-20", "80"), ("20", "80")):
|
||||
lines = [line("2300", "100"), line("2410", tax), line("2400", net)]
|
||||
checks = failed(lines, INC, FormVariant.FULL, rules)
|
||||
assert _net_profit_soft(checks) == [], (tax, net)
|
||||
|
||||
|
||||
def test_net_profit_group_simplified_any_sign_of_2410(rules: Rules) -> None:
|
||||
for tax, net in (("50", "450"), ("-50", "350"), ("50", "350")):
|
||||
lines = [line("2110", "1000"), line("2120", "-600"), line("2410", tax), line("2400", net)]
|
||||
assert _net_profit_soft(failed(lines, INC, FormVariant.SIMPLIFIED, rules)) == [], (tax, net)
|
||||
|
||||
|
||||
def test_net_profit_group_wrong_target_is_one_violation(rules: Rules) -> None:
|
||||
lines = [line("2300", "100"), line("2410", "20"), line("2400", "150")]
|
||||
soft = failed(lines, INC, FormVariant.FULL, rules).soft_relations_failed
|
||||
net = [f for f in soft if f.relation.startswith("2400 =")]
|
||||
# ближайшая к цели: 100 + 20 = 120 (расхождение 30) против 100 − 20 = 80 (70)
|
||||
assert [(f.relation, f.expected, f.actual) for f in net] == [("2400 = 2300 + 2410 + 2460", D("150"), D("120"))]
|
||||
|
||||
|
||||
def test_net_profit_group_empty_target_is_one_violation(rules: Rules) -> None:
|
||||
lines = [line("2300", "100"), line("2410", "20")]
|
||||
net = _net_profit_soft(failed(lines, INC, FormVariant.FULL, rules))
|
||||
assert net == ["2400 = 2300 - 2410 + 2420"]
|
||||
|
||||
|
||||
def test_net_profit_group_is_per_column(rules: Rules) -> None:
|
||||
def two(code: str, a: str, b: str) -> EvalLine:
|
||||
return EvalLine(
|
||||
f"k{code}",
|
||||
0,
|
||||
LineStatus.OK,
|
||||
code,
|
||||
100.0,
|
||||
False,
|
||||
(
|
||||
EvalValue("0", D(a), a, 100.0, True),
|
||||
EvalValue("1", D(b), b, 100.0, True),
|
||||
),
|
||||
)
|
||||
|
||||
lines = [two("2300", "100", "100"), two("2410", "20", "20"), two("2400", "120", "150")]
|
||||
outcomes = evaluate_relations(lines, ["0", "1"], INC, FormVariant.FULL, rules)
|
||||
soft = checks_from(outcomes, {"0": 0, "1": 1}).soft_relations_failed
|
||||
assert [(f.column_key, f.relation) for f in soft if f.relation.startswith("2400 =")] == [
|
||||
("1", "2400 = 2300 + 2410 + 2460")
|
||||
]
|
||||
assert [f.relation for f in checks.soft_relations_failed] == ["2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410"]
|
||||
|
||||
|
||||
def test_rounding_goes_soft(rules: Rules) -> None:
|
||||
|
||||
@ -53,51 +53,14 @@ def test_warning_table_matches_spec(rules: Rules) -> None:
|
||||
|
||||
|
||||
def test_simplified_income_relation_is_soft(rules: Rules) -> None:
|
||||
rule, alternative = rules.checks.relations_for(DocumentType.INCOME, FormVariant.SIMPLIFIED)
|
||||
(rule,) = rules.checks.relations_for(DocumentType.INCOME, FormVariant.SIMPLIFIED)
|
||||
assert rule.target == "2400" and not rule.strict
|
||||
assert rule.abs_terms == frozenset({"2120", "2330", "2350", "2410"})
|
||||
assert alternative.target == "2400" and not alternative.strict
|
||||
assert alternative.abs_terms == frozenset({"2120", "2330", "2350"})
|
||||
assert rules.checks.abs_codes(DocumentType.INCOME, FormVariant.FULL) == frozenset(
|
||||
{"2120", "2210", "2220", "2330", "2350", "2410"}
|
||||
)
|
||||
|
||||
|
||||
def test_net_profit_relations_share_group(rules: Rules) -> None:
|
||||
"""AURORA-1864: соотношения 2400 каждой формы — одна группа альтернатив."""
|
||||
for variant, count in ((FormVariant.FULL, 2), (FormVariant.SIMPLIFIED, 2)):
|
||||
net = [r for r in rules.checks.relations_for(DocumentType.INCOME, variant) if r.target == "2400"]
|
||||
assert len(net) == count
|
||||
assert len({r.group for r in net}) == 1 and net[0].group is not None
|
||||
assert all(not r.strict for r in net)
|
||||
others = [r for r in rules.checks.relations if r.target != "2400"]
|
||||
assert all(r.group is None for r in others)
|
||||
|
||||
|
||||
def test_group_on_strict_relation_rejected(tmp_path: Path) -> None:
|
||||
for path in RULES_DIR.glob("*.yaml"):
|
||||
(tmp_path / path.name).write_bytes(path.read_bytes())
|
||||
target = tmp_path / "statement_checks.yaml"
|
||||
text = target.read_text(encoding="utf-8")
|
||||
hard = 'relation: "2100 = 2110 - 2120", strict: true'
|
||||
assert hard in text
|
||||
target.write_text(text.replace(hard, hard + ", group: x"), encoding="utf-8")
|
||||
with pytest.raises(RulesError, match="statement_checks.*group"):
|
||||
load_rules(tmp_path)
|
||||
|
||||
|
||||
def test_group_with_different_targets_rejected(tmp_path: Path) -> None:
|
||||
for path in RULES_DIR.glob("*.yaml"):
|
||||
(tmp_path / path.name).write_bytes(path.read_bytes())
|
||||
target = tmp_path / "statement_checks.yaml"
|
||||
text = target.read_text(encoding="utf-8")
|
||||
soft = 'relation: "2500 = 2400 + 2510 + 2520 + 2530", strict: false'
|
||||
assert soft in text
|
||||
target.write_text(text.replace(soft, soft + ", group: net_profit"), encoding="utf-8")
|
||||
with pytest.raises(RulesError, match="statement_checks.*group"):
|
||||
load_rules(tmp_path)
|
||||
|
||||
|
||||
def test_code_orders_with_repeats(rules: Rules) -> None:
|
||||
fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL)
|
||||
assert fsbu.count("1220") == len(("1220", "1220")) and "1120" not in fsbu
|
||||
|
||||
@ -77,7 +77,6 @@ PUBLIC = {
|
||||
"type_hint",
|
||||
"continuation_type",
|
||||
"sheet_joins",
|
||||
"split_by_form",
|
||||
"parse_code",
|
||||
"parse_amount",
|
||||
"parse_date",
|
||||
|
||||
@ -210,62 +210,3 @@ def test_numbers_right_of_table_do_not_break_status_in_repeated_segments(rules:
|
||||
assert [c.column_index for c in doc.columns] == [2]
|
||||
assert [(w.code, w.severity) for w in doc.warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)]
|
||||
assert all(len(line.values) == 1 for line in doc.lines)
|
||||
|
||||
|
||||
# --- редкий столбец с неразобранной подписью между периодами (AURORA-1735, решение владельца 06.11.2026) ---
|
||||
|
||||
SPARSE_COL = 3 # столбец «31» раскладки Калтана
|
||||
KALTAN_HEAD = ["Наименование показателя", "Коды", "За 1 полугодие 2026 года", "31", "За I полугодие 2025 года"]
|
||||
|
||||
|
||||
def kaltan_rows(caption: str = "31", second: str | None = None) -> list[list[str | None]]:
|
||||
head = [*KALTAN_HEAD]
|
||||
head[3] = caption
|
||||
return [
|
||||
head,
|
||||
["Выручка", "2110", "9671", None, "15405"],
|
||||
["Себестоимость продаж", "2120", "-7084", second, "-10446"],
|
||||
["Валовая прибыль (убыток)", "2100", "2587", None, "4959"],
|
||||
["Прочие расходы", "2350", "-486", "——-", "-348"],
|
||||
["Чистая прибыль (убыток)", "2400", "1935", "$301 .", "4182"],
|
||||
]
|
||||
|
||||
|
||||
def test_sparse_column_between_periods_is_outside(rules: Rules) -> None:
|
||||
doc = normalize_candidate(ocr(kaltan_rows()), rules).document
|
||||
assert doc is not None
|
||||
assert [c.column_index for c in doc.columns] == [2, 4]
|
||||
assert all(v.column_index != SPARSE_COL for line in doc.lines for v in line.values)
|
||||
(warning,) = [w for w in doc.warnings if w.code == "NUMBERS_OUTSIDE_TABLE"]
|
||||
assert warning.severity is Severity.INFO
|
||||
assert warning.message == "числа вне колонок формы не учитываются: row:5"
|
||||
assert "COLUMN_WITHOUT_DATE" not in [w.code for w in doc.warnings]
|
||||
|
||||
|
||||
def test_unparsed_caption_with_two_numbers_stays_column(rules: Rules) -> None:
|
||||
doc = normalize_candidate(ocr(kaltan_rows("Прим.", second="12")), rules).document
|
||||
assert doc is not None
|
||||
assert [c.column_index for c in doc.columns] == [2, 3, 4]
|
||||
assert [w.column_index for w in doc.warnings if w.code == "COLUMN_WITHOUT_DATE"] == [3]
|
||||
assert "NUMBERS_OUTSIDE_TABLE" not in [w.code for w in doc.warnings]
|
||||
|
||||
|
||||
def test_sparse_column_without_period_to_the_right_stays(rules: Rules) -> None:
|
||||
rows = kaltan_rows("За I п\n20:")
|
||||
rows[0][4] = "юлугодие\n25 года"
|
||||
doc = normalize_candidate(ocr(rows), rules).document
|
||||
assert doc is not None
|
||||
assert [c.column_index for c in doc.columns] == [2, 3, 4]
|
||||
assert SPARSE_COL in [w.column_index for w in doc.warnings if w.code == "COLUMN_WITHOUT_DATE"]
|
||||
|
||||
|
||||
def test_shifted_value_in_sparse_column_keeps_column(rules: Rules) -> None:
|
||||
"""Единственное число столбца стоит в строке, где колонка периода пуста, — это сдвиг OCR, не мусор (ревью I-1)."""
|
||||
rows = kaltan_rows()
|
||||
rows[5][3] = None # без постороннего «$301»: единственное число столбца — сдвинутое значение 2460
|
||||
rows.insert(5, ["Прочее", "2460", "12", "55", None])
|
||||
doc = normalize_candidate(ocr(rows), rules).document
|
||||
assert doc is not None
|
||||
assert [c.column_index for c in doc.columns] == [2, 3, 4]
|
||||
assert SPARSE_COL in [w.column_index for w in doc.warnings if w.code == "COLUMN_WITHOUT_DATE"]
|
||||
assert doc.status is DocStatus.NEEDS_CHECK
|
||||
|
||||
@ -1,113 +0,0 @@
|
||||
"""Разрез кандидата по смене формы (AURORA-1734, решение владельца 06.11.2026)."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from arm_statements import WarningCode, make_warning, normalize_candidate, split_by_form
|
||||
from arm_statements.loader import Rules
|
||||
from arm_statements.model import DocumentType, Evidence
|
||||
from tests.builders import excel, hdr, ocr
|
||||
|
||||
BAL_HEAD = ["Наименование показателя", "Коды", "На 30 июня 2026 года", "На 31 декабря 2025 года"]
|
||||
INC_HEAD = ["Наименование показателя", "Коды", "За I полугодие 2026 года", "За I полугодие 2025 года"]
|
||||
HEADER = hdr(organization_name="КФХ «АК-ЮЛ»", inn="5634000539", report_date="30.06.2026", units="тыс. руб.")
|
||||
LOCATOR = "file:x.json;table:0"
|
||||
|
||||
|
||||
def balance_rows() -> list[list[str | None]]:
|
||||
"""ББ на две страницы: актив, повторная шапка, пассив."""
|
||||
return [
|
||||
BAL_HEAD,
|
||||
["НМА", "1110", "10", "10"],
|
||||
["Основные средства", "1150", "90", "90"],
|
||||
["Итого по разделу I", "1100", "100", "100"],
|
||||
["Запасы", "1210", "50", "50"],
|
||||
["Итого по разделу II", "1200", "50", "50"],
|
||||
["БАЛАНС", "1600", "150", "150"],
|
||||
BAL_HEAD,
|
||||
["Уставный капитал", "1310", "150", "150"],
|
||||
["Итого по разделу III", "1300", "150", "150"],
|
||||
["БАЛАНС", "1700", "150", "150"],
|
||||
]
|
||||
|
||||
|
||||
def income_rows() -> list[list[str | None]]:
|
||||
return [
|
||||
INC_HEAD,
|
||||
["Выручка", "2110", "4451", "2162"],
|
||||
["Себестоимость продаж", "2120", "-2700", "-1332"],
|
||||
["Валовая прибыль (убыток)", "2100", "1751", "830"],
|
||||
["Чистая прибыль (убыток)", "2400", "1751", "830"],
|
||||
]
|
||||
|
||||
|
||||
def test_balance_and_income_in_one_table_split(rules: Rules) -> None:
|
||||
rows = [*balance_rows(), *income_rows()]
|
||||
cand = ocr(rows, header=HEADER, type_hint=DocumentType.BALANCE, title="Бухгалтерский баланс", locator=LOCATOR)
|
||||
parts = split_by_form(cand, rules)
|
||||
assert len(parts) == 2 # noqa: PLR2004 - две формы
|
||||
bal, inc = parts
|
||||
assert bal.locator == LOCATOR and bal.type_hint is DocumentType.BALANCE and bal.evidence == cand.evidence
|
||||
assert len(bal.rows) == len(balance_rows())
|
||||
assert inc.locator == f"{LOCATOR};form:1"
|
||||
assert inc.type_hint is None and inc.evidence == Evidence(okud=None, title=None) and inc.warnings == ()
|
||||
assert inc.rows[0] == cand.rows[len(balance_rows())]
|
||||
assert inc.header == cand.header and inc.source is cand.source
|
||||
|
||||
bal_doc = normalize_candidate(bal, rules).document
|
||||
inc_doc = normalize_candidate(inc, rules).document
|
||||
assert bal_doc is not None and inc_doc is not None
|
||||
assert bal_doc.document_type is DocumentType.BALANCE
|
||||
assert not [line.line_code for line in bal_doc.lines if (line.line_code or "").startswith("2")]
|
||||
assert inc_doc.document_type is DocumentType.INCOME
|
||||
assert [c.period_date for c in inc_doc.columns] == [date(2026, 6, 30), date(2025, 6, 30)]
|
||||
assert "TYPE_CONFLICT" not in [w.code for w in inc_doc.warnings]
|
||||
|
||||
|
||||
def test_same_form_repeat_header_not_split(rules: Rules) -> None:
|
||||
cand = ocr(balance_rows(), header=HEADER, type_hint=DocumentType.BALANCE, locator=LOCATOR)
|
||||
result = split_by_form(cand, rules)
|
||||
assert result == (cand,)
|
||||
assert result[0] is cand
|
||||
|
||||
|
||||
def test_income_tail_25xx_not_split(rules: Rules) -> None:
|
||||
tail = [
|
||||
INC_HEAD,
|
||||
["Результат от переоценки", "2510", "0", "0"],
|
||||
["Результат от прочих операций", "2520", "0", "0"],
|
||||
["Совокупный финансовый результат", "2500", "1751", "830"],
|
||||
]
|
||||
cand = ocr([*income_rows(), *tail], locator=LOCATOR)
|
||||
assert split_by_form(cand, rules) == (cand,)
|
||||
|
||||
|
||||
def test_two_foreign_codes_not_split(rules: Rules) -> None:
|
||||
foreign = [INC_HEAD, ["Выручка", "2110", "4451", "2162"], ["Чистая прибыль (убыток)", "2400", "1751", "830"]]
|
||||
cand = ocr([*balance_rows(), *foreign], locator=LOCATOR)
|
||||
assert split_by_form(cand, rules) == (cand,)
|
||||
|
||||
|
||||
def test_foreign_segment_without_header_not_split(rules: Rules) -> None:
|
||||
cand = ocr([*balance_rows(), *income_rows()[1:]], locator=LOCATOR)
|
||||
assert split_by_form(cand, rules) == (cand,)
|
||||
|
||||
|
||||
def test_excel_breaks_shifted(rules: Rules) -> None:
|
||||
continuation = [["Прочие доходы", "2340", "1", "1"], ["Прочие расходы", "2350", "-1", "-1"]]
|
||||
cand = excel([("Б", balance_rows()), ("О", income_rows()), ("О2", continuation)], header=HEADER)
|
||||
cut = len(balance_rows())
|
||||
assert cand.segment_breaks == (cut, cut + len(income_rows()))
|
||||
bal, inc = split_by_form(cand, rules)
|
||||
assert bal.segment_breaks == ()
|
||||
assert inc.segment_breaks == (0, len(income_rows()))
|
||||
assert inc.locator == f"{cand.locator};form:1"
|
||||
assert len(inc.rows) == len(income_rows()) + len(continuation)
|
||||
|
||||
|
||||
def test_xlsb_source_warning_kept_in_every_part(rules: Rules) -> None:
|
||||
"""`XLSB_NO_FORMATS` — свойство источника, нужно каждой части для проверки масштаба (ревью I-2)."""
|
||||
no_formats = make_warning(WarningCode.XLSB_NO_FORMATS, "xlsb без числовых форматов", rules)
|
||||
cand = excel([("Б", balance_rows()), ("О", income_rows())], header=HEADER, warnings=[no_formats])
|
||||
bal, inc = split_by_form(cand, rules)
|
||||
assert bal.warnings == (no_formats,)
|
||||
assert inc.warnings == (no_formats,)
|
||||
@ -1,23 +1,74 @@
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import arm_statements.version as version_module
|
||||
from arm_statements.version import statements_version
|
||||
|
||||
|
||||
def test_version_from_distribution(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def distribution_version(name: str) -> str:
|
||||
assert name == "pylib-ai-arm-statements"
|
||||
return "0.1.2"
|
||||
|
||||
monkeypatch.setattr(version_module, "version", distribution_version)
|
||||
assert statements_version() == "0.1.2"
|
||||
FULL_HASH = "abc1234def5678abc1234def5678abc1234def56"
|
||||
|
||||
|
||||
def test_version_without_metadata_is_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_version_absent_file_is_none(tmp_path: Path) -> None:
|
||||
assert statements_version(tmp_path / "VERSION") is None
|
||||
|
||||
|
||||
def test_version_read_from_file(tmp_path: Path) -> None:
|
||||
marker = tmp_path / "VERSION"
|
||||
marker.write_text(f"{FULL_HASH}\n", encoding="utf-8")
|
||||
assert statements_version(marker) == FULL_HASH
|
||||
|
||||
|
||||
def test_version_empty_file_is_none(tmp_path: Path) -> None:
|
||||
marker = tmp_path / "VERSION"
|
||||
marker.write_text(" \n", encoding="utf-8")
|
||||
assert statements_version(marker) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("contents", [None, " \n"])
|
||||
def test_default_marker_falls_back_to_distribution(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, contents: str | None
|
||||
) -> None:
|
||||
marker = tmp_path / "VERSION"
|
||||
if contents is not None:
|
||||
marker.write_text(contents, encoding="utf-8")
|
||||
monkeypatch.setattr(version_module, "_DEFAULT_MARKER", marker)
|
||||
monkeypatch.setattr(version_module, "version", lambda name: "0.1.1" if name == "pylib-ai-arm-statements" else None)
|
||||
assert statements_version() == "0.1.1"
|
||||
|
||||
|
||||
def test_default_marker_takes_priority(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
marker = tmp_path / "VERSION"
|
||||
marker.write_text(f" {FULL_HASH}\n", encoding="utf-8")
|
||||
monkeypatch.setattr(version_module, "_DEFAULT_MARKER", marker)
|
||||
|
||||
def unexpected_metadata_lookup(name: str) -> str:
|
||||
raise AssertionError(f"Unexpected metadata lookup: {name}")
|
||||
|
||||
monkeypatch.setattr(version_module, "version", unexpected_metadata_lookup)
|
||||
assert statements_version() == FULL_HASH
|
||||
|
||||
|
||||
def test_default_version_without_metadata_is_none(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(version_module, "_DEFAULT_MARKER", tmp_path / "VERSION")
|
||||
|
||||
def missing_distribution(name: str) -> str:
|
||||
raise PackageNotFoundError(name)
|
||||
|
||||
monkeypatch.setattr(version_module, "version", missing_distribution)
|
||||
assert statements_version() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("contents", [None, " \n"])
|
||||
def test_explicit_marker_does_not_use_distribution(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, contents: str | None
|
||||
) -> None:
|
||||
marker = tmp_path / "VERSION"
|
||||
if contents is not None:
|
||||
marker.write_text(contents, encoding="utf-8")
|
||||
|
||||
def unexpected_metadata_lookup(name: str) -> str:
|
||||
raise AssertionError(f"Unexpected metadata lookup: {name}")
|
||||
|
||||
monkeypatch.setattr(version_module, "version", unexpected_metadata_lookup)
|
||||
assert statements_version(marker) is None
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user