From c915fd143ca2dfb8f5a4a0e52c7595592349b091 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 01:17:22 +0400 Subject: [PATCH 1/2] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D0=BE=D0=B4=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=B8=D0=B7=D0=B2=D0=B5=D1=81=D1=82=D0=BD=D1=83=D1=8E=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D0=BA=D1=83=20=C2=AB=D0=9F=D0=BE=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B8=C2=BB=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83=20(#72,=20AURORA-?= =?UTF-8?q?1724)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Нераспознанная метка в колонке «Показатели» давала OSV_ROWS на каждую строку (2 128 одинаковых на 11.xlsx). Теперь построчные OSV_ROWS «строка N: <текст>» с одинаковым текстом сводятся в одно: «метка «X» в колонке «Показатели» не распознана — K строк (первые 20 номеров, …) не участвуют в проверках»; прочие одинаковые построчные — «<текст> — строк: K (…)». Одиночное предупреждение не меняется. Коды и контракт не тронуты. Спека osv-parsing обновлена. Co-Authored-By: Claude Opus 5.5 --- api/src/parser/indicators.py | 6 +- api/src/parser/osv.py | 3 +- api/src/parser/osv_warnings.py | 68 ++++++++++++++++++++++ api/tests/unit/parser/test_osv_warnings.py | 67 ++++++++++++++++++++- openspec/specs/osv-parsing/spec.md | 7 ++- 5 files changed, 143 insertions(+), 8 deletions(-) diff --git a/api/src/parser/indicators.py b/api/src/parser/indicators.py index 7da5ee3..18031a9 100644 --- a/api/src/parser/indicators.py +++ b/api/src/parser/indicators.py @@ -8,6 +8,7 @@ from src.parser.deadline import Deadline from src.parser.detect import TableHeader from src.parser.grid import Sheet from src.parser.hierarchy import ROOT_LEVEL, BodyRow, Levels, resolve_levels +from src.parser.osv_warnings import unknown_label_warning from src.parser.rules import Rules, first_match, normalize_text __all__ = ["RowLabel", "column_label", "is_indicator_total", "levels_with_indicators", "read_label", "row_data_type"] @@ -46,10 +47,7 @@ def read_label(sheet: Sheet, row: int, text: str, header: TableHeader, rules: Ru 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) + return RowLabel(indicator=True, data_type=None, text=label, warning=unknown_label_warning(row, label)) 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) diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index 5bceb0e..7caccd8 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -28,6 +28,7 @@ from src.parser.osv_warnings import ( OSV_NO_DATA_ROWS, OSV_ROWS, OsvNote, + group_row_warnings, hierarchy_note, notes_of, ) @@ -235,7 +236,7 @@ class OsvParser: hierarchy_note(levels.strategy), *notes_of(OSV_HEADER, doc.warnings), *([OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING)] if defaulted else []), - *notes_of(OSV_ROWS, body_warnings), + *notes_of(OSV_ROWS, group_row_warnings(body_warnings)), *notes_of(OSV_ROWS, _by_account_warnings(classified)), *notes_of(OSV_INN_ROWS, glue.warnings), ] diff --git a/api/src/parser/osv_warnings.py b/api/src/parser/osv_warnings.py index b0d07ad..5debe25 100644 --- a/api/src/parser/osv_warnings.py +++ b/api/src/parser/osv_warnings.py @@ -4,6 +4,7 @@ страницы» — не код ОСВ, а предупреждение файла `XLS_NO_CODEPAGE` пакета (P4-13, R-2); здесь её нет. """ +import re from collections.abc import Iterable from dataclasses import dataclass @@ -19,7 +20,9 @@ __all__ = [ "OSV_XLSB_NO_OUTLINE", "HIERARCHY_TEXTS", "OsvNote", + "group_row_warnings", "hierarchy_note", + "unknown_label_warning", "notes_of", ] @@ -31,6 +34,10 @@ OSV_NO_DATA_ROWS = "OSV_NO_DATA_ROWS" OSV_BALANCE_IDENTITY = "OSV_BALANCE_IDENTITY" OSV_PARENT_SUM = "OSV_PARENT_SUM" OSV_TOTAL = "OSV_TOTAL" +WARNING_ROWS_SHOWN = 20 # сколько номеров строк показывать в сгруппированном предупреждении (как у `checks.py`) +_ROW_WARNING = re.compile(r"^строка (\d+): (.*)$", re.DOTALL) +_UNKNOWN_LABEL_TAIL = " в колонке «Показатели» не распознана — " +_UNKNOWN_LABEL = re.compile(r"^метка «(.*)»" + _UNKNOWN_LABEL_TAIL + r"строка не участвует в проверках$", re.DOTALL) OSV_XLSB_NO_OUTLINE = "OSV_XLSB_NO_OUTLINE" # заметка читателя xlsb: книга дописывает её в каждый документ ОСВ @@ -58,3 +65,64 @@ def hierarchy_note(strategy: str) -> OsvNote: def notes_of(code: str, messages: Iterable[str]) -> list[OsvNote]: return [OsvNote(code, message) for message in messages] + + +def unknown_label_warning(row: int, label: str) -> str: + """Предупреждение об одной строке с меткой колонки «Показатели», которой нет в словарях.""" + return f"строка {row}: метка «{label}»{_UNKNOWN_LABEL_TAIL}строка не участвует в проверках" + + +def _count(number: int) -> str: + return f"{number:,}".replace(",", " ") + + +def _rows_shown(rows: list[int]) -> str: + shown = ", ".join(str(number) for number in rows[:WARNING_ROWS_SHOWN]) + return f"{shown}, …" if len(rows) > WARNING_ROWS_SHOWN else shown + + +def _plural_rows(number: int) -> str: + last_two = number % 100 + if number % 10 == 1 and last_two != 11: # noqa: PLR2004 - склонение «строка / строки / строк» + return "строка" + if 2 <= number % 10 <= 4 and not 12 <= last_two <= 14: # noqa: PLR2004 + return "строки" + return "строк" + + +def group_row_warnings(messages: Iterable[str]) -> list[str]: + """Построчные предупреждения «строка N: <текст>» с одним текстом — в одно с числом и номерами строк (AURORA-1724). + + Одиночное предупреждение остаётся как есть. Нераспознанная метка «Показатели»: «метка «X» в колонке «Показатели» + не распознана — 2 128 строк (11, 14, …) не участвуют в проверках»; прочие — «<текст> — строк: N (…)». Номера + ограничены `WARNING_ROWS_SHOWN`, порядок групп — по первому появлению; сообщения без номера строки не меняются. + """ + items = list(messages) + groups: dict[str, list[int]] = {} + for message in items: + found = _ROW_WARNING.match(message) + if found: + groups.setdefault(found.group(2), []).append(int(found.group(1))) + result: list[str] = [] + done: set[str] = set() + for message in items: + found = _ROW_WARNING.match(message) + if not found: + result.append(message) + continue + text = found.group(2) + rows = groups[text] + if len(rows) == 1: + result.append(message) + elif text not in done: + done.add(text) + result.append(_grouped(text, rows)) + return result + + +def _grouped(text: str, rows: list[int]) -> str: + label = _UNKNOWN_LABEL.match(text) + count = f"{_count(len(rows))} {_plural_rows(len(rows))} ({_rows_shown(rows)})" + if label: + return f"метка «{label.group(1)}»{_UNKNOWN_LABEL_TAIL}{count} не участвуют в проверках" + return f"{text} — строк: {_count(len(rows))} ({_rows_shown(rows)})" diff --git a/api/tests/unit/parser/test_osv_warnings.py b/api/tests/unit/parser/test_osv_warnings.py index 49c7500..cf9f7c9 100644 --- a/api/tests/unit/parser/test_osv_warnings.py +++ b/api/tests/unit/parser/test_osv_warnings.py @@ -8,7 +8,7 @@ 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, hierarchy_note +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) @@ -92,3 +92,68 @@ def test_reader_notes_are_appended_last(rules: Rules) -> None: 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: другое", + ] diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md index 2f5905d..98d993a 100644 --- a/openspec/specs/osv-parsing/spec.md +++ b/openspec/specs/osv-parsing/spec.md @@ -547,8 +547,11 @@ design #14). 1. в таблице есть колонка «Показатели» (заголовок блока по словарю `osv_headers.yaml` — `service_columns.indicator`, «Показатели», «Показа-тели»), ячейка строки в ней непуста и метка не «БУ»: «НУ» → `data_type = NU`, «Кол.», «Кол. доп.» → QUANTITY, «Вал.» → CURRENCY (словарь `data_types.yaml` — `indicator_column`); «ПР», «ВР» (`indicator_column_untyped`) - → `data_type = null`; метка не из словарей → `data_type = null` и предупреждение `OSV_ROWS` «строка N: метка «X» в - колонке «Показатели» не распознана — строка не участвует в проверках»; + → `data_type = null`; метка не из словарей → `data_type = null` и предупреждение `OSV_ROWS`: одно на каждую + неизвестную метку, а не на каждую строку — «метка «X» в колонке «Показатели» не распознана — 2 128 строк (11, 14, …) + не участвуют в проверках» (показаны первые 20 номеров; для единственной строки текст прежний: «строка N: метка «X» в + колонке «Показатели» не распознана — строка не участвует в проверках»); так же группируются прочие построчные + предупреждения `OSV_ROWS` с одинаковым текстом («строка N: <текст>» → «<текст> — строк: K (…)»; AURORA-1724); 2. иначе — текст строки матчит `data_types.yaml` — `row` («Вал.», «Кол.», «Кол-во», «Кол-во, <единица>», «В валюте» → CURRENCY/QUANTITY) или равен коду валюты из `currency_codes`. From 99114508f7cca55f8894ec066b9d66c9fe2bbf1f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 01:18:26 +0400 Subject: [PATCH 2/2] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=C2=AB=D0=98=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=C2=BB=20=D1=81=D0=B2=D1=91=D1=80=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D0=B0=20=D0=BF=D1=80=D0=B8=20=D1=80=D0=B0=D0=B7=D0=B2?= =?UTF-8?q?=D1=91=D1=80=D0=BD=D1=83=D1=82=D0=BE=D0=BC=20=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=81=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=20=D0=BD=D0=B5=D1=82=D1=82=D0=BE=20(AUROR?= =?UTF-8?q?A-1708,=20#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/parser/checks.py | 46 +++++++++++++++++++++++--- api/tests/unit/parser/test_checks.py | 49 ++++++++++++++++++++++++++++ openspec/specs/osv-parsing/spec.md | 6 +++- 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 88d77a3..a50a55a 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -12,7 +12,8 @@ разными сторонами сальдо); 3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо — - по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2); корня нет + по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2; то же для единственного корня, когда + корень и «Итого развернутое» развёрнуты, а «Итого» свёрнута, AURORA-1708); корня нет (пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, результат `false`. Строки-показатели (валюта, количество, НУ) в сумму уровня 0 не входят: это другие единицы. @@ -225,14 +226,51 @@ def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence if roots: return _sums_match(_values(total), _sum_of(roots)) return all(abs(value) <= TOLERANCE for value in _values(total)) - if not _differs(_values(total), _values(root)): + if _matches_single_root(rows, total, root, secondary_totals): return True roots = _level_zero(rows) - # Сумма уровня 0 — «ребёнок» итоговой строки: те же допуски, что у родителя и детей (R-21): 1С показывает «Итого» - # свёрнутым сальдо, а субсчета — развёрнутым. Обороты по-прежнему по колонкам. + # Сумма уровня 0 — «ребёнок» итоговой строки: те же допуски (R-21) return len(roots) > 1 and _sums_match(_values(total), _sum_of(roots)) +def _matches_single_root( + rows: Sequence[ClassifiedRow], total: ClassifiedRow, root: ClassifiedRow, secondary: Sequence[Pattern] +) -> bool: + """«Итого» = корень: по колонкам; корень — «ребёнок» итоговой строки, те же допуски, что у родителя и детей + (R-21, AURORA-1708): 1С показывает «Итого» свёрнутым сальдо, а счёт (и «Итого развернутое») — развёрнутым, поэтому + при свёрнутом сальдо «Итого» оно сверяется по нетто. «Итого развернутое» = корню по колонкам — основная «Итого» + сверяется с корнем по нетто сальдо и оборотам по колонкам.""" + if not _differs(_values(total), _values(root)) or _sums_match(_values(total), _values(root)): + return True + expanded = _expanded_total(rows, secondary) + return ( + expanded is not None + and not _differs(_values(expanded), _values(root)) + and _net_matches(_values(total), _values(root)) + ) + + +def _expanded_total(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern]) -> ClassifiedRow | None: + """Вторая итоговая строка («Итого развернутое»): первая TOTAL не-показатель, чей текст матчит `secondary`.""" + return next( + ( + item + for item in rows + if item.group_type == "TOTAL" and not item.indicator and any(p.search(item.body.text) for p in secondary) + ), + None, + ) + + +def _net_matches(left: Sequence[Decimal], right: Sequence[Decimal]) -> bool: + """Обороты по колонкам, сальдо начала и конца по нетто «дебет − кредит» (±0.01).""" + return ( + abs(_net(left[_OPENING]) - _net(right[_OPENING])) <= TOLERANCE + and not _differs(left[_TURNOVER], right[_TURNOVER]) + and abs(_net(left[_CLOSING]) - _net(right[_CLOSING])) <= TOLERANCE + ) + + def _rows_text(rows: Iterable[int]) -> str: numbers = list(rows) shown = ", ".join(str(number) for number in numbers[:WARNING_ROWS_SHOWN]) diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index 54d1ef3..79c8c80 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -635,3 +635,52 @@ def test_total_skips_leading_indicator_total() -> None: rows.insert(0, row(2, ("9", None, None, None, "9", None), group_type="TOTAL", data_type="NU", indicator=True)) assert total_matches_root(rows) is True + + +# AURORA-1708 (#73): корень счёта развёрнут (Дт и Кт), основная «Итого» свёрнута, «Итого развернутое» = корню +_EXPANDED_ROOT: Sums = ("10", "4", "100", "70", "50", "20") +_COLLAPSED_TOTAL: Sums = ("6", None, "100", "70", "30", None) +_SECONDARY = [Pattern.compile(r"^итого\s+развернут")] + + +def test_collapsed_total_matches_expanded_root_by_net() -> None: + """(б) Только свёрнутая «Итого»: обороты по колонкам, сальдо по нетто — корень из одной строки тоже сходится.""" + rows = [account(7, _EXPANDED_ROOT), total(8, _COLLAPSED_TOTAL)] + + assert total_matches_root(rows) is True + + +def test_expanded_secondary_total_equal_to_root_with_collapsed_total() -> None: + """(а) «Итого развернутое» = корню по колонкам, основная «Итого» свёрнута: предупреждения нет.""" + rows = [account(7, _EXPANDED_ROOT), total(8, _COLLAPSED_TOTAL), _secondary(9, _EXPANDED_ROOT)] + _, warnings = run_checks(rows, secondary_totals=_SECONDARY) + + assert total_matches_root(rows, _SECONDARY) is True + assert not [w for w in warnings if "Итого" in w.message] + + +def test_expanded_secondary_total_before_collapsed_total() -> None: + """(а) То же в плоской форме: «Итого развернутое» стоит перед «Итого».""" + rows = [account(7, _EXPANDED_ROOT), _secondary(8, _EXPANDED_ROOT), total(9, _COLLAPSED_TOTAL)] + + assert total_matches_root(rows, _SECONDARY) is True + + +def test_collapsed_total_with_real_net_difference_still_fails() -> None: + """(в) Нетто «Итого» не совпало с корнем — расхождение остаётся, даже если «Итого развернутое» = корню.""" + wrong_net: Sums = ("7", None, "100", "70", "30", None) + wrong_turnover: Sums = ("6", None, "101", "70", "30", None) + + assert total_matches_root([account(7, _EXPANDED_ROOT), total(8, wrong_net)]) is False + assert total_matches_root([account(7, _EXPANDED_ROOT), total(8, wrong_turnover)]) is False + rows = [account(7, _EXPANDED_ROOT), total(8, wrong_net), _secondary(9, _EXPANDED_ROOT)] + assert total_matches_root(rows, _SECONDARY) is False + _, warnings = run_checks(rows, secondary_totals=_SECONDARY) + assert any("≠ корневому счёту" in w.message for w in warnings) + + +def test_expanded_total_differing_from_expanded_root_still_fails() -> None: + """Развёрнутая «Итого» (обе стороны) с другим распределением Дт/Кт нетто не спасает: свёрнутость — у «Итого».""" + shifted: Sums = ("16", "10", "100", "70", "50", "20") + + assert total_matches_root([account(7, _EXPANDED_ROOT), total(8, shifted)]) is False diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md index 2f5905d..c185cda 100644 --- a/openspec/specs/osv-parsing/spec.md +++ b/openspec/specs/osv-parsing/spec.md @@ -231,7 +231,7 @@ turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по `data_type` (±0.01), причём **обороты** сверяются по колонкам, а **сальдо** (начало, конец) — по колонкам либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто «дебет − кредит» (R-21: 1С показывает счёт нетто, а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки-показатели (требование «Строки-показатели и колонка -«Показатели»») в суммирование не входят и сами родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0) либо сумма строк уровня 0; основная «Итого» — первая строка TOTAL без признака показателя, текст которой не «Итого развернутое» (`total_row_secondary`), в каком бы порядке они ни стояли; итоговая строка-показатель («Итого» с меткой «НУ», «Кол.») в эту сверку не входит. Если корневой ACCOUNT нет, а строки уровня 0 есть (плоский список), «Итого» SHALL сверяться с их суммой (те же допуски R-21); если строк данных нет (пустая ОСВ, R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, `total_row_matches_root = false` и в +«Показатели»») в суммирование не входят и сами родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0) либо сумма строк уровня 0; основная «Итого» — первая строка TOTAL без признака показателя, текст которой не «Итого развернутое» (`total_row_secondary`), в каком бы порядке они ни стояли; итоговая строка-показатель («Итого» с меткой «НУ», «Кол.») в эту сверку не входит. Если «Итого» свёрнута (не больше одной ненулевой стороны сальдо), а корневая строка развёрнута (в 1С так бывает у счёта с развёрнутым сальдо; «Итого развернутое» равна корню), «Итого» SHALL сверяться с единственным корнем так же, как с родителем и детьми (R-21): обороты по колонкам, сальдо по нетто «дебет − кредит» (AURORA-1708, #73); расхождение по нетто или оборотам остаётся нарушением. Если корневой ACCOUNT нет, а строки уровня 0 есть (плоский список), «Итого» SHALL сверяться с их суммой (те же допуски R-21); если строк данных нет (пустая ОСВ, R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, `total_row_matches_root = false` и в `warnings` — «строка «Итого» не найдена» (R-20). Результат — `checks{total_row_matches_root, balance_identity_violations, parent_sum_violations}`. Результат проверки SHALL записываться в документ листа (контракт v2.0): `status` листа — `done`, когда контрольные суммы §1.4 сошлись (и строки ИНН без нарушений), иначе `needs_check` (прежние условия §1.6, применённые к листу); любое @@ -314,6 +314,10 @@ balance_identity_violations, parent_sum_violations}`. Результат про - **THEN** обе итоговые строки — TOTAL уровня 0 без родителя, «Итого развернутое» не ребёнок «76.Н.1»; `total_row_matches_root = true` (сверка по «Итого»), нарушений нет, `status = done` +#### Scenario: «Итого» свёрнута, корень и «Итого развернутое» развёрнуты +- **WHEN** корневая строка счёта несёт развёрнутое сальдо (Дт и Кт), «Итого» свёрнута, «Итого развернутое» равна корню по колонкам, а обороты и нетто сальдо «Итого» совпадают с корнем (либо «Итого развернутое» нет, «Итого» свёрнута и совпадает с корнем по нетто) +- **THEN** `total_row_matches_root = true`, предупреждения `OSV_TOTAL` нет; при расхождении нетто сальдо или оборотов `total_row_matches_root = false` (AURORA-1708, #73) + #### Scenario: «Итого развернутое» без оборотов - **WHEN** у «Итого развернутое» заполнены только сальдо (обороты пусты), а у основной «Итого» тождество строки выполняется - **THEN** `balance_identity_violations` не содержит «Итого развернутое», `status = done`; при нарушении тождества у основной