From 6efe79fec997f3d2a69158379de9b8fa5ac25ef5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 11:50:39 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B8=D0=BD=D1=82=D0=B5=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20s29=E2=80=93s31:=20xls=20=D0=B1=D0=B5=D0=B7=20CO?= =?UTF-8?q?DEPAGE,=20=C2=AB=D0=92=D0=B0=D0=BB=C2=BB/=C2=AB=D0=9A=D0=BE?= =?UTF-8?q?=D0=BB-=D0=B2=D0=BE=C2=BB,=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B8=D0=B6=D0=B5=20=C2=AB=D0=98=D1=82=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=C2=BB=20(#47,=20AURORA-1662,=201663,=201664)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 4 +- api/tests/synthetic/generate.py | 59 +++++++++++++++++++-- api/tests/synthetic/test_synthetic_files.py | 50 ++++++++++++++++- 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 73cc016..d147799 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам ( `../fixtures/osv-dkb/**` — `tests/fixtures/test_osv_dkb.py`: ожидаемые `needs_check` — манифест `tests/osv_dkb_expectations.yaml` (данные), период — по имени каталога (`2025-Q3`, `2024-10_2025-09`); - снимки `../fixtures/golden/expected_osv_parse/*.json` — после ручной сверки «Итого»; -- синтетика только для вариантов, которых нет в фикстурах (шапка вариант 2, Вал./Кол., xlsb, без outline level). +- синтетика только для вариантов, которых нет в фикстурах (шапка вариант 2, Вал./Кол., xlsb, xls без CODEPAGE, без outline level). ## Соглашения шаблона Тот же шаблон команды, что у `arm-core` («Шаблон FastAPI», `RSHB/DKB_ARMKA@ba172f8`); общие соглашения — @@ -70,7 +70,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают. - Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле (`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой. -- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 123 файла: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 28 синтетических) — регрессия, а не +- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 126 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 31 синтетический) — регрессия, а не оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность проверяют property-тест и ручная сверка «Итого». - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 74d1d02..136b4c5 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -2,8 +2,9 @@ Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь -раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35); описание — `osv/synthetic/README.md` в репозитории -fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — +раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s31 — xls без CODEPAGE, «Вал»/«Кол-во» и +строки ниже «Итого» (#47); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен +(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у @@ -26,6 +27,7 @@ from typing import Literal import openpyxl from openpyxl.styles import Alignment, Font +from tests.synthetic.xls_writer import write_xls from tests.synthetic.xlsb_writer import write_xlsb __all__ = ["FILE_NAMES", "generate_all", "main", "ORG", "SheetSpec", "OsvSpec", "build_osv", "base_tree", "to_xlsx"] @@ -686,6 +688,47 @@ def _inn_layout_tree() -> Node: return Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third, sidorov()])]) +def _s29() -> list[SheetSpec]: + """xls BIFF5 без записи CODEPAGE, строки в cp1251 (AURORA-1662): отступ — пробелами, outline в xls-писателе нет.""" + return [build_osv(OsvSpec(hierarchy="spaces", inn_col=5, first_sum_col=7), base_tree())] + + +def _s30() -> list[SheetSpec]: + """Счёт 10: материал «Вал» (без точки) — обычная номенклатура, не валютная строка; «Кол-во» — строка количества + (AURORA-1663). «Вал.» с точкой и код валюты остаются метками (s04).""" + shaft = leaf("Вал", pair("1500.00", None), pair("300.00", "100.00")) + shaft.kids = [leaf("Кол-во", pair("10", None), pair("2", "1"), indicator=True)] + bolt = leaf("Болт М8", pair("200.00", None), pair("50.00", None)) + bolt.kids = [leaf("Кол-во", pair("400", None), pair("100", None), indicator=True)] + root = Node("10", [Node("10.1", [shaft, bolt])]) + spec = OsvSpec( + account="10", + variant=2, + period="2 квартал 2025 г.", + detail="Номенклатура", + detail_label="Детализация по субсчетам, субконто", + inn_col=None, + first_sum_col=3, + ) + return [build_osv(spec, root)] + + +def _s31() -> list[SheetSpec]: + """Рабочий файл аналитика (AURORA-1664): под «Итого» дописаны подпись «Ответственный:» и блок с числами, в том + числе строка, начинающаяся с «Итого»; таблица кончается на первой «Итого», хвост вне документа.""" + sheet = build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree()) + last = max(row for row, _ in sheet.cells) + sheet.put(last + 2, 1, "Ответственный:") + sheet.put(last + 2, 3, "Иванова И. И.") + sheet.put(last + 4, 1, "ООО Альфа (ГК)") + sheet.put(last + 4, 7, D("420000.00")) + sheet.put(last + 4, 8, D("250000.50")) + sheet.put(last + 5, 1, "Итого по ГК") + sheet.put(last + 5, 7, D("420000.00")) + sheet.put(last + 5, 8, D("250000.50")) + return [sheet] + + def _s28() -> list[SheetSpec]: """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" tree = _inn_layout_tree() @@ -745,6 +788,13 @@ def to_xlsx(sheets: Sequence[SheetSpec]) -> bytes: return _normalized_zip(buffer.getvalue()) +def to_xls(sheets: Sequence[SheetSpec]) -> bytes: + """xls BIFF5 без записи CODEPAGE (строки cp1251), без стилей: отступ — ведущие пробелы (`hierarchy="spaces"`).""" + return write_xls( + {spec.name: {key: cell.value for key, cell in spec.cells.items()} for spec in sheets}, codepage=None + ) + + def to_xlsb(sheets: Sequence[SheetSpec]) -> bytes: """xlsb без стилей и объединений: отступ уже выражен ведущими пробелами текста (`hierarchy="spaces"`).""" return write_xlsb({spec.name: {key: cell.value for key, cell in spec.cells.items()} for spec in sheets}) @@ -779,6 +829,9 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s26_inn_below_sibling_sums.xlsx": (_inn_layout("below_sibling", sums=True), to_xlsx), "s27_inn_below_sibling_blank.xlsx": (_inn_layout("below_sibling", sums=False), to_xlsx), "s28_inn_bad_checksum.xlsx": (_s28, to_xlsx), + "s29_xls_no_codepage.xls": (_s29, to_xls), + "s30_val_kol_vo_materials.xlsx": (_s30, to_xlsx), + "s31_rows_below_total.xlsx": (_s31, to_xlsx), } FILE_NAMES = tuple(FILES) @@ -797,7 +850,7 @@ def generate_all(directory: Path) -> list[Path]: def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="python -m tests.synthetic.generate", - description="Синтетические ОСВ s01…s28 (xlsx и один xlsb) в указанный каталог.", + description="Синтетические ОСВ s01…s31 (xlsx, один xlsb и один xls) в указанный каталог.", ) parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic") args = parser.parse_args(argv) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index e76c92e..b1dd329 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -16,6 +16,7 @@ from result_contract import assert_valid_result from src.contract import FileType, OsvDocument, ResultStatus from src.contract.result import OsvRow from src.parser import ParseOutcome, get_parser +from src.parser.readers.xls import NO_CODEPAGE_WARNING pytestmark = pytest.mark.fixtures @@ -37,6 +38,9 @@ INN_LAYOUT_FILES = ( "s27_inn_below_sibling_blank.xlsx", ) INN_BAD_CHECKSUM_FILE = "s28_inn_bad_checksum.xlsx" +XLS_NO_CODEPAGE_FILE = "s29_xls_no_codepage.xls" +VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx" +ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx" TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ "s01_variant2_header.xlsx", @@ -60,6 +64,9 @@ EXPECTED_NAMES = [ "s19_inn_kpp_cell.xlsx", *INN_LAYOUT_FILES, INN_BAD_CHECKSUM_FILE, + XLS_NO_CODEPAGE_FILE, + VAL_KOL_VO_FILE, + ROWS_BELOW_TOTAL_FILE, ] XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" @@ -127,6 +134,12 @@ EXPECTED: dict[str, Expect] = { **{name: Expect("outline", 2025, "Q1", True) for name in INN_LAYOUT_FILES}, # ИНН с неверной контрольной суммой: строка остаётся строкой, документ на проверку INN_BAD_CHECKSUM_FILE: Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK), + # xls BIFF5 без CODEPAGE, строки cp1251; отступ пробелами + XLS_NO_CODEPAGE_FILE: Expect("indent", 2025, "Q1", True), + # счёт 10, вариант 2: материал «Вал» и строки «Кол-во» + VAL_KOL_VO_FILE: Expect("outline", 2025, "Q2", False, variant=2), + # подпись и блок чисел под «Итого» + ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True), } @@ -198,7 +211,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: - """В `fixtures/osv/synthetic/` лежат файлы набора s01…s28 (s13 — xlsb; допустима деградация: нет только его).""" + """В `fixtures/osv/synthetic/` лежат файлы набора s01…s31 (s13 — xlsb; допустима деградация: нет только его).""" present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file()) assert present == [name for name in EXPECTED_NAMES if name in present] missing = [name for name in EXPECTED_NAMES if name not in present] @@ -616,3 +629,38 @@ def test_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None: } assert doc.columns is not None assert doc.columns["inn"] == "E" + + +def test_s29_xls_without_codepage_is_read_as_cp1251(fixtures_dir: Path) -> None: + """AURORA-1662: в книге BIFF5 нет CODEPAGE — кириллица читается как cp1251, предупреждение об этом есть.""" + outcome = parse_named(fixtures_dir, XLS_NO_CODEPAGE_FILE) + doc = one_document(outcome) + + assert outcome.status is ResultStatus.DONE + assert doc.header.organization_name == ORG + assert {ALFA, BETA, GAMMA} <= set(rows_text(doc)) + assert NO_CODEPAGE_WARNING in warnings_of(outcome) + + +def test_s30_val_without_dot_is_a_material_kol_vo_is_quantity(fixtures_dir: Path) -> None: + """AURORA-1663: «Вал» без точки — номенклатура (не CURRENCY), «Кол-во» — QUANTITY.""" + doc = one_document(parse_named(fixtures_dir, VAL_KOL_VO_FILE)) + + (shaft,) = [row for row in doc.rows if row.row_value == "Вал"] + assert shaft.data_type == doc.header.data_type + assert shaft.group_type == "OTHER" + quantities = [row for row in doc.rows if row.row_value == "Кол-во"] + assert [row.data_type for row in quantities] == ["QUANTITY", "QUANTITY"] + assert not [row for row in doc.rows if row.data_type == "CURRENCY"] + + +def test_s31_rows_below_total_are_outside_the_document(fixtures_dir: Path) -> None: + """AURORA-1664: таблица кончается на первой «Итого»; подпись и блок с числами ниже — не строки, а предупреждение.""" + outcome = parse_named(fixtures_dir, ROWS_BELOW_TOTAL_FILE) + doc = one_document(outcome) + + assert outcome.status is ResultStatus.DONE + assert rows_text(doc)[-1] == "Итого" + assert [row.group_type for row in doc.rows].count("TOTAL") == 1 + assert not {"Ответственный:", "ООО Альфа (ГК)", "Итого по ГК"} & set(rows_text(doc)) + assert any("ниже таблицы" in warning for warning in warnings_of(outcome)), warnings_of(outcome)