refactor(osv): предупреждения листа ОСВ с кодами OSV_* (вывод 1.x прежний) (#54)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 20:35:29 +04:00
parent ebc1f438da
commit 702ea1dff9
8 changed files with 221 additions and 33 deletions

View File

@ -19,6 +19,7 @@ from src.parser.detect import SheetKind, classify_sheet
from src.parser.errors import InvalidDocumentError, ParserError, UnsupportedFormatError from src.parser.errors import InvalidDocumentError, ParserError, UnsupportedFormatError
from src.parser.grid import Sheet from src.parser.grid import Sheet
from src.parser.osv import OsvParser, data_rows, sheet_line, unique from src.parser.osv import OsvParser, data_rows, sheet_line, unique
from src.parser.osv_warnings import render_legacy
from src.parser.readers import read_book_detected from src.parser.readers import read_book_detected
from src.parser.readers._common import one_line_reason from src.parser.readers._common import one_line_reason
from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR
@ -118,7 +119,7 @@ class BookParser:
warnings.append(sheet_line(sheet, "skipped: нет признаков ОСВ или формы")) warnings.append(sheet_line(sheet, "skipped: нет признаков ОСВ или формы"))
continue continue
try: try:
document, sheet_warnings, sheet_ok = self._osv.parse_sheet(sheet, deadline) result = self._osv.parse_sheet(sheet, deadline)
except ParserError: except ParserError:
# T7.1: ответ с ошибкой называет листы форм, которые из-за неё остались неразобранными # T7.1: ответ с ошибкой называет листы форм, которые из-за неё остались неразобранными
warnings.extend( warnings.extend(
@ -126,9 +127,9 @@ class BookParser:
for form_sheet, _ in statement_sheets for form_sheet, _ in statement_sheets
) )
raise raise
osv_documents.append(document) osv_documents.append(result.document)
warnings.extend(sheet_warnings) warnings.extend(render_legacy(sheet.name, result.notes))
ok = ok and sheet_ok ok = ok and result.ok
statement_documents, statement_warnings, statements_ok = self._statements.parse_sheets( statement_documents, statement_warnings, statements_ok = self._statements.parse_sheets(
statement_sheets, deadline, formats_unreadable=read.detected == "XLSB" statement_sheets, deadline, formats_unreadable=read.detected == "XLSB"
) )

View File

@ -30,6 +30,7 @@ from decimal import Decimal
from src.contract.result import OsvChecks from src.contract.result import OsvChecks
from src.parser.classify import SUM_FIELDS, ClassifiedRow from src.parser.classify import SUM_FIELDS, ClassifiedRow
from src.parser.osv_warnings import OSV_BALANCE_IDENTITY, OSV_PARENT_SUM, OSV_TOTAL, OsvNote
from src.parser.rules import OsvCheckRules from src.parser.rules import OsvCheckRules
__all__ = [ __all__ = [
@ -216,23 +217,23 @@ def _total_warning(rows: Sequence[ClassifiedRow]) -> str:
return message return message
def run_checks(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> tuple[OsvChecks, list[str]]: def run_checks(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> tuple[OsvChecks, list[OsvNote]]:
"""Три проверки §1.4 и предупреждения с номерами строк (пустой список — всё сошлось). """Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось).
`exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью. `exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью.
""" """
identity = balance_identity(rows, exempt) identity = balance_identity(rows, exempt)
parents = parent_sums(rows, exempt) parents = parent_sums(rows, exempt)
matches = total_matches_root(rows) matches = total_matches_root(rows)
warnings: list[str] = [] warnings: list[OsvNote] = []
if identity: if identity:
warnings.append( text = f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(identity)}"
f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(identity)}" warnings.append(OsvNote(OSV_BALANCE_IDENTITY, text))
)
if parents: if parents:
warnings.append(f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}") text = f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}"
warnings.append(OsvNote(OSV_PARENT_SUM, text))
if not matches: if not matches:
warnings.append(_total_warning(rows)) warnings.append(OsvNote(OSV_TOTAL, _total_warning(rows)))
checks = OsvChecks( checks = OsvChecks(
total_row_matches_root=matches, total_row_matches_root=matches,
balance_identity_violations=identity, balance_identity_violations=identity,

View File

@ -7,7 +7,7 @@
import datetime import datetime
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
from dataclasses import replace from dataclasses import dataclass, replace
from src.contract import OsvDocument from src.contract import OsvDocument
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
@ -20,9 +20,18 @@ from src.parser.grid import Sheet, column_letter
from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header
from src.parser.hierarchy import resolve_levels from src.parser.hierarchy import resolve_levels
from src.parser.inn_rows import InnGlue, glue_inn_rows from src.parser.inn_rows import InnGlue, glue_inn_rows
from src.parser.osv_warnings import (
OSV_HEADER,
OSV_INN_ROWS,
OSV_NO_DATA_ROWS,
OSV_ROWS,
OsvNote,
hierarchy_note,
notes_of,
)
from src.parser.rules import Rules from src.parser.rules import Rules
__all__ = ["OsvParser", "data_rows", "sheet_line", "unique"] __all__ = ["OsvParser", "SheetResult", "data_rows", "sheet_line", "unique"]
LOCATOR_PREFIX = "sheet:" LOCATOR_PREFIX = "sheet:"
INN_COLUMN_KEY = "inn" INN_COLUMN_KEY = "inn"
@ -120,6 +129,14 @@ def data_rows(document: OsvDocument) -> int:
return sum(1 for row in document.rows if row.group_type != "TOTAL") return sum(1 for row in document.rows if row.group_type != "TOTAL")
def _unique_notes(notes: Iterable[OsvNote]) -> list[OsvNote]:
"""Без повторов по тексту, в порядке первого появления (как `unique` для строк вывода 1.x)."""
seen: dict[str, OsvNote] = {}
for note in notes:
seen.setdefault(note.message, note)
return list(seen.values())
def _invalid_format(sheet: Sheet, step: str, exc: InvalidFormatError) -> InvalidFormatError: def _invalid_format(sheet: Sheet, step: str, exc: InvalidFormatError) -> InvalidFormatError:
"""Сообщение `INVALID_FORMAT` с листом и шагом; префикс «лист «…»:» детектора не дублируется.""" """Сообщение `INVALID_FORMAT` с листом и шагом; префикс «лист «…»:» детектора не дублируется."""
reason = exc.message.removeprefix(f"лист «{sheet.name}»: ") reason = exc.message.removeprefix(f"лист «{sheet.name}»: ")
@ -140,15 +157,24 @@ def _check_inn_owners(glue: InnGlue, classified: list[ClassifiedRow]) -> InnGlue
return replace(glue, warnings=[*glue.warnings, *extra], ok=False) return replace(glue, warnings=[*glue.warnings, *extra], ok=False)
@dataclass(frozen=True)
class SheetResult:
"""Итог листа ОСВ: документ, заметки с кодами `OSV_*` (первая — иерархия) и признак «контрольные суммы сошлись»."""
document: OsvDocument
notes: tuple[OsvNote, ...]
ok: bool
class OsvParser: class OsvParser:
"""Конвейер листа ОСВ по словарям `rules`; книгу разбирает `BookParser`.""" """Конвейер листа ОСВ по словарям `rules`; книгу разбирает `BookParser`."""
def __init__(self, rules: Rules) -> None: def __init__(self, rules: Rules) -> None:
self._rules = rules self._rules = rules
def parse_sheet(self, sheet: Sheet, deadline: Deadline) -> tuple[OsvDocument, list[str], bool]: def parse_sheet(self, sheet: Sheet, deadline: Deadline) -> SheetResult:
"""Документ листа ОСВ, его предупреждения (первое — `sheet=<имя> hierarchy=<стратегия>`) и признак """Документ листа ОСВ, его заметки (первая — иерархия) и признак «контрольные суммы сошлись»;
«контрольные суммы сошлись»; нет заголовка/колонок/иерархии — `InvalidFormatError` с листом и шагом. нет заголовка/колонок/иерархии — `InvalidFormatError` с листом и шагом.
""" """
rules = self._rules rules = self._rules
step = "заголовок таблицы" step = "заголовок таблицы"
@ -191,16 +217,21 @@ class OsvParser:
glue = _check_inn_owners(glue, classified) glue = _check_inn_owners(glue, classified)
doc = _with_table_account(doc, classified) doc = _with_table_account(doc, classified)
deadline.check(f"лист «{sheet.name}»: контрольные суммы") deadline.check(f"лист «{sheet.name}»: контрольные суммы")
checks, check_warnings = run_checks(classified, rules.checks) checks, check_notes = run_checks(classified, rules.checks)
document = self._document( document = self._document(
sheet=sheet, header=header, doc=doc, schema=schema.groups, rows=classified, checks=checks sheet=sheet, header=header, doc=doc, schema=schema.groups, rows=classified, checks=checks
) )
warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings, *glue.warnings] notes = [
hierarchy_note(levels.strategy),
*notes_of(OSV_HEADER, doc.warnings),
*notes_of(OSV_ROWS, body_warnings),
*notes_of(OSV_INN_ROWS, glue.warnings),
]
if not any(item.group_type != "TOTAL" for item in classified): if not any(item.group_type != "TOTAL" for item in classified):
warnings.append(NO_DATA_ROWS_WARNING) notes.append(OsvNote(OSV_NO_DATA_ROWS, NO_DATA_ROWS_WARNING))
warnings.extend(check_warnings) notes.extend(check_notes)
# Строка ИНН без пары, похожая на ИНН строка или расхождение с колонкой (#35) — needs_check # Строка ИНН без пары, похожая на ИНН строка или расхождение с колонкой (#35) — needs_check
return document, unique(warnings), _is_ok(checks) and glue.ok return SheetResult(document, tuple(_unique_notes(notes)), _is_ok(checks) and glue.ok)
# --- внутреннее --- # --- внутреннее ---

View File

@ -0,0 +1,63 @@
"""Предупреждения листа ОСВ с кодами `OSV_*` (P4-12): код — по источнику заметки, уровень info.
Пока вывод 1.x прежний: `render_legacy` собирает из заметок те же строки `metrics.warnings`, что были до кодов
(текст меняет задача 7). Заметка читателя «xls без кодовой страницы» — не код ОСВ, а предупреждение файла
`XLS_NO_CODEPAGE` пакета (P4-13, R-2); здесь её нет.
"""
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
__all__ = [
"OSV_BALANCE_IDENTITY",
"OSV_HEADER",
"OSV_HIERARCHY_PREFIX",
"OSV_INN_ROWS",
"OSV_NO_DATA_ROWS",
"OSV_PARENT_SUM",
"OSV_ROWS",
"OSV_TOTAL",
"OSV_XLSB_NO_OUTLINE",
"OsvNote",
"hierarchy_note",
"legacy_line",
"notes_of",
"render_legacy",
]
OSV_HIERARCHY_PREFIX = "OSV_HIERARCHY_"
OSV_HEADER = "OSV_HEADER"
OSV_ROWS = "OSV_ROWS"
OSV_INN_ROWS = "OSV_INN_ROWS"
OSV_NO_DATA_ROWS = "OSV_NO_DATA_ROWS"
OSV_BALANCE_IDENTITY = "OSV_BALANCE_IDENTITY"
OSV_PARENT_SUM = "OSV_PARENT_SUM"
OSV_TOTAL = "OSV_TOTAL"
OSV_XLSB_NO_OUTLINE = "OSV_XLSB_NO_OUTLINE" # заметка читателя xlsb, подключается в задаче 7
@dataclass(frozen=True)
class OsvNote:
code: str
message: str
def hierarchy_note(strategy: str) -> OsvNote:
"""Стратегия иерархии: `outline|indent|account_pattern|sums|trivial` → `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`."""
return OsvNote(f"{OSV_HIERARCHY_PREFIX}BY_{strategy.upper()}", f"hierarchy={strategy}")
def notes_of(code: str, messages: Iterable[str]) -> list[OsvNote]:
return [OsvNote(code, message) for message in messages]
def legacy_line(sheet_name: str, note: OsvNote) -> str:
return f"sheet={sheet_name} {note.message}"
def render_legacy(sheet_name: str, notes: Sequence[OsvNote]) -> list[str]:
"""Строки вывода 1.x: заметка иерархии с префиксом листа, остальные — как были; без повторов, порядок прежний."""
lines = [
legacy_line(sheet_name, note) if note.code.startswith(OSV_HIERARCHY_PREFIX) else note.message for note in notes
]
return list(dict.fromkeys(lines))

View File

@ -79,7 +79,8 @@ def test_tolerance_is_one_kopeck() -> None:
def test_consistent_tree_has_no_violations() -> None: def test_consistent_tree_has_no_violations() -> None:
checks, warnings = run_checks(tree()) checks, notes = run_checks(tree())
warnings = [note.message for note in notes]
assert checks.total_row_matches_root is True assert checks.total_row_matches_root is True
assert checks.balance_identity_violations == [] assert checks.balance_identity_violations == []
@ -250,7 +251,8 @@ def multi_root_tree(total_sums: Sums = SUB_SUM) -> list[ClassifiedRow]:
def test_total_matches_sum_of_level_zero_rows_when_there_are_several_roots() -> None: def test_total_matches_sum_of_level_zero_rows_when_there_are_several_roots() -> None:
rows = multi_root_tree() rows = multi_root_tree()
checks, warnings = run_checks(rows) checks, notes = run_checks(rows)
warnings = [note.message for note in notes]
assert total_matches_root(rows) is True assert total_matches_root(rows) is True
assert checks.total_row_matches_root is True assert checks.total_row_matches_root is True
@ -288,7 +290,8 @@ def test_expanded_balance_total_is_not_matched_by_net_when_total_has_both_sides(
def test_total_that_equals_neither_first_root_nor_sum_of_roots_mismatches() -> None: def test_total_that_equals_neither_first_root_nor_sum_of_roots_mismatches() -> None:
rows = multi_root_tree(("140", None, "40", "20", "161", None)) rows = multi_root_tree(("140", None, "40", "20", "161", None))
checks, warnings = run_checks(rows) checks, notes = run_checks(rows)
warnings = [note.message for note in notes]
assert checks.total_row_matches_root is False assert checks.total_row_matches_root is False
assert any("Итого" in warning and "11" in warning and "сумме 2 строк уровня 0" in warning for warning in warnings) assert any("Итого" in warning and "11" in warning and "сумме 2 строк уровня 0" in warning for warning in warnings)
@ -326,7 +329,8 @@ def test_missing_total_fails_root_check_with_warning() -> None:
"""R-20: без «Итого» сверку выполнить нельзя — `false` и предупреждение.""" """R-20: без «Итого» сверку выполнить нельзя — `false` и предупреждение."""
rows = tree()[:-1] rows = tree()[:-1]
checks, warnings = run_checks(rows) checks, notes = run_checks(rows)
warnings = [note.message for note in notes]
assert total_matches_root(rows) is False assert total_matches_root(rows) is False
assert checks.total_row_matches_root is False assert checks.total_row_matches_root is False
@ -337,7 +341,8 @@ def test_run_checks_warnings_name_row_numbers() -> None:
rows = tree() rows = tree()
rows[2] = row(9, ("40", None, "30", "20", "51", None), level=1, parent=7) rows[2] = row(9, ("40", None, "30", "20", "51", None), level=1, parent=7)
checks, warnings = run_checks(rows) checks, notes = run_checks(rows)
warnings = [note.message for note in notes]
assert checks.balance_identity_violations == [9] assert checks.balance_identity_violations == [9]
assert checks.parent_sum_violations == [7] assert checks.parent_sum_violations == [7]
@ -350,7 +355,8 @@ def test_run_checks_reports_total_mismatch_with_row_numbers() -> None:
rows = tree() rows = tree()
rows[-1] = total(10, ("100", None, "50", "30", "121", None)) rows[-1] = total(10, ("100", None, "50", "30", "121", None))
checks, warnings = run_checks(rows) checks, notes = run_checks(rows)
warnings = [note.message for note in notes]
assert checks.total_row_matches_root is False assert checks.total_row_matches_root is False
assert any("10" in warning and "7" in warning and "Итого" in warning for warning in warnings), warnings assert any("10" in warning and "7" in warning and "Итого" in warning for warning in warnings), warnings
@ -361,7 +367,8 @@ def test_run_checks_warning_lists_are_capped() -> None:
broken: Sums = ("1", None, None, None, "2", None) broken: Sums = ("1", None, None, None, "2", None)
rows = [row(number, broken) for number in range(1, 101)] rows = [row(number, broken) for number in range(1, 101)]
checks, warnings = run_checks(rows) checks, notes = run_checks(rows)
warnings = [note.message for note in notes]
assert checks.balance_identity_violations == list(range(1, 101)) assert checks.balance_identity_violations == list(range(1, 101))
(identity,) = [warning for warning in warnings if "сальдо" in warning] (identity,) = [warning for warning in warnings if "сальдо" in warning]
@ -431,7 +438,8 @@ def test_total_row_is_still_checked_on_such_accounts() -> None:
rows[-1] = total(9, (None, None, "31", "30", None, None)) rows[-1] = total(9, (None, None, "31", "30", None, None))
assert total_matches_root(rows) is False assert total_matches_root(rows) is False
checks, warnings = run_checks(rows, NO_SALDO) checks, notes = run_checks(rows, NO_SALDO)
warnings = [note.message for note in notes]
assert checks.total_row_matches_root is False assert checks.total_row_matches_root is False
assert any("Итого" in warning for warning in warnings) assert any("Итого" in warning for warning in warnings)
@ -449,7 +457,8 @@ def test_exemption_is_matched_by_account_root_not_by_prefix_of_digits() -> None:
def test_run_checks_without_violations_on_account_91_gives_clean_status() -> None: def test_run_checks_without_violations_on_account_91_gives_clean_status() -> None:
checks, warnings = run_checks(tree_91(), NO_SALDO) checks, notes = run_checks(tree_91(), NO_SALDO)
warnings = [note.message for note in notes]
assert checks.balance_identity_violations == [] assert checks.balance_identity_violations == []
assert checks.parent_sum_violations == [] assert checks.parent_sum_violations == []

View File

@ -39,7 +39,9 @@ def test_inn_glued_to_row_that_is_not_a_counterparty_needs_check(rules: Rules) -
] ]
) )
document, warnings, ok = OsvParser(rules).parse_sheet(sheet, Deadline(TIMEOUT_SECONDS)) result = OsvParser(rules).parse_sheet(sheet, Deadline(TIMEOUT_SECONDS))
document, ok = result.document, result.ok
warnings = [note.message for note in result.notes]
assert [(row.row_value, row.group_type) for row in document.rows][1:2] == [("ООО Альфа", "DEPARTMENT")] assert [(row.row_value, row.group_type) for row in document.rows][1:2] == [("ООО Альфа", "DEPARTMENT")]
assert not ok assert not ok

View File

@ -49,7 +49,8 @@ def r(level: int, text: object, sums: Sums) -> list[object]:
def _parse(sheet: Sheet, rules: Rules) -> tuple[OsvDocument, list[str], bool]: def _parse(sheet: Sheet, rules: Rules) -> tuple[OsvDocument, list[str], bool]:
return OsvParser(rules).parse_sheet(sheet, Deadline(TIMEOUT_SECONDS)) result = OsvParser(rules).parse_sheet(sheet, Deadline(TIMEOUT_SECONDS))
return result.document, [note.message for note in result.notes], result.ok
def _values(document: OsvDocument) -> list[str]: def _values(document: OsvDocument) -> list[str]:

View File

@ -0,0 +1,80 @@
"""Предупреждения ОСВ с кодами `OSV_*` (P4-12): коды по источнику, вывод 1.x прежний."""
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, hierarchy_note, legacy_line, render_legacy
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", "hierarchy=sums")
assert hierarchy_note("account_pattern").code == "OSV_HIERARCHY_BY_ACCOUNT_PATTERN"
def test_legacy_line_and_render() -> None:
note = hierarchy_note("outline")
assert legacy_line("Лист1", note) == "sheet=Лист1 hierarchy=outline"
other = OsvNote("OSV_ROWS", "текст")
assert render_legacy("Лист1", [note, other, other]) == ["sheet=Лист1 hierarchy=outline", "текст"]
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_legacy_lines_are_unchanged(rules: Rules) -> None:
sheet = _sheet([L(0), "60.01", *BROKEN], [L(1), "ООО Альфа", *SUMS], [L(0), "Итого", *BROKEN])
result = OsvParser(rules).parse_sheet(sheet, Deadline(60))
lines = render_legacy(sheet.name, result.notes)
assert lines == [
"sheet=Лист_1 hierarchy=outline",
"валюта в шапке не найдена",
"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 9, 11",
"§1.4: суммы родителя ≠ сумме детей (±0.01): строки 9",
]