test: манифест реальных форм и Review Focus под общий код и v2.0 (#54)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 21:41:54 +04:00
parent f5d071f602
commit 0f12c0eb3d
4 changed files with 110 additions and 46 deletions

View File

@ -17,29 +17,20 @@ EXPECTED_FILES = 18
MAX_PARSE_SECONDS = 5 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: def explained_needs_check(document: StatementDocument) -> bool:
"""Причина needs_check видна в результате: нарушенное соотношение, дубликаты кодов, нет колонок или шапки.""" """Причина needs_check видна в результате: предупреждение error, нарушенное соотношение, пометка проверки
у строки, значения или шапки, нечитаемая или пропущенная строка."""
checks = document.checks checks = document.checks
return bool( return bool(
checks is None any(warning.severity == "error" for warning in document.warnings)
or checks.relations_failed or (checks is not None and checks.relations_failed)
or checks.duplicate_line_codes or document.header.needs_review_fields
or not document.columns or any(
or not (document.header.organization_name or document.header.inn) 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) all_done = all(item.status == "done" for item in expectation.documents)
assert outcome.status is (ResultStatus.DONE if all_done else ResultStatus.NEEDS_CHECK) assert outcome.status is (ResultStatus.DONE if all_done else ResultStatus.NEEDS_CHECK)
for document, item in zip(documents, expectation.documents, strict=True): for document, item in zip(documents, expectation.documents, strict=True):
codes = [line.line_code for line in document.lines] # повторы кодов строк законны (v2.0); ссылки на строки уникальны, значения ссылаются на колонки документа
assert len(codes) == len(set(codes)), "коды уникальны" refs = [line.ref for line in document.lines]
assert all(value.column_index < len(document.columns) for line in document.lines for value in line.values) 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: if item.sheets:
assert document.locator.count(",") == item.sheets - 1 assert document.locator.count(",") == item.sheets - 1
failed = {relation.relation for relation in document.checks.relations_failed} if document.checks else set() failed = {relation.relation for relation in document.checks.relations_failed} if document.checks else set()
if item.failed_relations is not None: if item.failed_relations is not None:
assert failed == set(item.failed_relations) 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: if item.complete:
assert document.checks is not None assert document.checks is not None
assert document.checks.relations_failed == [], document.checks assert document.checks.relations_failed == [], document.checks
assert document.columns assert document.columns
assert all(column.period_date is not None for column in document.columns) assert all(column.period_date is not None for column in document.columns)
# статус документа — по критерию парсера (statement.py::_is_ok), независимо от манифеста assert document.status == item.status, (item, document.header)
assert document_ok(document) == (item.status == "done"), (item, document.header)
if item.status == "needs_check": if item.status == "needs_check":
assert not document_ok(document)
assert explained_needs_check(document), "needs_check должен объясняться полями результата" assert explained_needs_check(document), "needs_check должен объясняться полями результата"
if expectation.organization: if expectation.organization:
assert document.header.organization_name == expectation.organization assert document.header.organization_name == expectation.organization

View File

@ -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

View File

@ -20,6 +20,7 @@ class DocumentExpectation(BaseModel):
reason: str | None = None reason: str | None = None
sheets: int | None = None sheets: int | None = None
failed_relations: list[str] | None = None # None — не проверяется; список — точное множество relations_failed failed_relations: list[str] | None = None # None — не проверяется; список — точное множество relations_failed
error_warnings: list[str] | None = None # None — не проверяется; список — точное множество кодов уровня error
class Expectation(BaseModel): class Expectation(BaseModel):

View File

@ -1,66 +1,69 @@
# Ожидания по реальным формам fixtures/statements-xlsx/** (README там же). Ключ — путь относительно statements-xlsx. # Ожидания по реальным формам fixtures/statements-xlsx/** (README там же). Ключ — путь относительно statements-xlsx.
# documents — по порядку документов результата: type, status (done | needs_check), complete (вся форма: §7 сходится). # documents — по порядку документов результата: type, status (done | needs_check), complete (вся форма: §7 сходится).
# error_warnings — точное множество кодов предупреждений документа уровня error (P4-18); отсутствие поля — не проверяется.
# Причина needs_check — данные файла (страница без шапки, шаблон без сумм), не дефект разбора. # Причина needs_check — данные файла (страница без шапки, шаблон без сумм), не дефект разбора.
# Значения сверены прогоном парсера на файлах (R-13): расхождения с первоначальным планом помечены «по факту». # Значения сверены прогоном парсера на файлах (R-13): расхождения с первоначальным планом помечены «по факту».
# Значения ББ/ОФР — как их показывает числовой формат ячейки (#42): рубли с копейками из 1С приходят в тысячах; # Значения ББ/ОФР — как их показывает числовой формат ячейки (#42): рубли с копейками из 1С приходят в тысячах;
# мягкие нарушения (в пределах допуска округления, OQ-138) статус не меняют; набор — по факту разбора, см. golden. # мягкие нарушения (в пределах допуска округления, OQ-138) статус не меняют; набор — по факту разбора, см. golden.
# Страница 2 без шапки: организации и ИНН нет (NO_ORGANIZATION_NO_INN), а 1600 = 1700 не сходится, потому что актив
# на странице 1, а пассив на странице 2 (v2.0: проверка идёт по самой странице).
files: files:
"unagrande-co/2024-FY/Б.Б Лист 1.xls": "unagrande-co/2024-FY/Б.Б Лист 1.xls":
organization: 'Акционерное общество "Унагранде Компани"' organization: 'Акционерное общество "Унагранде Компани"'
inn: "3249004697" 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": "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": "unagrande-co/2024-FY/ОФР. Лист 1.xls":
organization: 'Акционерное общество "Унагранде Компани"' organization: 'Акционерное общество "Унагранде Компани"'
inn: "3249004697" inn: "3249004697"
documents: 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": "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": "unagrande-group/2024-FY/ББ. Лист 1 .xls":
organization: 'Акционерное общество "Унагранде Групп"' organization: 'Акционерное общество "Унагранде Групп"'
inn: "7728757141" 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": "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": "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": "unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls":
organization: 'Акционерное общество "Унагранде Групп"' organization: 'Акционерное общество "Унагранде Групп"'
inn: "7728757141" 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": "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": "unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls":
organization: 'Акционерное общество "Унагранде Групп"' organization: 'Акционерное общество "Унагранде Групп"'
inn: "7728757141" inn: "7728757141"
documents: 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": "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": "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx":
organization: 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"' organization: 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"'
inn: "2315995868" inn: "2315995868"
inn_optional_for: [INCOME] inn_optional_for: [INCOME]
documents: documents:
- {type: BALANCE, status: done, complete: true, sheets: 5, failed_relations: []} # мягко расходятся детализации 1150, 1250, 1410, 1500, 1510 (сверка с итогом в самой выгрузке, OQ-138) - {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: []} # мягко расходятся 2400 и 2410 (OQ-138); лист с единственной строкой 2400 присоединён (O-9) - {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": "mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx":
organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"'
inn: "7016006635" 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": "mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx":
organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"'
inn: "7016006635" 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": "templates/ББ до 01.01.2025.xlsx":
documents: documents:
- {type: BALANCE, status: needs_check, complete: false, reason: "шаблон без сумм и шапки", failed_relations: []} - {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"} - {type: DISCLOSURE, status: done, complete: false, reason: "по факту: «Примечания» шаблона образуют пустой документ DISCLOSURE", error_warnings: []}
"templates/ББ после 01.01.2025.xlsx": "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": "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": "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]}]