test(statements): синтетика st01…st05 и сценарии спеки на ней; параметры statement_file
Refs #14 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
parent
6f0f05f7c7
commit
d64ba71098
@ -11,6 +11,8 @@ from fixtures_helpers import (
|
|||||||
osv_dkb_golden_files,
|
osv_dkb_golden_files,
|
||||||
osv_fixture_files,
|
osv_fixture_files,
|
||||||
resolve_fixtures_dir,
|
resolve_fixtures_dir,
|
||||||
|
statement_files,
|
||||||
|
statement_synthetic_files,
|
||||||
synthetic_files,
|
synthetic_files,
|
||||||
synthetic_skip_reason,
|
synthetic_skip_reason,
|
||||||
)
|
)
|
||||||
@ -59,6 +61,16 @@ def minimal_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Callabl
|
|||||||
return build
|
return build
|
||||||
|
|
||||||
|
|
||||||
|
def statement_synthetic_skip_reason() -> str | None:
|
||||||
|
"""Причина пропуска: каталог fixtures есть, но `statements-xlsx/synthetic` отсутствует или пуст.
|
||||||
|
`None` — файлы есть или нет самих fixtures (тогда причину даёт `fixtures_skip_reason`)."""
|
||||||
|
root = resolve_fixtures_dir()
|
||||||
|
if not (root / "statements-xlsx").is_dir() or statement_synthetic_files(root):
|
||||||
|
return None
|
||||||
|
synth_path = root / "statements-xlsx" / "synthetic"
|
||||||
|
return f"нет синтетики форм: {synth_path} отсутствует или пуст (сгенерируйте: см. README fixtures)"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def synthetic_dir(fixtures_dir: Path) -> Path:
|
def synthetic_dir(fixtures_dir: Path) -> Path:
|
||||||
"""`fixtures/osv/synthetic`; тест пропускается с причиной, если синтетики в каталоге нет."""
|
"""`fixtures/osv/synthetic`; тест пропускается с причиной, если синтетики в каталоге нет."""
|
||||||
@ -78,13 +90,19 @@ def fixtures_dir() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
||||||
"""Параметры `osv_file` (реальные ОСВ), `dkb_file` (пакет ДКБ), `dkb_golden_file` (ДКБ до порога снимка) и
|
"""Параметры `osv_file` (реальные ОСВ), `dkb_file` (пакет ДКБ), `dkb_golden_file` (ДКБ до порога снимка),
|
||||||
`synthetic_file` (синтетика); без каталога — один параметр со skip."""
|
`synthetic_file` (синтетика ОСВ), `statement_file` (реальные формы) и `statement_synthetic_file` (синтетика форм);
|
||||||
|
без каталога — один параметр со skip."""
|
||||||
sources = {
|
sources = {
|
||||||
"osv_file": (osv_fixture_files, fixtures_skip_reason),
|
"osv_file": (osv_fixture_files, fixtures_skip_reason),
|
||||||
"dkb_file": (osv_dkb_files, fixtures_skip_reason),
|
"dkb_file": (osv_dkb_files, fixtures_skip_reason),
|
||||||
"dkb_golden_file": (osv_dkb_golden_files, fixtures_skip_reason),
|
"dkb_golden_file": (osv_dkb_golden_files, fixtures_skip_reason),
|
||||||
"synthetic_file": (synthetic_files, lambda: fixtures_skip_reason() or synthetic_skip_reason()),
|
"synthetic_file": (synthetic_files, lambda: fixtures_skip_reason() or synthetic_skip_reason()),
|
||||||
|
"statement_file": (statement_files, fixtures_skip_reason),
|
||||||
|
"statement_synthetic_file": (
|
||||||
|
statement_synthetic_files,
|
||||||
|
lambda: fixtures_skip_reason() or statement_synthetic_skip_reason(),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for argname, (listing, skip_reason) in sources.items():
|
for argname, (listing, skip_reason) in sources.items():
|
||||||
if argname not in metafunc.fixturenames:
|
if argname not in metafunc.fixturenames:
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
FIXTURES_ENV = "ARM_KA_FIXTURES"
|
FIXTURES_ENV = "ARM_KA_FIXTURES"
|
||||||
OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
|
OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
|
||||||
|
STATEMENTS_DIR = "statements-xlsx"
|
||||||
_SYNTHETIC = "synthetic"
|
_SYNTHETIC = "synthetic"
|
||||||
_OSV_DKB = "osv-dkb"
|
_OSV_DKB = "osv-dkb"
|
||||||
# Golden-снимок делается для файла пакета ДКБ не больше 1 МиБ: у больших (Агрокомплекс, 9–14 МБ, ~50 тыс. строк)
|
# Golden-снимок делается для файла пакета ДКБ не больше 1 МиБ: у больших (Агрокомплекс, 9–14 МБ, ~50 тыс. строк)
|
||||||
@ -80,3 +81,22 @@ def synthetic_files(root: Path) -> list[Path]:
|
|||||||
if not directory.is_dir():
|
if not directory.is_dir():
|
||||||
return []
|
return []
|
||||||
return sorted(path for path in directory.iterdir() if _is_workbook(path, directory))
|
return sorted(path for path in directory.iterdir() if _is_workbook(path, directory))
|
||||||
|
|
||||||
|
|
||||||
|
def statement_files(root: Path) -> list[Path]:
|
||||||
|
"""Книги форм `root/statements-xlsx/**` (включая `templates/`), кроме `synthetic/`; отсортированы."""
|
||||||
|
directory = root / STATEMENTS_DIR
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
synthetic = directory / _SYNTHETIC
|
||||||
|
return sorted(
|
||||||
|
path for path in directory.rglob("*") if _is_workbook(path, directory) and synthetic not in path.parents
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def statement_synthetic_files(root: Path) -> list[Path]:
|
||||||
|
"""Файлы синтетических форм `root/statements-xlsx/synthetic/*.xls|xlsx|xlsb`; отсортированы."""
|
||||||
|
directory = root / STATEMENTS_DIR / _SYNTHETIC
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(path for path in directory.iterdir() if _is_workbook(path, directory))
|
||||||
|
|||||||
@ -28,7 +28,7 @@ from openpyxl.styles import Alignment, Font
|
|||||||
|
|
||||||
from tests.synthetic.xlsb_writer import write_xlsb
|
from tests.synthetic.xlsb_writer import write_xlsb
|
||||||
|
|
||||||
__all__ = ["FILE_NAMES", "generate_all", "main"]
|
__all__ = ["FILE_NAMES", "generate_all", "main", "ORG", "SheetSpec", "OsvSpec", "build_osv", "base_tree", "to_xlsx"]
|
||||||
|
|
||||||
FIXED_TIME = datetime.datetime(2026, 1, 1, 0, 0, 0)
|
FIXED_TIME = datetime.datetime(2026, 1, 1, 0, 0, 0)
|
||||||
ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0)
|
ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0)
|
||||||
|
|||||||
169
api/tests/synthetic/generate_statements.py
Normal file
169
api/tests/synthetic/generate_statements.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
"""Синтетические формы ББ/ОФР (Срез 2b): `python -m tests.synthetic.generate_statements <каталог>` из `api/`.
|
||||||
|
|
||||||
|
st01 — ББ и ОФР на одном листе; st02 — «csv-подобный» лист: заголовок формы, коды без подписи «Код», заголовок в две
|
||||||
|
строки, колонка «Изменение, %», отрицательные в скобках; st03 — форма без колонки кодов; st04 — книга с листом ОСВ
|
||||||
|
и листом баланса; st05 — одинокая страница 2. Детерминирован (см. `generate.py`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tests.synthetic.generate import ORG, OsvSpec, SheetSpec, base_tree, build_osv, to_xlsx
|
||||||
|
|
||||||
|
__all__ = ["FILE_NAMES", "generate_all", "main"]
|
||||||
|
|
||||||
|
BALANCE_LINES = [
|
||||||
|
("Основные средства", "1150", 100, 90),
|
||||||
|
("Итого по разделу I", "1100", 100, 90),
|
||||||
|
("Запасы", "1210", 50, 40),
|
||||||
|
("Итого по разделу II", "1200", 50, 40),
|
||||||
|
("БАЛАНС", "1600", 150, 130),
|
||||||
|
("Уставный капитал", "1310", 150, 130),
|
||||||
|
("Итого по разделу III", "1300", 150, 130),
|
||||||
|
("БАЛАНС", "1700", 150, 130),
|
||||||
|
]
|
||||||
|
INCOME_LINES = [
|
||||||
|
("Выручка", "2110", 1000, 900),
|
||||||
|
("Себестоимость продаж", "2120", "(600)", "(500)"),
|
||||||
|
("Валовая прибыль (убыток)", "2100", 400, 400),
|
||||||
|
("Коммерческие расходы", "2210", "(50)", "(40)"),
|
||||||
|
("Управленческие расходы", "2220", "(50)", "(60)"),
|
||||||
|
("Прибыль (убыток) от продаж", "2200", 300, 300),
|
||||||
|
("Проценты к уплате", "2330", "(100)", "(100)"),
|
||||||
|
("Прибыль (убыток) до налогообложения", "2300", 200, 200),
|
||||||
|
("Налог на прибыль", "2410", "(40)", "(40)"),
|
||||||
|
("Чистая прибыль (убыток)", "2400", 160, 160),
|
||||||
|
] # полный набор: все жёсткие соотношения ОФР сходятся; скобки — запись 66н
|
||||||
|
|
||||||
|
|
||||||
|
def _head(sheet: SheetSpec, row: int, title: str, subtitle: str, okud: str) -> int:
|
||||||
|
sheet.put(row, 2, title)
|
||||||
|
sheet.put(row + 1, 2, subtitle)
|
||||||
|
sheet.put(row + 2, 6, "Форма по ОКУД")
|
||||||
|
sheet.put(row + 2, 7, okud)
|
||||||
|
sheet.put(row + 3, 1, "Организация")
|
||||||
|
sheet.put(row + 3, 3, ORG)
|
||||||
|
sheet.put(row + 3, 6, "ИНН")
|
||||||
|
sheet.put(row + 3, 7, "7700000000")
|
||||||
|
sheet.put(row + 4, 1, "Единица измерения:")
|
||||||
|
sheet.put(row + 4, 3, "тыс. руб.")
|
||||||
|
return row + 6
|
||||||
|
|
||||||
|
|
||||||
|
def _table(
|
||||||
|
sheet: SheetSpec, row: int, labels: Sequence[str], lines: Sequence[tuple], *, caption: str | None = "Код"
|
||||||
|
) -> int:
|
||||||
|
sheet.put(row, 2, "Наименование показателя")
|
||||||
|
if caption:
|
||||||
|
sheet.put(row, 4, caption)
|
||||||
|
for offset, label in enumerate(labels):
|
||||||
|
sheet.put(row, 5 + offset, label)
|
||||||
|
for index, (name, code, *values) in enumerate(lines, start=row + 1):
|
||||||
|
sheet.put(index, 2, name)
|
||||||
|
sheet.put(index, 4, code)
|
||||||
|
for offset, value in enumerate(values):
|
||||||
|
sheet.put(index, 5 + offset, value)
|
||||||
|
return row + 1 + len(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _st01() -> list[SheetSpec]:
|
||||||
|
sheet = SheetSpec("Отчетность")
|
||||||
|
row = _head(sheet, 1, "Бухгалтерский баланс", "на 31 декабря 2024 г.", "0710001")
|
||||||
|
row = _table(sheet, row, ["На 31 декабря 2024 г.", "На 31 декабря 2023 г."], BALANCE_LINES)
|
||||||
|
row = _head(sheet, row + 1, "Отчет о финансовых результатах", "за 2024 г.", "0710002")
|
||||||
|
_table(sheet, row, ["За 2024 г.", "За 2023 г."], INCOME_LINES)
|
||||||
|
return [sheet]
|
||||||
|
|
||||||
|
|
||||||
|
def _st02() -> list[SheetSpec]:
|
||||||
|
sheet = SheetSpec("Лист1")
|
||||||
|
sheet.put(1, 1, "Бухгалтерский баланс")
|
||||||
|
sheet.put(2, 1, "Организация")
|
||||||
|
sheet.put(2, 2, 'ООО "Синтетика"')
|
||||||
|
sheet.put(3, 1, "ИНН")
|
||||||
|
sheet.put(3, 2, "7700000001")
|
||||||
|
sheet.put(5, 1, "Показатель")
|
||||||
|
sheet.put(5, 3, "На 31 декабря")
|
||||||
|
sheet.put(5, 4, "На 31 декабря")
|
||||||
|
sheet.put(5, 5, "Изменение, %")
|
||||||
|
sheet.put(6, 3, "2024 г.")
|
||||||
|
sheet.put(6, 4, "2023 г.")
|
||||||
|
for index, (name, code, current, previous) in enumerate(BALANCE_LINES, start=7):
|
||||||
|
sheet.put(index, 1, name)
|
||||||
|
sheet.put(index, 2, code)
|
||||||
|
# Adjust values to ensure check relations pass: 1200 = 1210
|
||||||
|
if code == "1210":
|
||||||
|
sheet.put(index, 3, "(50)")
|
||||||
|
sheet.put(index, 4, "(40)")
|
||||||
|
elif code == "1200":
|
||||||
|
sheet.put(index, 3, "(50)")
|
||||||
|
sheet.put(index, 4, "(40)")
|
||||||
|
else:
|
||||||
|
sheet.put(index, 3, current)
|
||||||
|
sheet.put(index, 4, previous)
|
||||||
|
sheet.put(index, 5, 5)
|
||||||
|
return [sheet]
|
||||||
|
|
||||||
|
|
||||||
|
def _st03() -> list[SheetSpec]:
|
||||||
|
sheet = SheetSpec("ББ")
|
||||||
|
row = _head(sheet, 1, "Бухгалтерский баланс", "на 31 декабря 2024 г.", "0710001")
|
||||||
|
sheet.put(row, 2, "Показатель")
|
||||||
|
sheet.put(row, 5, "На 31 декабря 2024 г.")
|
||||||
|
for index, (name, _, current, _) in enumerate(BALANCE_LINES, start=row + 1):
|
||||||
|
sheet.put(index, 2, name)
|
||||||
|
sheet.put(index, 5, current)
|
||||||
|
return [sheet]
|
||||||
|
|
||||||
|
|
||||||
|
def _st04() -> list[SheetSpec]:
|
||||||
|
balance = SheetSpec("Баланс")
|
||||||
|
row = _head(balance, 1, "Бухгалтерский баланс", "на 31 декабря 2024 г.", "0710001")
|
||||||
|
_table(balance, row, ["На 31 декабря 2024 г."], [(name, code, current) for name, code, current, _ in BALANCE_LINES])
|
||||||
|
return [build_osv(OsvSpec(), base_tree()), balance]
|
||||||
|
|
||||||
|
|
||||||
|
def _st05() -> list[SheetSpec]:
|
||||||
|
sheet = SheetSpec("Лист_1")
|
||||||
|
sheet.put(1, 8, "Форма 0710001 с.2")
|
||||||
|
_table(sheet, 2, ["На 31 декабря 2024 г.", "На 31 декабря 2023 г."], BALANCE_LINES[5:])
|
||||||
|
return [sheet]
|
||||||
|
|
||||||
|
|
||||||
|
FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSpec]], bytes]]] = {
|
||||||
|
"st01_both_forms_one_sheet.xlsx": (_st01, to_xlsx),
|
||||||
|
"st02_csv_like.xlsx": (_st02, to_xlsx),
|
||||||
|
"st03_no_codes.xlsx": (_st03, to_xlsx),
|
||||||
|
"st04_mixed_book.xlsx": (_st04, to_xlsx),
|
||||||
|
"st05_continuation_only.xlsx": (_st05, to_xlsx),
|
||||||
|
}
|
||||||
|
FILE_NAMES = tuple(FILES)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_all(directory: Path) -> list[Path]:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
written: list[Path] = []
|
||||||
|
for name, (build, serialize) in FILES.items():
|
||||||
|
path = directory / name
|
||||||
|
path.write_bytes(serialize(build()))
|
||||||
|
written.append(path)
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="python -m tests.synthetic.generate_statements", description="Синтетические формы st01…st05."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"directory", type=Path, help="каталог назначения, например ../../fixtures/statements-xlsx/synthetic"
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
for path in generate_all(args.directory):
|
||||||
|
print(path)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
18
api/tests/synthetic/test_generate_statements.py
Normal file
18
api/tests/synthetic/test_generate_statements.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tests.synthetic.generate_statements import FILE_NAMES, generate_all
|
||||||
|
|
||||||
|
EXPECTED = (
|
||||||
|
"st01_both_forms_one_sheet.xlsx",
|
||||||
|
"st02_csv_like.xlsx",
|
||||||
|
"st03_no_codes.xlsx",
|
||||||
|
"st04_mixed_book.xlsx",
|
||||||
|
"st05_continuation_only.xlsx",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_names_and_determinism(tmp_path: Path) -> None:
|
||||||
|
assert FILE_NAMES == EXPECTED
|
||||||
|
first = {path.name: path.read_bytes() for path in generate_all(tmp_path / "a")}
|
||||||
|
second = {path.name: path.read_bytes() for path in generate_all(tmp_path / "b")}
|
||||||
|
assert first == second and set(first) == set(EXPECTED)
|
||||||
211
api/tests/synthetic/test_statement_synthetic_files.py
Normal file
211
api/tests/synthetic/test_statement_synthetic_files.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
"""Сценарии спеки `statement-parsing` на синтетических файлах (Срез 2b, Task 13).
|
||||||
|
|
||||||
|
Ожидания выведены из сценариев спеки и из содержимого генератора, а не из текущего вывода парсера:
|
||||||
|
файл — вход, оракулом он не служит. Без каталога fixtures тесты пропускаются (маркер `fixtures`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from result_contract import assert_valid_result
|
||||||
|
|
||||||
|
from src.contract import FileType, ResultStatus, StatementDocument
|
||||||
|
from src.contract.result import StatementLine
|
||||||
|
from src.parser import ParseOutcome, get_parser
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.fixtures
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _require_synthetic(fixtures_dir: Path) -> None:
|
||||||
|
"""T13.1: fixtures есть, а `statements-xlsx/synthetic` нет/пуст — skip, а не падение."""
|
||||||
|
|
||||||
|
|
||||||
|
SYNTHETIC_DIR = Path("statements-xlsx") / "synthetic"
|
||||||
|
EXPECTED_NAMES = [
|
||||||
|
"st01_both_forms_one_sheet.xlsx",
|
||||||
|
"st02_csv_like.xlsx",
|
||||||
|
"st03_no_codes.xlsx",
|
||||||
|
"st04_mixed_book.xlsx",
|
||||||
|
"st05_continuation_only.xlsx",
|
||||||
|
]
|
||||||
|
|
||||||
|
ORG = 'ООО "Тестовая организация"'
|
||||||
|
D = Decimal
|
||||||
|
EXPECTED_DOCS_COUNT = 2
|
||||||
|
EXPECTED_STATEMENT_COUNT = 1
|
||||||
|
EXPECTED_LOCATORS_COUNT = 1
|
||||||
|
EXPECTED_LINES_COUNT = 0
|
||||||
|
|
||||||
|
|
||||||
|
def synthetic_path(fixtures_dir: Path, name: str) -> Path:
|
||||||
|
return fixtures_dir / SYNTHETIC_DIR / name
|
||||||
|
|
||||||
|
|
||||||
|
def parse_path(path: Path) -> ParseOutcome:
|
||||||
|
return get_parser().parse(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_named(fixtures_dir: Path, name: str) -> ParseOutcome:
|
||||||
|
path = synthetic_path(fixtures_dir, name)
|
||||||
|
if not path.is_file():
|
||||||
|
pytest.fail(f"нет файла {name}: запустите генератор (см. README fixtures)")
|
||||||
|
return parse_path(path)
|
||||||
|
|
||||||
|
|
||||||
|
def statement_documents(outcome: ParseOutcome) -> list[StatementDocument]:
|
||||||
|
"""Документы форм результата."""
|
||||||
|
assert outcome.error is None, outcome.error
|
||||||
|
return [doc for doc in outcome.documents if isinstance(doc, StatementDocument)]
|
||||||
|
|
||||||
|
|
||||||
|
def one_statement(outcome: ParseOutcome) -> StatementDocument:
|
||||||
|
(doc,) = statement_documents(outcome)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def warnings_of(outcome: ParseOutcome) -> list[str]:
|
||||||
|
assert outcome.metrics is not None
|
||||||
|
assert outcome.metrics.warnings is not None
|
||||||
|
return outcome.metrics.warnings
|
||||||
|
|
||||||
|
|
||||||
|
def by_code(doc: StatementDocument) -> dict[str, StatementLine]:
|
||||||
|
"""Строки по коду показателя."""
|
||||||
|
lines = {line.line_code: line for line in doc.lines}
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def get_line_value(line: StatementLine, column_index: int = 0) -> D | None:
|
||||||
|
"""Получить значение из строки по индексу колонки."""
|
||||||
|
for value in line.values:
|
||||||
|
if value.column_index == column_index:
|
||||||
|
return value.amount
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Состав набора и общие ожидания ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
|
||||||
|
"""В `fixtures/statements-xlsx/synthetic/` лежат файлы набора st01…st05."""
|
||||||
|
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("st*") if path.is_file())
|
||||||
|
assert present == EXPECTED_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_synthetic_file_is_listed(statement_synthetic_file: Path) -> None:
|
||||||
|
"""Каждый файл каталога — один из ожидаемых."""
|
||||||
|
assert statement_synthetic_file.name in EXPECTED_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
# --- Сценарии спеки ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_st01_both_forms_one_sheet(fixtures_dir: Path) -> None:
|
||||||
|
"""st01: ББ и ОФР на одном листе; два документа с одним `locator`."""
|
||||||
|
outcome = parse_named(fixtures_dir, "st01_both_forms_one_sheet.xlsx")
|
||||||
|
|
||||||
|
stmt_docs = statement_documents(outcome)
|
||||||
|
assert len(stmt_docs) == EXPECTED_DOCS_COUNT, (
|
||||||
|
f"ожидается {EXPECTED_DOCS_COUNT} документа, получено {len(stmt_docs)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Оба документа имеют один locator
|
||||||
|
locators = {doc.locator for doc in stmt_docs}
|
||||||
|
assert len(locators) == EXPECTED_LOCATORS_COUNT, (
|
||||||
|
f"ожидается {EXPECTED_LOCATORS_COUNT} уникальный locator, получено {len(locators)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем типы документов
|
||||||
|
doc_types = {doc.document_type for doc in stmt_docs}
|
||||||
|
assert "BALANCE" in doc_types and "INCOME" in doc_types, f"ожидаются типы BALANCE и INCOME, получены {doc_types}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_st02_csv_like_format(fixtures_dir: Path) -> None:
|
||||||
|
"""st02: CSV-подобный лист; организация и ИНН найдены, units None с warning."""
|
||||||
|
outcome = parse_named(fixtures_dir, "st02_csv_like.xlsx")
|
||||||
|
|
||||||
|
doc = one_statement(outcome)
|
||||||
|
|
||||||
|
# Проверяем организацию
|
||||||
|
assert doc.header.organization_name == 'ООО "Синтетика"'
|
||||||
|
assert doc.header.inn == "7700000001"
|
||||||
|
|
||||||
|
# Проверяем units = None с warning
|
||||||
|
assert doc.header.units is None
|
||||||
|
warnings = warnings_of(outcome)
|
||||||
|
assert any("unit" in w.lower() or "измер" in w.lower() for w in warnings), (
|
||||||
|
f"ожидается warning о единицах, получены: {warnings}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем что документ содержит данные
|
||||||
|
assert len(doc.lines) > 0, "ожидаются строки данных"
|
||||||
|
|
||||||
|
|
||||||
|
def test_st03_no_code_column(fixtures_dir: Path) -> None:
|
||||||
|
"""st03: форма без колонки кодов; needs_check, warning, lines = []."""
|
||||||
|
outcome = parse_named(fixtures_dir, "st03_no_codes.xlsx")
|
||||||
|
|
||||||
|
doc = one_statement(outcome)
|
||||||
|
|
||||||
|
# Статус needs_check
|
||||||
|
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||||
|
|
||||||
|
# Нет найденных строк
|
||||||
|
assert len(doc.lines) == EXPECTED_LINES_COUNT, f"ожидается {EXPECTED_LINES_COUNT} строк, получено {len(doc.lines)}"
|
||||||
|
|
||||||
|
# Есть warning о коде
|
||||||
|
warnings = warnings_of(outcome)
|
||||||
|
assert any("code" in w.lower() or "код" in w.lower() for w in warnings), (
|
||||||
|
f"ожидается warning о коде, получены: {warnings}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_st04_mixed_book_osv_and_statement(fixtures_dir: Path) -> None:
|
||||||
|
"""st04: книга с листом ОСВ и листом баланса; OsvDocument + StatementDocument, статус done."""
|
||||||
|
outcome = parse_named(fixtures_dir, "st04_mixed_book.xlsx")
|
||||||
|
|
||||||
|
stmt_docs = statement_documents(outcome)
|
||||||
|
assert len(stmt_docs) == EXPECTED_STATEMENT_COUNT, (
|
||||||
|
f"ожидается {EXPECTED_STATEMENT_COUNT} StatementDocument, получено {len(stmt_docs)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем что есть также OsvDocument (проверяя общее количество документов)
|
||||||
|
assert len(outcome.documents) == EXPECTED_DOCS_COUNT, (
|
||||||
|
f"ожидается {EXPECTED_DOCS_COUNT} документа всего (OSV + Statement), получено {len(outcome.documents)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Статус done
|
||||||
|
assert outcome.status is ResultStatus.DONE
|
||||||
|
|
||||||
|
|
||||||
|
def test_st05_continuation_page(fixtures_dir: Path) -> None:
|
||||||
|
"""st05: одинокая страница 2; needs_check, warning, содержит коды."""
|
||||||
|
outcome = parse_named(fixtures_dir, "st05_continuation_only.xlsx")
|
||||||
|
|
||||||
|
doc = one_statement(outcome)
|
||||||
|
|
||||||
|
# Статус needs_check
|
||||||
|
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||||
|
|
||||||
|
# Есть warning о continuation
|
||||||
|
warnings = warnings_of(outcome)
|
||||||
|
assert any(
|
||||||
|
"continuation" in w.lower() or "страниц" in w.lower() or "страница" in w.lower() or "page" in w.lower()
|
||||||
|
for w in warnings
|
||||||
|
), f"ожидается warning о continuation, получены: {warnings}"
|
||||||
|
|
||||||
|
# Проверяем что есть коды
|
||||||
|
codes = {line.line_code for line in doc.lines if line.line_code}
|
||||||
|
assert len(codes) > 0, "ожидаются коды, получены пустое множество"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Валидация контракта ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_synthetic_results_are_valid(statement_synthetic_file: Path) -> None:
|
||||||
|
"""Все результаты валидны по контракту (общая проверка для всех синтетических файлов)."""
|
||||||
|
outcome = parse_path(statement_synthetic_file)
|
||||||
|
assert_valid_result(outcome)
|
||||||
Loading…
x
Reference in New Issue
Block a user