"""Сборка `statementDocument` из таблицы OCR (spec «Нормализация ответа ИС», D-6, D-7).""" from datetime import date from decimal import Decimal from pathlib import Path from src.normalizer.ocr.document import BuiltDocument, build_document from src.normalizer.ocr.header import FileHeader from src.normalizer.rules import load_rules API_DIR = Path(__file__).resolve().parents[3] RULES = load_rules(API_DIR / "rules") HEADER_2024 = FileHeader( "ООО Ромашка", 100.0, "7700000000", 100.0, "тыс. руб.", "на 31 декабря 2024 г.", date(2024, 12, 31), True ) def _row(index: int, *cells: tuple[int, str, float]) -> dict: return {"row_index": index, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]} def _table(label: str, *rows: dict) -> dict: return {"label": {"form_name": label, "confidence": 100}, "rows": list(rows)} CLEAN_BALANCE = _table( "Бухгалтерский баланс", _row( 0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 31 декабря 2024 г.", 100), (3, "На 31 декабря 2023 г.", 100), ), _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1 234", 100), (3, "(56)", 100)), _row(2, (0, "Итого по разделу I", 100), (1, "1100", 100), (2, "1 234", 100), (3, "-", 100)), ) def _build(table: dict, header: FileHeader = HEADER_2024, index: int = 0) -> BuiltDocument: built = build_document(table, index, "job.json", header, RULES) assert built is not None return built class TestCleanTable: def test_document_fields(self) -> None: built = _build(CLEAN_BALANCE) doc = built.document assert doc.document_type == "BALANCE" assert doc.locator == "file:job.json;table:0" assert doc.header.form_name == "Бухгалтерский баланс" assert doc.header.inn == "7700000000" and doc.header.inn_confidence == 100 assert doc.header.report_date == date(2024, 12, 31) and doc.header.report_date_text == "на 31 декабря 2024 г." assert doc.header.units == "тыс. руб." and doc.header.okud is None assert [(c.column_index, c.label, c.period_date) for c in doc.columns] == [ (0, "На 31 декабря 2024 г.", date(2024, 12, 31)), (1, "На 31 декабря 2023 г.", date(2023, 12, 31)), ] assert doc.checks is None def test_lines_and_values(self) -> None: doc = _build(CLEAN_BALANCE).document assert [line.line_code for line in doc.lines] == ["1150", "1100"] first = doc.lines[0] assert ( first.line_name == "Основные средства" and first.line_code_confidence == 100 and first.line_name_confidence == 100 ) values = first.values assert [v.column_index for v in values] == [0, 1] assert ( values[0].amount == Decimal("1234") and values[0].string_value is None and values[0].is_recognized is True ) assert values[1].amount == Decimal("-56") and values[1].is_recognized is True blank = doc.lines[1].values[1] assert blank.amount is None and blank.string_value is None and blank.is_recognized is True def test_fully_recognized_and_edition(self) -> None: built = _build(CLEAN_BALANCE) assert built.fully_recognized is True and built.warnings == [] and built.edition_id == "66n" class TestNoise: def test_unknown_code_uses_ocr_name_and_caps_confidence(self) -> None: table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "Расшифровка", 100), (1, "11501", 100), (2, "1", 100), (3, "2", 100)), _row(2, (0, "ОС", 100), (1, "1150", 90), (2, "1", 100), (3, "2", 100)), ) built = _build(table) doc = built.document # кода нет в справочнике — имя из OCR, уверенность кода не выше 99 (spec: «иначе min(confidence, 99)») assert doc.lines[0].line_code == "11501" and doc.lines[0].line_name == "Расшифровка" assert doc.lines[0].line_code_confidence == 99 and doc.lines[0].line_name_confidence == 100 assert doc.lines[1].line_code == "1150" and doc.lines[1].line_code_confidence == 90 # уже < 100, не поднимается assert doc.lines[1].line_name == "Основные средства" assert built.fully_recognized is False def test_non_integer_code_row_is_dropped(self) -> None: """Перенос `_classify_row`: «1150 :» не целое число → строка считается повторным заголовком и пропускается.""" table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "НМА", 100), (1, "1110", 100), (2, "1", 100), (3, "2", 100)), _row(2, (0, "ОС", 100), (1, "1150 :", 100), (2, "1", 100), (3, "2", 100)), ) assert [line.line_code for line in _build(table).document.lines] == ["1110"] def test_duplicate_code_keeps_first(self) -> None: """Решение №15: DBML уникален по (meta_id, line_code) — повтор кода даёт одну строку и предупреждение.""" table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), _row(2, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100), (3, "8", 100)), ) built = _build(table) assert [line.line_code for line in built.document.lines] == ["1150"] assert built.document.lines[0].values[0].amount == Decimal("1") assert "код 1150 повторяется: оставлена первая строка" in built.warnings and built.fully_recognized is False def test_missing_cells_keep_column_count(self) -> None: """Review Focus 2: у строки нет ячеек под колонками — values всё равно по числу колонок.""" table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100))) values = _build(table).document.lines[0].values assert [(v.column_index, v.amount, v.string_value, v.confidence, v.is_recognized) for v in values] == [ (0, None, None, 0, False), (1, None, None, 0, False), ] def test_unverified_amount_is_not_recognized(self) -> None: table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "(7744536}", 100), (3, "x", 100)), ) values = _build(table).document.lines[0].values assert ( values[0].amount == Decimal("7744536") and values[0].is_recognized is False and values[0].string_value is None ) assert values[1].amount is None and values[1].is_recognized is False and values[1].string_value == "x" def test_synthetic_header_columns_without_dates(self) -> None: table = _table( "Бухгалтерский баланс", _row(0, (1, "1150", 100), (0, "ОС", 100), (2, "1", 100)), _row(1, (1, "1110", 100), (0, "НМА", 100), (2, "2", 100)), ) built = _build(table) assert [(c.column_index, c.label, c.period_date) for c in built.document.columns] == [(0, "", None)] assert "шапка синтезирована" in " ".join(built.warnings) and built.fully_recognized is False class TestTypeAndEdition: def test_type_by_codes_when_label_unknown(self) -> None: built = _build(_table("Бvxгалтерский баланс", *CLEAN_BALANCE["rows"])) assert built.document.document_type == "BALANCE" and "тип определён по кодам" in built.warnings def test_label_wins_over_codes_with_warning(self) -> None: built = _build(_table("Отчет о финансовых результатах", *CLEAN_BALANCE["rows"])) assert built.document.document_type == "INCOME" assert any(w.startswith("тип по кодам (BALANCE) не совпал с label") for w in built.warnings) def test_other_when_nothing_known(self) -> None: table = _table( "Приложение", CLEAN_BALANCE["rows"][0], _row(1, (0, "a", 100), (1, "5100", 100), (2, "1", 100), (3, "2", 100)), _row(2, (0, "b", 100), (1, "5200", 100), (2, "1", 100), (3, "2", 100)), ) built = _build(table) assert built.document.document_type == "OTHER" and "тип не определён: OTHER" in built.warnings def test_edition_2025_is_empty(self) -> None: header = FileHeader("ООО", 100.0, "7700000000", 100.0, "тыс. руб.", None, None, False) table = _table( "Бухгалтерский баланс", _row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 30 июня 2025 г.", 100)), _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100)), ) built = _build(table, header) assert built.edition_id == "fsbu4" and "редакция fsbu4 не заполнена" in built.warnings assert built.document.lines[0].line_name == "Основные средства" # из OCR assert built.document.lines[0].line_name_confidence == 100 # confidence ячейки наименования assert built.fully_recognized is False def test_edition_by_codes_when_no_dates(self) -> None: header = FileHeader(None, None, None, None, None, None, None, False) table = _table("Бухгалтерский баланс", _row(0, (1, "1150", 100), (0, "ОС", 100), (2, "1", 100))) built = _build(table, header) assert built.edition_id == "66n" and "редакция определена по кодам: 66n" in built.warnings def test_unparsed_table_is_none(self) -> None: assert ( build_document(_table("x", _row(0, (0, "a", 100), (1, "b", 100))), 0, "job.json", HEADER_2024, RULES) is None ) def test_missing_label_omits_form_name() -> None: """Контракт: `form_name` без `null` — при отсутствии label поле не задаётся, сборка не падает.""" table = {"rows": CLEAN_BALANCE["rows"]} built = _build(table) assert built.document.header.form_name is None assert "form_name" not in built.document.to_wire()["header"] def test_table_rows_not_list_is_none() -> None: assert build_document({"label": {"form_name": "x"}, "rows": "oops"}, 0, "job.json", HEADER_2024, RULES) is None