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:
commit
623794936c
@ -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` — только по контрольным суммам, структуру под сходимость не
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -42,6 +42,11 @@ service_columns:
|
||||
total_row:
|
||||
- '^итого\b'
|
||||
|
||||
# Итоговая строка, чей текст — ровно «Итого»: ищется первой, чтобы контрагент «Итого-Сервис» на плоском листе (без outline
|
||||
# и отступов) не обрезал таблицу; нет такой — действует `total_row` (начало текста)
|
||||
total_row_exact:
|
||||
- '^итого$'
|
||||
|
||||
# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них
|
||||
total_row_secondary:
|
||||
- '^итого\s+развернут'
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
170
api/tests/synthetic/xls_writer.py
Normal file
170
api/tests/synthetic/xls_writer.py
Normal 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))
|
||||
@ -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),
|
||||
|
||||
@ -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 == []
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user