Синтетика s29–s31: xls без CODEPAGE, «Вал»/«Кол-во», строки ниже «Итого» (#47, AURORA-1662, 1663, 1664)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8b91bcae3d
commit
6efe79fec9
@ -18,7 +18,7 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам (
|
|||||||
`../fixtures/osv-dkb/**` — `tests/fixtures/test_osv_dkb.py`: ожидаемые `needs_check` — манифест
|
`../fixtures/osv-dkb/**` — `tests/fixtures/test_osv_dkb.py`: ожидаемые `needs_check` — манифест
|
||||||
`tests/osv_dkb_expectations.yaml` (данные), период — по имени каталога (`2025-Q3`, `2024-10_2025-09`);
|
`tests/osv_dkb_expectations.yaml` (данные), период — по имени каталога (`2025-Q3`, `2024-10_2025-09`);
|
||||||
- снимки `../fixtures/golden/expected_osv_parse/*.json` — после ручной сверки «Итого»;
|
- снимки `../fixtures/golden/expected_osv_parse/*.json` — после ручной сверки «Итого»;
|
||||||
- синтетика только для вариантов, которых нет в фикстурах (шапка вариант 2, Вал./Кол., xlsb, без outline level).
|
- синтетика только для вариантов, которых нет в фикстурах (шапка вариант 2, Вал./Кол., xlsb, xls без CODEPAGE, без outline level).
|
||||||
|
|
||||||
## Соглашения шаблона
|
## Соглашения шаблона
|
||||||
Тот же шаблон команды, что у `arm-core` («Шаблон FastAPI», `RSHB/DKB_ARMKA@ba172f8`); общие соглашения —
|
Тот же шаблон команды, что у `arm-core` («Шаблон FastAPI», `RSHB/DKB_ARMKA@ba172f8`); общие соглашения —
|
||||||
@ -70,7 +70,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
|
|||||||
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
|
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
|
||||||
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
|
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
|
||||||
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
|
(`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`. Корректность
|
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
|
||||||
проверяют property-тест и ручная сверка «Итого».
|
проверяют property-тест и ручная сверка «Итого».
|
||||||
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
|
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
|
||||||
|
|||||||
@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
|
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
|
||||||
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
|
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
|
||||||
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35); описание — `osv/synthetic/README.md` в репозитории
|
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s31 — xls без CODEPAGE, «Вал»/«Кол-во» и
|
||||||
fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты —
|
строки ниже «Итого» (#47); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
|
||||||
|
(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты —
|
||||||
метки времени книги и zip постоянны, порядок записи фиксирован.
|
метки времени книги и zip постоянны, порядок записи фиксирован.
|
||||||
|
|
||||||
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
||||||
@ -26,6 +27,7 @@ from typing import Literal
|
|||||||
import openpyxl
|
import openpyxl
|
||||||
from openpyxl.styles import Alignment, Font
|
from openpyxl.styles import Alignment, Font
|
||||||
|
|
||||||
|
from tests.synthetic.xls_writer import write_xls
|
||||||
from tests.synthetic.xlsb_writer import write_xlsb
|
from tests.synthetic.xlsb_writer import write_xlsb
|
||||||
|
|
||||||
__all__ = ["FILE_NAMES", "generate_all", "main", "ORG", "SheetSpec", "OsvSpec", "build_osv", "base_tree", "to_xlsx"]
|
__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()])])
|
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]:
|
def _s28() -> list[SheetSpec]:
|
||||||
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
|
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
|
||||||
tree = _inn_layout_tree()
|
tree = _inn_layout_tree()
|
||||||
@ -745,6 +788,13 @@ def to_xlsx(sheets: Sequence[SheetSpec]) -> bytes:
|
|||||||
return _normalized_zip(buffer.getvalue())
|
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:
|
def to_xlsb(sheets: Sequence[SheetSpec]) -> bytes:
|
||||||
"""xlsb без стилей и объединений: отступ уже выражен ведущими пробелами текста (`hierarchy="spaces"`)."""
|
"""xlsb без стилей и объединений: отступ уже выражен ведущими пробелами текста (`hierarchy="spaces"`)."""
|
||||||
return write_xlsb({spec.name: {key: cell.value for key, cell in spec.cells.items()} for spec in sheets})
|
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),
|
"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),
|
"s27_inn_below_sibling_blank.xlsx": (_inn_layout("below_sibling", sums=False), to_xlsx),
|
||||||
"s28_inn_bad_checksum.xlsx": (_s28, 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)
|
FILE_NAMES = tuple(FILES)
|
||||||
|
|
||||||
@ -797,7 +850,7 @@ def generate_all(directory: Path) -> list[Path]:
|
|||||||
def main(argv: Sequence[str] | None = None) -> int:
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="python -m tests.synthetic.generate",
|
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")
|
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|||||||
@ -16,6 +16,7 @@ from result_contract import assert_valid_result
|
|||||||
from src.contract import FileType, OsvDocument, ResultStatus
|
from src.contract import FileType, OsvDocument, ResultStatus
|
||||||
from src.contract.result import OsvRow
|
from src.contract.result import OsvRow
|
||||||
from src.parser import ParseOutcome, get_parser
|
from src.parser import ParseOutcome, get_parser
|
||||||
|
from src.parser.readers.xls import NO_CODEPAGE_WARNING
|
||||||
|
|
||||||
pytestmark = pytest.mark.fixtures
|
pytestmark = pytest.mark.fixtures
|
||||||
|
|
||||||
@ -37,6 +38,9 @@ INN_LAYOUT_FILES = (
|
|||||||
"s27_inn_below_sibling_blank.xlsx",
|
"s27_inn_below_sibling_blank.xlsx",
|
||||||
)
|
)
|
||||||
INN_BAD_CHECKSUM_FILE = "s28_inn_bad_checksum.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
|
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
|
||||||
EXPECTED_NAMES = [
|
EXPECTED_NAMES = [
|
||||||
"s01_variant2_header.xlsx",
|
"s01_variant2_header.xlsx",
|
||||||
@ -60,6 +64,9 @@ EXPECTED_NAMES = [
|
|||||||
"s19_inn_kpp_cell.xlsx",
|
"s19_inn_kpp_cell.xlsx",
|
||||||
*INN_LAYOUT_FILES,
|
*INN_LAYOUT_FILES,
|
||||||
INN_BAD_CHECKSUM_FILE,
|
INN_BAD_CHECKSUM_FILE,
|
||||||
|
XLS_NO_CODEPAGE_FILE,
|
||||||
|
VAL_KOL_VO_FILE,
|
||||||
|
ROWS_BELOW_TOTAL_FILE,
|
||||||
]
|
]
|
||||||
XLSB_NAME = "s13_no_outline_indent.xlsb"
|
XLSB_NAME = "s13_no_outline_indent.xlsb"
|
||||||
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
|
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},
|
**{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),
|
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:
|
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())
|
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]
|
assert present == [name for name in EXPECTED_NAMES if name in present]
|
||||||
missing = [name for name in EXPECTED_NAMES if name not 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 is not None
|
||||||
assert doc.columns["inn"] == "E"
|
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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user