Andrey Epifancev b5f0b9d773 ОСВ: запасной признак листа по заголовку таблицы (#81, AURORA-1717)
Лист без названия ведомости, не опознанный как форма, — ОСВ, если в первых 30 строках полный заголовок:
три группы, у каждой Дт и Кт рядом. Проверяется раньше слабого продолжения формы (R-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:48:46 +04:00

233 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Адаптер книги -> `FormCandidate`: план листов, склейка листов-продолжений, предупреждения (план 4, задача 5)."""
import pytest
from gridbuilder import at, sheet_from_rows, with_formats
from arm_statements import DocumentType, Severity, Source, default_rules
from src.parser.deadline import Deadline
from src.parser.detect import SheetKind, is_osv_sheet
from src.parser.grid import Sheet
from src.parser.rules import load_rules
from src.parser.statements.adapter import build_candidates, plan_sheets
from src.parser.statements.forms import StatementRules
NO_DEADLINE = Deadline(10_000.0)
STATEMENT = SheetKind.STATEMENT
CONTINUATION = SheetKind.STATEMENT_CONTINUATION
BALANCE_CODES = (1110, 1150, 1170, 1100)
INCOME_CODES = (2110, 2120, 2400)
@pytest.fixture(scope="module")
def rules() -> StatementRules:
return default_rules()
def _form(title: str, codes: tuple[int, ...], name: str, *, amounts: int = 3, marker: str | None = None) -> Sheet:
head = [[marker]] if marker else [[title]]
caption = at({1: "Показатель", 3: "Код", **{4 + i: f"На 3{i + 1}.12.2024" for i in range(amounts)}})
body = [at({1: "Статья", 3: code, **{4 + i: 10 + i for i in range(amounts)}}) for code in codes]
return sheet_from_rows([*head, caption, *body], name=name)
def test_balance_with_marked_continuation_and_income(rules: StatementRules) -> None:
balance = _form("Бухгалтерский баланс", BALANCE_CODES, "Баланс")
marked = _form("", (1210, 1230, 1600), "Баланс (2)", marker="Форма 0710001 с.2")
ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР")
candidates = build_candidates(
[(balance, STATEMENT), (marked, CONTINUATION), (ofr, STATEMENT)], rules, NO_DEADLINE, xlsb=False
)
assert [(c.type_hint, c.locator) for c in candidates] == [
(DocumentType.BALANCE, "sheet:Баланс,Баланс (2)"),
(DocumentType.INCOME, "sheet:ОФР"),
]
assert candidates[0].segment_breaks == (balance.n_rows,)
assert len({len(row) for row in candidates[0].rows}) == 1
assert candidates[0].source is Source.EXCEL
assert len(candidates[0].rows) == balance.n_rows + marked.n_rows
def test_refs_of_cells(rules: StatementRules) -> None:
rows = [at({1: "Бухгалтерский баланс"})] + [[] for _ in range(24)]
rows.append(at({3: "Код", 4: "На 31.12.2024"}))
rows.append(at({1: "Статья", 3: 1110, 4: 1}))
rows += [at({1: "Статья", 3: code, 4: 1}) for code in (1150, 1170, 1100)]
assert rows[26] == at({1: "Статья", 3: 1110, 4: 1})
for name, ref in (("Баланс", "Баланс!C27"), ("Бухгалтерский баланс. Лист 1", "'Бухгалтерский баланс. Лист 1'!C27")):
[candidate] = build_candidates([(sheet_from_rows(rows, name=name), STATEMENT)], rules, NO_DEADLINE, xlsb=False)
cells = [cell for row in candidate.rows for cell in row if cell is not None and cell.text == "1110"]
assert [cell.ref for cell in cells] == [ref]
def test_marker_type_conflict_is_separate_candidate(rules: StatementRules) -> None:
balance = _form("Бухгалтерский баланс", BALANCE_CODES, "Баланс")
other = _form("", INCOME_CODES, "Лист 2", marker="Форма 0710002 с.2")
first, second = build_candidates([(balance, STATEMENT), (other, CONTINUATION)], rules, NO_DEADLINE, xlsb=False)
assert first.warnings == ()
assert second.type_hint is DocumentType.INCOME
assert [(w.code, w.severity) for w in second.warnings] == [("CONTINUATION_TYPE_CONFLICT", Severity.ERROR)]
assert second.segment_breaks == ()
assert len(second.rows) == other.n_rows
def test_single_line_continuation_joins(rules: StatementRules) -> None:
ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР")
tail = sheet_from_rows([at({1: "Чистая прибыль", 3: 2400, 4: 1, 5: 2, 6: 3})], name="ОФР (2)")
[candidate] = build_candidates([(ofr, STATEMENT), (tail, CONTINUATION)], rules, NO_DEADLINE, xlsb=False)
assert candidate.segment_breaks == (ofr.n_rows,)
assert candidate.warnings == ()
def test_wider_sheet_with_three_codes_is_not_joined(rules: StatementRules) -> None:
balance = _form("Бухгалтерский баланс", BALANCE_CODES, "Баланс", amounts=3)
wide = sheet_from_rows(
[at({1: "Статья", 3: code, 4: 1, 5: 2, 6: 3, 7: 4}) for code in (1210, 1230, 1250)], name="Лист 2"
)
first, second = build_candidates([(balance, STATEMENT), (wide, CONTINUATION)], rules, NO_DEADLINE, xlsb=False)
assert first.segment_breaks == ()
assert second.type_hint is None
assert [(w.code, w.severity) for w in second.warnings] == [("SHEET_NOT_JOINED", Severity.ERROR)]
def test_lonely_continuation_has_no_warning(rules: StatementRules) -> None:
marked = _form("", (1210, 1230, 1600), "Баланс (2)", marker="Форма 0710001 с.2")
[candidate] = build_candidates([(marked, CONTINUATION)], rules, NO_DEADLINE, xlsb=False)
assert candidate.type_hint is DocumentType.BALANCE
assert candidate.warnings == ()
assert candidate.header.inn is None
assert len(candidate.rows) == marked.n_rows
def test_two_forms_on_one_sheet_share_locator(rules: StatementRules) -> None:
balance = [at({1: "Бухгалтерский баланс"}), at({3: "Код", 4: "На 31.12.2024"})]
balance += [at({1: "Статья", 3: code, 4: 1}) for code in BALANCE_CODES]
income = [at({1: "Отчет о финансовых результатах"}), at({3: "Код", 4: "За 2024"})]
income += [at({1: "Статья", 3: code, 4: 1}) for code in INCOME_CODES]
sheet = sheet_from_rows([*balance, *income], name="Формы")
candidates = build_candidates([(sheet, STATEMENT)], rules, NO_DEADLINE, xlsb=False)
assert [(c.type_hint, c.locator) for c in candidates] == [
(DocumentType.BALANCE, "sheet:Формы"),
(DocumentType.INCOME, "sheet:Формы"),
]
assert len(candidates[0].rows) == len(balance)
assert len(candidates[1].rows) == len(income)
def test_xlsb_and_unparsed_formats_warnings(rules: StatementRules) -> None:
balance = _form("Бухгалтерский баланс", BALANCE_CODES, "Баланс", amounts=1)
tail = _form("", (1210, 1230, 1250), "Баланс (2)", amounts=1, marker="Форма 0710001 с.2")
formats = {(row, 4): "0.0%" for row in range(3, balance.n_rows + 1)}
balance = with_formats(balance, formats)
tail = with_formats(tail, {(row, 4): "0.0%" for row in range(3, tail.n_rows + 1)})
ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР", amounts=1)
candidates = build_candidates(
[(balance, STATEMENT), (tail, CONTINUATION), (ofr, STATEMENT)], rules, NO_DEADLINE, xlsb=True
)
codes = [[w.code for w in c.warnings] for c in candidates]
assert codes == [["XLSB_NO_FORMATS", "NUMBER_FORMAT_UNPARSED"], ["XLSB_NO_FORMATS"]]
assert "«0.0%»" in candidates[0].warnings[1].message
assert candidates[0].warnings[1].message.count("0.0%") == 1
def test_plan_sheets_weak_continuation_only_after_form(rules: StatementRules) -> None:
ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР")
weak = sheet_from_rows([["Чистая прибыль", "2400", 10, 8]], name="2400")
notes = sheet_from_rows([["Примечания"], ["Текст пояснения к отчетности"]], name="Примечания")
osv = sheet_from_rows([["Оборотно-сальдовая ведомость"]], name="ОСВ")
def is_osv(sheet: Sheet) -> bool:
return sheet.name == "ОСВ"
plan = plan_sheets([ofr, weak], is_osv, rules, NO_DEADLINE)
assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT), ("2400", CONTINUATION)]
plan = plan_sheets([osv, weak], is_osv, rules, NO_DEADLINE)
assert (plan.osv, plan.statements, plan.other) == ((osv,), (), (weak,))
plan = plan_sheets([ofr, notes, weak], is_osv, rules, NO_DEADLINE)
assert [s.name for s in plan.other] == ["Примечания", "2400"]
class _CountingTable:
"""`osv_by_table` для `plan_sheets`: лист с именем из `osv` — ОСВ по таблице; запоминает, для каких листов звали."""
def __init__(self, *osv: str) -> None:
self.osv = set(osv)
self.asked: list[str] = []
def __call__(self, sheet: Sheet) -> bool:
self.asked.append(sheet.name)
return sheet.name in self.osv
def _no_osv(sheet: Sheet) -> bool:
return False
def test_plan_sheets_table_fallback_only_for_other(rules: StatementRules) -> None:
"""#81, D1: запасной признак не спрашивается у листа формы; лист с таблицей ОСВ — ОСВ, «Справка» — прочее."""
form = _form("Бухгалтерский баланс", BALANCE_CODES, "ББ")
table = sheet_from_rows([["НачалоДт"]], name="ОВ")
reference = sheet_from_rows([["Справка"], ["Аренда", 100]], name="Справка")
by_table = _CountingTable("ОВ")
plan = plan_sheets([form, table, reference], _no_osv, rules, NO_DEADLINE, osv_by_table=by_table)
assert plan.osv == (table,)
assert [(s.name, k) for s, k in plan.statements] == [("ББ", STATEMENT)]
assert plan.other == (reference,)
assert by_table.asked == ["ОВ", "Справка"]
def test_plan_sheets_table_fallback_before_weak_continuation(rules: StatementRules) -> None:
"""R-12: лист с таблицей ОСВ после формы — ОСВ, а не слабое продолжение; без таблицы — продолжение, как было
(Review Focus 4)."""
ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР")
weak = sheet_from_rows([["Чистая прибыль", "2400", 10, 8]], name="2400")
plan = plan_sheets([ofr, weak], _no_osv, rules, NO_DEADLINE, osv_by_table=_CountingTable())
assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT), ("2400", CONTINUATION)]
assert plan.osv == ()
plan = plan_sheets([ofr, weak], _no_osv, rules, NO_DEADLINE, osv_by_table=_CountingTable("2400"))
assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT)]
assert plan.osv == (weak,)
def test_plan_sheets_without_table_fallback_is_unchanged(rules: StatementRules) -> None:
"""Без `osv_by_table` — прежнее поведение: «прочее» остаётся прочим."""
reference = sheet_from_rows([["Справка"], ["Аренда", 100]], name="Справка")
plan = plan_sheets([reference], _no_osv, rules, NO_DEADLINE)
assert (plan.osv, plan.statements, plan.other) == ((), (), (reference,))
def test_is_osv_sheet_by_marker() -> None:
rules = load_rules()
osv = sheet_from_rows([["Оборотно-сальдовая ведомость по счету 60"]])
assert is_osv_sheet(osv, rules)
assert not is_osv_sheet(sheet_from_rows([["Бухгалтерский баланс"]]), rules)
def test_unmarked_page_joins_separate_draft_without_open_form(rules: StatementRules) -> None:
"""M-1: черновик продолжения без открытой формы несёт число колонок сумм — стр. 3 без маркера присоединяется."""
page2 = _form("", (1210, 1230, 1600), "Баланс (2)", marker="Форма 0710001 с.2")
page3 = _form("", (1250, 1260, 1700), "Баланс (3)")
candidates = build_candidates([(page2, CONTINUATION), (page3, CONTINUATION)], rules, NO_DEADLINE, xlsb=False)
[candidate] = candidates
assert candidate.type_hint is DocumentType.BALANCE
assert candidate.segment_breaks == (page2.n_rows,)
assert candidate.warnings == ()
def test_unmarked_page_joins_draft_after_type_conflict(rules: StatementRules) -> None:
"""M-1: то же после CONTINUATION_TYPE_CONFLICT — следующий лист присоединяется к конфликтному черновику."""
balance = _form("Бухгалтерский баланс", BALANCE_CODES, "Баланс")
conflict = _form("", INCOME_CODES, "Лист 2", marker="Форма 0710002 с.2")
page3 = _form("", (2200, 2300, 2410), "Лист 3")
first, second = build_candidates(
[(balance, STATEMENT), (conflict, CONTINUATION), (page3, CONTINUATION)], rules, NO_DEADLINE, xlsb=False
)
assert first.warnings == ()
assert [w.code for w in second.warnings] == ["CONTINUATION_TYPE_CONFLICT"]
assert second.segment_breaks == (conflict.n_rows,)