ББ/ОФР из Excel: значение как показывает числовой формат (#42, AURORA-1528) #43
@ -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).
|
||||||
|
|||||||
19
README.md
19
README.md
@ -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 |
|
||||||
|
|||||||
@ -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 с предупреждением «в пределах округления».
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
271
api/src/parser/statements/number_format.py
Normal file
271
api/src/parser/statements/number_format.py
Normal 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)
|
||||||
@ -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)."""
|
||||||
|
|||||||
43
api/tests/fixtures/test_statements_number_format.py
vendored
Normal file
43
api/tests/fixtures/test_statements_number_format.py
vendored
Normal 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")
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
172
api/tests/unit/parser/statements/test_number_format.py
Normal file
172
api/tests/unit/parser/statements/test_number_format.py
Normal 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)
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
75
api/tests/unit/parser/test_reader_number_format.py
Normal file
75
api/tests/unit/parser/test_reader_number_format.py
Normal 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())
|
||||||
Loading…
x
Reference in New Issue
Block a user