diff --git a/api/src/parser/grid.py b/api/src/parser/grid.py index 91af5f9..9918f3d 100644 --- a/api/src/parser/grid.py +++ b/api/src/parser/grid.py @@ -30,6 +30,9 @@ class Cell: kind: CellKind bold: bool = False indent: int = 0 # alignment.indent, а при его отсутствии — число ведущих пробелов текста + # Числовой формат ячейки: только у числовых ячеек с форматом не General (xlsx, xls; xlsb формат не отдаёт). + # Используется только разбором ББ/ОФР; ОСВ его игнорирует + number_format: str | None = None @property def text(self) -> str: diff --git a/api/src/parser/readers/_common.py b/api/src/parser/readers/_common.py index 8446fdb..2f5b5f9 100644 --- a/api/src/parser/readers/_common.py +++ b/api/src/parser/readers/_common.py @@ -15,13 +15,14 @@ def leading_spaces(text: str) -> int: return len(text) - len(stripped) if stripped else 0 -def make_cell(value: object, *, bold: bool = False, indent: int = 0) -> Cell | None: +def make_cell(value: object, *, bold: bool = False, indent: int = 0, number_format: str | None = None) -> Cell | None: """Ячейка из значения библиотеки чтения; `None` — пустая ячейка (в `Sheet.cells` не попадает). Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); целый float — как int (без «.0»); bool проверяется раньше int. Строка из одних пробельных символов (включая NBSP) — пустая ячейка (`None`): «пробелы» = пусто на уровне хранения, а не только в аксессорах `Sheet`. Время без даты — текстом. + `number_format` сохраняется только у числовых ячеек и только если он непустой и не General. """ if value is None or value == "": return None @@ -31,7 +32,8 @@ def make_cell(value: object, *, bold: bool = False, indent: int = 0) -> Cell | N # xls хранит любое число как float: «2370012036.0» — артефакт чтения, в файле целое (OQ-162) value = int(value) if isinstance(value, int | float): - return Cell(Decimal(str(value)), "number", bold=bold, indent=indent) + shown = number_format if number_format and number_format.strip().lower() != "general" else None + return Cell(Decimal(str(value)), "number", bold=bold, indent=indent, number_format=shown) if isinstance(value, datetime.date): # datetime.datetime — подкласс date return Cell(value, "date", bold=bold, indent=indent) text = value if isinstance(value, str) else str(value) diff --git a/api/src/parser/readers/xls.py b/api/src/parser/readers/xls.py index f5d05d0..349f4cf 100644 --- a/api/src/parser/readers/xls.py +++ b/api/src/parser/readers/xls.py @@ -34,6 +34,7 @@ def _sheet(book: Any, sheet: Any) -> Sheet: value, bold=bool(book.font_list[xf.font_index].bold), indent=int(xf.alignment.indent_level or 0), + number_format=_number_format(book, xf), ) if cell is not None: cells[(rowx + 1, colx + 1)] = cell @@ -66,3 +67,9 @@ def _value(book: Any, ctype: int, raw: Any) -> object: except (xlrd.xldate.XLDateError, ValueError, OverflowError): return raw # серийный номер вне диапазона дат — остаётся числом, как в файле return raw + + +def _number_format(book: Any, xf: Any) -> str | None: + """Строка числового формата XF; неизвестный ключ формата — None (как General).""" + fmt = book.format_map.get(xf.format_key) + return str(fmt.format_str) if fmt is not None else None diff --git a/api/src/parser/readers/xlsb.py b/api/src/parser/readers/xlsb.py index 760fe58..6b7b557 100644 --- a/api/src/parser/readers/xlsb.py +++ b/api/src/parser/readers/xlsb.py @@ -1,4 +1,5 @@ -"""Чтение XLSB (BIFF12) через pyxlsb: только значения; outline level, merged и шрифт библиотека не отдаёт.""" +"""Чтение XLSB (BIFF12) через pyxlsb: только значения; outline level, merged, шрифт и +числовой формат (Cell.number_format) библиотека не отдаёт.""" import io from typing import Any diff --git a/api/src/parser/readers/xlsx.py b/api/src/parser/readers/xlsx.py index efaf1cc..065963e 100644 --- a/api/src/parser/readers/xlsx.py +++ b/api/src/parser/readers/xlsx.py @@ -24,7 +24,12 @@ def _sheet(ws: Any) -> Sheet: # `_cells` — разреженное хранилище openpyxl (версия закреплена): iter_rows создал бы пустые ячейки на весь # прямоугольник листа for source in ws._cells.values(): - cell = make_cell(source.value, bold=bool(source.font.bold), indent=int(source.alignment.indent or 0)) + cell = make_cell( + source.value, + bold=bool(source.font.bold), + indent=int(source.alignment.indent or 0), + number_format=source.number_format, + ) if cell is not None: cells[(source.row, source.column)] = cell merged = tuple( diff --git a/api/tests/unit/parser/test_reader_number_format.py b/api/tests/unit/parser/test_reader_number_format.py new file mode 100644 index 0000000..91d7a1f --- /dev/null +++ b/api/tests/unit/parser/test_reader_number_format.py @@ -0,0 +1,75 @@ +"""Числовой формат числовых ячеек в `Cell.number_format` (#42): xlsx синтетикой, xls — реальным файлом из fixtures.""" + +import datetime +import io +from pathlib import Path + +import openpyxl +import pytest + +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 tests.unit.parser.test_readers import _FakeXlsbBook, _zip_with + +FILLED_CELLS = 6 +UNAGRANDE_XLS = "statements-xlsx/unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls" + + +def _xlsx() -> bytes: + book = openpyxl.Workbook() + ws = book.active + assert ws is not None + ws["A1"] = 10741981390 + ws["A1"].number_format = "#,##0," + ws["A2"] = 586260.26 + ws["A2"].number_format = "0," + ws["A3"] = 5 + ws["A4"] = 7 + ws["A4"].number_format = "General" + ws["A5"] = "текст" + ws["A5"].number_format = "0," + ws["A6"] = datetime.datetime(2025, 9, 30) + ws["A6"].number_format = "dd.mm.yyyy" + out = io.BytesIO() + book.save(out) + return out.getvalue() + + +def test_xlsx_number_format_is_kept_for_numbers_only() -> None: + sheets, _ = read_book(_xlsx(), "XLSX") + sheet = sheets[0] + + assert sheet.cell(1, 1).number_format == "#,##0," + assert sheet.cell(2, 1).number_format == "0," + assert sheet.cell(3, 1).number_format is None # General по умолчанию + assert sheet.cell(4, 1).number_format is None # явный General + assert sheet.cell(5, 1).number_format is None # текст + assert sheet.cell(6, 1).kind == "date" + assert sheet.cell(6, 1).number_format is None # дата + assert len(sheet.cells) == FILLED_CELLS # разреженность: Cell только у непустых + + +def test_make_cell_number_format_rules() -> None: + assert make_cell(1.5, number_format="GENERAL").number_format is None # type: ignore[union-attr] + assert make_cell(1.5, number_format="").number_format is None # type: ignore[union-attr] + assert make_cell(1.5, number_format="0,").number_format == "0," # type: ignore[union-attr] + assert make_cell("x", number_format="0,").number_format is None # type: ignore[union-attr] + assert make_cell(datetime.datetime(2025, 1, 1), number_format="0,").number_format is None # type: ignore[union-attr] + + +@pytest.mark.fixtures +def test_xls_number_format_is_read(fixtures_dir: Path) -> None: + sheets, _ = read_book((fixtures_dir / UNAGRANDE_XLS).read_bytes(), "XLS") + + formats = {cell.number_format for sheet in sheets for cell in sheet.cells.values() if cell.kind == "number"} + assert {"#,##0,", "0,", '[=0]"-"'} <= formats + assert all(cell.number_format is None for sheet in sheets for cell in sheet.cells.values() if cell.kind != "number") + + +def test_xlsb_cells_have_no_number_format(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(xlsb_reader.pyxlsb, "open_workbook", lambda _stream: _FakeXlsbBook()) + + sheets, _ = read_book(_zip_with("xl/workbook.bin"), "XLSB") + + assert all(cell.number_format is None for cell in sheets[0].cells.values())