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")