Merge pull request 'ОСВ: xls без CODEPAGE → cp1251, «Кол-во»/«Вал», таблица кончается на первой «Итого» (#47, AURORA-1662, 1663, 1664)' (#48) from feat/47-osv-encoding-labels-tail into main

This commit is contained in:
aep 2026-10-18 11:15:40 +03:00
commit 623794936c
13 changed files with 526 additions and 41 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/`, 127 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 32 синтетических) — регрессия, а не
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
проверяют property-тест и ручная сверка «Итого».
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не

View File

@ -12,10 +12,12 @@ header:
standalone_header:
- ['^бухгалтерск\w*(\s+учет\w*)?$', BU]
# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную
# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную. «Вал» без точки — не метка:
# так называется материал (счёт 10); валютная строка — «Вал.» с точкой или код валюты из `currency_codes` (AURORA-1663).
# «Кол-во»: нормализация текста превращает дефис в пробел
row:
- ['^вал\.?$', CURRENCY]
- ['^кол(ичество)?\.?$', QUANTITY]
- ['^вал\.$', CURRENCY]
- ['^кол(ичество|\s?во)?\.?$', QUANTITY]
# Коды валют ISO 4217 (буквенные): строка, целиком равная коду, — валютная; код же — результат `currency_aliases`
currency_codes: [RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED]

View File

@ -42,6 +42,11 @@ service_columns:
total_row:
- '^итого\b'
# Итоговая строка, чей текст — ровно «Итого»: ищется первой, чтобы контрагент «Итого-Сервис» на плоском листе (без outline
# и отступов) не обрезал таблицу; нет такой — действует `total_row` (начало текста)
total_row_exact:
- '^итого$'
# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них
total_row_secondary:
- '^итого\s+развернут'

View File

@ -115,34 +115,60 @@ def _text_cell_style(sheet: Sheet, row: int, col: int) -> tuple[bool, int]:
def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: Rules) -> int | None:
"""Строка «Итого»: последняя строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления.
"""Строка «Итого»: первая строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления.
Суммы не требуются (R-13: пустая ОСВ — «Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной
строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше,
чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» в середине данных не TOTAL (за ним есть настоящее
«Итого»), а последний «Итого-Сервис» под счётом с outline/отступом — тоже не TOTAL (T3.3).
Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — «Итого»
перед ней; без обычной «Итого» вторая строка служит единственной итоговой.
Известный риск: на листе без outline и отступов последняя строка-контрагент «Итого-…» при отсутствии настоящего
«Итого» станет TOTAL; это ловит проверка «TOTAL = корень» (§1.4).
Таблица кончается на первой «Итого» (AURORA-1664): всё ниже — вне документа, даже если там снова строки «Итого …»
(аналитик дописывает под ОСВ подпись и свои блоки ГК/ГСК с итогами). Сначала ищется первая строка корневого уровня,
чей текст — ровно «Итого» (`total_row_exact`), и только если такой нет — первая по `total_row` («Итого …»; I-1:
иначе контрагент «Итого-Сервис» на плоском листе обрезал бы таблицу). Суммы не требуются (R-13: пустая ОСВ —
«Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной строкой «Итого»), поэтому «корневой
уровень» проверяется по оформлению: outline level и отступ строки не больше, чем у любой другой строки таблицы.
Так контрагент «Итого-Сервис» под счётом с outline/отступом — не TOTAL (T3.3), а настоящее «Итого» ниже него —
TOTAL.
Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — обычная «Итого»
(в плоской форме она может стоять после «Итого развернутое»); без обычной «Итого» первая вторая строка служит
единственной итоговой.
Остаточный риск: если точного «Итого» нет вовсе, а на листе нет outline и отступов, контрагент «Итого-…» на корневом
уровне становится TOTAL; это ловит проверка «TOTAL = корень» (§1.4).
"""
if not rows:
return None
outline_floor = min(sheet.outline(row) for row in rows)
indent_floor = min(_text_cell_style(sheet, row, header.text_col)[1] for row in rows)
only_secondary: int | None = None
for row in reversed(rows):
text = sheet.text_at(row, header.text_col)
if not any(pattern.search(text) for pattern in rules.headers.total_row):
continue
_, indent = _text_cell_style(sheet, row, header.text_col)
if sheet.outline(row) != outline_floor or indent != indent_floor:
return only_secondary
if _is_secondary_total(text, rules):
roots = [
row
for row in rows
if sheet.outline(row) == outline_floor and _text_cell_style(sheet, row, header.text_col)[1] == indent_floor
]
for patterns in (rules.headers.total_row_exact, rules.headers.total_row):
only_secondary: int | None = None
for row in roots:
text = sheet.text_at(row, header.text_col)
if not any(pattern.search(text) for pattern in patterns):
continue
if not _is_secondary_total(text, rules):
return row
only_secondary = only_secondary or row
continue
return row
return only_secondary
if only_secondary is not None:
return only_secondary
return None
def _numbers_below(sheet: Sheet, header: TableHeader, last_row: int) -> list[int]:
"""Строки ниже `last_row` с числовыми ячейками в колонках таблицы (от колонки текста до правого края сумм):
хвост вне документа. Числа правее таблицы (контрольные суммы) хвостом не считаются."""
return sorted(
{
row
for (row, col), cell in sheet.cells.items()
if row > last_row and cell.kind == "number" and header.text_col <= col <= header.right_edge
}
)
def _tail_warning(rows: Sequence[int]) -> str:
where = f"строка {rows[0]}" if len(rows) == 1 else f"строки {rows[0]}–{rows[-1]}"
return f"{where}: ниже таблицы (после «Итого») есть числа — строки ниже «Итого» вне документа и не разобраны"
def _is_unlabelled_total(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> bool:
@ -268,7 +294,11 @@ def collect_body_rows(
f"строка {total_row}: строка сумм без подписи в конце таблицы принята за «Итого» (подписи в файле нет)"
)
if total_row is not None:
rows = rows[: rows.index(_trailing_totals(sheet, header, rows, total_row, rules)) + 1]
last = rows.index(_trailing_totals(sheet, header, rows, total_row, rules))
tail = _numbers_below(sheet, header, rows[last])
if tail:
warnings.append(_tail_warning(tail))
rows = rows[: last + 1]
label_cols = _label_cols(sheet, header, rows)
body: list[BodyRow] = []
for index, row in enumerate(rows, start=1):

View File

@ -26,7 +26,7 @@ from src.parser.readers._limits import (
check_xlsx_merged_area,
check_zip_uncompressed_size,
)
from src.parser.readers.xls import read_xls
from src.parser.readers.xls import NO_CODEPAGE_WARNING, has_no_codepage, read_xls
from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb
from src.parser.readers.xlsx import read_xlsx
@ -87,6 +87,8 @@ def read_book_detected(data: bytes, file_type: FileType, *, max_uncompressed_byt
check_merged_area(sheets)
if detected == "XLSB":
warnings.append(OUTLINE_UNAVAILABLE_WARNING)
if detected == "XLS" and has_no_codepage(data):
warnings.append(NO_CODEPAGE_WARNING)
return BookRead(sheets, warnings, detected)

View File

@ -8,12 +8,35 @@ import xlrd
from src.parser.grid import Cell, MergedRange, Sheet
from src.parser.readers._common import make_cell
__all__ = ["read_xls"]
__all__ = ["NO_CODEPAGE_WARNING", "has_no_codepage", "read_xls"]
# Кодовая страница 1С и Excel в русской локали: в BIFF5/7 она задаёт кодировку всех байтовых строк книги
FALLBACK_ENCODING = "cp1251"
NO_CODEPAGE_WARNING = f"xls без кодовой страницы — строки прочитаны как {FALLBACK_ENCODING}"
# BIFF8 хранит строки в Unicode и от CODEPAGE не зависит; `biff_version` xlrd для него — 80
_BIFF8 = 80
def has_no_codepage(data: bytes) -> bool:
"""В книге BIFF5/7 нет записи CODEPAGE: xlrd в этом случае декодирует байтовые строки как latin-1 (кириллица
превращается в мусор). Проверяется только глобальный раздел книги (`on_demand`: листы не разбираются)."""
try:
book = xlrd.open_workbook(file_contents=data, on_demand=True, logfile=io.StringIO())
except Exception: # noqa: BLE001 - нечитаемая книга: ошибку чтения даёт сам читатель, проба лишь не мешает ему
return False
try:
return bool(book.biff_version < _BIFF8 and book.codepage is None)
finally:
book.release_resources()
def read_xls(data: bytes) -> tuple[Sheet, ...]:
# logfile: xlrd пишет замечания о структуре BIFF в stdout — в выводе сервиса им не место
book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO())
# Нет CODEPAGE — читаем как cp1251, не угадывая кодировку по содержимому (решение владельца, AURORA-1662)
override = FALLBACK_ENCODING if has_no_codepage(data) else None
book = xlrd.open_workbook(
file_contents=data, formatting_info=True, logfile=io.StringIO(), encoding_override=override
)
try:
return tuple(_sheet(book, sheet) for sheet in book.sheets())
finally:

View File

@ -179,6 +179,7 @@ class OsvHeaderRules(_Model):
credit: list[Pattern]
service_columns: dict[str, list[Pattern]]
total_row: list[Pattern]
total_row_exact: list[Pattern]
total_row_secondary: list[Pattern]
column_captions: list[Pattern]
counterparty_inn_suffix: list[Pattern]

View File

@ -2,9 +2,10 @@
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35); описание — `osv/synthetic/README.md` в репозитории
fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты —
метки времени книги и zip постоянны, порядок записи фиксирован.
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47); описание — `osv/synthetic/README.md`
в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и
детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован.
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
@ -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,56 @@ 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 _s32() -> list[SheetSpec]:
"""Итоговая строка с подписью «Итого по счету 60» вместо точного «Итого» (I-1): правило `total_row` (начало текста)
действует, когда точного «Итого» на листе нет."""
sheet = build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree())
last = max(row for row, _ in sheet.cells)
sheet.cells[(last, 1)] = CellSpec("Итого по счету 60", bold=sheet.cells[(last, 1)].bold)
return [sheet]
def _s28() -> list[SheetSpec]:
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
tree = _inn_layout_tree()
@ -745,6 +797,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 +838,10 @@ 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),
"s32_total_with_suffix.xlsx": (_s32, to_xlsx),
}
FILE_NAMES = tuple(FILES)
@ -797,7 +860,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…s32 (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,10 @@ 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"
TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx"
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
EXPECTED_NAMES = [
"s01_variant2_header.xlsx",
@ -60,6 +65,10 @@ 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,
TOTAL_WITH_SUFFIX_FILE,
]
XLSB_NAME = "s13_no_outline_indent.xlsb"
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
@ -127,6 +136,14 @@ 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),
# итог подписан «Итого по счету 60»: точного «Итого» нет
TOTAL_WITH_SUFFIX_FILE: Expect("outline", 2025, "Q1", True),
}
@ -198,7 +215,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…s32 (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 +633,46 @@ 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)
def test_s32_total_with_suffix_is_total(fixtures_dir: Path) -> None:
"""I-1: нет точного «Итого» — итогом служит строка, начинающаяся с «Итого» («Итого по счету 60»)."""
doc = one_document(parse_named(fixtures_dir, TOTAL_WITH_SUFFIX_FILE))
assert doc.rows[-1].row_value == "Итого по счету 60"
assert doc.rows[-1].group_type == "TOTAL"

View File

@ -0,0 +1,170 @@
"""Минимальный писатель XLS BIFF5/7 для тестов: значения ячеек, имена листов, кодовая страница по выбору.
Это НЕ файл Excel: нет стилей (одна гарнитура и один XF), объединений, outline и свойств документа. Структура —
ровно то, что нужно `xlrd` с `formatting_info=True`: поток `Book` в контейнере OLE2 (v3, сектор 512 байт), globals
(`BOF`, [`CODEPAGE`], `FONT`, `XF`, `BOUNDSHEET`, `EOF`) и по подпотоку на лист (`BOF`, `DIMENSIONS`, `LABEL`/`NUMBER`,
`EOF`). Байтовые строки записываются в кодировке книги (cp1251 для русской локали 1С/Excel).
Назначение — xls без записи CODEPAGE (AURORA-1662): такие книги пишет 1С и старые выгрузки, а Excel и LibreOffice
запись CODEPAGE всегда добавляют, поэтому получить их из обычных инструментов нельзя. Запись детерминирована.
"""
import struct
from collections.abc import Mapping
from decimal import Decimal
__all__ = ["write_xls"]
CellValue = str | int | Decimal | float
SheetCells = Mapping[tuple[int, int], CellValue] # (строка, колонка) 1-based -> значение
CP1251 = 1251
_BOF = 0x0809
_EOF = 0x000A
_CODEPAGE = 0x0042
_FONT = 0x0031
_XF = 0x00E0
_BOUNDSHEET = 0x0085
_DIMENSIONS = 0x0000
_NUMBER = 0x0203
_LABEL = 0x0204
_BIFF5 = 0x0500
_TYPE_GLOBALS = 0x0005
_TYPE_SHEET = 0x0010
_MAX_STRING = 255
# --- контейнер OLE2 ---
_SECTOR = 512
_MINI_STREAM_CUTOFF = 4096 # поток короче лежит в мини-потоке — писатель его не делает, поэтому добивает поток нулями
_FAT_ENTRIES = _SECTOR // 4
_ENDOFCHAIN = 0xFFFFFFFE
_FATSECT = 0xFFFFFFFD
_FREESECT = 0xFFFFFFFF
_NOSTREAM = 0xFFFFFFFF
_DIR_ENTRY = 128
_OLE_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1")
def _record(opcode: int, payload: bytes = b"") -> bytes:
return struct.pack("<HH", opcode, len(payload)) + payload
def _bof(kind: int) -> bytes:
# версия BIFF5, тип подпотока, номер сборки и год — как у Excel 95/97 в режиме BIFF5
return _record(_BOF, struct.pack("<HHHH", _BIFF5, kind, 0x1CF2, 0x07CC))
def _label(row: int, col: int, text: str, encoding: str) -> bytes:
raw = text.encode(encoding, errors="replace")[:_MAX_STRING]
return _record(_LABEL, struct.pack("<HHHH", row - 1, col - 1, 0, len(raw)) + raw)
def _number(row: int, col: int, value: Decimal | float | int) -> bytes:
return _record(_NUMBER, struct.pack("<HHHd", row - 1, col - 1, 0, float(value)))
def _sheet_stream(cells: SheetCells, encoding: str) -> bytes:
rows = [row for row, _ in cells]
cols = [col for _, col in cells]
body = [_bof(_TYPE_SHEET)]
body.append(
_record(
_DIMENSIONS,
struct.pack(
"<HHHH", min(rows, default=1) - 1, max(rows, default=0), min(cols, default=1) - 1, max(cols, default=0)
),
)
)
for (row, col), value in sorted(cells.items()):
if isinstance(value, str):
body.append(_label(row, col, value, encoding))
else:
body.append(_number(row, col, value))
body.append(_record(_EOF))
return b"".join(body)
def _globals_stream(names: list[bytes], sheet_sizes: list[int], codepage: int | None) -> bytes:
"""Globals; смещения листов зависят от длины самих globals, поэтому длина считается по заготовке с нулями."""
def build(offsets: list[int]) -> bytes:
parts = [_bof(_TYPE_GLOBALS)]
if codepage is not None:
parts.append(_record(_CODEPAGE, struct.pack("<H", codepage)))
arial = b"Arial"
parts.append(_record(_FONT, struct.pack("<HHHHHBBBBB", 200, 0, 0x7FFF, 400, 0, 0, 0, 0, 0, len(arial)) + arial))
# XF BIFF5 (16 байт): шрифт 0, формат General (0), стиль-родитель, без выравнивания/границ/заливки
parts.append(_record(_XF, struct.pack("<HHHBBIi", 0, 0, 0xFFF5, 0, 0, 0, 0)))
for name, offset in zip(names, offsets, strict=True):
parts.append(_record(_BOUNDSHEET, struct.pack("<IBBB", offset, 0, 0, len(name)) + name))
parts.append(_record(_EOF))
return b"".join(parts)
head = len(build([0] * len(names)))
offsets: list[int] = []
position = head
for size in sheet_sizes:
offsets.append(position)
position += size
return build(offsets)
def _directory(stream_size: int) -> bytes:
def entry(name: str, kind: int, child: int, start: int, size: int) -> bytes:
encoded = name.encode("utf-16-le") + b"\x00\x00" if name else b""
fields = struct.pack(
"<64sHBBIII16sIQQIII",
encoded,
len(encoded),
kind,
1,
_NOSTREAM,
_NOSTREAM,
child,
b"",
0,
0,
0,
start,
size,
0,
)
return fields
empty = entry("", 0, _NOSTREAM, 0, 0)
entries = [entry("Root Entry", 5, 1, _ENDOFCHAIN, 0), entry("Book", 2, _NOSTREAM, 2, stream_size), empty, empty]
return b"".join(entries)
def _ole2(stream: bytes) -> bytes:
"""Контейнер OLE2 с единственным потоком `Book`: сектор 0 — FAT, 1 — каталог, дальше — поток."""
padded = stream.ljust(max(len(stream), _MINI_STREAM_CUTOFF), b"\x00")
padded = padded.ljust(-(-len(padded) // _SECTOR) * _SECTOR, b"\x00")
count = len(padded) // _SECTOR
if count + 2 > _FAT_ENTRIES:
raise ValueError(f"книга {len(padded)} байт не помещается в один сектор FAT писателя")
fat = [_FATSECT, _ENDOFCHAIN, *range(3, count + 2), _ENDOFCHAIN]
fat += [_FREESECT] * (_FAT_ENTRIES - len(fat))
header = struct.pack(
"<8s16sHHHHH6sIIIIIIIII",
_OLE_SIGNATURE, b"", 0x003E, 3, 0xFFFE, 9, 6, b"",
0, 1, 1, 0, _MINI_STREAM_CUTOFF, _ENDOFCHAIN, 0, _ENDOFCHAIN, 0,
) # fmt: skip
difat = struct.pack("<109I", 0, *([_FREESECT] * 108))
return (
(header + difat).ljust(_SECTOR, b"\x00")
+ struct.pack(f"<{_FAT_ENTRIES}I", *fat)
+ _directory(len(padded)).ljust(_SECTOR, b"\x00")
+ padded
)
def write_xls(sheets: Mapping[str, SheetCells], *, codepage: int | None = CP1251, encoding: str = "cp1251") -> bytes:
"""xls BIFF5 с листами `sheets` (имя -> ячейки). `codepage=None` — записи CODEPAGE в книге нет (AURORA-1662);
строки при этом всё равно записаны в `encoding`: так их пишет 1С в русской локали."""
names = [name.encode(encoding, errors="replace") for name in sheets]
streams = [_sheet_stream(cells, encoding) for cells in sheets.values()]
globals_part = _globals_stream(names, [len(item) for item in streams], codepage)
return _ole2(globals_part + b"".join(streams))

View File

@ -12,7 +12,7 @@ import pytest
from gridbuilder import C, L, M, sheet_from_rows
from src.contract.common import DataType
from src.parser.classify import ACCOUNT_RE, ClassifiedRow, classify_rows, collect_body_rows
from src.parser.classify import ACCOUNT_RE, ClassifiedRow, classify_rows, collect_body_rows, row_data_type
from src.parser.deadline import Deadline
from src.parser.detect import find_table_header
from src.parser.errors import ParseTimeoutError
@ -28,6 +28,7 @@ TURNOVER = "Обороты за период"
CLOSING = "Сальдо на конец периода"
TIMEOUT_SECONDS = 60
EXPIRED_AFTER_SECONDS = 1000
TAIL_NUMBERS_MARK = "ниже таблицы"
ACCOUNT_ROW = 7 # первая строка данных в сетке `osv(...)`: шапка 3 строки + блок заголовка 3 строки
DUPLICATED_EXTRA_COLUMNS = 2
@ -411,6 +412,23 @@ def test_currency_and_quantity_rows(rules: Rules) -> None:
assert (usd.group_type, usd.group_name, usd.account_code_raw) == ("OTHER", None, "60")
@pytest.mark.parametrize(
("text", "expected"),
[
("Кол.", "QUANTITY"),
("Количество", "QUANTITY"),
("Кол-во", "QUANTITY"), # AURORA-1663, O-5
("кол-во.", "QUANTITY"),
("Вал.", "CURRENCY"),
("USD", "CURRENCY"),
("Вал", None), # AURORA-1663, O-6: «Вал» без точки — наименование материала (ОСВ 10.1), не метка валюты
("Вал стальной", None),
],
)
def test_row_data_type_labels(rules: Rules, text: str, expected: str | None) -> None:
assert row_data_type(text, rules, frozenset(rules.data_types.currency_codes)) == expected
def test_doc_data_type_none_passes_through(rules: Rules) -> None:
parsed = parse(_basic(), rules, doc_data_type=None)
@ -478,6 +496,68 @@ def test_footer_after_total_is_not_data_nor_extra(rules: Rules) -> None:
assert parsed.total_row == parsed.one("Итого").body.row
def test_table_ends_at_first_total_everything_below_is_outside(rules: Rules) -> None:
"""AURORA-1664 (O-12): под таблицей дописаны подпись и блок аналитика, в нём есть и строка «Итого …»: таблица
кончается на первой «Итого», хвост вне документа — ни строкой, ни TOTAL; числа в хвосте — предупреждение.
"""
sheet = osv(
r(0, "60", S100),
r(1, "ООО Поставщик", S100, inn="7701234567"),
r(None, "Итого", S100),
[],
["Ответственный:", None, "Иванова"],
r(None, "ООО Альфа (ГК)", (7, None, 5, None, 12, None)),
r(None, "Итого по ГК", (7, None, 5, None, 12, None)),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"]
assert parsed.total_row == parsed.one("Итого").body.row
assert [row.group_type for row in parsed.rows].count("TOTAL") == 1
assert any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings
def test_text_only_tail_below_total_gives_no_warning(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(None, "Итого", S100),
["Ответственный:", None, "Иванова"],
)
parsed = parse(sheet, rules)
assert not any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings
def test_second_total_then_tail_numbers(rules: Rules) -> None:
"""«Итого развернутое» сразу за «Итого» — ещё таблица; числа ниже неё — хвост."""
sheet = osv(
r(0, "60", S100),
r(None, "Итого", S100),
r(None, "Итого развернутое", S100),
r(None, "Итого", (1, None, None, None, 1, None)),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "Итого", "Итого развернутое"]
assert any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings)
def test_numbers_right_of_table_below_total_give_no_warning(rules: Rules) -> None:
"""Контрольные суммы правее таблицы под «Итого» — не хвост таблицы: предупреждения нет."""
sheet = osv(
r(0, "60", S100),
r(None, "Итого", S100),
[None, None, None, None, None, None, None, None, None, None, None, 375490642],
)
parsed = parse(sheet, rules)
assert not any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings
def test_column_filled_only_in_footer_is_not_an_extra_key(rules: Rules) -> None:
"""T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы."""
sheet = sheet_from_rows(
@ -798,6 +878,24 @@ def test_counterparty_named_like_total_in_the_middle(rules: Rules) -> None:
assert [row.group_type for row in parsed.rows].count("TOTAL") == 1
def test_flat_counterparty_named_like_total_before_exact_total(rules: Rules) -> None:
"""I-1: плоский лист без outline и отступов; «Итого-Сервис» с суммами в середине — строка данных, а не конец
таблицы: TOTAL — точное «Итого», данных ниже контрагента не теряется, хвоста нет."""
sheet = osv(
r(None, "60", (110, None, 50, 30, 130, None)),
r(None, "Итого-Сервис", S100, inn="7701234567"),
r(None, "ООО Поставщик", (10, None, None, None, 10, None), inn="7707654321"),
r(None, "Итого", (110, None, 50, 30, 130, None)),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "Итого-Сервис", "ООО Поставщик", "Итого"]
assert parsed.total_row == parsed.one("Итого").body.row
assert parsed.one("Итого-Сервис").group_type != "TOTAL"
assert not any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings
def test_last_row_named_like_total_is_rejected_by_outline_level(rules: Rules) -> None:
"""Последняя строка «Итого-Сервис» без настоящего «Итого»: outline глубже минимального — не TOTAL."""
sheet = osv(
@ -812,9 +910,9 @@ def test_last_row_named_like_total_is_rejected_by_outline_level(rules: Rules) ->
def test_known_risk_last_counterparty_named_like_total_without_outline(rules: Rules) -> None:
"""Известный риск (документирован): без outline и отступов последняя строка «Итого-Сервис» при отсутствии
настоящего «Итого» неотличима от итоговой строки и становится TOTAL; контрольные суммы §1.4 (TOTAL = корень)
это поймают, если суммы не совпадают.
"""Остаточный риск (документирован): только когда точного «Итого» нет вовсе, а на листе нет outline и отступов,
первая строка «Итого-Сервис» на корневом уровне неотличима от итоговой строки и становится TOTAL; контрольные суммы
§1.4 (TOTAL = корень) это поймают, если суммы не совпадают.
"""
sheet = osv(
r(None, "60", S100),

View File

@ -18,6 +18,8 @@ from src.parser.grid import EMPTY_CELL, Cell, MergedRange
from src.parser.readers import read_book
from src.parser.readers import xlsb as xlsb_reader
from src.parser.readers._common import make_cell
from src.parser.readers.xls import NO_CODEPAGE_WARNING
from tests.synthetic.xls_writer import write_xls
def _synthetic_xlsx() -> bytes:
@ -208,3 +210,27 @@ def test_integral_float_is_read_without_fraction(value: float, expected_text: st
assert cell is not None
assert (cell.kind, cell.text) == ("number", expected_text)
assert cell.value == Decimal(str(value))
# --- xls без записи CODEPAGE (AURORA-1662) ---
_CYRILLIC_CELLS = {(1, 1): "Оборотно-сальдовая ведомость", (2, 2): Decimal("12.5")}
def test_xls_without_codepage_is_read_as_cp1251_with_warning() -> None:
data = write_xls({"ОСВ 60": _CYRILLIC_CELLS}, codepage=None)
sheets, warnings = read_book(data, "XLS")
assert [sheet.name for sheet in sheets] == ["ОСВ 60"]
assert sheets[0].text_at(1, 1) == "Оборотно-сальдовая ведомость"
assert warnings == [NO_CODEPAGE_WARNING]
def test_xls_with_codepage_is_read_as_before_without_warning() -> None:
data = write_xls({"ОСВ 60": _CYRILLIC_CELLS}, codepage=1251)
sheets, warnings = read_book(data, "XLS")
assert sheets[0].text_at(1, 1) == "Оборотно-сальдовая ведомость"
assert warnings == []

View File

@ -571,13 +571,18 @@ def test_header_data_type_unknown(rules: Rules) -> None:
@pytest.mark.parametrize(
("text", "expected"),
[("Вал.", "CURRENCY"), ("вал", "CURRENCY"), ("Кол.", "QUANTITY"), ("Количество", "QUANTITY")],
[
("Вал.", "CURRENCY"),
("Кол.", "QUANTITY"),
("Количество", "QUANTITY"),
("Кол во", "QUANTITY"), # «Кол-во» после нормализации текста (дефис -> пробел)
],
)
def test_row_data_type(rules: Rules, text: str, expected: str) -> None:
assert first_match(rules.data_types.row, text) == expected
@pytest.mark.parametrize("text", ["Валюта расчетов ООО", "Колесо", "60.01", "АО Русский бисквит"])
@pytest.mark.parametrize("text", ["Вал", "Валюта расчетов ООО", "Колесо", "60.01", "АО Русский бисквит"])
def test_row_data_type_does_not_fire_on_ordinary_rows(rules: Rules, text: str) -> None:
assert first_match(rules.data_types.row, text) is None