ББ/ОФР из Excel: значение как показывает числовой формат (#42, AURORA-1528) #43

Merged
aep merged 4 commits from feat/42-xls-number-format into main 2026-10-15 17:37:31 +03:00
25 changed files with 869 additions and 51 deletions

View File

@ -117,5 +117,6 @@ property, покрытие словарей); без каталога `../fixtur
просмотром diff и отдельным коммитом в `fixtures`. просмотром diff и отдельным коммитом в `fixtures`.
**Контракт v1.2:** `CONTRACT_VERSION = "1.2"`; `documents[]` получает `BALANCE`/`INCOME`/`DISCLOSURE` (`statementDocument`: **Контракт v1.2:** `CONTRACT_VERSION = "1.2"`; `documents[]` получает `BALANCE`/`INCOME`/`DISCLOSURE` (`statementDocument`:
`header`, `lines`, `checks`); копия `api/contracts/` идентична `../docs/contracts/`. Интеграционный тест книги НПЗ ожидает `header`, `lines`, `checks`); копия `api/contracts/` идентична `../docs/contracts/`. Значения ББ/ОФР из Excel — как их показывает
`needs_check` (рубли при шапке «тыс. руб.», OQ-138), а не `done`. числовой формат ячейки (#42, `statements/number_format.py`; xlsb — как в файле + предупреждение); ОСВ формат не применяет.
Книга НПЗ в манифесте — `done` (итоги расходятся в пределах округления, OQ-138).

View File

@ -297,9 +297,16 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63
### Ограничения ### Ограничения
- Единицы не нормализуются: `amount` как в ячейке, `units` — текст шапки. При «тыс. руб.» и доле значений с дробной - Единицы не нормализуются: `units` — текст шапки. Значение `amount` — **как его показывает документ** (#42): в xlsx/xls
частью не меньше `fractional_share_percent` выдаётся одно предупреждение на документ; само оно на статус не влияет — применяется числовой формат ячейки (`statements/number_format.py`): `#,##0,` и `0,` делят на 1000 (рубли с копейками
`needs_check` задают нарушенные соотношения (при рублях с копейками итоги расходятся, OQ-138). из 1С становятся тысячами), `#,##0` округляет до целого (половина от нуля, как в Excel), условные секции
(`[=0]"-"`, `[=-646532]"(647)"`) дают прочерк или текст-литерал («(647)» -> -647). Формат `General` — сырое значение.
Формат, который не удалось разобрать (`%`, даты, дроби, экспонента), — сырое значение и одно предупреждение на
документ: `формат чисел не разобран, значения как в ячейках: «…»`. В xlsb числовые форматы не читаются (pyxlsb): значения
как в файле и одно предупреждение на документ `формат чисел xlsb не читается: значения как в файле`. Разбор ОСВ
формат ячейки не применяет. При «тыс. руб.» и доле значений (по показываемым) с дробной частью не меньше
`fractional_share_percent` выдаётся одно предупреждение на документ; само оно на статус не влияет — `needs_check`
задают нарушенные соотношения (расхождение до `rounding_tolerance_percent` — мягкое нарушение, OQ-138).
- Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся - Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся
одиноким продолжением и даёт `needs_check`. одиноким продолжением и даёт `needs_check`.
- Лист с 1–2 кодами форм (меньше `min_codes`) формой не считается, если идёт первым; после листа формы он берётся - Лист с 1–2 кодами форм (меньше `min_codes`) формой не считается, если идёт первым; после листа формы он берётся
@ -314,16 +321,16 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63
|---|---|---| |---|---|---|
| `unagrande-co/2024-FY/Б.Б Лист 1.xls` | BALANCE `needs_check` | только актив: `1600 = 1700` не сходится | | `unagrande-co/2024-FY/Б.Б Лист 1.xls` | BALANCE `needs_check` | только актив: `1600 = 1700` не сходится |
| `unagrande-co/2024-FY/Б.Б. Лист 2.xls` | BALANCE `needs_check` | страница 2 без шапки | | `unagrande-co/2024-FY/Б.Б. Лист 2.xls` | BALANCE `needs_check` | страница 2 без шапки |
| `unagrande-co/2024-FY/ОФР. Лист 1.xls` | INCOME `needs_check` | рубли с копейками при шапке «тыс. руб.»: 2100/2200/2300 расходятся с суммой частей (OQ-138) | | `unagrande-co/2024-FY/ОФР. Лист 1.xls` | INCOME `done` | значения по числовому формату (тыс. руб.); мягко расходится `2400 = 2300 - 2410 + 2420` (в пределах допуска, OQ-138) |
| `unagrande-co/2024-FY/ОФР. Лист 2 .xls` | INCOME `needs_check` | страница 2 без шапки | | `unagrande-co/2024-FY/ОФР. Лист 2 .xls` | INCOME `needs_check` | страница 2 без шапки |
| `unagrande-group/2024-FY/ББ. Лист 1 .xls` | BALANCE `needs_check` | только актив | | `unagrande-group/2024-FY/ББ. Лист 1 .xls` | BALANCE `needs_check` | только актив |
| `unagrande-group/2024-FY/ББ. Лист 2 (2).xls` | BALANCE `needs_check` | страница 2 без шапки | | `unagrande-group/2024-FY/ББ. Лист 2 (2).xls` | BALANCE `needs_check` | страница 2 без шапки |
| `unagrande-group/2024-FY/ОФР. Лист 2.xls` | INCOME `needs_check` | страница 2 без шапки, страницы 1 в пакете нет | | `unagrande-group/2024-FY/ОФР. Лист 2.xls` | INCOME `needs_check` | страница 2 без шапки, страницы 1 в пакете нет |
| `unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls` | BALANCE `needs_check` | только актив | | `unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls` | BALANCE `needs_check` | только актив |
| `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` | BALANCE `needs_check` | страница 2 без шапки | | `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` | BALANCE `needs_check` | страница 2 без шапки |
| `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls` | INCOME `needs_check` | рубли при шапке «тыс. руб.»: расхождения 2100/2200/2300 (OQ-138) | | `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls` | INCOME `done` | значения по числовому формату (тыс. руб.); мягко расходятся `2400 = 2300 - 2410 + 2420` и `2410 = 2411 + 2412` (OQ-138) |
| `unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls` | INCOME `needs_check` | страница 2 без шапки | | `unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls` | INCOME `needs_check` | страница 2 без шапки |
| `npz/2025-Q3/Бухгалтерская отчетность … (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx` | BALANCE `needs_check`, INCOME `needs_check` | рубли при шапке «тыс. руб.»: `1600 = 1700` расходится на 1.94/9.02/2.48 руб.; ОФР — 2100/2200/2300 (OQ-138); лист с единственной строкой 2400 присоединён (O-9) | | `npz/2025-Q3/Бухгалтерская отчетность … (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx` | BALANCE `done`, INCOME `done` | значения по числовому формату `#,##0,` (тыс. руб.); мягко расходятся детализации 1150, 1250, 1410, 1500, 1510 (ББ) и 2400, 2410 (ОФР), жёстких нет (OQ-138); лист с единственной строкой 2400 присоединён (O-9) |
| `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` | BALANCE `done` | все соотношения сходятся | | `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` | BALANCE `done` | все соотношения сходятся |
| `mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx` | INCOME `done` | все соотношения сходятся | | `mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx` | INCOME `done` | все соотношения сходятся |
| `templates/ББ до 01.01.2025.xlsx` | BALANCE `needs_check`, DISCLOSURE `done` | шаблон без сумм и шапки; «Примечания» шаблона — пустой DISCLOSURE | | `templates/ББ до 01.01.2025.xlsx` | BALANCE `needs_check`, DISCLOSURE `done` | шаблон без сумм и шапки; «Примечания» шаблона — пустой DISCLOSURE |

View File

@ -4,6 +4,7 @@
# скобках: правая часть считается и как записано, и с этими членами по модулю (design D-7). # скобках: правая часть считается и как записано, и с этими членами по модулю (design D-7).
tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно) tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно)
# OQ-138 (решение владельца 06.10.2026): единицы шапки верны, расхождение до 0,001 % итоговой строки — округление. # OQ-138 (решение владельца 06.10.2026): единицы шапки верны, расхождение до 0,001 % итоговой строки — округление.
# Значения форм берутся как их показывает числовой формат ячейки (#42, 15.10.2026): рубли с копейками в тысячах.
# Жёсткое соотношение с расхождением больше `tolerance`, но не больше этой доли от наибольшего по модулю числа # Жёсткое соотношение с расхождением больше `tolerance`, но не больше этой доли от наибольшего по модулю числа
# соотношения (итог или слагаемое: итог бывает малой разностью больших слагаемых), не портит статус: # соотношения (итог или слагаемое: итог бывает малой разностью больших слагаемых), не портит статус:
# уходит в soft_relations_failed с предупреждением «в пределах округления». # уходит в soft_relations_failed с предупреждением «в пределах округления».

View File

@ -19,7 +19,7 @@ from src.parser.detect import SheetKind, classify_sheet
from src.parser.errors import InvalidDocumentError, ParserError, UnsupportedFormatError from src.parser.errors import InvalidDocumentError, ParserError, UnsupportedFormatError
from src.parser.grid import Sheet from src.parser.grid import Sheet
from src.parser.osv import OsvParser, data_rows, sheet_line, unique from src.parser.osv import OsvParser, data_rows, sheet_line, unique
from src.parser.readers import read_book from src.parser.readers import read_book_detected
from src.parser.readers._common import one_line_reason from src.parser.readers._common import one_line_reason
from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR
from src.parser.rules import Rules from src.parser.rules import Rules
@ -33,7 +33,7 @@ STATEMENT_KINDS = frozenset({SheetKind.STATEMENT, SheetKind.STATEMENT_CONTINUATI
class StatementSheetParser(Protocol): class StatementSheetParser(Protocol):
def parse_sheets( def parse_sheets(
self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline, *, formats_unreadable: bool = False
) -> tuple[list[StatementDocument], list[str], bool]: ... ) -> tuple[list[StatementDocument], list[str], bool]: ...
@ -93,10 +93,11 @@ class BookParser:
"книга не открывается" "книга не открывается"
) )
deadline = Deadline(self._timeout_seconds, self._clock) deadline = Deadline(self._timeout_seconds, self._clock)
sheets, read_warnings = read_book( read = read_book_detected(
data, file_type, max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * self._max_bytes data, file_type, max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * self._max_bytes
) )
warnings.extend(unique(read_warnings)) sheets = read.sheets
warnings.extend(unique(read.warnings))
osv_documents: list[OsvDocument] = [] osv_documents: list[OsvDocument] = []
statement_sheets: list[tuple[Sheet, SheetKind]] = [] statement_sheets: list[tuple[Sheet, SheetKind]] = []
ok = True ok = True
@ -129,7 +130,7 @@ class BookParser:
warnings.extend(sheet_warnings) warnings.extend(sheet_warnings)
ok = ok and sheet_ok ok = ok and sheet_ok
statement_documents, statement_warnings, statements_ok = self._statements.parse_sheets( statement_documents, statement_warnings, statements_ok = self._statements.parse_sheets(
statement_sheets, deadline statement_sheets, deadline, formats_unreadable=read.detected == "XLSB"
) )
warnings.extend(statement_warnings) warnings.extend(statement_warnings)
documents: list[OsvDocument | StatementDocument] = [*osv_documents, *statement_documents] documents: list[OsvDocument | StatementDocument] = [*osv_documents, *statement_documents]

View File

@ -30,6 +30,9 @@ class Cell:
kind: CellKind kind: CellKind
bold: bool = False bold: bool = False
indent: int = 0 # alignment.indent, а при его отсутствии — число ведущих пробелов текста indent: int = 0 # alignment.indent, а при его отсутствии — число ведущих пробелов текста
# Числовой формат ячейки: только у числовых ячеек с форматом не General (xlsx, xls; xlsb формат не отдаёт).
# Используется только разбором ББ/ОФР; ОСВ его игнорирует
number_format: str | None = None
@property @property
def text(self) -> str: def text(self) -> str:

View File

@ -24,6 +24,7 @@ __all__ = [
"inn_checksum_ok", "inn_checksum_ok",
"inn_digits", "inn_digits",
"inn_only", "inn_only",
"quantize_money",
"row_inn", "row_inn",
"split_inn_suffix", "split_inn_suffix",
"to_money", "to_money",
@ -79,8 +80,10 @@ def _shorten(raw: str) -> str:
return raw if len(raw) <= _WARNING_VALUE_LIMIT else raw[:_WARNING_VALUE_LIMIT] + "…" return raw if len(raw) <= _WARNING_VALUE_LIMIT else raw[:_WARNING_VALUE_LIMIT] + "…"
def _quantize(value: Decimal, shown: str) -> MoneyParse: def quantize_money(value: Decimal, shown: str) -> MoneyParse:
"""Квантование до 0.01 с отсечением нечисел и значений вне numeric(18,2); отрицательный ноль -> 0.00.""" """Квантование до 0.01 с отсечением нечисел и значений вне numeric(18,2); отрицательный ноль -> 0.00.
Публичная: ту же проверку применяет `statements.number_format` к отображаемому значению (#42, R-8)."""
if not value.is_finite(): if not value.is_finite():
return _skip(f"нечисловое значение суммы «{_shorten(shown)}»") return _skip(f"нечисловое значение суммы «{_shorten(shown)}»")
if value != 0 and not _MIN_ADJUSTED <= value.adjusted() < _MAX_INTEGER_DIGITS: if value != 0 and not _MIN_ADJUSTED <= value.adjusted() < _MAX_INTEGER_DIGITS:
@ -104,7 +107,7 @@ def _parse_text(raw: str) -> MoneyParse:
if not _NUMBER.match(compact): if not _NUMBER.match(compact):
return _skip(f"нечисловое значение в колонке сумм: «{_shorten(raw.strip())}»") return _skip(f"нечисловое значение в колонке сумм: «{_shorten(raw.strip())}»")
try: try:
return _quantize(Decimal(compact), raw.strip()) return quantize_money(Decimal(compact), raw.strip())
except InvalidOperation: except InvalidOperation:
return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(raw.strip())}»") return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(raw.strip())}»")
@ -115,7 +118,7 @@ def to_money(cell: Cell) -> MoneyParse:
return _skip() return _skip()
if cell.kind == "number": if cell.kind == "number":
# str(): точная десятичная запись; Decimal(str(float)) — и для тестов с «сырыми» int/float в ячейке # str(): точная десятичная запись; Decimal(str(float)) — и для тестов с «сырыми» int/float в ячейке
return _quantize(Decimal(str(cell.value)), str(cell.value)) return quantize_money(Decimal(str(cell.value)), str(cell.value))
if cell.kind == "text": if cell.kind == "text":
return _parse_text(str(cell.value)) return _parse_text(str(cell.value))
if cell.kind == "bool": if cell.kind == "bool":

View File

@ -13,6 +13,7 @@ PK\\x03\\x04 (zip) → XLSX или XLSB (XLSB — если в архиве ес
import io import io
import zipfile import zipfile
from collections.abc import Callable from collections.abc import Callable
from typing import NamedTuple
from src.contract import FileType from src.contract import FileType
from src.core.config import Settings from src.core.config import Settings
@ -29,7 +30,7 @@ from src.parser.readers.xls import read_xls
from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb
from src.parser.readers.xlsx import read_xlsx from src.parser.readers.xlsx import read_xlsx
__all__ = ["default_uncompressed_limit", "read_book"] __all__ = ["BookRead", "default_uncompressed_limit", "read_book", "read_book_detected"]
ZIP_SIGNATURE = b"PK\x03\x04" ZIP_SIGNATURE = b"PK\x03\x04"
OLE2_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1") OLE2_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1")
@ -49,9 +50,23 @@ def default_uncompressed_limit() -> int:
return ZIP_UNCOMPRESSED_LIMIT_FACTOR * max_bytes return ZIP_UNCOMPRESSED_LIMIT_FACTOR * max_bytes
class BookRead(NamedTuple):
"""Результат чтения: листы, предупреждения и формат, определённый по содержимому (а не заявленный `file_type`)."""
sheets: tuple[Sheet, ...]
warnings: list[str]
detected: FileType
def read_book( def read_book(
data: bytes, file_type: FileType, *, max_uncompressed_bytes: int | None = None data: bytes, file_type: FileType, *, max_uncompressed_bytes: int | None = None
) -> tuple[tuple[Sheet, ...], list[str]]: ) -> tuple[tuple[Sheet, ...], list[str]]:
"""Листы и предупреждения чтения; см. `read_book_detected`."""
result = read_book_detected(data, file_type, max_uncompressed_bytes=max_uncompressed_bytes)
return result.sheets, result.warnings
def read_book_detected(data: bytes, file_type: FileType, *, max_uncompressed_bytes: int | None = None) -> BookRead:
"""Листы книги и предупреждения чтения (несовпадение `file_type`, ограничения xlsb). """Листы книги и предупреждения чтения (несовпадение `file_type`, ограничения xlsb).
`max_uncompressed_bytes` — лимит распакованного zip (xlsx/xlsb); `OsvParser` передаёт `max_uncompressed_bytes` — лимит распакованного zip (xlsx/xlsb); `OsvParser` передаёт
@ -72,7 +87,7 @@ def read_book(
check_merged_area(sheets) check_merged_area(sheets)
if detected == "XLSB": if detected == "XLSB":
warnings.append(OUTLINE_UNAVAILABLE_WARNING) warnings.append(OUTLINE_UNAVAILABLE_WARNING)
return sheets, warnings return BookRead(sheets, warnings, detected)
def _detect(data: bytes, file_type: FileType, uncompressed_limit: int) -> FileType: def _detect(data: bytes, file_type: FileType, uncompressed_limit: int) -> FileType:

View File

@ -15,13 +15,14 @@ def leading_spaces(text: str) -> int:
return len(text) - len(stripped) if stripped else 0 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` не попадает). """Ячейка из значения библиотеки чтения; `None` — пустая ячейка (в `Sheet.cells` не попадает).
Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); целый float — как int (без «.0»); Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); целый float — как int (без «.0»);
bool проверяется раньше int. bool проверяется раньше int.
Строка из одних пробельных символов (включая NBSP) — пустая ячейка (`None`): «пробелы» = пусто на уровне Строка из одних пробельных символов (включая NBSP) — пустая ячейка (`None`): «пробелы» = пусто на уровне
хранения, а не только в аксессорах `Sheet`. Время без даты — текстом. хранения, а не только в аксессорах `Sheet`. Время без даты — текстом.
`number_format` сохраняется только у числовых ячеек и только если он непустой и не General.
""" """
if value is None or value == "": if value is None or value == "":
return None 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) # xls хранит любое число как float: «2370012036.0» — артефакт чтения, в файле целое (OQ-162)
value = int(value) value = int(value)
if isinstance(value, int | float): 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 if isinstance(value, datetime.date): # datetime.datetime — подкласс date
return Cell(value, "date", bold=bold, indent=indent) return Cell(value, "date", bold=bold, indent=indent)
text = value if isinstance(value, str) else str(value) text = value if isinstance(value, str) else str(value)

View File

@ -34,6 +34,7 @@ def _sheet(book: Any, sheet: Any) -> Sheet:
value, value,
bold=bool(book.font_list[xf.font_index].bold), bold=bool(book.font_list[xf.font_index].bold),
indent=int(xf.alignment.indent_level or 0), indent=int(xf.alignment.indent_level or 0),
number_format=_number_format(book, xf),
) )
if cell is not None: if cell is not None:
cells[(rowx + 1, colx + 1)] = cell 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): except (xlrd.xldate.XLDateError, ValueError, OverflowError):
return raw # серийный номер вне диапазона дат — остаётся числом, как в файле return raw # серийный номер вне диапазона дат — остаётся числом, как в файле
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

View File

@ -1,4 +1,5 @@
"""Чтение XLSB (BIFF12) через pyxlsb: только значения; outline level, merged и шрифт библиотека не отдаёт.""" """Чтение XLSB (BIFF12) через pyxlsb: только значения; outline level, merged, шрифт и
числовой формат (Cell.number_format) библиотека не отдаёт."""
import io import io
from typing import Any from typing import Any

View File

@ -24,7 +24,12 @@ def _sheet(ws: Any) -> Sheet:
# `_cells` — разреженное хранилище openpyxl (версия закреплена): iter_rows создал бы пустые ячейки на весь # `_cells` — разреженное хранилище openpyxl (версия закреплена): iter_rows создал бы пустые ячейки на весь
# прямоугольник листа # прямоугольник листа
for source in ws._cells.values(): 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: if cell is not None:
cells[(source.row, source.column)] = cell cells[(source.row, source.column)] = cell
merged = tuple( merged = tuple(

View File

@ -1,21 +1,23 @@
"""Строки формы с кодом и их значения (design D-6). """Строки формы с кодом и их значения (design D-6).
Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них
есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа — `numbers.to_money` есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа —
(Decimal, скобки → минус, прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не `statement_money`: показ по числовому формату ячейки (#42), иначе `numbers.to_money` (Decimal, скобки → минус,
нормализуются; счётчики дробных значений нужны предупреждению документа (`statement.py`: одно на документ). прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не нормализуются; счётчики
дробных значений нужны предупреждению документа (`statement.py`: одно на документ).
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from decimal import Decimal from decimal import Decimal
from src.contract.result import StatementLine, StatementValue from src.contract.result import StatementLine, StatementValue
from src.parser.grid import Sheet from src.parser.grid import Cell, Sheet
from src.parser.numbers import to_money from src.parser.numbers import MoneyParse, to_money
from src.parser.rules import Rules from src.parser.rules import Rules
from src.parser.statements.columns import TableLayout, cell_label, is_numbering_row, line_code from src.parser.statements.columns import TableLayout, cell_label, is_numbering_row, line_code
from src.parser.statements.number_format import display_amount
__all__ = ["LinesResult", "collect_lines"] __all__ = ["LinesResult", "collect_lines", "statement_money"]
_FULL_CONFIDENCE = 100 _FULL_CONFIDENCE = 100
@ -27,13 +29,28 @@ class LinesResult:
skipped_with_sums: int = 0 skipped_with_sums: int = 0
fractional_values: int = 0 fractional_values: int = 0
numeric_values: int = 0 numeric_values: int = 0
unparsed_formats: set[str] = field(default_factory=set) # числовые форматы, которые не удалось разобрать (#42)
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
def _value(sheet: Sheet, row: int, col: int, column_index: int) -> tuple[StatementValue, bool]: def statement_money(cell: Cell, unparsed_formats: set[str] | None = None) -> MoneyParse:
"""Значение ячейки ББ/ОФР — как его показывает документ (#42): число с числовым форматом проходит через
`display_amount`; формат не разобран — сырое значение, формат попадает в `unparsed_formats`."""
if cell.kind == "number" and cell.number_format and isinstance(cell.value, Decimal):
shown = display_amount(cell.value, cell.number_format)
if shown.kind == "value":
return MoneyParse(shown.value, None)
if shown.kind == "unparsed" and unparsed_formats is not None:
unparsed_formats.add(cell.number_format)
return to_money(cell)
def _value(
sheet: Sheet, row: int, col: int, column_index: int, unparsed_formats: set[str]
) -> tuple[StatementValue, bool]:
"""Значение ячейки и признак «число с дробной частью».""" """Значение ячейки и признак «число с дробной частью»."""
cell = sheet.cell(*sheet.merged_origin(row, col)) cell = sheet.cell(*sheet.merged_origin(row, col))
parsed = to_money(cell) parsed = statement_money(cell, unparsed_formats)
if parsed.value is not None: if parsed.value is not None:
has_fractional = parsed.value % 1 != 0 has_fractional = parsed.value % 1 != 0
value = StatementValue( value = StatementValue(
@ -100,7 +117,10 @@ def collect_lines( # noqa: PLR0913
if code is None: if code is None:
if is_numbering_row(sheet, row, rules): # «1 2 3 4 5», повторённая на разрыве страницы if is_numbering_row(sheet, row, rules): # «1 2 3 4 5», повторённая на разрыве страницы
continue continue
if any(_is_sum(to_money(sheet.cell(row, column.col)).value) for column in layout.periods): row_values = (
statement_money(sheet.cell(row, column.col), result.unparsed_formats).value for column in layout.periods
)
if any(_is_sum(value) for value in row_values):
result.skipped_with_sums += 1 result.skipped_with_sums += 1
continue continue
if code in seen: if code in seen:
@ -109,7 +129,7 @@ def collect_lines( # noqa: PLR0913
seen.add(code) seen.add(code)
values: list[StatementValue] = [] values: list[StatementValue] = []
for index, column in enumerate(layout.periods): for index, column in enumerate(layout.periods):
value, fractional = _value(sheet, row, column.col, index) value, fractional = _value(sheet, row, column.col, index, result.unparsed_formats)
values.append(value) values.append(value)
if value.amount is not None: if value.amount is not None:
result.numeric_values += 1 result.numeric_values += 1

View File

@ -21,6 +21,7 @@ class Draft:
lonely_continuation: bool = False lonely_continuation: bool = False
fractional_values: int = 0 fractional_values: int = 0
numeric_values: int = 0 numeric_values: int = 0
unparsed_formats: set[str] = field(default_factory=set)
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
@property @property

View File

@ -0,0 +1,271 @@
"""Отображаемое значение числа по числовому формату ячейки Excel (#42, R-2…R-6, R-8).
В выгрузках 1С суммы ББ/ОФР лежат рублями с копейками, а в тысячи их переводит формат ячейки (`#,##0,`, `0,`).
Значение документа — то, что показывает Excel, поэтому формат применяется к сырому числу.
Семантика Excel, которую воспроизводит модуль:
- Секции через `;` (кавычки, `\\x` и `[...]` не разделяют). Без условий: 1 секция — всё; 2 — (>=0, <0); 3 — (>0, <0,
остальное); четвёртая (текст) игнорируется. С явными условиями `[=x] [<x] [>x] [<=x] [>=x] [<>x]` секции проверяются
по порядку (R-2): неявное условие нефинальной секции — умолчание по номеру, финальной — «всё остальное»; ни одна не
подошла — формат не разобран.
- Знак показываемого значения — знак сырого значения, скобки/минус формата — оформление (R-3). Поэтому `#,##0;#,##0`
для -1500 даёт -1500: цифры в секции отрицательных показываются по модулю, а значение документа остаётся
отрицательным.
- Запятая между цифровыми плейсхолдерами — разделитель групп; запятые сразу после последнего плейсхолдера (и перед
точкой) — масштаб: каждая делит число на 1000. Округление — половина от нуля (ROUND_HALF_UP по модулю), как в Excel.
- Секция без цифровых плейсхолдеров показывает только текст; он разбирается тем же разбором, что текстовые ячейки
(`to_money`: «(872 207)» -> -872207, «-» и пусто -> нет значения). Не разобранный текст — формат не разобран (R-5).
- `General` -> сырое значение (R-4); проценты, экспонента, дроби, даты/время, `@`, неизвестные буквы -> не
разобран (R-6).
Разбор формата кэшируется: форматов в файле единицы, ячеек десятки тысяч. Арифметика — Decimal, без float.
"""
import re
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Context, Decimal, InvalidOperation
from functools import lru_cache
from typing import Literal, NamedTuple
from src.parser.grid import Cell
from src.parser.numbers import quantize_money, to_money
__all__ = ["FormatResult", "display_amount"]
_PLACEHOLDERS = "0#?"
_MAX_PARTS = 4
_MAX_SECTIONS = 3 # четвёртая секция — текст, для чисел не используется
_MAX_DECIMALS = 2 # Money = numeric(18,2)
_SCALE_STEP = 3 # одна масштабирующая запятая = деление на 1000
# Знаки, которые Excel показывает без кавычек как есть
_PLAIN_CHARS = frozenset(" $-+()<>=:!&~{}^' ")
_CONDITION = re.compile(r"^(<>|<=|>=|=|<|>)\s*([-+]?[0-9]*\.?[0-9]+)$")
_GENERAL = "general"
class FormatResult(NamedTuple):
"""`raw` — General/пусто, действует прежняя логика; `value` — отображаемое значение (None — нет значения);
`unparsed` — формат не разобран."""
kind: Literal["raw", "value", "unparsed"]
value: Decimal | None = None
_RAW = FormatResult("raw")
_UNPARSED = FormatResult("unparsed")
_NO_VALUE = FormatResult("value", None)
_EXCEL_DIGITS = 15 # Excel сравнивает числа с точностью 15 значащих цифр
_EXCEL_CONTEXT = Context(prec=_EXCEL_DIGITS, rounding=ROUND_HALF_UP)
def _excel_precision(value: Decimal) -> Decimal:
"""Число до 15 значащих цифр, половина от нуля, как в Excel.
-1486533582.3500001 (артефакт float в xls) равно условию `[=-1486533582.35]`; 1500499.9999999998 — это 1500500.
"""
return _EXCEL_CONTEXT.plus(value)
@dataclass(frozen=True, slots=True)
class _Condition:
operator: str
operand: Decimal
def holds(self, raw: Decimal) -> bool:
value = _excel_precision(raw)
match self.operator:
case "=":
return value == self.operand
case "<>":
return value != self.operand
case "<":
return value < self.operand
case "<=":
return value <= self.operand
case ">":
return value > self.operand
case _:
return value >= self.operand
@dataclass(frozen=True, slots=True)
class _Section:
condition: _Condition | None
general: bool
has_digits: bool
literal: str # показываемый текст (для секций без цифровых плейсхолдеров)
scale: int # число масштабирующих запятых
decimals: int # число плейсхолдеров после точки
def _split_sections(fmt: str) -> list[str] | None:
"""Делит по `;` вне кавычек, `\\x` и `[...]`; незакрытая кавычка/скобка -> None."""
sections: list[str] = []
current: list[str] = []
index = 0
while index < len(fmt):
char = fmt[index]
if char in ('"', "["):
closing = '"' if char == '"' else "]"
end = fmt.find(closing, index + 1)
if end < 0:
return None
current.append(fmt[index : end + 1])
index = end + 1
continue
if char == "\\" and index + 1 < len(fmt):
current.append(fmt[index : index + 2])
index += 2
continue
if char == ";":
sections.append("".join(current))
current = []
else:
current.append(char)
index += 1
sections.append("".join(current))
return sections
def _parse_section(text: str) -> _Section | None: # noqa: C901, PLR0912 - линейный разбор символов формата
condition: _Condition | None = None
literal: list[str] = []
kinds: list[str] = [] # "p" — плейсхолдер, "," — запятая, "." — точка, прочее не фиксируем
index = 0
while index < len(text):
char = text[index]
if char == '"':
end = text.index('"', index + 1)
literal.append(text[index + 1 : end])
index = end + 1
continue
if char == "[":
end = text.index("]", index + 1)
body = text[index + 1 : end].strip()
found = _CONDITION.match(body)
if found is not None:
if condition is not None:
return None
condition = _Condition(found.group(1), _excel_precision(Decimal(found.group(2))))
elif body and body[0] in "<>=":
return None # условие, которое не разобрать (`[=1E-5]`, `[=1,5]`), нельзя молча принять за оформление
index = end + 1 # цвет, [$-419] и прочее оформление игнорируются
continue
if char == "\\":
literal.append(text[index + 1 : index + 2])
index += 2
continue
if char in "_*":
index += 2 # отступ/заполнение: оформление
continue
if text[index : index + len(_GENERAL)].lower() == _GENERAL:
kinds.append("g")
index += len(_GENERAL)
continue
if char in _PLACEHOLDERS:
kinds.append("p")
elif char in ",.":
kinds.append(char)
elif char in _PLAIN_CHARS:
literal.append(char)
else: # %, E+, /, @, буквы дат и любые неизвестные знаки
return None
index += 1
return _build_section(condition, literal, kinds)
def _build_section(condition: _Condition | None, literal: list[str], kinds: list[str]) -> _Section | None:
if "g" in kinds:
return _Section(condition, True, False, "", 0, 0) if kinds == ["g"] else None
last = max((i for i, kind in enumerate(kinds) if kind == "p"), default=-1)
if last < 0:
return _Section(condition, False, False, "".join(literal).strip(), 0, 0)
scale = 0
position = 0
while position < len(kinds):
if kinds[position] != ",":
position += 1
continue
end = position
while end < len(kinds) and kinds[end] == ",":
end += 1
follows_placeholder = position > 0 and kinds[position - 1] == "p"
if follows_placeholder and (end == len(kinds) or kinds[end] != "p"):
scale += end - position
position = end
decimals = 0
if "." in kinds:
decimals = sum(1 for kind in kinds[kinds.index(".") :] if kind == "p")
return _Section(condition, False, True, "", scale, decimals)
@lru_cache(maxsize=256)
def _parse_format(fmt: str) -> tuple[_Section, ...] | None:
"""Секции формата либо None, если формат не разобран."""
parts = _split_sections(fmt)
if parts is None or len(parts) > _MAX_PARTS:
return None # Excel не принимает формат больше чем из четырёх секций
sections: list[_Section] = []
for part in parts[:_MAX_SECTIONS]:
section = _parse_section(part)
if section is None:
return None
sections.append(section)
return tuple(sections)
_DEFAULT_CONDITIONS: dict[int, tuple[_Condition, ...]] = {
2: (_Condition(">=", Decimal(0)),),
3: (_Condition(">", Decimal(0)), _Condition("<", Decimal(0))),
}
def _pick_section(sections: tuple[_Section, ...], raw: Decimal) -> _Section | None:
count = len(sections)
defaults = _DEFAULT_CONDITIONS.get(count, ())
for position, section in enumerate(sections):
final = position == count - 1
if section.condition is not None:
matched = section.condition.holds(raw)
elif final:
matched = True
else:
matched = defaults[position].holds(raw)
if matched:
return section
return None
def _scaled(raw: Decimal, section: _Section) -> FormatResult:
digits = min(section.decimals, _MAX_DECIMALS)
try:
magnitude = abs(_excel_precision(raw)).scaleb(-_SCALE_STEP * section.scale)
rounded = magnitude.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP)
except InvalidOperation: # число длиннее точности контекста: заведомо вне numeric(18,2)
return _UNPARSED
signed = -rounded if raw < 0 else rounded # знак от сырого значения (R-3)
money = quantize_money(signed, str(raw))
return _UNPARSED if money.warning is not None else FormatResult("value", money.value)
def _literal_result(section: _Section) -> FormatResult:
parsed = to_money(Cell(section.literal, "text"))
if parsed.warning is not None:
return _UNPARSED
return _NO_VALUE if parsed.value is None else FormatResult("value", parsed.value)
def display_amount(raw: Decimal, fmt: str) -> FormatResult:
"""Значение, которое Excel показывает для числа `raw` в формате `fmt` (см. описание модуля)."""
sections = _parse_format(fmt) if fmt.strip() else ()
if sections is None:
return _UNPARSED
section = _pick_section(sections, raw) if sections else None
if not sections or (section is not None and section.general):
return _RAW
if section is None:
return _UNPARSED
return _scaled(raw, section) if section.has_digits else _literal_result(section)

View File

@ -18,12 +18,22 @@ from src.parser.statements.header import parse_header
from src.parser.statements.lines import collect_lines from src.parser.statements.lines import collect_lines
from src.parser.statements.merge import Draft, can_join, join, join_blocker from src.parser.statements.merge import Draft, can_join, join, join_blocker
__all__ = ["CONTINUATION_WARNING", "LOCATOR_PREFIX", "LOCATOR_SEPARATOR", "StatementParser"] __all__ = ["CONTINUATION_WARNING", "XLSB_FORMAT_WARNING", "LOCATOR_PREFIX", "LOCATOR_SEPARATOR", "StatementParser"]
LOCATOR_PREFIX = "sheet:" LOCATOR_PREFIX = "sheet:"
LOCATOR_SEPARATOR = "," LOCATOR_SEPARATOR = ","
CONTINUATION_WARNING = "страница-продолжение без шапки: загрузите форму одним файлом" CONTINUATION_WARNING = "страница-продолжение без шапки: загрузите форму одним файлом"
SKIPPED_COLUMN_WARNING = "колонка без периода пропущена" SKIPPED_COLUMN_WARNING = "колонка без периода пропущена"
XLSB_FORMAT_WARNING = "формат чисел xlsb не читается: значения как в файле"
MAX_FORMAT_LENGTH = 30
def _shorten(fmt: str) -> str:
return fmt if len(fmt) <= MAX_FORMAT_LENGTH else fmt[: MAX_FORMAT_LENGTH - 1] + "…"
def _line_of(draft: Draft, text: str) -> str:
return f"sheet={draft.sheet_names[0]} {text}"
def _line(sheet: Sheet, text: str) -> str: def _line(sheet: Sheet, text: str) -> str:
@ -35,8 +45,10 @@ class StatementParser:
self._rules = rules self._rules = rules
def parse_sheets( def parse_sheets(
self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline, *, formats_unreadable: bool = False
) -> tuple[list[StatementDocument], list[str], bool]: ) -> tuple[list[StatementDocument], list[str], bool]:
"""`formats_unreadable` — книга xlsb: числовые форматы недоступны, каждый документ с колонкой кодов получает
предупреждение «значения как в файле» (R-7)."""
drafts: list[Draft] = [] drafts: list[Draft] = []
warnings: list[str] = [] warnings: list[str] = []
dropped = False dropped = False
@ -49,6 +61,10 @@ class StatementParser:
drafts.append(self._block_draft(sheet, block, warnings)) drafts.append(self._block_draft(sheet, block, warnings))
else: else:
dropped = not self._continuation(sheet, drafts, warnings) or dropped dropped = not self._continuation(sheet, drafts, warnings) or dropped
if formats_unreadable:
for draft in drafts:
if draft.kind != "DISCLOSURE" and draft.has_code_column:
draft.warnings.append(_line_of(draft, XLSB_FORMAT_WARNING))
documents = [self._document(draft) for draft in drafts] documents = [self._document(draft) for draft in drafts]
for draft in drafts: for draft in drafts:
warnings.extend(draft.warnings) warnings.extend(draft.warnings)
@ -105,6 +121,7 @@ class StatementParser:
join(open_draft, sheet.name, result.lines, result.duplicates) join(open_draft, sheet.name, result.lines, result.duplicates)
open_draft.fractional_values += result.fractional_values open_draft.fractional_values += result.fractional_values
open_draft.numeric_values += result.numeric_values open_draft.numeric_values += result.numeric_values
open_draft.unparsed_formats |= result.unparsed_formats
warnings.extend(_line(sheet, text) for text in result.warnings) warnings.extend(_line(sheet, text) for text in result.warnings)
if weak: # N-1: присоединение по 1–2 строкам видно аналитику if weak: # N-1: присоединение по 1–2 строкам видно аналитику
warnings.append( warnings.append(
@ -155,6 +172,7 @@ class StatementParser:
draft.lines = result.lines draft.lines = result.lines
draft.duplicates = result.duplicates draft.duplicates = result.duplicates
draft.fractional_values, draft.numeric_values = result.fractional_values, result.numeric_values draft.fractional_values, draft.numeric_values = result.fractional_values, result.numeric_values
draft.unparsed_formats = result.unparsed_formats
texts = [*header_warnings, *result.warnings] texts = [*header_warnings, *result.warnings]
if layout.skipped_labels: if layout.skipped_labels:
texts.append(f"{SKIPPED_COLUMN_WARNING}: {', '.join(layout.skipped_labels)}") texts.append(f"{SKIPPED_COLUMN_WARNING}: {', '.join(layout.skipped_labels)}")
@ -177,6 +195,7 @@ class StatementParser:
columns=columns if draft.has_code_column else [], columns=columns if draft.has_code_column else [],
lines=[], lines=[],
) )
draft.warnings.extend(self._unparsed_formats_warning(draft))
draft.warnings.extend(self._fractional_warning(draft)) draft.warnings.extend(self._fractional_warning(draft))
checks, check_warnings = run_statement_checks( checks, check_warnings = run_statement_checks(
draft.lines, len(columns), kind if kind != "OTHER" else "BALANCE", self._rules draft.lines, len(columns), kind if kind != "OTHER" else "BALANCE", self._rules
@ -187,6 +206,15 @@ class StatementParser:
document_type=kind, locator=locator, header=draft.header, columns=columns, lines=draft.lines, checks=checks document_type=kind, locator=locator, header=draft.header, columns=columns, lines=draft.lines, checks=checks
) )
@staticmethod
def _unparsed_formats_warning(draft: Draft) -> list[str]:
"""Одно предупреждение на документ (R-7): числовые форматы, которые не удалось разобрать; значения взяты как
в ячейках."""
if not draft.unparsed_formats:
return []
shown = ", ".join(f"«{_shorten(fmt)}»" for fmt in sorted(draft.unparsed_formats))
return [f"sheet={draft.sheet_names[0]} формат чисел не разобран, значения как в ячейках: {shown}"]
def _fractional_warning(self, draft: Draft) -> list[str]: def _fractional_warning(self, draft: Draft) -> list[str]:
"""Одно предупреждение на документ (все его листы): при «тыс.» в единицах доля значений с дробной частью ≥ """Одно предупреждение на документ (все его листы): при «тыс.» в единицах доля значений с дробной частью ≥
порога словаря — вероятно, значения в рублях (OQ-137).""" порога словаря — вероятно, значения в рублях (OQ-137)."""

View File

@ -0,0 +1,43 @@
"""Значение ББ/ОФР из Excel — как его показывает числовой формат ячейки (#42): конкретные ячейки реальных выгрузок."""
from decimal import Decimal
from pathlib import Path
from typing import cast
import pytest
from src.contract import FileType, StatementDocument
from src.parser import get_parser
pytestmark = pytest.mark.fixtures
STATEMENTS = "statements-xlsx"
NPZ = "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx"
UNAGRANDE_INCOME = "unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls"
MEZHENINOVSKAYA_BALANCE = "mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx"
def _amount(fixtures_dir: Path, relative: str, document_type: str, code: str, column: int = 0) -> Decimal | None:
path = fixtures_dir / STATEMENTS / relative
outcome = get_parser().parse(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
assert outcome.error is None, outcome.error
for document in outcome.documents:
if isinstance(document, StatementDocument) and document.document_type == document_type:
for line in document.lines:
if line.line_code == code:
return line.values[column].amount
raise AssertionError(f"строка {code} не найдена")
def test_npz_scaling_comma_gives_thousands(fixtures_dir: Path) -> None:
"""P43: 10741981390 с форматом `#,##0,` показывается как 10 741 981 (тыс. руб.)."""
assert _amount(fixtures_dir, NPZ, "BALANCE", "1150") == Decimal("10741981")
def test_unagrande_conditional_literal_gives_negative_thousands(fixtures_dir: Path) -> None:
"""Значение -646532 с форматом `[=-646532]"(647)"` показывается текстом «(647)» -> -647."""
assert _amount(fixtures_dir, UNAGRANDE_INCOME, "INCOME", "2411") == Decimal("-647")
def test_mezheninovskaya_rounds_kopecks_to_displayed_integer(fixtures_dir: Path) -> None:
"""M31: 898852.21795 с форматом `#,##0` показывается как 898 852."""
assert _amount(fixtures_dir, MEZHENINOVSKAYA_BALANCE, "BALANCE", "1150") == Decimal("898852")

View File

@ -335,7 +335,7 @@ async def test_real_osv_through_worker_gives_done_and_valid_result_file(
async def test_real_statements_through_worker_give_done_with_two_documents( async def test_real_statements_through_worker_give_done_with_two_documents(
core: Core, uploads: Uploads, fixtures_dir: Path core: Core, uploads: Uploads, fixtures_dir: Path
) -> None: ) -> None:
"""«Реальные формы через воркер»: книга НПЗ → ack → `done`; расхождения итогов — округление (OQ-138, мягкие).""" """«Реальные формы через воркер»: книга НПЗ → ack → `done`; жёстких расхождений нет, мягкие — по golden."""
request = _new_request(key_name="npz_2025_q3.xlsx") request = _new_request(key_name="npz_2025_q3.xlsx")
request["file_type"] = "XLSX" request["file_type"] = "XLSX"
await uploads.put(request, (fixtures_dir / REAL_NPZ).read_bytes(), XLSX_CONTENT_TYPE) await uploads.put(request, (fixtures_dir / REAL_NPZ).read_bytes(), XLSX_CONTENT_TYPE)
@ -356,9 +356,15 @@ async def test_real_statements_through_worker_give_done_with_two_documents(
balance, income = stored["documents"] balance, income = stored["documents"]
assert [balance["document_type"], income["document_type"]] == ["BALANCE", "INCOME"] assert [balance["document_type"], income["document_type"]] == ["BALANCE", "INCOME"]
assert balance["checks"]["relations_failed"] == [] and income["checks"]["relations_failed"] == [] assert balance["checks"]["relations_failed"] == [] and income["checks"]["relations_failed"] == []
# расхождения в пределах округления (OQ-138) видны аналитику как мягкие # мягкие расхождения (OQ-138) видны аналитику; набор — как в golden НПЗ
assert "1600 = 1700" in {item["relation"] for item in balance["checks"]["soft_relations_failed"]} assert {item["relation"] for item in balance["checks"]["soft_relations_failed"]} >= {
assert "2100 = 2110 - 2120" in {item["relation"] for item in income["checks"]["soft_relations_failed"]} "1500 = 1510 + 1520 + 1530 + 1540 + 1550",
"1410 = 14101 + 14102 + 14103 + 14104 + 14105",
}
assert {item["relation"] for item in income["checks"]["soft_relations_failed"]} >= {
"2400 = 2300 - 2410 + 2420",
"2410 = 2411 + 2412",
}
# лист с единственной строкой 2400 присоединён (O-9) # лист с единственной строкой 2400 присоединён (O-9)
assert "2400" in {line["line_code"] for line in income["lines"]} assert "2400" in {line["line_code"] for line in income["lines"]}
await core.assert_quiet(request) await core.assert_quiet(request)

View File

@ -1,7 +1,9 @@
# Ожидания по реальным формам fixtures/statements-xlsx/** (README там же). Ключ — путь относительно statements-xlsx. # Ожидания по реальным формам fixtures/statements-xlsx/** (README там же). Ключ — путь относительно statements-xlsx.
# documents — по порядку документов результата: type, status (done | needs_check), complete (вся форма: §7 сходится). # documents — по порядку документов результата: type, status (done | needs_check), complete (вся форма: §7 сходится).
# Причина needs_check — данные файла (страница без шапки, шаблон без сумм, рубли при шапке «тыс. руб.»), не дефект разбора. # Причина needs_check — данные файла (страница без шапки, шаблон без сумм), не дефект разбора.
# Значения сверены прогоном парсера на файлах (R-13, OQ-138): расхождения с первоначальным планом помечены «по факту». # Значения сверены прогоном парсера на файлах (R-13): расхождения с первоначальным планом помечены «по факту».
# Значения ББ/ОФР — как их показывает числовой формат ячейки (#42): рубли с копейками из 1С приходят в тысячах;
# мягкие нарушения (в пределах допуска округления, OQ-138) статус не меняют; набор — по факту разбора, см. golden.
files: files:
"unagrande-co/2024-FY/Б.Б Лист 1.xls": "unagrande-co/2024-FY/Б.Б Лист 1.xls":
organization: 'Акционерное общество "Унагранде Компани"' organization: 'Акционерное общество "Унагранде Компани"'
@ -13,7 +15,7 @@ files:
organization: 'Акционерное общество "Унагранде Компани"' organization: 'Акционерное общество "Унагранде Компани"'
inn: "3249004697" inn: "3249004697"
documents: documents:
- {type: INCOME, status: done, complete: true, failed_relations: []} # итоги округлены до тысяч: мягкие нарушения «в пределах округления» (OQ-138) - {type: INCOME, status: done, complete: true, failed_relations: []} # значения по формату в тыс. руб.; мягко расходится 2400 = 2300 - 2410 + 2420 (в пределах допуска, OQ-138)
"unagrande-co/2024-FY/ОФР. Лист 2 .xls": "unagrande-co/2024-FY/ОФР. Лист 2 .xls":
documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}]
"unagrande-group/2024-FY/ББ. Лист 1 .xls": "unagrande-group/2024-FY/ББ. Лист 1 .xls":
@ -34,7 +36,7 @@ files:
organization: 'Акционерное общество "Унагранде Групп"' organization: 'Акционерное общество "Унагранде Групп"'
inn: "7728757141" inn: "7728757141"
documents: documents:
- {type: INCOME, status: done, complete: true, failed_relations: []} # 2200 — малая разность больших слагаемых, округление (OQ-138) - {type: INCOME, status: done, complete: true, failed_relations: []} # мягко расходятся 2400 = 2300 - 2410 + 2420 и 2410 = 2411 + 2412 (в пределах допуска, OQ-138)
"unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls": "unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls":
documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}]
"npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx": "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx":
@ -42,8 +44,8 @@ files:
inn: "2315995868" inn: "2315995868"
inn_optional_for: [INCOME] inn_optional_for: [INCOME]
documents: documents:
- {type: BALANCE, status: done, complete: true, sheets: 5, failed_relations: []} # 1600 = 1700 на 1.94/9.02/2.48 — округление (OQ-138) - {type: BALANCE, status: done, complete: true, sheets: 5, failed_relations: []} # мягко расходятся детализации 1150, 1250, 1410, 1500, 1510 (сверка с итогом в самой выгрузке, OQ-138)
- {type: INCOME, status: done, complete: true, sheets: 3, failed_relations: []} # округление итогов (OQ-138); лист с единственной строкой 2400 присоединён (O-9) - {type: INCOME, status: done, complete: true, sheets: 3, failed_relations: []} # мягко расходятся 2400 и 2410 (OQ-138); лист с единственной строкой 2400 присоединён (O-9)
"mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx": "mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx":
organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"'
inn: "7016006635" inn: "7016006635"

View File

@ -35,6 +35,7 @@
`n_rows`/`n_cols` — по длине списка строк и самой длинной строке (с учётом ширины `M`), как габариты листа у читателя. `n_rows`/`n_cols` — по длине списка строк и самой длинной строке (с учётом ширины `M`), как габариты листа у читателя.
""" """
import dataclasses
import datetime import datetime
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
@ -147,3 +148,11 @@ def sheet_from_rows(rows: Sequence[Sequence[object]], name: str = DEFAULT_SHEET_
n_rows=len(rows), n_rows=len(rows),
n_cols=n_cols, n_cols=n_cols,
) )
def with_formats(sheet: Sheet, formats: dict[tuple[int, int], str]) -> Sheet:
"""Копия листа, где числовым ячейкам (row, col) проставлен числовой формат (#42)."""
cells = dict(sheet.cells)
for position, number_format in formats.items():
cells[position] = dataclasses.replace(cells[position], number_format=number_format)
return dataclasses.replace(sheet, cells=cells)

View File

@ -3,7 +3,7 @@
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from gridbuilder import sheet_from_rows from gridbuilder import sheet_from_rows, with_formats
from src.parser.rules import Rules, load_rules from src.parser.rules import Rules, load_rules
from src.parser.statements.columns import find_layout from src.parser.statements.columns import find_layout
@ -198,3 +198,74 @@ def test_explanation_values_on_continuation_without_header_are_not_names(rules:
names = {line.line_code: line.line_name for line in result.lines} names = {line.line_code: line.line_name for line in result.lines}
assert names == {"1210": "Запасы", "1230": "Дебиторская задолженность", "1200": ""} assert names == {"1210": "Запасы", "1230": "Дебиторская задолженность", "1200": ""}
# --- числовой формат ячейки (#42): значение — как его показывает документ ---
FORMAT_ROWS = [
["Наименование показателя", "Код", "На 31 декабря 2024 г."],
["Основные средства", "1150", Decimal("10741981390")],
["Запасы", "1210", Decimal("586260.26")],
["Прочие", "1190", Decimal("0")],
["Дебиторская", "1230", Decimal("-872206880.07")],
["Финансовые", "1240", Decimal("-646532")],
["Резерв", "1250", Decimal("0.35")],
["Итого по разделу I", "1100", Decimal("898852.21795")],
]
def _formatted(rules: Rules, formats: dict[int, str]) -> tuple[dict[str, object], set[str]]:
sheet = with_formats(sheet_from_rows(FORMAT_ROWS), {(row, 3): fmt for row, fmt in formats.items()})
layout = find_layout(sheet, rules, 1, sheet.n_rows)
result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows)
return {line.line_code: line.values[0] for line in result.lines}, result.unparsed_formats
def test_scaling_comma_and_literals_follow_number_format(rules: Rules) -> None:
values, unparsed = _formatted(
rules,
{
2: "#,##0,",
3: "0,",
4: '[=0]"-"',
5: '[=-872206880.07]"(872\xa0207)";#,##0,',
6: '[=-646532]"(647)";#,##0,',
8: "#,##0",
},
)
assert values["1150"].amount == Decimal("10741981.00")
assert values["1210"].amount == Decimal("586.00")
assert values["1190"].amount is None and values["1190"].is_recognized # прочерк без предупреждения
assert values["1230"].amount == Decimal("-872207.00") # литерал со скобками
assert values["1240"].amount == Decimal("-647.00")
assert values["1100"].amount == Decimal("898852.00")
assert unparsed == set()
def test_unparsed_format_keeps_raw_value_and_is_collected(rules: Rules) -> None:
values, unparsed = _formatted(rules, {7: "0%", 8: "0.00%"})
assert values["1250"].amount == Decimal("0.35") and values["1100"].amount == Decimal("898852.22")
assert unparsed == {"0%", "0.00%"}
def test_unparsed_format_in_row_without_code_is_collected(rules: Rules) -> None:
rows = [*FORMAT_ROWS, ["Без кода", None, Decimal("5")]]
sheet = with_formats(sheet_from_rows(rows), {(len(rows), 3): "0%"})
layout = find_layout(sheet, rules, 1, sheet.n_rows)
result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows)
assert result.skipped_with_sums == 1 and result.unparsed_formats == {"0%"}
def test_fractional_share_counts_displayed_values(rules: Rules) -> None:
"""Рубли с копейками, показанные в тысячах целыми (`#,##0,`), дробными не считаются."""
sheet = with_formats(sheet_from_rows(FORMAT_ROWS), {(2, 3): "#,##0,", (3, 3): "0,", (8, 3): "#,##0"})
layout = find_layout(sheet, rules, 1, sheet.n_rows)
result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows)
# дробными остаются только значения без формата: 1190 = 0 (не считается), 1230, 1250 (-872206880.07 и 0.35)
assert result.fractional_values == 2 # noqa: PLR2004

View File

@ -0,0 +1,172 @@
"""Отображаемое значение числа по числовому формату ячейки (#42, R-2…R-6): примеры из реальных выгрузок 1С."""
from decimal import Decimal
import pytest
from src.parser.statements.number_format import FormatResult, display_amount
def shown(raw: str, fmt: str) -> tuple[str, Decimal | None]:
result = display_amount(Decimal(raw), fmt)
return result.kind, result.value
@pytest.mark.parametrize(
("raw", "fmt", "expected"),
[
("10741981390", "#,##0,", "10741981.00"),
("586260.26", "0,", "586.00"),
("898852.21795", "#,##0", "898852.00"),
("898852.5", "#,##0", "898853.00"),
("1234.5", "#,##0.00", "1234.50"),
("1234.567", "#,##0.00", "1234.57"),
("1500", "#,##0,", "2.00"),
("-1500", "#,##0,", "-2.00"),
("1499.99", "#,##0,", "1.00"),
("-1499.99", "#,##0,", "-1.00"),
("586260.26", "0.00,", "586.26"),
("5000000", "#,##0,,", "5.00"),
("1500", '#,##0,;(#,##0,);"-"', "2.00"),
("-1500", '#,##0,;(#,##0,);"-"', "-2.00"),
("1500", "#,##0;(#,##0)", "1500.00"),
("-1500", "#,##0;(#,##0)", "-1500.00"),
("-1500", "#,##0;#,##0", "-1500.00"),
("1500", "[Red]#,##0", "1500.00"),
("1500", "[$-419]#,##0", "1500.00"),
("1500", "#,##0_);[Red](#,##0)", "1500.00"),
("-1500", "#,##0_);[Red](#,##0)", "-1500.00"),
("1500.126", "0.000", "1500.13"),
("1500", '0 "тыс."', "1500.00"),
("-872206880.07", '[=-872206880.07]"(872 207)";#,##0,', "-872207.00"),
("-872206881", '[=-872206880.07]"(872 207)";#,##0,', "-872207.00"),
("-646532", '[=-646532]"(647)"', "-647.00"),
("12345678", '[=-872206880.07]"(872 207)";#,##0,', "12346.00"),
("1500499.9999999998", "#,##0,", "1501.00"),
("1500", "[$-419]#,##0,", "2.00"),
("1500", "\\(#,##0\\)", "1500.00"),
("5", "[=5]0;[=5]0,", "5.00"),
("-1500", "[$-419]#,##0,", "-2.00"),
],
)
def test_value(raw: str, fmt: str, expected: str) -> None:
kind, value = shown(raw, fmt)
assert kind == "value"
assert value is not None
assert str(value) == expected
@pytest.mark.parametrize(
("raw", "fmt"),
[
("0", '[=0]"-"'),
("0", '[=0]""'),
("0", '[=0]"-";General'),
("-0.79", '[=-0.79]"-"'),
("0", '#,##0,;(#,##0,);"-"'),
("0", ";;;"),
("7", ";;;"),
],
)
def test_no_value(raw: str, fmt: str) -> None:
assert shown(raw, fmt) == ("value", None)
@pytest.mark.parametrize(
("raw", "fmt"),
[
("5", '[=0]"-"'),
("5", "0%"),
("5", "0.00E+00"),
("5", "# ?/?"),
("5", "@"),
("5", "dd.mm.yyyy"),
("5", "h:mm AM/PM"),
("0", '[=0]"н/д"'),
("5", '"н/д"'),
("5", "0 руб"),
("5", "[=1E-5]0"),
("5", "[=1,5]0"),
("5", "0 ₽"),
("5", "0;0;0;@;0"),
],
)
def test_unparsed(raw: str, fmt: str) -> None:
assert shown(raw, fmt) == ("unparsed", None)
@pytest.mark.parametrize("fmt", ["General", "general", "", " "])
def test_raw(fmt: str) -> None:
assert shown("5.5", fmt) == ("raw", None)
def test_general_section_after_condition() -> None:
assert shown("5.5", '[=0]"-";General') == ("raw", None)
@pytest.mark.parametrize(
("raw", "fmt", "expected"),
[
("5", "[<10]0;[>=10]0,", "5.00"),
("20000", "[<10]0;[>=10]0,", "20.00"),
("5", "[<>5]0;0.0", "5.0"),
],
)
def test_comparison_conditions(raw: str, fmt: str, expected: str) -> None:
kind, value = shown(raw, fmt)
assert kind == "value"
assert value is not None
assert Decimal(expected) == value
def test_fourth_section_ignored() -> None:
assert shown("1500", "#,##0,;(#,##0,);0;@") == ("value", Decimal("2.00"))
def test_semicolon_in_quotes_does_not_split() -> None:
assert shown("0", '[=0]"a;b"') == ("unparsed", None)
def test_escaped_char_is_decoration() -> None:
assert shown("1500", "#,##0\\ \\р") == ("value", Decimal("1500.00"))
def test_huge_value_out_of_range_is_unparsed() -> None:
assert shown("1" + "0" * 40, "#,##0") == ("unparsed", None)
def test_max_numeric_18_2_range() -> None:
"""Excel показывает 15 значащих цифр: 16-значное целое округляется до 1E+16, что уже вне numeric(18,2)."""
assert shown("999999999999999", "0") == ("value", Decimal("999999999999999.00"))
assert shown("9999999999999999", "0") == ("unparsed", None)
assert shown("99999999999999999", "0") == ("unparsed", None)
def test_scaled_huge_value_fits() -> None:
assert shown("99999999999999999000", "0,") == ("unparsed", None)
assert shown("999999999999999000", "0,") == ("value", Decimal("999999999999999.00"))
def test_condition_compares_with_excel_15_digit_precision() -> None:
"""Из xls значение приходит как -1486533582.3500001, условие записано `-1486533582.35`: Excel считает их равными."""
fmt = '[=-1486533582.35]"(1\u00a0486\u00a0534)"'
result = display_amount(Decimal("-1486533582.3500001"), fmt)
assert result == FormatResult("value", Decimal("-1486534"))
def test_value_is_rounded_to_15_digits_half_up_before_scaling() -> None:
"""Артефакт float 1500499.9999999998 в Excel — 1500500, после деления на 1000 половина округляется вверх."""
assert shown("1500499.9999999998", "#,##0,") == ("value", Decimal("1501"))
def test_condition_operand_is_rounded_to_15_digits() -> None:
"""Литерал «-» — «нет значения»; «x» дал бы unparsed по R-5 независимо от условия, поэтому берём прочерк."""
result = display_amount(Decimal("0.1234567890123456"), '[=0.1234567890123456]"-"')
assert result == FormatResult("value", None)
def test_condition_that_does_not_match_the_pattern_is_unparsed() -> None:
assert shown("0", '[=1E-5]"x"') == ("unparsed", None)

View File

@ -5,7 +5,7 @@
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from gridbuilder import at, sheet_from_rows from gridbuilder import at, sheet_from_rows, with_formats
from src.parser.deadline import Deadline from src.parser.deadline import Deadline
from src.parser.detect import SheetKind from src.parser.detect import SheetKind
@ -417,3 +417,40 @@ def test_single_code_sheet_of_another_form_does_not_join(parser: StatementParser
text.startswith("sheet=Справка лист с кодами форм (1 < 3) не присоединён: коды 1150 не из формы INCOME") text.startswith("sheet=Справка лист с кодами форм (1 < 3) не присоединён: коды 1150 не из формы INCOME")
for text in warnings for text in warnings
) )
def test_unparsed_number_format_gives_one_document_warning(parser: StatementParser) -> None:
long_format = "0.0000%" + "x" * 40
page_1 = with_formats(
sheet_from_rows(BALANCE_PAGE_1, "ББ1"), {(7, 3): "0%", (8, 3): "0%", (9, 3): long_format, (11, 3): "0.00%"}
)
page_2 = with_formats(sheet_from_rows(BALANCE_PAGE_2, "ББ2"), {(3, 3): "0%"})
sheets = [(page_1, SheetKind.STATEMENT), (page_2, SheetKind.STATEMENT_CONTINUATION)]
_, warnings, _ = parser.parse_sheets(sheets, deadline())
found = [text for text in warnings if "формат чисел не разобран" in text]
shortened = long_format[:29] + "…"
assert found == [f"sheet=ББ1 формат чисел не разобран, значения как в ячейках: «0%», «0.00%», «{shortened}»"]
def test_parsed_formats_give_no_format_warning(parser: StatementParser) -> None:
page_1 = with_formats(sheet_from_rows(BALANCE_PAGE_1, "ББ1"), {(7, 3): "#,##0", (8, 3): "0,"})
_, warnings, _ = parser.parse_sheets([(page_1, SheetKind.STATEMENT)], deadline())
assert not any("формат чисел" in text for text in warnings)
def test_xlsb_flag_adds_warning_to_statement_documents_only(parser: StatementParser) -> None:
sheets = [
(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT),
(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT),
]
_, warnings, _ = parser.parse_sheets(sheets, deadline(), formats_unreadable=True)
_, plain, _ = parser.parse_sheets(sheets, deadline())
text = "формат чисел xlsb не читается: значения как в файле"
assert [w for w in warnings if "xlsb" in w] == [f"sheet=ББ {text}", f"sheet=ОФР {text}"]
assert not any("xlsb" in w for w in plain)

View File

@ -35,6 +35,7 @@ from src.parser.hierarchy import BodyRow
from src.parser.osv import OsvParser from src.parser.osv import OsvParser
from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE, Rules, load_rules from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE, Rules, load_rules
from src.parser.statements.statement import StatementParser from src.parser.statements.statement import StatementParser
from tests.synthetic.xlsb_writer import write_xlsb
OPENING = "Сальдо на начало периода" OPENING = "Сальдо на начало периода"
TURNOVER = "Обороты за период" TURNOVER = "Обороты за период"
@ -642,10 +643,12 @@ class _FakeStatements:
def __init__(self) -> None: def __init__(self) -> None:
self.received: list[tuple[str, SheetKind]] = [] self.received: list[tuple[str, SheetKind]] = []
self.formats_unreadable = False
def parse_sheets( def parse_sheets(
self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline, *, formats_unreadable: bool = False
) -> tuple[list[StatementDocument], list[str], bool]: ) -> tuple[list[StatementDocument], list[str], bool]:
self.formats_unreadable = formats_unreadable
self.received = [(sheet.name, kind) for sheet, kind in sheets] self.received = [(sheet.name, kind) for sheet, kind in sheets]
documents = [ documents = [
StatementDocument( StatementDocument(
@ -704,9 +707,9 @@ def test_book_without_osv_and_forms_is_invalid_document(rules: Rules) -> None:
def test_statement_not_ok_makes_book_needs_check(rules: Rules) -> None: def test_statement_not_ok_makes_book_needs_check(rules: Rules) -> None:
class _NotOk(_FakeStatements): class _NotOk(_FakeStatements):
def parse_sheets( def parse_sheets(
self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline, *, formats_unreadable: bool = False
) -> tuple[list[StatementDocument], list[str], bool]: ) -> tuple[list[StatementDocument], list[str], bool]:
documents, warnings, _ = super().parse_sheets(sheets, deadline) documents, warnings, _ = super().parse_sheets(sheets, deadline, formats_unreadable=formats_unreadable)
return documents, warnings, False return documents, warnings, False
outcome = book(rules, statements=_NotOk()).parse(xlsx({"ББ": BALANCE}), "XLSX") outcome = book(rules, statements=_NotOk()).parse(xlsx({"ББ": BALANCE}), "XLSX")
@ -764,3 +767,22 @@ def test_statement_sheets_are_reported_when_osv_sheet_fails(rules: Rules) -> Non
assert outcome.status is ResultStatus.ERROR assert outcome.status is ResultStatus.ERROR
assert "sheet=ББ форма не разобрана: книга завершилась ошибкой листа ОСВ «ОСВ 60»" in warnings_of(outcome) assert "sheet=ББ форма не разобрана: книга завершилась ошибкой листа ОСВ «ОСВ 60»" in warnings_of(outcome)
def test_xlsb_book_marks_formats_unreadable_for_statements_only(rules: Rules) -> None:
"""#42, R-7: xlsb определяется по содержимому книги (без подмены предупреждений); ОСВ предупреждения не получает."""
cells = {
name: {position: cell.value for position, cell in sheet_from_rows(rows, name).cells.items()}
for name, rows in (("ББ", BALANCE_ROWS), ("ОСВ 60", mini_osv()))
}
outcome = book(rules).parse(write_xlsb(cells), "XLSB")
xlsb_warnings = [w for w in warnings_of(outcome) if "формат чисел xlsb" in w]
assert xlsb_warnings == ["sheet=ББ формат чисел xlsb не читается: значения как в файле"]
assert [type(document).__name__ for document in outcome.documents] == ["OsvDocument", "StatementDocument"]
def test_xlsx_book_has_no_xlsb_format_warning(rules: Rules) -> None:
outcome = book(rules).parse(xlsx({"ББ": BALANCE_ROWS}), "XLSX")
assert not any("формат чисел" in w for w in warnings_of(outcome))

View File

@ -7,7 +7,7 @@ import pytest
from src.parser import detect from src.parser import detect
from src.parser.grid import EMPTY_CELL, Cell from src.parser.grid import EMPTY_CELL, Cell
from src.parser.numbers import MONEY_Q, MoneyParse, inn_checksum_ok, inn_digits, row_inn, to_money from src.parser.numbers import MONEY_Q, MoneyParse, inn_checksum_ok, inn_digits, quantize_money, row_inn, to_money
from src.parser.rules import Rules, load_rules from src.parser.rules import Rules, load_rules
MAX_WARNING_LENGTH = 100 MAX_WARNING_LENGTH = 100
@ -286,3 +286,18 @@ def rules() -> Rules:
) )
def test_row_inn(rules: Rules, cell: Cell, expected: str | None) -> None: def test_row_inn(rules: Rules, cell: Cell, expected: str | None) -> None:
assert row_inn(cell, rules) == expected assert row_inn(cell, rules) == expected
@pytest.mark.parametrize(
("value", "expected"),
[("1.005", "1.01"), ("-1.005", "-1.01"), ("0.004", "0.00"), ("-0.004", "0.00"), ("10741981", "10741981.00")],
)
def test_quantize_money_half_up_and_no_negative_zero(value: str, expected: str) -> None:
assert_money(quantize_money(Decimal(value), value), expected)
@pytest.mark.parametrize("value", ["1E+16", "NaN", "Infinity", "1E-2000"])
def test_quantize_money_rejects_out_of_range(value: str) -> None:
parsed = quantize_money(Decimal(value), value)
assert parsed.value is None
assert parsed.warning is not None

View File

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