This repository has been archived on 2026-10-26. You can view files and clone it, but cannot push or open issues or pull requests.
Andrey Epifancev 29198ddc44 feat: шаг 6 и конвейер кандидата — тип по кодам, вариант формы, пояснения, предупреждения файла
normalize_candidate / normalize; __init__ экспортирует уже реализованный публичный API.
DISCLOSURE: шапка не проверяется, предупреждений шапки нет, статус done (P1-6).

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

197 lines
9.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.

from decimal import Decimal as D
from arm_statements import document_status, normalize, normalize_candidate, status_input
from arm_statements.issues import make_warning
from arm_statements.loader import Rules
from arm_statements.model import DocStatus, DocumentType, FormVariant, Severity
from arm_statements.tests.builders import excel, hdr, ocr
HEAD = ["Наименование показателя", "Код", "На 30.09.2025"]
FULL_CONFIDENCE = 100.0
FULL_HEADER = hdr(organization_name="ООО «Пример»", inn="7700000000", report_date="30.09.2025", units="тыс. руб.")
def bal_rows() -> list[list[str]]:
return [
HEAD,
["Запасы", "1210", "60"],
["Дебиторы", "1230", "40"],
["Итого II", "1200", "100"],
["НМА", "1110", "50"],
["Итого I", "1100", "50"],
["БАЛАНС", "1600", "150"],
["Капитал", "1310", "150"],
["Итого III", "1300", "150"],
["БАЛАНС", "1700", "150"],
]
def test_clean_ocr_balance_done(rules: Rules) -> None:
cand = ocr(bal_rows(), header=FULL_HEADER, type_hint=DocumentType.BALANCE, title="Бухгалтерский баланс")
result = normalize_candidate(cand, rules)
doc = result.document
assert doc is not None and result.file_warnings == ()
assert doc.header.form_name == "Бухгалтерский баланс"
assert document_status(status_input(doc)) is doc.status
assert (doc.document_type, doc.form_variant, doc.status) == (DocumentType.BALANCE, FormVariant.FULL, DocStatus.DONE)
assert doc.checks is not None and doc.checks.relations_failed == ()
assert doc.header.inn.value == "7700000000"
def test_disclosure_hint_with_balance_codes(rules: Rules) -> None:
doc = normalize_candidate(ocr(bal_rows(), header=FULL_HEADER, type_hint=DocumentType.DISCLOSURE), rules).document
assert doc is not None and doc.document_type is DocumentType.BALANCE
assert [(w.code, w.severity) for w in doc.warnings if w.code == "TYPE_CONFLICT"] == [
("TYPE_CONFLICT", Severity.ERROR)
]
def test_balance_hint_with_income_codes(rules: Rules) -> None:
rows = [
HEAD,
["Выручка", "2110", "100"],
["Себестоимость", "2120", "(60)"],
["Валовая", "2100", "40"],
["Коммерческие", "2210", "(10)"],
["Прибыль от продаж", "2200", "30"],
]
doc = normalize_candidate(ocr(rows, header=FULL_HEADER, type_hint=DocumentType.BALANCE), rules).document
assert doc is not None and doc.document_type is DocumentType.INCOME
assert "TYPE_CONFLICT" in [w.code for w in doc.warnings]
def test_excel_other_sheet(rules: Rules) -> None:
result = normalize_candidate(excel([("Примечания", [["Пояснительный текст без цифр"]])]), rules)
assert result.document is None
assert [(w.code, w.severity, w.locator) for w in result.file_warnings] == [
("SHEET_OTHER", Severity.INFO, "sheet:Примечания")
]
def test_ocr_lost_and_skipped_tables(rules: Rules) -> None:
lost = normalize_candidate(ocr([["Итого", "", "15 000"]], locator="file:a.json;table:1"), rules)
assert [(w.code, w.locator) for w in lost.file_warnings] == [("TABLE_LOST", "file:a.json;table:1")]
skipped = normalize_candidate(ocr([["Руководитель", "Иванов"]]), rules)
assert [w.code for w in skipped.file_warnings] == ["TABLE_SKIPPED"] and skipped.document is None
def test_hint_without_code_column(rules: Rules) -> None:
doc = normalize_candidate(
ocr([["Итого", "15 000"]], type_hint=DocumentType.BALANCE, header=FULL_HEADER), rules
).document
assert doc is not None and (doc.lines, doc.columns, doc.checks) == ((), (), None)
assert doc.form_variant is FormVariant.FULL
assert doc.status is DocStatus.NEEDS_CHECK and "CODE_COLUMN_NOT_FOUND" in [w.code for w in doc.warnings]
disc = normalize_candidate(ocr([["Текст"]], type_hint=DocumentType.DISCLOSURE), rules).document
assert disc is not None and (disc.status, disc.form_variant, disc.lines) == (DocStatus.DONE, None, ())
assert disc.header.form_name is None
def test_disclosure_by_codes(rules: Rules) -> None:
rows = [HEAD, ["Пояснение", "5101", "1"], ["Пояснение", "5102", "2"], ["Пояснение", "5100", "3"]]
doc = normalize_candidate(ocr(rows, header=FULL_HEADER), rules).document
assert doc is not None and (doc.document_type, doc.lines, doc.status) == (
DocumentType.DISCLOSURE,
(),
DocStatus.DONE,
)
def test_simplified_balance(rules: Rules) -> None:
rows = [
HEAD,
["Матактивы", "1150", "50"],
["Запасы", "1210", "50"],
["БАЛАНС", "1600", "100"],
["Капитал", "1300", "100"],
["БАЛАНС", "1700", "100"],
]
doc = normalize_candidate(ocr(rows, header=FULL_HEADER), rules).document
assert doc is not None and doc.form_variant is FormVariant.SIMPLIFIED
assert doc.checks is not None and doc.checks.relations_failed == ()
def test_income_without_inn_still_done(rules: Rules) -> None:
rows: list[list[str | D]] = [
["Наименование", "Код", "За 9 месяцев 2025 г."],
["Выручка", "2110", D("100")],
["Себестоимость", "2120", D("-60")],
["Валовая прибыль", "2100", D("40")],
["Прибыль от продаж", "2200", D("40")],
["Прибыль до налогообложения", "2300", D("40")],
["Чистая прибыль", "2400", D("40")],
]
head = hdr(organization_name="ООО «Пример»", units="тыс. руб.")
doc = normalize_candidate(excel([("ОФР", rows)], header=head), rules).document
assert doc is not None and doc.status is DocStatus.DONE
assert [(w.code, w.severity) for w in doc.warnings] == [("INN_NOT_FOUND", Severity.INFO)]
def test_normalize_collects(rules: Rules) -> None:
result = normalize([ocr(bal_rows(), header=FULL_HEADER), ocr([["Руководитель", "Иванов"]])], rules)
assert len(result.documents) == 1 and [w.code for w in result.file_warnings] == ["TABLE_SKIPPED"]
# --- P1-6: у DISCLOSURE предупреждений шапки нет, статус DONE (требование ревью T15.1) ---
def test_disclosure_by_hint_empty_header_has_no_warnings(rules: Rules) -> None:
doc = normalize_candidate(ocr([["Текст"]], type_hint=DocumentType.DISCLOSURE), rules).document
assert doc is not None
assert (doc.warnings, doc.status) == ((), DocStatus.DONE)
def test_disclosure_by_codes_empty_header_has_no_warnings(rules: Rules) -> None:
rows = [HEAD, ["Пояснение", "5101", "1"], ["Пояснение", "5102", "2"], ["Пояснение", "5100", "3"]]
doc = normalize_candidate(ocr(rows), rules).document
assert doc is not None and doc.document_type is DocumentType.DISCLOSURE
assert (doc.warnings, doc.status) == ((), DocStatus.DONE)
# --- ROW_SPLIT: предупреждение документа без locator (место — locator документа, спека «Шаг 8 — предупреждения») ---
def test_row_split_has_no_locator(rules: Rules) -> None:
rows = bal_rows()
rows[1] = ["Запасы и дебиторы", "1210\n1230", "60\n40"]
doc = normalize_candidate(ocr(rows, header=FULL_HEADER, type_hint=DocumentType.BALANCE), rules).document
assert doc is not None
split = [w for w in doc.warnings if w.code == "ROW_SPLIT"]
assert len(split) == 1 and split[0].locator is None and split[0].severity is Severity.INFO
# --- строка «в тысячах рублей» над таблицей и уверенность даты отчёта у Excel ---
def test_units_row_above_table_is_not_header_and_excel_date_is_confident(rules: Rules) -> None:
rows = [["Бухгалтерский баланс"], ["в тысячах рублей"], *bal_rows()]
cand = excel([("Баланс", rows)], header=FULL_HEADER, type_hint=DocumentType.BALANCE)
doc = normalize_candidate(cand, rules).document
assert doc is not None
assert doc.header.report_date.confidence == FULL_CONFIDENCE and doc.header.report_date.value is not None
assert [c.column_index for c in doc.columns] == [2]
assert (doc.status, [w.code for w in doc.warnings]) == (DocStatus.DONE, [])
# --- порядок предупреждений документа: адаптера, раскладки, строк, единиц, TYPE_CONFLICT, шапки ---
def test_document_warnings_order(rules: Rules) -> None:
adapter = make_warning("NUMBER_FORMAT_UNPARSED", "формат", rules)
rows = [["Наименование", "Код", "Итого"], *bal_rows()[1:]]
rows[1] = ["Запасы и дебиторы", "1210\n1230", "60,5\n40,5"]
cand = excel([("Баланс", rows)], header=hdr(units="тыс. руб."), type_hint=DocumentType.INCOME, warnings=[adapter])
doc = normalize_candidate(cand, rules).document
assert doc is not None
codes = [w.code for w in doc.warnings]
expected = [
"NUMBER_FORMAT_UNPARSED",
"COLUMN_WITHOUT_DATE",
"ROW_SPLIT",
"FRACTIONAL_IN_THOUSANDS",
"TYPE_CONFLICT",
"NO_ORGANIZATION_NO_INN",
]
assert [c for c in codes if c in expected] == [c for c in expected if c in codes]
assert codes[0] == "NUMBER_FORMAT_UNPARSED" and codes.index("TYPE_CONFLICT") < codes.index("NO_ORGANIZATION_NO_INN")