diff --git a/api/tests/fixtures/test_statements_property.py b/api/tests/fixtures/test_statements_property.py index f82553f..7dad1c8 100644 --- a/api/tests/fixtures/test_statements_property.py +++ b/api/tests/fixtures/test_statements_property.py @@ -17,29 +17,20 @@ EXPECTED_FILES = 18 MAX_PARSE_SECONDS = 5 -def document_ok(document: StatementDocument) -> bool: - """Документ пригоден (done): критерий `StatementParser._is_ok`, выраженный полями результата.""" - if document.document_type == "DISCLOSURE": - return True - checks = document.checks - return ( - checks is not None - and bool(document.columns) - and bool(document.header.organization_name or document.header.inn) - and not checks.relations_failed - and not checks.duplicate_line_codes - ) - - def explained_needs_check(document: StatementDocument) -> bool: - """Причина needs_check видна в результате: нарушенное соотношение, дубликаты кодов, нет колонок или шапки.""" + """Причина needs_check видна в результате: предупреждение error, нарушенное соотношение, пометка проверки + у строки, значения или шапки, нечитаемая или пропущенная строка.""" checks = document.checks return bool( - checks is None - or checks.relations_failed - or checks.duplicate_line_codes - or not document.columns - or not (document.header.organization_name or document.header.inn) + any(warning.severity == "error" for warning in document.warnings) + or (checks is not None and checks.relations_failed) + or document.header.needs_review_fields + or any( + line.needs_review + or line.line_status in ("unreadable", "missing") + or any(value.needs_review for value in line.values) + for line in document.lines + ) ) @@ -61,23 +52,26 @@ def test_statement_file_matches_manifest(fixtures_dir: Path, statement_file: Pat all_done = all(item.status == "done" for item in expectation.documents) assert outcome.status is (ResultStatus.DONE if all_done else ResultStatus.NEEDS_CHECK) for document, item in zip(documents, expectation.documents, strict=True): - codes = [line.line_code for line in document.lines] - assert len(codes) == len(set(codes)), "коды уникальны" - assert all(value.column_index < len(document.columns) for line in document.lines for value in line.values) + # повторы кодов строк законны (v2.0); ссылки на строки уникальны, значения ссылаются на колонки документа + refs = [line.ref for line in document.lines] + assert len(refs) == len(set(refs)), "ref уникальны" + column_indexes = {column.column_index for column in document.columns} + assert all(value.column_index in column_indexes for line in document.lines for value in line.values) if item.sheets: assert document.locator.count(",") == item.sheets - 1 failed = {relation.relation for relation in document.checks.relations_failed} if document.checks else set() if item.failed_relations is not None: assert failed == set(item.failed_relations) + if item.error_warnings is not None: + errors = {warning.code for warning in document.warnings if warning.severity == "error"} + assert errors == set(item.error_warnings) if item.complete: assert document.checks is not None assert document.checks.relations_failed == [], document.checks assert document.columns assert all(column.period_date is not None for column in document.columns) - # статус документа — по критерию парсера (statement.py::_is_ok), независимо от манифеста - assert document_ok(document) == (item.status == "done"), (item, document.header) + assert document.status == item.status, (item, document.header) if item.status == "needs_check": - assert not document_ok(document) assert explained_needs_check(document), "needs_check должен объясняться полями результата" if expectation.organization: assert document.header.organization_name == expectation.organization diff --git a/api/tests/fixtures/test_statements_real.py b/api/tests/fixtures/test_statements_real.py new file mode 100644 index 0000000..3cc735f --- /dev/null +++ b/api/tests/fixtures/test_statements_real.py @@ -0,0 +1,66 @@ +"""Review Focus плана 4 на реальных книгах `../fixtures/statements-xlsx/**` (спека «Манифест ожиданий...»).""" + +from pathlib import Path +from typing import cast + +import pytest + +from src.contract import FileType, StatementDocument +from src.parser import get_parser + +pytestmark = pytest.mark.fixtures +STATEMENTS = "statements-xlsx" +TITLE_BLOCK_FILES = [ + "unagrande-co/2024-FY/Б.Б Лист 1.xls", + "unagrande-co/2024-FY/ОФР. Лист 1.xls", + "unagrande-group/2024-FY/ББ. Лист 1 .xls", + "unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls", + "unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls", +] +NPZ = "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx" +MEZHENINOVSKAYA = [ + "mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx", + "mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx", +] + + +def statement_docs(fixtures_dir: Path, key: str) -> list[StatementDocument]: + path = fixtures_dir / STATEMENTS / key + outcome = get_parser().parse(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper())) + assert outcome.error is None, outcome.error + return [document for document in outcome.documents if isinstance(document, StatementDocument)] + + +def test_unagrande_title_codes_block(fixtures_dir: Path) -> None: + """Review Focus 1: ОКОПФ из блока кодов шапки не становится строкой формы.""" + for key in TITLE_BLOCK_FILES: + for doc in statement_docs(fixtures_dir, key): + assert doc.header.okopf == "12267", key + assert not any("12267" in (line.line_code_text or "") for line in doc.lines), key + + +def test_npz_documents(fixtures_dir: Path) -> None: + """Review Focus 2; спека «НПЗ «Реализация иного имущества»».""" + balance, income = statement_docs(fixtures_dir, NPZ) + assert (balance.status, income.status) == ("done", "done") + bad = {"SHEET_NOT_JOINED", "SEGMENT_COLUMNS_MISMATCH", "SEGMENT_WITHOUT_CODES", "HEADER_ROW_NOT_FOUND"} + assert not bad & {w.code for d in (balance, income) for w in d.warnings} + cols = {c.column_index for c in balance.columns} + assert all(v.column_index in cols for line in balance.lines for v in line.values) + income_cols = {c.column_index for c in income.columns} + assert all(v.column_index in income_cols for line in income.lines for v in line.values) + # Строки F34/F44 «Реализация иного имущества…» состоят только из прочерков (0 с форматом `[=0]"-"`, value None): + # по спеке строка без кода и без содержимого — не строка данных, пакет её не выдаёт (R-10; вопрос владельцу O-8.2: + # сценарий спеки «с суммами» не соответствует файлу, где суммы — прочерки). + assert any( + line.line_code == "2400" and line.ref.startswith("'Отчет о финансовых результата_2'") for line in income.lines + ) + + +def test_mezheninovskaya_numbering_and_signatures(fixtures_dir: Path) -> None: + """Review Focus 3: строка нумерации колонок «2» и подписи не становятся строками формы.""" + for key in MEZHENINOVSKAYA: + (doc,) = statement_docs(fixtures_dir, key) + assert doc.status == "done" and doc.header.inn == "7016006635", key + assert not any(line.line_code_text == "2" for line in doc.lines), key + assert not any(line.line_status in ("unreadable", "missing") for line in doc.lines), key diff --git a/api/tests/statement_helpers.py b/api/tests/statement_helpers.py index b125199..d384bf0 100644 --- a/api/tests/statement_helpers.py +++ b/api/tests/statement_helpers.py @@ -20,6 +20,7 @@ class DocumentExpectation(BaseModel): reason: str | None = None sheets: int | None = None failed_relations: list[str] | None = None # None — не проверяется; список — точное множество relations_failed + error_warnings: list[str] | None = None # None — не проверяется; список — точное множество кодов уровня error class Expectation(BaseModel): diff --git a/api/tests/statements_manifest.yaml b/api/tests/statements_manifest.yaml index 4338a85..f0ea3b2 100644 --- a/api/tests/statements_manifest.yaml +++ b/api/tests/statements_manifest.yaml @@ -1,66 +1,69 @@ # Ожидания по реальным формам fixtures/statements-xlsx/** (README там же). Ключ — путь относительно statements-xlsx. # documents — по порядку документов результата: type, status (done | needs_check), complete (вся форма: §7 сходится). +# error_warnings — точное множество кодов предупреждений документа уровня error (P4-18); отсутствие поля — не проверяется. # Причина needs_check — данные файла (страница без шапки, шаблон без сумм), не дефект разбора. # Значения сверены прогоном парсера на файлах (R-13): расхождения с первоначальным планом помечены «по факту». # Значения ББ/ОФР — как их показывает числовой формат ячейки (#42): рубли с копейками из 1С приходят в тысячах; # мягкие нарушения (в пределах допуска округления, OQ-138) статус не меняют; набор — по факту разбора, см. golden. +# Страница 2 без шапки: организации и ИНН нет (NO_ORGANIZATION_NO_INN), а 1600 = 1700 не сходится, потому что актив +# на странице 1, а пассив на странице 2 (v2.0: проверка идёт по самой странице). files: "unagrande-co/2024-FY/Б.Б Лист 1.xls": organization: 'Акционерное общество "Унагранде Компани"' inn: "3249004697" - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив: 1600 = 1700 не сходится", failed_relations: ["1600 = 1700"]}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив: 1600 = 1700 не сходится", failed_relations: ["1600 = 1700"], error_warnings: []}] "unagrande-co/2024-FY/Б.Б. Лист 2.xls": - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: ["1600 = 1700"], error_warnings: [NO_ORGANIZATION_NO_INN]}] "unagrande-co/2024-FY/ОФР. Лист 1.xls": organization: 'Акционерное общество "Унагранде Компани"' inn: "3249004697" documents: - - {type: INCOME, status: done, complete: true, failed_relations: []} # значения по формату в тыс. руб.; мягко расходится 2400 = 2300 - 2410 + 2420 (в пределах допуска, OQ-138) + - {type: INCOME, status: done, complete: true, failed_relations: [], error_warnings: []} # значения по формату в тыс. руб.; мягко расходится 2400 = 2300 - 2410 + 2420 (в пределах допуска, OQ-138) "unagrande-co/2024-FY/ОФР. Лист 2 .xls": - documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: [], error_warnings: [NO_ORGANIZATION_NO_INN]}] "unagrande-group/2024-FY/ББ. Лист 1 .xls": organization: 'Акционерное общество "Унагранде Групп"' inn: "7728757141" - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив", failed_relations: ["1600 = 1700"]}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив", failed_relations: ["1600 = 1700"], error_warnings: []}] "unagrande-group/2024-FY/ББ. Лист 2 (2).xls": - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: ["1600 = 1700"], error_warnings: [NO_ORGANIZATION_NO_INN]}] "unagrande-group/2024-FY/ОФР. Лист 2.xls": - documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки, стр. 1 в пакете нет", failed_relations: []}] + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки, стр. 1 в пакете нет", failed_relations: [], error_warnings: [NO_ORGANIZATION_NO_INN]}] "unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls": organization: 'Акционерное общество "Унагранде Групп"' inn: "7728757141" - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив", failed_relations: ["1600 = 1700"]}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив", failed_relations: ["1600 = 1700"], error_warnings: []}] "unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls": - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: ["1600 = 1700"], error_warnings: [NO_ORGANIZATION_NO_INN]}] "unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls": organization: 'Акционерное общество "Унагранде Групп"' inn: "7728757141" documents: - - {type: INCOME, status: done, complete: true, failed_relations: []} # мягко расходятся 2400 = 2300 - 2410 + 2420 и 2410 = 2411 + 2412 (в пределах допуска, OQ-138) + - {type: INCOME, status: done, complete: true, failed_relations: [], error_warnings: []} # мягко расходятся 2400 = 2300 - 2410 + 2420 и 2410 = 2411 + 2412 (в пределах допуска, OQ-138) "unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls": - documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: [], error_warnings: [NO_ORGANIZATION_NO_INN]}] "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx": organization: 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"' inn: "2315995868" inn_optional_for: [INCOME] documents: - - {type: BALANCE, status: done, complete: true, sheets: 5, failed_relations: []} # мягко расходятся детализации 1150, 1250, 1410, 1500, 1510 (сверка с итогом в самой выгрузке, OQ-138) - - {type: INCOME, status: done, complete: true, sheets: 3, failed_relations: []} # мягко расходятся 2400 и 2410 (OQ-138); лист с единственной строкой 2400 присоединён (O-9) + - {type: BALANCE, status: done, complete: true, sheets: 5, failed_relations: [], error_warnings: []} # мягко расходятся детализации 1150, 1250, 1410, 1500, 1510 (сверка с итогом в самой выгрузке, OQ-138) + - {type: INCOME, status: done, complete: true, sheets: 3, failed_relations: [], error_warnings: []} # мягко расходятся 2400 и 2410 (OQ-138); лист с единственной строкой 2400 присоединён (O-9) "mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx": organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' inn: "7016006635" - documents: [{type: BALANCE, status: done, complete: true, failed_relations: []}] + documents: [{type: BALANCE, status: done, complete: true, failed_relations: [], error_warnings: []}] # колонки сумм M/Q/S; числа правее таблицы формы — NUMBERS_OUTSIDE_TABLE (info, Р-44) "mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx": organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' inn: "7016006635" - documents: [{type: INCOME, status: done, complete: true, failed_relations: []}] + documents: [{type: INCOME, status: done, complete: true, failed_relations: [], error_warnings: []}] "templates/ББ до 01.01.2025.xlsx": documents: - - {type: BALANCE, status: needs_check, complete: false, reason: "шаблон без сумм и шапки", failed_relations: []} - - {type: DISCLOSURE, status: done, complete: false, reason: "по факту: «Примечания» шаблона образуют пустой документ DISCLOSURE"} + - {type: BALANCE, status: needs_check, complete: false, reason: "шаблон без сумм и шапки", failed_relations: [], error_warnings: [NO_ORGANIZATION_NO_INN, PERIOD_UNKNOWN, SEGMENT_COLUMNS_MISMATCH]} + - {type: DISCLOSURE, status: done, complete: false, reason: "по факту: «Примечания» шаблона образуют пустой документ DISCLOSURE", error_warnings: []} "templates/ББ после 01.01.2025.xlsx": - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "шаблон", failed_relations: []}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "шаблон", failed_relations: [], error_warnings: [PERIOD_UNKNOWN]}] "templates/ОФР_до 01.01.2025.xlsx": - documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон", failed_relations: []}] + documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон", failed_relations: [], error_warnings: [NO_ORGANIZATION_NO_INN, PERIOD_UNKNOWN]}] "templates/ОФР_после 01.01.2025.xlsx": - documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон", failed_relations: []}] + documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон", failed_relations: [], error_warnings: [PERIOD_UNKNOWN]}]