Andrey Epifancev c858a85d85 feat(statements): адаптер шага 1 — книга → FormCandidate, склейка листов, segment_breaks (#54)
Тест test_npz_candidates красный: НПЗ — BLOCKED (Р-31), см. отчёт задачи 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 20:24:47 +04:00

154 lines
8.5 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)
assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT), ("2400", CONTINUATION)]
plan = plan_sheets([osv, weak], is_osv, rules)
assert (plan.osv, plan.statements, plan.other) == ((osv,), (), (weak,))
plan = plan_sheets([ofr, notes, weak], is_osv, rules)
assert [s.name for s in plan.other] == ["Примечания", "2400"]
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)