Нераспознанная метка в колонке «Показатели» давала OSV_ROWS на каждую строку (2 128 одинаковых на 11.xlsx). Теперь построчные OSV_ROWS «строка N: <текст>» с одинаковым текстом сводятся в одно: «метка «X» в колонке «Показатели» не распознана — K строк (первые 20 номеров, …) не участвуют в проверках»; прочие одинаковые построчные — «<текст> — строк: K (…)». Одиночное предупреждение не меняется. Коды и контракт не тронуты. Спека osv-parsing обновлена. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
8.1 KiB
Python
160 lines
8.1 KiB
Python
"""Предупреждения ОСВ с кодами `OSV_*` (P4-12): коды по источнику, тексты — в `warnings` документа (контракт 2.0)."""
|
||
|
||
import pytest
|
||
from gridbuilder import L, M, sheet_from_rows
|
||
from test_checks import row, total, tree
|
||
|
||
from src.parser.checks import run_checks
|
||
from src.parser.deadline import Deadline
|
||
from src.parser.grid import Sheet
|
||
from src.parser.osv import OsvParser
|
||
from src.parser.osv_warnings import OsvNote, group_row_warnings, hierarchy_note
|
||
from src.parser.rules import Rules, load_rules
|
||
|
||
SUMS = (None, None, 100, 100, None, None)
|
||
BROKEN = (None, None, 100, 100, None, 5)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def rules() -> Rules:
|
||
return load_rules()
|
||
|
||
|
||
def _sheet(*rows: list[object]) -> Sheet:
|
||
head: list[list[object]] = [
|
||
["ООО Зерновая"],
|
||
["Оборотно-сальдовая ведомость по счету 60"],
|
||
["Период: 9 месяцев 2025 г."],
|
||
["Детализация по субсчетам, субконто: Контрагенты"],
|
||
["Выводимые данные: сумма"],
|
||
[],
|
||
["Субконто", M("Сальдо на начало периода", 2), M("Оборот за период", 2), M("Сальдо на конец периода", 2)],
|
||
[None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||
]
|
||
return sheet_from_rows([*head, *rows])
|
||
|
||
|
||
def test_check_notes_have_codes() -> None:
|
||
rows = tree()
|
||
rows[2] = row(9, ("40", None, "30", "20", "51", None), level=1, parent=7)
|
||
rows[-1] = total(10, ("100", None, "50", "30", "121", None))
|
||
|
||
_, notes = run_checks(rows)
|
||
|
||
assert [note.code for note in notes] == ["OSV_BALANCE_IDENTITY", "OSV_PARENT_SUM", "OSV_TOTAL"]
|
||
|
||
|
||
def test_hierarchy_note_code_by_strategy() -> None:
|
||
assert hierarchy_note("sums") == OsvNote("OSV_HIERARCHY_BY_SUMS", "иерархия строк — по суммам (sums)")
|
||
assert hierarchy_note("account_pattern").code == "OSV_HIERARCHY_BY_ACCOUNT_PATTERN"
|
||
|
||
|
||
def test_hierarchy_note_text_names_the_strategy() -> None:
|
||
assert hierarchy_note("outline").message == "иерархия строк — по группировке листа (outline)"
|
||
assert hierarchy_note("indent").message == "иерархия строк — по отступам (indent)"
|
||
assert hierarchy_note("account_pattern").message == "иерархия строк — по кодам счетов (account_pattern)"
|
||
assert hierarchy_note("sums").message == "иерархия строк — по суммам (sums)"
|
||
assert hierarchy_note("trivial").message == "иерархия строк — плоский список (trivial)"
|
||
|
||
|
||
def test_parse_sheet_notes_start_with_hierarchy(rules: Rules) -> None:
|
||
sheet = _sheet([L(0), "60.01", *BROKEN], [L(1), "ООО Альфа", *SUMS], [L(0), "Итого", *BROKEN])
|
||
|
||
result = OsvParser(rules).parse_sheet(sheet, Deadline(60))
|
||
|
||
assert result.notes[0].code.startswith("OSV_HIERARCHY_BY_")
|
||
assert all(note.code.startswith("OSV_") for note in result.notes)
|
||
assert not result.ok
|
||
|
||
|
||
def test_document_warnings_are_the_notes(rules: Rules) -> None:
|
||
sheet = _sheet([L(0), "60.01", *BROKEN], [L(1), "ООО Альфа", *SUMS], [L(0), "Итого", *BROKEN])
|
||
|
||
result = OsvParser(rules).parse_sheet(sheet, Deadline(60))
|
||
|
||
assert [(w.code, w.severity, w.message) for w in result.document.warnings] == [
|
||
(note.code, "info", note.message) for note in result.notes
|
||
]
|
||
assert [note.message for note in result.notes] == [
|
||
"иерархия строк — по группировке листа (outline)",
|
||
"валюта в шапке не найдена",
|
||
"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 9, 11",
|
||
"§1.4: суммы родителя ≠ сумме детей (±0.01): строки 9",
|
||
]
|
||
assert result.document.status == "needs_check"
|
||
|
||
|
||
def test_reader_notes_are_appended_last(rules: Rules) -> None:
|
||
sheet = _sheet([L(0), "60.01", *SUMS], [L(1), "ООО Альфа", *SUMS], [L(0), "Итого", *SUMS])
|
||
note = OsvNote("OSV_XLSB_NO_OUTLINE", "xlsb: outline level недоступен")
|
||
|
||
result = OsvParser(rules).parse_sheet(sheet, Deadline(60), [note])
|
||
|
||
assert result.notes[-1] == note
|
||
assert result.document.warnings[-1].code == "OSV_XLSB_NO_OUTLINE"
|
||
|
||
|
||
def _indicator_rows(label: str, count: int) -> list[list[object]]:
|
||
rows: list[list[object]] = [
|
||
[],
|
||
["Оборотно-сальдовая ведомость по счету: 76"],
|
||
["Контрагенты"],
|
||
["за 2025 г."],
|
||
['ООО "Тест"'],
|
||
[],
|
||
[
|
||
"Субконто",
|
||
"Показатели",
|
||
M("Сальдо на начало периода", 2),
|
||
M("Обороты за период", 2),
|
||
M("Сальдо на конец периода", 2),
|
||
],
|
||
[None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||
]
|
||
for number in range(count):
|
||
rows.append([f"Контрагент {number}", "БУ", 10, None, None, None, 10, None])
|
||
rows.append([None, label, 5, None, None, None, 5, None])
|
||
rows.append(["Итого", "БУ", 10 * count, None, None, None, 10 * count, None])
|
||
return rows
|
||
|
||
|
||
def test_unknown_indicator_label_gives_one_warning_for_all_rows(rules: Rules) -> None:
|
||
"""AURORA-1724: неизвестная метка «Показатели» в 30 строках — одно предупреждение с числом и номерами строк."""
|
||
count = 30
|
||
result = OsvParser(rules).parse_sheet(sheet_from_rows(_indicator_rows("Неизв.", count)), Deadline(60))
|
||
texts = [note.message for note in result.notes if note.code == "OSV_ROWS" and "«Неизв.»" in note.message]
|
||
assert len(texts) == 1, texts
|
||
assert "30 строк (10, 12, 14," in texts[0]
|
||
assert "не участвуют в проверках" in texts[0]
|
||
assert "…" in texts[0] # номера строк ограничены, как у OSV_PARENT_SUM
|
||
|
||
|
||
def test_single_unknown_indicator_label_keeps_row_wording(rules: Rules) -> None:
|
||
result = OsvParser(rules).parse_sheet(sheet_from_rows(_indicator_rows("Неизв.", 1)), Deadline(60))
|
||
texts = [note.message for note in result.notes if "«Неизв.»" in note.message]
|
||
assert texts == ["строка 10: метка «Неизв.» в колонке «Показатели» не распознана — строка не участвует в проверках"]
|
||
|
||
|
||
def test_different_unknown_labels_are_grouped_separately() -> None:
|
||
messages = [
|
||
"строка 3: метка «А» в колонке «Показатели» не распознана — строка не участвует в проверках",
|
||
"строка 4: метка «Б» в колонке «Показатели» не распознана — строка не участвует в проверках",
|
||
"строка 5: метка «А» в колонке «Показатели» не распознана — строка не участвует в проверках",
|
||
]
|
||
assert group_row_warnings(messages) == [
|
||
"метка «А» в колонке «Показатели» не распознана — 2 строки (3, 5) не участвуют в проверках",
|
||
"строка 4: метка «Б» в колонке «Показатели» не распознана — строка не участвует в проверках",
|
||
]
|
||
|
||
|
||
def test_same_text_row_warnings_are_grouped() -> None:
|
||
messages = [
|
||
"строка 3: нечисловое значение в колонке сумм: «х»",
|
||
"строка 4: другое",
|
||
"строка 5: нечисловое значение в колонке сумм: «х»",
|
||
]
|
||
assert group_row_warnings(messages) == [
|
||
"нечисловое значение в колонке сумм: «х» — строк: 2 (3, 5)",
|
||
"строка 4: другое",
|
||
]
|