Синтетика s29–s31: xls без CODEPAGE, «Вал»/«Кол-во», строки ниже «Итого» (#47, AURORA-1662, 1663, 1664)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 11:50:39 +04:00
parent 8b91bcae3d
commit 6efe79fec9
3 changed files with 107 additions and 6 deletions

View File

@ -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` — только по контрольным суммам, структуру под сходимость не

View File

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

View File

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