213 lines
10 KiB
Python
213 lines
10 KiB
Python
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")
|
||
|
||
|
||
# --- числа правее таблицы формы (решение владельца 18.10.2026): не колонки сумм, статус не портят ---
|
||
|
||
|
||
def test_numbers_right_of_table_do_not_break_status_in_repeated_segments(rules: Rules) -> None:
|
||
rows: list[list[str | None]] = [[*r, None] for r in bal_rows()]
|
||
rows[6][3] = "7" # формула аналитика правее таблицы, в строке БАЛАНС (1600)
|
||
rows[9][3] = "8"
|
||
rows.insert(7, [*HEAD, None]) # повторная шапка второго сегмента (страница 2 формы)
|
||
cand = ocr(rows, header=FULL_HEADER, type_hint=DocumentType.BALANCE)
|
||
doc = normalize_candidate(cand, rules).document
|
||
assert doc is not None and doc.status is DocStatus.DONE
|
||
assert [c.column_index for c in doc.columns] == [2]
|
||
assert [(w.code, w.severity) for w in doc.warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)]
|
||
assert all(len(line.values) == 1 for line in doc.lines)
|